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: ®ex::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: ®ex::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: ®ex::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: ®ex::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#", &litude_key);
650 let contents = title_regex()
651 .replace(&contents, |_: ®ex::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("&", "&")
712 .replace("<", "<")
713 .replace(">", ">"),
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}