1use anyhow::{Context, Result};
2use mdbook::BookItem;
3use mdbook::book::{Book, Chapter};
4use mdbook::preprocess::CmdPreprocessor;
5use regex::Regex;
6use settings::KeymapFile;
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<Vec<ActionDef>> = 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 {
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 .binary_search_by(|action| action.name.as_str().cmp(name))
260 .ok()
261 .map(|index| &ALL_ACTIONS[index])
262}
263
264fn actions_available() -> bool {
265 !ALL_ACTIONS.is_empty()
266}
267
268fn is_missing_action(name: &str) -> bool {
269 actions_available() && find_action_by_name(name).is_none()
270}
271
272fn find_binding(os: &str, action: &str) -> Option<String> {
273 let keymap = match os {
274 "macos" => &KEYMAP_MACOS,
275 "linux" | "freebsd" => &KEYMAP_LINUX,
276 "windows" => &KEYMAP_WINDOWS,
277 _ => unreachable!("Not a valid OS: {}", os),
278 };
279
280 // Find the binding in reverse order, as the last binding takes precedence.
281 keymap.sections().rev().find_map(|section| {
282 section.bindings().rev().find_map(|(keystroke, a)| {
283 if name_for_action(a.to_string()) == action {
284 Some(keystroke.to_string())
285 } else {
286 None
287 }
288 })
289 })
290}
291
292fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
293 fn for_each_labeled_code_block_mut(
294 book: &mut Book,
295 errors: &mut HashSet<PreprocessorError>,
296 f: impl Fn(&str, &str) -> anyhow::Result<()>,
297 ) {
298 const TAGGED_JSON_BLOCK_START: &'static str = "```json [";
299 const JSON_BLOCK_END: &'static str = "```";
300
301 for_each_chapter_mut(book, |chapter| {
302 let mut offset = 0;
303 while let Some(loc) = chapter.content[offset..].find(TAGGED_JSON_BLOCK_START) {
304 let loc = loc + offset;
305 let tag_start = loc + TAGGED_JSON_BLOCK_START.len();
306 offset = tag_start;
307 let Some(tag_end) = chapter.content[tag_start..].find(']') else {
308 errors.insert(PreprocessorError::new_for_invalid_settings_json(
309 chapter,
310 loc,
311 chapter.content[loc..tag_start].to_string(),
312 "Unclosed JSON block tag".to_string(),
313 ));
314 continue;
315 };
316 let tag_end = tag_end + tag_start;
317
318 let tag = &chapter.content[tag_start..tag_end];
319
320 if tag.contains('\n') {
321 errors.insert(PreprocessorError::new_for_invalid_settings_json(
322 chapter,
323 loc,
324 chapter.content[loc..tag_start].to_string(),
325 "Unclosed JSON block tag".to_string(),
326 ));
327 continue;
328 }
329
330 let snippet_start = tag_end + 1;
331 offset = snippet_start;
332
333 let Some(snippet_end) = chapter.content[snippet_start..].find(JSON_BLOCK_END)
334 else {
335 errors.insert(PreprocessorError::new_for_invalid_settings_json(
336 chapter,
337 loc,
338 chapter.content[loc..tag_end + 1].to_string(),
339 "Missing closing code block".to_string(),
340 ));
341 continue;
342 };
343 let snippet_end = snippet_start + snippet_end;
344 let snippet_json = &chapter.content[snippet_start..snippet_end];
345 offset = snippet_end + 3;
346
347 if let Err(err) = f(tag, snippet_json) {
348 errors.insert(PreprocessorError::new_for_invalid_settings_json(
349 chapter,
350 loc,
351 chapter.content[loc..snippet_end + 3].to_string(),
352 err.to_string(),
353 ));
354 continue;
355 };
356 let tag_range_complete = tag_start - 1..tag_end + 1;
357 offset -= tag_range_complete.len();
358 chapter.content.replace_range(tag_range_complete, "");
359 }
360 });
361 }
362
363 for_each_labeled_code_block_mut(book, errors, |label, snippet_json| {
364 let mut snippet_json_fixed = snippet_json
365 .to_string()
366 .replace("\n>", "\n")
367 .trim()
368 .to_string();
369 while snippet_json_fixed.starts_with("//") {
370 if let Some(line_end) = snippet_json_fixed.find('\n') {
371 snippet_json_fixed.replace_range(0..line_end, "");
372 snippet_json_fixed = snippet_json_fixed.trim().to_string();
373 }
374 }
375 match label {
376 "settings" => {
377 if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
378 snippet_json_fixed.insert(0, '{');
379 snippet_json_fixed.push_str("\n}");
380 }
381 settings::parse_json_with_comments::<settings::SettingsContent>(
382 &snippet_json_fixed,
383 )?;
384 }
385 "keymap" => {
386 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
387 snippet_json_fixed.insert(0, '[');
388 snippet_json_fixed.push_str("\n]");
389 }
390
391 let keymap = settings::KeymapFile::parse(&snippet_json_fixed)
392 .context("Failed to parse keymap JSON")?;
393 for section in keymap.sections() {
394 for (_keystrokes, action) in section.bindings() {
395 if let Some((action_name, _)) = settings::KeymapFile::parse_action(action)
396 .map_err(|err| anyhow::format_err!(err))
397 .context("Failed to parse action")?
398 {
399 anyhow::ensure!(
400 !is_missing_action(action_name),
401 "Action not found: {}",
402 action_name
403 );
404 }
405 }
406 }
407 }
408 "debug" => {
409 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
410 snippet_json_fixed.insert(0, '[');
411 snippet_json_fixed.push_str("\n]");
412 }
413
414 settings::parse_json_with_comments::<task::DebugTaskFile>(&snippet_json_fixed)?;
415 }
416 "tasks" => {
417 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
418 snippet_json_fixed.insert(0, '[');
419 snippet_json_fixed.push_str("\n]");
420 }
421
422 settings::parse_json_with_comments::<task::TaskTemplates>(&snippet_json_fixed)?;
423 }
424 "icon-theme" => {
425 if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
426 snippet_json_fixed.insert(0, '{');
427 snippet_json_fixed.push_str("\n}");
428 }
429
430 settings::parse_json_with_comments::<theme::IconThemeFamilyContent>(
431 &snippet_json_fixed,
432 )?;
433 }
434 label => {
435 anyhow::bail!("Unexpected JSON code block tag: {}", label)
436 }
437 };
438 Ok(())
439 });
440}
441
442/// Removes any configurable options from the stringified action if existing,
443/// ensuring that only the actual action name is returned. If the action consists
444/// only of a string and nothing else, the string is returned as-is.
445///
446/// Example:
447///
448/// This will return the action name unmodified.
449///
450/// ```
451/// let action_as_str = "assistant::Assist";
452/// let action_name = name_for_action(action_as_str);
453/// assert_eq!(action_name, "assistant::Assist");
454/// ```
455///
456/// This will return the action name with any trailing options removed.
457///
458///
459/// ```
460/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
461/// let action_name = name_for_action(action_as_str);
462/// assert_eq!(action_name, "editor::ToggleComments");
463/// ```
464fn name_for_action(action_as_str: String) -> String {
465 action_as_str
466 .split(",")
467 .next()
468 .map(|name| name.trim_matches('"').to_string())
469 .unwrap_or(action_as_str)
470}
471
472fn chapter_breadcrumbs(chapter: &Chapter) -> String {
473 let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
474 breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
475 breadcrumbs.push(chapter.name.as_str());
476 format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
477}
478
479fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
480 let content = util::asset_str::<settings::SettingsAssets>(asset_path);
481 KeymapFile::parse(content.as_ref())
482}
483
484fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
485where
486 F: FnMut(&mut Chapter),
487{
488 book.for_each_mut(|item| {
489 let BookItem::Chapter(chapter) = item else {
490 return;
491 };
492 func(chapter);
493 });
494}
495
496#[derive(Debug, serde::Serialize, serde::Deserialize)]
497struct ActionDef {
498 name: String,
499 human_name: String,
500 deprecated_aliases: Vec<String>,
501 #[serde(rename = "documentation")]
502 docs: Option<String>,
503}
504
505fn load_all_actions() -> Vec<ActionDef> {
506 let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
507 match std::fs::read_to_string(asset_path) {
508 Ok(content) => {
509 let mut actions: Vec<ActionDef> =
510 serde_json::from_str(&content).expect("Failed to parse actions.json");
511 actions.sort_by(|a, b| a.name.cmp(&b.name));
512 actions
513 }
514 Err(err) => {
515 if std::env::var("CI").is_ok() {
516 panic!("actions.json not found at {}: {}", asset_path, err);
517 }
518 eprintln!(
519 "Warning: actions.json not found, action validation will be skipped: {}",
520 err
521 );
522 Vec::new()
523 }
524 }
525}
526
527fn handle_postprocessing() -> Result<()> {
528 let logger = zlog::scoped!("render");
529 let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
530 let output = ctx
531 .config
532 .get_mut("output")
533 .expect("has output")
534 .as_table_mut()
535 .expect("output is table");
536 let zed_html = output.remove("zed-html").expect("zed-html output defined");
537 let default_description = zed_html
538 .get("default-description")
539 .expect("Default description not found")
540 .as_str()
541 .expect("Default description not a string")
542 .to_string();
543 let default_title = zed_html
544 .get("default-title")
545 .expect("Default title not found")
546 .as_str()
547 .expect("Default title not a string")
548 .to_string();
549 let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
550
551 output.insert("html".to_string(), zed_html);
552 mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
553 let ignore_list = ["toc.html"];
554
555 let root_dir = ctx.destination.clone();
556 let mut files = Vec::with_capacity(128);
557 let mut queue = Vec::with_capacity(64);
558 queue.push(root_dir.clone());
559 while let Some(dir) = queue.pop() {
560 for entry in std::fs::read_dir(&dir).context("failed to read docs dir")? {
561 let Ok(entry) = entry else {
562 continue;
563 };
564 let file_type = entry.file_type().context("Failed to determine file type")?;
565 if file_type.is_dir() {
566 queue.push(entry.path());
567 }
568 if file_type.is_file()
569 && matches!(
570 entry.path().extension().and_then(std::ffi::OsStr::to_str),
571 Some("html")
572 )
573 {
574 if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
575 zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
576 } else {
577 files.push(entry.path());
578 }
579 }
580 }
581 }
582
583 zlog::info!(logger => "Processing {} `.html` files", files.len());
584 let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
585 for file in files {
586 let contents = std::fs::read_to_string(&file)?;
587 let mut meta_description = None;
588 let mut meta_title = None;
589 let contents = meta_regex.replace(&contents, |caps: ®ex::Captures| {
590 let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
591 for (kind, content) in metadata {
592 match kind.as_str() {
593 "description" => {
594 meta_description = Some(content);
595 }
596 "title" => {
597 meta_title = Some(content);
598 }
599 _ => {
600 zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
601 }
602 }
603 }
604 String::new()
605 });
606 let meta_description = meta_description.as_ref().unwrap_or_else(|| {
607 zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
608 &default_description
609 });
610 let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
611 let meta_title = meta_title.as_ref().unwrap_or_else(|| {
612 zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
613 &default_title
614 });
615 let meta_title = format!("{} | {}", page_title, meta_title);
616 zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
617 let contents = contents.replace("#description#", meta_description);
618 let contents = contents.replace("#amplitude_key#", &litude_key);
619 let contents = title_regex()
620 .replace(&contents, |_: ®ex::Captures| {
621 format!("<title>{}</title>", meta_title)
622 })
623 .to_string();
624 // let contents = contents.replace("#title#", &meta_title);
625 std::fs::write(file, contents)?;
626 }
627 return Ok(());
628
629 fn pretty_path<'a>(
630 path: &'a std::path::PathBuf,
631 root: &'a std::path::PathBuf,
632 ) -> &'a std::path::Path {
633 path.strip_prefix(&root).unwrap_or(path)
634 }
635 fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
636 let title_tag_contents = &title_regex()
637 .captures(contents)
638 .with_context(|| format!("Failed to find title in {:?}", pretty_path))
639 .expect("Page has <title> element")[1];
640
641 title_tag_contents
642 .trim()
643 .strip_suffix("- Zed")
644 .unwrap_or(title_tag_contents)
645 .trim()
646 .to_string()
647 }
648}
649
650fn title_regex() -> &'static Regex {
651 static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
652 TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap())
653}
654
655fn generate_big_table_of_actions() -> String {
656 let actions = &*ALL_ACTIONS;
657 let mut output = String::new();
658
659 let mut actions_sorted = actions.iter().collect::<Vec<_>>();
660 actions_sorted.sort_by_key(|a| a.name.as_str());
661
662 // Start the definition list with custom styling for better spacing
663 output.push_str("<dl style=\"line-height: 1.8;\">\n");
664
665 for action in actions_sorted.into_iter() {
666 // Add the humanized action name as the term with margin
667 output.push_str(
668 "<dt style=\"margin-top: 1.5em; margin-bottom: 0.5em; font-weight: bold;\"><code>",
669 );
670 output.push_str(&action.human_name);
671 output.push_str("</code></dt>\n");
672
673 // Add the definition with keymap name and description
674 output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
675
676 // Add the description, escaping HTML if needed
677 if let Some(description) = action.docs.as_ref() {
678 output.push_str(
679 &description
680 .replace("&", "&")
681 .replace("<", "<")
682 .replace(">", ">"),
683 );
684 output.push_str("<br>\n");
685 }
686 output.push_str("Keymap Name: <code>");
687 output.push_str(&action.name);
688 output.push_str("</code><br>\n");
689 if !action.deprecated_aliases.is_empty() {
690 output.push_str("Deprecated Alias(es): ");
691 for alias in action.deprecated_aliases.iter() {
692 output.push_str("<code>");
693 output.push_str(alias);
694 output.push_str("</code>, ");
695 }
696 }
697 output.push_str("\n</dd>\n");
698 }
699
700 // Close the definition list
701 output.push_str("</dl>\n");
702
703 output
704}