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 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 binding in reverse order, as the last binding takes precedence.
340fn find_binding_in_keymap(keymap: &KeymapFile, action: &str) -> Option<String> {
341 keymap.sections().rev().find_map(|section| {
342 section.bindings().rev().find_map(|(keystroke, a)| {
343 if name_for_action(a.to_string()) == action {
344 Some(keystroke.to_string())
345 } else {
346 None
347 }
348 })
349 })
350}
351
352fn find_binding(os: Os, action: &str) -> Option<String> {
353 let keymap = match os {
354 Os::MacOs => &KEYMAP_MACOS,
355 Os::Linux => &KEYMAP_LINUX,
356 Os::Windows => &KEYMAP_WINDOWS,
357 };
358 find_binding_in_keymap(keymap, action)
359}
360
361fn find_binding_with_overlay(
362 os: Os,
363 action: &str,
364 overlay: Option<KeymapOverlay>,
365) -> Option<String> {
366 overlay
367 .and_then(|overlay| find_binding_in_keymap(overlay.keymap(os), action))
368 .or_else(|| find_binding(os, action))
369}
370
371fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
372 let settings_schema = SettingsStore::json_schema(&Default::default());
373 let settings_validator = jsonschema::validator_for(&settings_schema)
374 .expect("failed to compile settings JSON schema");
375
376 let keymap_schema =
377 keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
378 let keymap_validator =
379 jsonschema::validator_for(&keymap_schema).expect("failed to compile keymap JSON schema");
380
381 fn for_each_labeled_code_block_mut(
382 book: &mut Book,
383 errors: &mut HashSet<PreprocessorError>,
384 f: &dyn Fn(&str, &str) -> anyhow::Result<()>,
385 ) {
386 const TAGGED_JSON_BLOCK_START: &'static str = "```json [";
387 const JSON_BLOCK_END: &'static str = "```";
388
389 for_each_chapter_mut(book, |chapter| {
390 let mut offset = 0;
391 while let Some(loc) = chapter.content[offset..].find(TAGGED_JSON_BLOCK_START) {
392 let loc = loc + offset;
393 let tag_start = loc + TAGGED_JSON_BLOCK_START.len();
394 offset = tag_start;
395 let Some(tag_end) = chapter.content[tag_start..].find(']') else {
396 errors.insert(PreprocessorError::new_for_invalid_settings_json(
397 chapter,
398 loc,
399 chapter.content[loc..tag_start].to_string(),
400 "Unclosed JSON block tag".to_string(),
401 ));
402 continue;
403 };
404 let tag_end = tag_end + tag_start;
405
406 let tag = &chapter.content[tag_start..tag_end];
407
408 if tag.contains('\n') {
409 errors.insert(PreprocessorError::new_for_invalid_settings_json(
410 chapter,
411 loc,
412 chapter.content[loc..tag_start].to_string(),
413 "Unclosed JSON block tag".to_string(),
414 ));
415 continue;
416 }
417
418 let snippet_start = tag_end + 1;
419 offset = snippet_start;
420
421 let Some(snippet_end) = chapter.content[snippet_start..].find(JSON_BLOCK_END)
422 else {
423 errors.insert(PreprocessorError::new_for_invalid_settings_json(
424 chapter,
425 loc,
426 chapter.content[loc..tag_end + 1].to_string(),
427 "Missing closing code block".to_string(),
428 ));
429 continue;
430 };
431 let snippet_end = snippet_start + snippet_end;
432 let snippet_json = &chapter.content[snippet_start..snippet_end];
433 offset = snippet_end + 3;
434
435 if let Err(err) = f(tag, snippet_json) {
436 errors.insert(PreprocessorError::new_for_invalid_settings_json(
437 chapter,
438 loc,
439 chapter.content[loc..snippet_end + 3].to_string(),
440 err.to_string(),
441 ));
442 continue;
443 };
444 let tag_range_complete = tag_start - 1..tag_end + 1;
445 offset -= tag_range_complete.len();
446 chapter.content.replace_range(tag_range_complete, "");
447 }
448 });
449 }
450
451 for_each_labeled_code_block_mut(book, errors, &|label, snippet_json| {
452 let mut snippet_json_fixed = snippet_json
453 .to_string()
454 .replace("\n>", "\n")
455 .trim()
456 .to_string();
457 while snippet_json_fixed.starts_with("//") {
458 if let Some(line_end) = snippet_json_fixed.find('\n') {
459 snippet_json_fixed.replace_range(0..line_end, "");
460 snippet_json_fixed = snippet_json_fixed.trim().to_string();
461 }
462 }
463 match label {
464 "settings" => {
465 if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
466 snippet_json_fixed.insert(0, '{');
467 snippet_json_fixed.push_str("\n}");
468 }
469 let value =
470 settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
471 let validation_errors: Vec<String> = settings_validator
472 .iter_errors(&value)
473 .map(|err| err.to_string())
474 .collect();
475 if !validation_errors.is_empty() {
476 anyhow::bail!("{}", validation_errors.join("\n"));
477 }
478 }
479 "keymap" => {
480 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
481 snippet_json_fixed.insert(0, '[');
482 snippet_json_fixed.push_str("\n]");
483 }
484
485 let value =
486 settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
487 let validation_errors: Vec<String> = keymap_validator
488 .iter_errors(&value)
489 .map(|err| err.to_string())
490 .collect();
491 if !validation_errors.is_empty() {
492 anyhow::bail!("{}", validation_errors.join("\n"));
493 }
494 }
495 "debug" => {
496 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
497 snippet_json_fixed.insert(0, '[');
498 snippet_json_fixed.push_str("\n]");
499 }
500
501 settings::parse_json_with_comments::<task::DebugTaskFile>(&snippet_json_fixed)?;
502 }
503 "tasks" => {
504 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
505 snippet_json_fixed.insert(0, '[');
506 snippet_json_fixed.push_str("\n]");
507 }
508
509 settings::parse_json_with_comments::<task::TaskTemplates>(&snippet_json_fixed)?;
510 }
511 "icon-theme" => {
512 if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
513 snippet_json_fixed.insert(0, '{');
514 snippet_json_fixed.push_str("\n}");
515 }
516
517 settings::parse_json_with_comments::<theme::IconThemeFamilyContent>(
518 &snippet_json_fixed,
519 )?;
520 }
521 "semantic_token_rules" => {
522 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
523 snippet_json_fixed.insert(0, '[');
524 snippet_json_fixed.push_str("\n]");
525 }
526
527 settings::parse_json_with_comments::<settings::SemanticTokenRules>(
528 &snippet_json_fixed,
529 )?;
530 }
531 label => anyhow::bail!("Unexpected JSON code block tag: {label}"),
532 };
533 Ok(())
534 });
535}
536
537/// Removes any configurable options from the stringified action if existing,
538/// ensuring that only the actual action name is returned. If the action consists
539/// only of a string and nothing else, the string is returned as-is.
540///
541/// Example:
542///
543/// This will return the action name unmodified.
544///
545/// ```
546/// let action_as_str = "workspace::Save";
547/// let action_name = name_for_action(action_as_str);
548/// assert_eq!(action_name, "workspace::Save");
549/// ```
550///
551/// This will return the action name with any trailing options removed.
552///
553///
554/// ```
555/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
556/// let action_name = name_for_action(action_as_str);
557/// assert_eq!(action_name, "editor::ToggleComments");
558/// ```
559fn name_for_action(action_as_str: String) -> String {
560 action_as_str
561 .split(",")
562 .next()
563 .map(|name| name.trim_matches('"').to_string())
564 .unwrap_or(action_as_str)
565}
566
567fn chapter_breadcrumbs(chapter: &Chapter) -> String {
568 let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
569 breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
570 breadcrumbs.push(chapter.name.as_str());
571 format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
572}
573
574fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
575 let content = util::asset_str::<settings::SettingsAssets>(asset_path);
576 KeymapFile::parse(content.as_ref())
577}
578
579fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
580where
581 F: FnMut(&mut Chapter),
582{
583 book.for_each_mut(|item| {
584 let BookItem::Chapter(chapter) = item else {
585 return;
586 };
587 func(chapter);
588 });
589}
590
591#[derive(Debug, serde::Serialize, serde::Deserialize)]
592struct ActionDef {
593 name: String,
594 human_name: String,
595 #[serde(default)]
596 schema: Option<serde_json::Value>,
597 deprecated_aliases: Vec<String>,
598 #[serde(default)]
599 deprecation_message: Option<String>,
600 #[serde(rename = "documentation")]
601 docs: Option<String>,
602}
603
604#[derive(Debug, serde::Deserialize)]
605struct ActionManifest {
606 actions: Vec<ActionDef>,
607 #[serde(default)]
608 schema_definitions: serde_json::Map<String, serde_json::Value>,
609}
610
611fn load_all_actions() -> ActionManifest {
612 let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
613 match std::fs::read_to_string(asset_path) {
614 Ok(content) => {
615 let mut manifest: ActionManifest =
616 serde_json::from_str(&content).expect("Failed to parse actions.json");
617 manifest.actions.sort_by(|a, b| a.name.cmp(&b.name));
618 manifest
619 }
620 Err(err) => {
621 if std::env::var("CI").is_ok() {
622 panic!("actions.json not found at {}: {}", asset_path, err);
623 }
624 eprintln!(
625 "Warning: actions.json not found, action validation will be skipped: {}",
626 err
627 );
628 ActionManifest {
629 actions: Vec::new(),
630 schema_definitions: serde_json::Map::new(),
631 }
632 }
633 }
634}
635
636fn handle_postprocessing() -> Result<()> {
637 let logger = zlog::scoped!("render");
638 let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
639 let output = ctx
640 .config
641 .get_mut("output")
642 .expect("has output")
643 .as_table_mut()
644 .expect("output is table");
645 let zed_html = output.remove("zed-html").expect("zed-html output defined");
646 let default_description = zed_html
647 .get("default-description")
648 .expect("Default description not found")
649 .as_str()
650 .expect("Default description not a string")
651 .to_string();
652 let default_title = zed_html
653 .get("default-title")
654 .expect("Default title not found")
655 .as_str()
656 .expect("Default title not a string")
657 .to_string();
658 let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
659 let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
660 let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
661 let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
662 "<meta name=\"robots\" content=\"noindex, nofollow\">"
663 } else {
664 ""
665 };
666
667 output.insert("html".to_string(), zed_html);
668 mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
669 let ignore_list = ["toc.html"];
670
671 let root_dir = ctx.destination.clone();
672 let mut files = Vec::with_capacity(128);
673 let mut queue = Vec::with_capacity(64);
674 queue.push(root_dir.clone());
675 while let Some(dir) = queue.pop() {
676 for entry in std::fs::read_dir(&dir).context("failed to read docs dir")? {
677 let Ok(entry) = entry else {
678 continue;
679 };
680 let file_type = entry.file_type().context("Failed to determine file type")?;
681 if file_type.is_dir() {
682 queue.push(entry.path());
683 }
684 if file_type.is_file()
685 && matches!(
686 entry.path().extension().and_then(std::ffi::OsStr::to_str),
687 Some("html")
688 )
689 {
690 if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
691 zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
692 } else {
693 files.push(entry.path());
694 }
695 }
696 }
697 }
698
699 zlog::info!(logger => "Processing {} `.html` files", files.len());
700 let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
701 for file in files {
702 let contents = std::fs::read_to_string(&file)?;
703 let mut meta_description = None;
704 let mut meta_title = None;
705 let contents = meta_regex.replace(&contents, |caps: ®ex::Captures| {
706 let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
707 for (kind, content) in metadata {
708 match kind.as_str() {
709 "description" => {
710 meta_description = Some(content);
711 }
712 "title" => {
713 meta_title = Some(content);
714 }
715 _ => {
716 zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
717 }
718 }
719 }
720 String::new()
721 });
722 let meta_description = meta_description.as_ref().unwrap_or_else(|| {
723 zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
724 &default_description
725 });
726 let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
727 let meta_title = meta_title.as_ref().unwrap_or_else(|| {
728 zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
729 &default_title
730 });
731 let meta_title = format!("{} | {}", page_title, meta_title);
732 zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
733 let contents = contents.replace("#description#", meta_description);
734 let contents = contents.replace("#amplitude_key#", &litude_key);
735 let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
736 let contents = contents.replace("#noindex#", noindex);
737 let contents = title_regex()
738 .replace(&contents, |_: ®ex::Captures| {
739 format!("<title>{}</title>", meta_title)
740 })
741 .to_string();
742 // let contents = contents.replace("#title#", &meta_title);
743 std::fs::write(file, contents)?;
744 }
745 return Ok(());
746
747 fn pretty_path<'a>(
748 path: &'a std::path::PathBuf,
749 root: &'a std::path::PathBuf,
750 ) -> &'a std::path::Path {
751 path.strip_prefix(&root).unwrap_or(path)
752 }
753 fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
754 let title_tag_contents = &title_regex()
755 .captures(contents)
756 .with_context(|| format!("Failed to find title in {:?}", pretty_path))
757 .expect("Page has <title> element")[1];
758
759 title_tag_contents
760 .trim()
761 .strip_suffix("- Zed")
762 .unwrap_or(title_tag_contents)
763 .trim()
764 .to_string()
765 }
766}
767
768fn title_regex() -> &'static Regex {
769 static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
770 TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap())
771}
772
773fn generate_big_table_of_actions() -> String {
774 let actions = &ALL_ACTIONS.actions;
775 let mut output = String::new();
776
777 let mut actions_sorted = actions.iter().collect::<Vec<_>>();
778 actions_sorted.sort_by_key(|a| a.name.as_str());
779
780 // Start the definition list with custom styling for better spacing
781 output.push_str("<dl style=\"line-height: 1.8;\">\n");
782
783 for action in actions_sorted.into_iter() {
784 // Add the humanized action name as the term with margin
785 output.push_str(
786 "<dt style=\"margin-top: 1.5em; margin-bottom: 0.5em; font-weight: bold;\"><code>",
787 );
788 output.push_str(&action.human_name);
789 output.push_str("</code></dt>\n");
790
791 // Add the definition with keymap name and description
792 output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
793
794 // Add the description, escaping HTML if needed
795 if let Some(description) = action.docs.as_ref() {
796 output.push_str(
797 &description
798 .replace("&", "&")
799 .replace("<", "<")
800 .replace(">", ">"),
801 );
802 output.push_str("<br>\n");
803 }
804 output.push_str("Keymap Name: <code>");
805 output.push_str(&action.name);
806 output.push_str("</code><br>\n");
807 if !action.deprecated_aliases.is_empty() {
808 output.push_str("Deprecated Alias(es): ");
809 for alias in action.deprecated_aliases.iter() {
810 output.push_str("<code>");
811 output.push_str(alias);
812 output.push_str("</code>, ");
813 }
814 }
815 output.push_str("\n</dd>\n");
816 }
817
818 // Close the definition list
819 output.push_str("</dl>\n");
820
821 output
822}
823
824fn keymap_schema_for_actions(
825 actions: &[ActionDef],
826 schema_definitions: &serde_json::Map<String, serde_json::Value>,
827) -> serde_json::Value {
828 let mut generator = KeymapFile::action_schema_generator();
829
830 for (name, definition) in schema_definitions {
831 generator
832 .definitions_mut()
833 .insert(name.clone(), definition.clone());
834 }
835
836 let mut action_schemas = Vec::new();
837 let mut documentation = collections::HashMap::<&str, &str>::default();
838 let mut deprecations = collections::HashMap::<&str, &str>::default();
839 let mut deprecation_messages = collections::HashMap::<&str, &str>::default();
840
841 for action in actions {
842 let schema = action
843 .schema
844 .as_ref()
845 .and_then(|v| serde_json::from_value::<schemars::Schema>(v.clone()).ok());
846 action_schemas.push((action.name.as_str(), schema));
847 if let Some(doc) = &action.docs {
848 documentation.insert(action.name.as_str(), doc.as_str());
849 }
850 if let Some(msg) = &action.deprecation_message {
851 deprecation_messages.insert(action.name.as_str(), msg.as_str());
852 }
853 for alias in &action.deprecated_aliases {
854 deprecations.insert(alias.as_str(), action.name.as_str());
855 let alias_schema = action
856 .schema
857 .as_ref()
858 .and_then(|v| serde_json::from_value::<schemars::Schema>(v.clone()).ok());
859 action_schemas.push((alias.as_str(), alias_schema));
860 }
861 }
862
863 KeymapFile::generate_json_schema(
864 generator,
865 action_schemas,
866 &documentation,
867 &deprecations,
868 &deprecation_messages,
869 )
870}