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