main.rs

  1use anyhow::{Context, Result};
  2use mdbook::BookItem;
  3use mdbook::book::{Book, Chapter};
  4use mdbook::preprocess::CmdPreprocessor;
  5use regex::Regex;
  6use settings::{KeymapFile, SettingsStore};
  7use std::borrow::Cow;
  8use std::collections::{HashMap, HashSet};
  9use std::io::{self, Read};
 10use std::process;
 11use std::sync::{LazyLock, OnceLock};
 12
 13static KEYMAP_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
 14    load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap")
 15});
 16
 17static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
 18    load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap")
 19});
 20
 21static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
 22    load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
 23});
 24
 25static ALL_ACTIONS: LazyLock<ActionManifest> = LazyLock::new(load_all_actions);
 26
 27const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
 28
 29fn main() -> Result<()> {
 30    zlog::init();
 31    zlog::init_output_stderr();
 32    let args = std::env::args().skip(1).collect::<Vec<_>>();
 33
 34    match args.get(0).map(String::as_str) {
 35        Some("supports") => {
 36            let renderer = args.get(1).expect("Required argument");
 37            let supported = renderer != "not-supported";
 38            if supported {
 39                process::exit(0);
 40            } else {
 41                process::exit(1);
 42            }
 43        }
 44        Some("postprocess") => handle_postprocessing()?,
 45        _ => handle_preprocessing()?,
 46    }
 47
 48    Ok(())
 49}
 50
 51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
 52enum PreprocessorError {
 53    ActionNotFound {
 54        action_name: String,
 55    },
 56    DeprecatedActionUsed {
 57        used: String,
 58        should_be: String,
 59    },
 60    InvalidFrontmatterLine(String),
 61    InvalidSettingsJson {
 62        file: std::path::PathBuf,
 63        line: usize,
 64        snippet: String,
 65        error: String,
 66    },
 67}
 68
 69impl PreprocessorError {
 70    fn new_for_not_found_action(action_name: String) -> Self {
 71        for action in &ALL_ACTIONS.actions {
 72            for alias in &action.deprecated_aliases {
 73                if alias == action_name.as_str() {
 74                    return PreprocessorError::DeprecatedActionUsed {
 75                        used: action_name,
 76                        should_be: action.name.to_string(),
 77                    };
 78                }
 79            }
 80        }
 81        PreprocessorError::ActionNotFound { action_name }
 82    }
 83
 84    fn new_for_invalid_settings_json(
 85        chapter: &Chapter,
 86        location: usize,
 87        snippet: String,
 88        error: String,
 89    ) -> Self {
 90        PreprocessorError::InvalidSettingsJson {
 91            file: chapter.path.clone().expect("chapter has path"),
 92            line: chapter.content[..location].lines().count() + 1,
 93            snippet,
 94            error,
 95        }
 96    }
 97}
 98
 99impl std::fmt::Display for PreprocessorError {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            PreprocessorError::InvalidFrontmatterLine(line) => {
103                write!(f, "Invalid frontmatter line: {}", line)
104            }
105            PreprocessorError::ActionNotFound { action_name } => {
106                write!(f, "Action not found: {}", action_name)
107            }
108            PreprocessorError::DeprecatedActionUsed { used, should_be } => write!(
109                f,
110                "Deprecated action used: {} should be {}",
111                used, should_be
112            ),
113            PreprocessorError::InvalidSettingsJson {
114                file,
115                line,
116                snippet,
117                error,
118            } => {
119                write!(
120                    f,
121                    "Invalid settings JSON at {}:{}\nError: {}\n\n{}",
122                    file.display(),
123                    line,
124                    error,
125                    snippet
126                )
127            }
128        }
129    }
130}
131
132fn handle_preprocessing() -> Result<()> {
133    let mut stdin = io::stdin();
134    let mut input = String::new();
135    stdin.read_to_string(&mut input)?;
136
137    let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
138
139    let mut errors = HashSet::<PreprocessorError>::new();
140    handle_frontmatter(&mut book, &mut errors);
141    template_big_table_of_actions(&mut book);
142    template_and_validate_keybindings(&mut book, &mut errors);
143    template_and_validate_actions(&mut book, &mut errors);
144    template_and_validate_json_snippets(&mut book, &mut errors);
145
146    if !errors.is_empty() {
147        const ANSI_RED: &str = "\x1b[31m";
148        const ANSI_RESET: &str = "\x1b[0m";
149        for error in &errors {
150            eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
151        }
152        return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
153    }
154
155    serde_json::to_writer(io::stdout(), &book)?;
156
157    Ok(())
158}
159
160fn handle_frontmatter(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
161    let frontmatter_regex = Regex::new(r"(?s)^\s*---(.*?)---").unwrap();
162    for_each_chapter_mut(book, |chapter| {
163        let new_content = frontmatter_regex.replace(&chapter.content, |caps: &regex::Captures| {
164            let frontmatter = caps[1].trim();
165            let frontmatter = frontmatter.trim_matches(&[' ', '-', '\n']);
166            let mut metadata = HashMap::<String, String>::default();
167            for line in frontmatter.lines() {
168                let Some((name, value)) = line.split_once(':') else {
169                    errors.insert(PreprocessorError::InvalidFrontmatterLine(format!(
170                        "{}: {}",
171                        chapter_breadcrumbs(chapter),
172                        line
173                    )));
174                    continue;
175                };
176                let name = name.trim();
177                let value = value.trim();
178                metadata.insert(name.to_string(), value.to_string());
179            }
180            FRONT_MATTER_COMMENT.replace(
181                "{}",
182                &serde_json::to_string(&metadata).expect("Failed to serialize metadata"),
183            )
184        });
185        if let Cow::Owned(content) = new_content {
186            chapter.content = content;
187        }
188    });
189}
190
191fn template_big_table_of_actions(book: &mut Book) {
192    for_each_chapter_mut(book, |chapter| {
193        let needle = "{#ACTIONS_TABLE#}";
194        if let Some(start) = chapter.content.rfind(needle) {
195            chapter.content.replace_range(
196                start..start + needle.len(),
197                &generate_big_table_of_actions(),
198            );
199        }
200    });
201}
202
203fn format_binding(binding: String) -> String {
204    binding.replace("\\", "\\\\")
205}
206
207fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
208    let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
209
210    for_each_chapter_mut(book, |chapter| {
211        chapter.content = regex
212            .replace_all(&chapter.content, |caps: &regex::Captures| {
213                let action = caps[1].trim();
214                if is_missing_action(action) {
215                    errors.insert(PreprocessorError::new_for_not_found_action(
216                        action.to_string(),
217                    ));
218                    return String::new();
219                }
220                let macos_binding = find_binding("macos", action).unwrap_or_default();
221                let linux_binding = find_binding("linux", action).unwrap_or_default();
222
223                if macos_binding.is_empty() && linux_binding.is_empty() {
224                    return "<div>No default binding</div>".to_string();
225                }
226
227                let formatted_macos_binding = format_binding(macos_binding);
228                let formatted_linux_binding = format_binding(linux_binding);
229
230                format!("<kbd class=\"keybinding\">{formatted_macos_binding}|{formatted_linux_binding}</kbd>")
231            })
232            .into_owned()
233    });
234}
235
236fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
237    let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
238
239    for_each_chapter_mut(book, |chapter| {
240        chapter.content = regex
241            .replace_all(&chapter.content, |caps: &regex::Captures| {
242                let name = caps[1].trim();
243                let Some(action) = find_action_by_name(name) else {
244                    if actions_available() {
245                        errors.insert(PreprocessorError::new_for_not_found_action(
246                            name.to_string(),
247                        ));
248                    }
249                    return format!("<code class=\"hljs\">{}</code>", name);
250                };
251                format!("<code class=\"hljs\">{}</code>", &action.human_name)
252            })
253            .into_owned()
254    });
255}
256
257fn find_action_by_name(name: &str) -> Option<&ActionDef> {
258    ALL_ACTIONS
259        .actions
260        .binary_search_by(|action| action.name.as_str().cmp(name))
261        .ok()
262        .map(|index| &ALL_ACTIONS.actions[index])
263}
264
265fn actions_available() -> bool {
266    !ALL_ACTIONS.actions.is_empty()
267}
268
269fn is_missing_action(name: &str) -> bool {
270    actions_available() && find_action_by_name(name).is_none()
271}
272
273fn find_binding(os: &str, action: &str) -> Option<String> {
274    let keymap = match os {
275        "macos" => &KEYMAP_MACOS,
276        "linux" | "freebsd" => &KEYMAP_LINUX,
277        "windows" => &KEYMAP_WINDOWS,
278        _ => unreachable!("Not a valid OS: {}", os),
279    };
280
281    // Find the binding in reverse order, as the last binding takes precedence.
282    keymap.sections().rev().find_map(|section| {
283        section.bindings().rev().find_map(|(keystroke, a)| {
284            if name_for_action(a.to_string()) == action {
285                Some(keystroke.to_string())
286            } else {
287                None
288            }
289        })
290    })
291}
292
293fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
294    let settings_schema = SettingsStore::json_schema(&Default::default());
295    let settings_validator = jsonschema::validator_for(&settings_schema)
296        .expect("failed to compile settings JSON schema");
297
298    let keymap_schema =
299        keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
300    let keymap_validator =
301        jsonschema::validator_for(&keymap_schema).expect("failed to compile keymap JSON schema");
302
303    fn for_each_labeled_code_block_mut(
304        book: &mut Book,
305        errors: &mut HashSet<PreprocessorError>,
306        f: &dyn Fn(&str, &str) -> anyhow::Result<()>,
307    ) {
308        const TAGGED_JSON_BLOCK_START: &'static str = "```json [";
309        const JSON_BLOCK_END: &'static str = "```";
310
311        for_each_chapter_mut(book, |chapter| {
312            let mut offset = 0;
313            while let Some(loc) = chapter.content[offset..].find(TAGGED_JSON_BLOCK_START) {
314                let loc = loc + offset;
315                let tag_start = loc + TAGGED_JSON_BLOCK_START.len();
316                offset = tag_start;
317                let Some(tag_end) = chapter.content[tag_start..].find(']') else {
318                    errors.insert(PreprocessorError::new_for_invalid_settings_json(
319                        chapter,
320                        loc,
321                        chapter.content[loc..tag_start].to_string(),
322                        "Unclosed JSON block tag".to_string(),
323                    ));
324                    continue;
325                };
326                let tag_end = tag_end + tag_start;
327
328                let tag = &chapter.content[tag_start..tag_end];
329
330                if tag.contains('\n') {
331                    errors.insert(PreprocessorError::new_for_invalid_settings_json(
332                        chapter,
333                        loc,
334                        chapter.content[loc..tag_start].to_string(),
335                        "Unclosed JSON block tag".to_string(),
336                    ));
337                    continue;
338                }
339
340                let snippet_start = tag_end + 1;
341                offset = snippet_start;
342
343                let Some(snippet_end) = chapter.content[snippet_start..].find(JSON_BLOCK_END)
344                else {
345                    errors.insert(PreprocessorError::new_for_invalid_settings_json(
346                        chapter,
347                        loc,
348                        chapter.content[loc..tag_end + 1].to_string(),
349                        "Missing closing code block".to_string(),
350                    ));
351                    continue;
352                };
353                let snippet_end = snippet_start + snippet_end;
354                let snippet_json = &chapter.content[snippet_start..snippet_end];
355                offset = snippet_end + 3;
356
357                if let Err(err) = f(tag, snippet_json) {
358                    errors.insert(PreprocessorError::new_for_invalid_settings_json(
359                        chapter,
360                        loc,
361                        chapter.content[loc..snippet_end + 3].to_string(),
362                        err.to_string(),
363                    ));
364                    continue;
365                };
366                let tag_range_complete = tag_start - 1..tag_end + 1;
367                offset -= tag_range_complete.len();
368                chapter.content.replace_range(tag_range_complete, "");
369            }
370        });
371    }
372
373    for_each_labeled_code_block_mut(book, errors, &|label, snippet_json| {
374        let mut snippet_json_fixed = snippet_json
375            .to_string()
376            .replace("\n>", "\n")
377            .trim()
378            .to_string();
379        while snippet_json_fixed.starts_with("//") {
380            if let Some(line_end) = snippet_json_fixed.find('\n') {
381                snippet_json_fixed.replace_range(0..line_end, "");
382                snippet_json_fixed = snippet_json_fixed.trim().to_string();
383            }
384        }
385        match label {
386            "settings" => {
387                if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
388                    snippet_json_fixed.insert(0, '{');
389                    snippet_json_fixed.push_str("\n}");
390                }
391                let value =
392                    settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
393                let validation_errors: Vec<String> = settings_validator
394                    .iter_errors(&value)
395                    .map(|err| err.to_string())
396                    .collect();
397                if !validation_errors.is_empty() {
398                    anyhow::bail!("{}", validation_errors.join("\n"));
399                }
400            }
401            "keymap" => {
402                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
403                    snippet_json_fixed.insert(0, '[');
404                    snippet_json_fixed.push_str("\n]");
405                }
406
407                let value =
408                    settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
409                let validation_errors: Vec<String> = keymap_validator
410                    .iter_errors(&value)
411                    .map(|err| err.to_string())
412                    .collect();
413                if !validation_errors.is_empty() {
414                    anyhow::bail!("{}", validation_errors.join("\n"));
415                }
416            }
417            "debug" => {
418                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
419                    snippet_json_fixed.insert(0, '[');
420                    snippet_json_fixed.push_str("\n]");
421                }
422
423                settings::parse_json_with_comments::<task::DebugTaskFile>(&snippet_json_fixed)?;
424            }
425            "tasks" => {
426                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
427                    snippet_json_fixed.insert(0, '[');
428                    snippet_json_fixed.push_str("\n]");
429                }
430
431                settings::parse_json_with_comments::<task::TaskTemplates>(&snippet_json_fixed)?;
432            }
433            "icon-theme" => {
434                if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
435                    snippet_json_fixed.insert(0, '{');
436                    snippet_json_fixed.push_str("\n}");
437                }
438
439                settings::parse_json_with_comments::<theme::IconThemeFamilyContent>(
440                    &snippet_json_fixed,
441                )?;
442            }
443            "semantic_token_rules" => {
444                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
445                    snippet_json_fixed.insert(0, '[');
446                    snippet_json_fixed.push_str("\n]");
447                }
448
449                settings::parse_json_with_comments::<settings::SemanticTokenRules>(
450                    &snippet_json_fixed,
451                )?;
452            }
453            label => anyhow::bail!("Unexpected JSON code block tag: {label}"),
454        };
455        Ok(())
456    });
457}
458
459/// Removes any configurable options from the stringified action if existing,
460/// ensuring that only the actual action name is returned. If the action consists
461/// only of a string and nothing else, the string is returned as-is.
462///
463/// Example:
464///
465/// This will return the action name unmodified.
466///
467/// ```
468/// let action_as_str = "assistant::Assist";
469/// let action_name = name_for_action(action_as_str);
470/// assert_eq!(action_name, "assistant::Assist");
471/// ```
472///
473/// This will return the action name with any trailing options removed.
474///
475///
476/// ```
477/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
478/// let action_name = name_for_action(action_as_str);
479/// assert_eq!(action_name, "editor::ToggleComments");
480/// ```
481fn name_for_action(action_as_str: String) -> String {
482    action_as_str
483        .split(",")
484        .next()
485        .map(|name| name.trim_matches('"').to_string())
486        .unwrap_or(action_as_str)
487}
488
489fn chapter_breadcrumbs(chapter: &Chapter) -> String {
490    let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
491    breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
492    breadcrumbs.push(chapter.name.as_str());
493    format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
494}
495
496fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
497    let content = util::asset_str::<settings::SettingsAssets>(asset_path);
498    KeymapFile::parse(content.as_ref())
499}
500
501fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
502where
503    F: FnMut(&mut Chapter),
504{
505    book.for_each_mut(|item| {
506        let BookItem::Chapter(chapter) = item else {
507            return;
508        };
509        func(chapter);
510    });
511}
512
513#[derive(Debug, serde::Serialize, serde::Deserialize)]
514struct ActionDef {
515    name: String,
516    human_name: String,
517    #[serde(default)]
518    schema: Option<serde_json::Value>,
519    deprecated_aliases: Vec<String>,
520    #[serde(default)]
521    deprecation_message: Option<String>,
522    #[serde(rename = "documentation")]
523    docs: Option<String>,
524}
525
526#[derive(Debug, serde::Deserialize)]
527struct ActionManifest {
528    actions: Vec<ActionDef>,
529    #[serde(default)]
530    schema_definitions: serde_json::Map<String, serde_json::Value>,
531}
532
533fn load_all_actions() -> ActionManifest {
534    let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
535    match std::fs::read_to_string(asset_path) {
536        Ok(content) => {
537            let mut manifest: ActionManifest =
538                serde_json::from_str(&content).expect("Failed to parse actions.json");
539            manifest.actions.sort_by(|a, b| a.name.cmp(&b.name));
540            manifest
541        }
542        Err(err) => {
543            if std::env::var("CI").is_ok() {
544                panic!("actions.json not found at {}: {}", asset_path, err);
545            }
546            eprintln!(
547                "Warning: actions.json not found, action validation will be skipped: {}",
548                err
549            );
550            ActionManifest {
551                actions: Vec::new(),
552                schema_definitions: serde_json::Map::new(),
553            }
554        }
555    }
556}
557
558fn handle_postprocessing() -> Result<()> {
559    let logger = zlog::scoped!("render");
560    let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
561    let output = ctx
562        .config
563        .get_mut("output")
564        .expect("has output")
565        .as_table_mut()
566        .expect("output is table");
567    let zed_html = output.remove("zed-html").expect("zed-html output defined");
568    let default_description = zed_html
569        .get("default-description")
570        .expect("Default description not found")
571        .as_str()
572        .expect("Default description not a string")
573        .to_string();
574    let default_title = zed_html
575        .get("default-title")
576        .expect("Default title not found")
577        .as_str()
578        .expect("Default title not a string")
579        .to_string();
580    let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
581
582    output.insert("html".to_string(), zed_html);
583    mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
584    let ignore_list = ["toc.html"];
585
586    let root_dir = ctx.destination.clone();
587    let mut files = Vec::with_capacity(128);
588    let mut queue = Vec::with_capacity(64);
589    queue.push(root_dir.clone());
590    while let Some(dir) = queue.pop() {
591        for entry in std::fs::read_dir(&dir).context("failed to read docs dir")? {
592            let Ok(entry) = entry else {
593                continue;
594            };
595            let file_type = entry.file_type().context("Failed to determine file type")?;
596            if file_type.is_dir() {
597                queue.push(entry.path());
598            }
599            if file_type.is_file()
600                && matches!(
601                    entry.path().extension().and_then(std::ffi::OsStr::to_str),
602                    Some("html")
603                )
604            {
605                if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
606                    zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
607                } else {
608                    files.push(entry.path());
609                }
610            }
611        }
612    }
613
614    zlog::info!(logger => "Processing {} `.html` files", files.len());
615    let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
616    for file in files {
617        let contents = std::fs::read_to_string(&file)?;
618        let mut meta_description = None;
619        let mut meta_title = None;
620        let contents = meta_regex.replace(&contents, |caps: &regex::Captures| {
621            let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
622            for (kind, content) in metadata {
623                match kind.as_str() {
624                    "description" => {
625                        meta_description = Some(content);
626                    }
627                    "title" => {
628                        meta_title = Some(content);
629                    }
630                    _ => {
631                        zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
632                    }
633                }
634            }
635            String::new()
636        });
637        let meta_description = meta_description.as_ref().unwrap_or_else(|| {
638            zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
639            &default_description
640        });
641        let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
642        let meta_title = meta_title.as_ref().unwrap_or_else(|| {
643            zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
644            &default_title
645        });
646        let meta_title = format!("{} | {}", page_title, meta_title);
647        zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
648        let contents = contents.replace("#description#", meta_description);
649        let contents = contents.replace("#amplitude_key#", &amplitude_key);
650        let contents = title_regex()
651            .replace(&contents, |_: &regex::Captures| {
652                format!("<title>{}</title>", meta_title)
653            })
654            .to_string();
655        // let contents = contents.replace("#title#", &meta_title);
656        std::fs::write(file, contents)?;
657    }
658    return Ok(());
659
660    fn pretty_path<'a>(
661        path: &'a std::path::PathBuf,
662        root: &'a std::path::PathBuf,
663    ) -> &'a std::path::Path {
664        path.strip_prefix(&root).unwrap_or(path)
665    }
666    fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
667        let title_tag_contents = &title_regex()
668            .captures(contents)
669            .with_context(|| format!("Failed to find title in {:?}", pretty_path))
670            .expect("Page has <title> element")[1];
671
672        title_tag_contents
673            .trim()
674            .strip_suffix("- Zed")
675            .unwrap_or(title_tag_contents)
676            .trim()
677            .to_string()
678    }
679}
680
681fn title_regex() -> &'static Regex {
682    static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
683    TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap())
684}
685
686fn generate_big_table_of_actions() -> String {
687    let actions = &ALL_ACTIONS.actions;
688    let mut output = String::new();
689
690    let mut actions_sorted = actions.iter().collect::<Vec<_>>();
691    actions_sorted.sort_by_key(|a| a.name.as_str());
692
693    // Start the definition list with custom styling for better spacing
694    output.push_str("<dl style=\"line-height: 1.8;\">\n");
695
696    for action in actions_sorted.into_iter() {
697        // Add the humanized action name as the term with margin
698        output.push_str(
699            "<dt style=\"margin-top: 1.5em; margin-bottom: 0.5em; font-weight: bold;\"><code>",
700        );
701        output.push_str(&action.human_name);
702        output.push_str("</code></dt>\n");
703
704        // Add the definition with keymap name and description
705        output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
706
707        // Add the description, escaping HTML if needed
708        if let Some(description) = action.docs.as_ref() {
709            output.push_str(
710                &description
711                    .replace("&", "&amp;")
712                    .replace("<", "&lt;")
713                    .replace(">", "&gt;"),
714            );
715            output.push_str("<br>\n");
716        }
717        output.push_str("Keymap Name: <code>");
718        output.push_str(&action.name);
719        output.push_str("</code><br>\n");
720        if !action.deprecated_aliases.is_empty() {
721            output.push_str("Deprecated Alias(es): ");
722            for alias in action.deprecated_aliases.iter() {
723                output.push_str("<code>");
724                output.push_str(alias);
725                output.push_str("</code>, ");
726            }
727        }
728        output.push_str("\n</dd>\n");
729    }
730
731    // Close the definition list
732    output.push_str("</dl>\n");
733
734    output
735}
736
737fn keymap_schema_for_actions(
738    actions: &[ActionDef],
739    schema_definitions: &serde_json::Map<String, serde_json::Value>,
740) -> serde_json::Value {
741    let mut generator = KeymapFile::action_schema_generator();
742
743    for (name, definition) in schema_definitions {
744        generator
745            .definitions_mut()
746            .insert(name.clone(), definition.clone());
747    }
748
749    let mut action_schemas = Vec::new();
750    let mut documentation = collections::HashMap::<&str, &str>::default();
751    let mut deprecations = collections::HashMap::<&str, &str>::default();
752    let mut deprecation_messages = collections::HashMap::<&str, &str>::default();
753
754    for action in actions {
755        let schema = action
756            .schema
757            .as_ref()
758            .and_then(|v| serde_json::from_value::<schemars::Schema>(v.clone()).ok());
759        action_schemas.push((action.name.as_str(), schema));
760        if let Some(doc) = &action.docs {
761            documentation.insert(action.name.as_str(), doc.as_str());
762        }
763        if let Some(msg) = &action.deprecation_message {
764            deprecation_messages.insert(action.name.as_str(), msg.as_str());
765        }
766        for alias in &action.deprecated_aliases {
767            deprecations.insert(alias.as_str(), action.name.as_str());
768            let alias_schema = action
769                .schema
770                .as_ref()
771                .and_then(|v| serde_json::from_value::<schemars::Schema>(v.clone()).ok());
772            action_schemas.push((alias.as_str(), alias_schema));
773        }
774    }
775
776    KeymapFile::generate_json_schema(
777        generator,
778        action_schemas,
779        &documentation,
780        &deprecations,
781        &deprecation_messages,
782    )
783}