Skip to main content

webui_docs/
markdown.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Markdown → HTML conversion with syntax highlighting.
5//! Uses comrak for GFM markdown and syntect for dual-theme code highlighting.
6
7use comrak::nodes::{NodeHtmlBlock, NodeValue};
8use comrak::{parse_document, Arena, Options};
9use syntect::parsing::SyntaxSet;
10use syntect::util::LinesWithEndings;
11
12use crate::error::Result;
13
14/// Highlighter with pre-loaded syntax definitions and theme.
15/// Outputs semantic CSS classes (`hl-keyword`, `hl-string`, etc.)
16/// instead of inline styles — colors are controlled via design tokens.
17pub struct Highlighter {
18    ss: SyntaxSet,
19    theme: syntect::highlighting::Theme,
20}
21
22impl Default for Highlighter {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl Highlighter {
29    pub fn new() -> Self {
30        use syntect::highlighting::ThemeSet;
31        let mut ts = ThemeSet::load_defaults();
32        // Move out of the map so we own it (avoids per-call clone).
33        let theme = ts
34            .themes
35            .remove("InspiredGitHub")
36            .unwrap_or_else(|| ts.themes.into_values().next().unwrap_or_default());
37        Self {
38            ss: SyntaxSet::load_defaults_newlines(),
39            theme,
40        }
41    }
42
43    /// Highlight a code block using semantic CSS classes.
44    /// Colors are controlled via CSS custom properties for dual light/dark
45    /// theme support (GitHub Light / GitHub Dark style).
46    fn highlight_code(&self, code: &str, lang: &str) -> String {
47        use syntect::easy::HighlightLines;
48
49        // Map language aliases — syntect's defaults lack TypeScript, TOML, etc.
50        let mapped_lang = match lang {
51            "ts" | "typescript" | "tsx" | "jsx" => "js",
52            "sh" | "shell" | "zsh" => "bash",
53            "yml" => "yaml",
54            "golang" => "go",
55            "csharp" => "cs",
56            "toml" => "ini", // closest available
57            other => other,
58        };
59
60        let syntax = self
61            .ss
62            .find_syntax_by_token(mapped_lang)
63            .unwrap_or_else(|| self.ss.find_syntax_plain_text());
64
65        let mut hl = HighlightLines::new(syntax, &self.theme);
66
67        let mut out = String::with_capacity(code.len() * 2);
68        out.push_str("<code-block><pre class=\"code-block\"><code>");
69
70        for line in LinesWithEndings::from(code) {
71            let regions = hl.highlight_line(line, &self.ss).unwrap_or_default();
72            for &(style, text) in &regions {
73                let cls = scope_to_css_class(style.foreground);
74                if cls.is_empty() {
75                    emit_escaped(&mut out, text);
76                } else {
77                    out.push_str("<span class=\"hl-");
78                    out.push_str(cls);
79                    out.push_str("\">");
80                    emit_escaped(&mut out, text);
81                    out.push_str("</span>");
82                }
83            }
84        }
85
86        out.push_str("</code></pre></code-block>");
87        out
88    }
89}
90
91fn emit_escaped(buf: &mut String, s: &str) {
92    // Byte-scan: find the next char needing escape, bulk-copy the rest.
93    // `{` and `}` are escaped so `{{...}}` inside code spans/blocks is not
94    // interpreted as a WebUI signal binding by the template parser.
95    let bytes = s.as_bytes();
96    let mut start = 0;
97    for (i, &b) in bytes.iter().enumerate() {
98        let esc = match b {
99            b'&' => "&amp;",
100            b'<' => "&lt;",
101            b'>' => "&gt;",
102            b'{' => "&#123;",
103            b'}' => "&#125;",
104            _ => continue,
105        };
106        // SAFETY: all matched bytes are ASCII so byte indices are valid char boundaries.
107        buf.push_str(&s[start..i]);
108        buf.push_str(esc);
109        start = i + 1;
110    }
111    buf.push_str(&s[start..]);
112}
113
114/// Map a highlight color from the InspiredGitHub theme to a semantic CSS class.
115/// The actual display colors come from CSS custom properties — this mapping
116/// just classifies tokens into semantic groups for theming.
117fn scope_to_css_class(fg: syntect::highlighting::Color) -> &'static str {
118    let hex = ((fg.r as u32) << 16) | ((fg.g as u32) << 8) | (fg.b as u32);
119    match hex {
120        // Keywords (if, else, return, import, export, const, let, fn, pub, use, etc.)
121        0xa71d5d => "keyword",
122        // Strings
123        0x183691 => "string",
124        // Comments
125        0x969896 => "comment",
126        // Numbers
127        0xed6a43 => "number",
128        // Tags (HTML)
129        0x63a35c => "tag",
130        // Attributes / decorators
131        0x795da3 => "attr",
132        // Properties / built-ins
133        0x0086b3 => "property",
134        // Functions
135        0x6f42c1 => "function",
136        // Types (in some themes)
137        0x333333 => "variable",
138        _ => "",
139    }
140}
141
142/// Convert heading text to a URL-friendly slug.
143fn slugify(text: &str) -> String {
144    let mut slug = String::with_capacity(text.len());
145    for ch in text.chars() {
146        if ch.is_ascii_alphanumeric() {
147            slug.push(ch.to_ascii_lowercase());
148        } else if (ch == ' ' || ch == '-' || ch == '_') && !slug.ends_with('-') {
149            slug.push('-');
150        }
151    }
152    slug.trim_end_matches('-').to_string()
153}
154
155/// Collect text from a comrak node's children (iterative).
156/// Returns `(plain_text, html_text)` — plain for slugs, HTML for display.
157fn collect_node_text<'a>(
158    node: &'a comrak::arena_tree::Node<'a, std::cell::RefCell<comrak::nodes::Ast>>,
159) -> (String, String) {
160    let mut plain = String::new();
161    let mut html = String::new();
162    let mut stack: Vec<&comrak::arena_tree::Node<'a, std::cell::RefCell<comrak::nodes::Ast>>> =
163        Vec::new();
164    // Push children in reverse for left-to-right traversal
165    let mut child = node.last_child();
166    while let Some(c) = child {
167        stack.push(c);
168        child = c.previous_sibling();
169    }
170    while let Some(n) = stack.pop() {
171        let data = n.data.borrow();
172        match &data.value {
173            NodeValue::Text(t) => {
174                plain.push_str(t);
175                html.push_str(t);
176            }
177            NodeValue::Code(code) => {
178                plain.push_str(&code.literal);
179                html.push_str("<code>");
180                // Escape HTML entities and template braces inside code spans
181                // so `{{...}}` is not interpreted as a WebUI signal binding.
182                emit_escaped(&mut html, &code.literal);
183                html.push_str("</code>");
184            }
185            _ => {
186                drop(data);
187                let mut c = n.last_child();
188                while let Some(ch) = c {
189                    stack.push(ch);
190                    c = ch.previous_sibling();
191                }
192            }
193        }
194    }
195    (plain, html)
196}
197
198fn has_uri_scheme(url: &str) -> bool {
199    let mut bytes = url.bytes();
200    let Some(first) = bytes.next() else {
201        return false;
202    };
203    if !first.is_ascii_alphabetic() {
204        return false;
205    }
206
207    for byte in bytes {
208        match byte {
209            b':' => return true,
210            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'-' | b'.' => {}
211            _ => return false,
212        }
213    }
214    false
215}
216
217fn has_base_path_prefix(url: &str, base_prefix: &str) -> bool {
218    if base_prefix.is_empty() {
219        return true;
220    }
221    let Some(suffix) = url.strip_prefix(base_prefix) else {
222        return false;
223    };
224    suffix.is_empty()
225        || suffix
226            .as_bytes()
227            .first()
228            .is_some_and(|byte| matches!(*byte, b'/' | b'?' | b'#'))
229}
230
231fn resolve_relative_link(base_path: &str, link_base_url: &str, target: &str) -> Option<String> {
232    if target.is_empty()
233        || target.starts_with('/')
234        || target.starts_with('#')
235        || target.starts_with('?')
236        || has_uri_scheme(target)
237    {
238        return None;
239    }
240
241    let suffix_start = target.find(['?', '#']).unwrap_or(target.len());
242    let path = &target[..suffix_start];
243    if path.is_empty() {
244        return None;
245    }
246
247    let trailing_slash = path.ends_with('/') || path == "." || path == "..";
248    let segment_capacity = link_base_url.bytes().filter(|byte| *byte == b'/').count()
249        + path.bytes().filter(|byte| *byte == b'/').count()
250        + 1;
251    let base_segment_count = base_path
252        .split('/')
253        .filter(|segment| !segment.is_empty())
254        .count();
255    let mut segments = Vec::with_capacity(segment_capacity);
256    for segment in link_base_url.split('/').chain(path.split('/')) {
257        match segment {
258            "" | "." => {}
259            ".." => {
260                if segments.len() > base_segment_count {
261                    segments.pop();
262                }
263            }
264            _ => segments.push(segment),
265        }
266    }
267
268    let mut resolved = String::with_capacity(link_base_url.len() + target.len() + 1);
269    resolved.push('/');
270    for (index, segment) in segments.iter().enumerate() {
271        if index > 0 {
272            resolved.push('/');
273        }
274        resolved.push_str(segment);
275    }
276    if trailing_slash && !resolved.ends_with('/') {
277        resolved.push('/');
278    }
279    resolved.push_str(&target[suffix_start..]);
280    Some(resolved)
281}
282
283/// Render markdown content to HTML with syntax-highlighted code blocks.
284/// Root-absolute internal links are prefixed with `base_path`, and relative
285/// links are resolved from `link_base_url`.
286///
287/// `page_url` is the current page's canonical URL path (base-prefixed, with a
288/// trailing slash — e.g. `/webui/guide/`). In-page fragment links (heading
289/// anchors and `[text](#section)` links) are emitted as absolute paths
290/// (`{page_url}#slug`) rather than bare `#slug`. A bare fragment would resolve
291/// against the document's `<base href>` — which points at the site root — and
292/// navigate away from the current page instead of scrolling within it.
293///
294/// `link_base_url` is the URL directory corresponding to the Markdown source
295/// file. It can differ from `page_url` when configuration overrides the route.
296pub fn render_markdown(
297    content: &str,
298    highlighter: &Highlighter,
299    base_path: &str,
300    page_url: &str,
301    link_base_url: &str,
302) -> Result<String> {
303    let arena = Arena::new();
304    let mut options = Options::default();
305    options.extension.table = true;
306    options.extension.strikethrough = true;
307    options.extension.autolink = true;
308    options.render.r#unsafe = true; // Allow raw HTML passthrough
309
310    let root = parse_document(&arena, content, &options);
311    let base_prefix = base_path.trim_end_matches('/');
312
313    // Multi-pass approach: collect node pointers first, then modify.
314    // This avoids modifying the tree during iteration and lets heading
315    // rendering see original code-span nodes before they are converted to raw
316    // HTML for WebUI template-signal escaping.
317
318    // Pass 1: Rewrite internal links and images before custom heading rendering.
319    for node in root.descendants() {
320        let mut data = node.data.borrow_mut();
321        if let NodeValue::Link(ref mut link) | NodeValue::Image(ref mut link) = data.value {
322            if link.url.is_empty() {
323                link.url = page_url.to_string();
324            } else if link.url.starts_with('#') {
325                // In-page fragment link. Make it absolute to the current page so
326                // it isn't resolved against `<base href>` (the site root).
327                link.url = format!("{page_url}{}", link.url);
328            } else if link.url.starts_with('?') {
329                // Query-only links also target the current page, not the site root.
330                link.url = format!("{page_url}{}", link.url);
331            } else if link.url.starts_with('/')
332                && !link.url.starts_with("//")
333                && !has_base_path_prefix(&link.url, base_prefix)
334            {
335                // Prepend base_path to absolute internal links
336                link.url = format!("{base_prefix}{}", link.url);
337            } else if let Some(resolved) =
338                resolve_relative_link(base_path, link_base_url, &link.url)
339            {
340                // The document-level `<base href>` points at the site root, so
341                // relative Markdown links must be resolved during the build.
342                link.url = resolved;
343            }
344        }
345    }
346
347    // Pass 2: Collect heading nodes, then replace them
348    let headings: Vec<_> = root
349        .descendants()
350        .filter(|node| matches!(node.data.borrow().value, NodeValue::Heading(_)))
351        .collect();
352
353    for node in headings {
354        let (level, plain_text, html_text) = {
355            let data = node.data.borrow();
356            if let NodeValue::Heading(ref h) = data.value {
357                let (plain, html) = collect_node_text(node);
358                (h.level, plain, html)
359            } else {
360                continue;
361            }
362        };
363        let slug = slugify(&plain_text);
364        let anchor_label = html_escape::encode_double_quoted_attribute(&plain_text);
365        let html = format!(
366            "<h{level} id=\"{slug}\">{html_text} <a class=\"header-anchor\" href=\"{page_url}#{slug}\" aria-label=\"Link to {anchor_label}\">#</a></h{level}>"
367        );
368        // Detach children first, then replace value. The heading is a
369        // block-level node, so its raw-HTML replacement must be an
370        // `HtmlBlock` (not `HtmlInline`) — comrak 0.53 validates that block
371        // containers only hold block-level children.
372        while let Some(child) = node.first_child() {
373            child.detach();
374        }
375        node.data.borrow_mut().value = NodeValue::HtmlBlock(NodeHtmlBlock {
376            block_type: 6,
377            literal: html,
378        });
379    }
380
381    // Pass 3: Replace code blocks and inline code after heading extraction,
382    // so headings can still collect code-span literals for display and slugs.
383    for node in root.descendants() {
384        let mut data = node.data.borrow_mut();
385        match &mut data.value {
386            NodeValue::CodeBlock(ref mut block) => {
387                let lang = if block.info.is_empty() {
388                    "text"
389                } else {
390                    block.info.split_whitespace().next().unwrap_or("text")
391                };
392                let highlighted = highlighter.highlight_code(&block.literal, lang);
393                // A code block is block-level: replace with an `HtmlBlock` so
394                // comrak 0.53's AST validation accepts it as a block child.
395                data.value = NodeValue::HtmlBlock(NodeHtmlBlock {
396                    block_type: 6,
397                    literal: highlighted,
398                });
399            }
400            NodeValue::Code(ref code) => {
401                // Replace inline code with pre-escaped HTML so `{{...}}` inside
402                // code spans is not interpreted as a WebUI signal binding.
403                let mut html = String::with_capacity(code.literal.len() + 13);
404                html.push_str("<code>");
405                emit_escaped(&mut html, &code.literal);
406                html.push_str("</code>");
407                data.value = NodeValue::HtmlInline(html);
408            }
409            _ => {}
410        }
411    }
412
413    let mut html = String::with_capacity(content.len());
414    comrak::format_html(root, &options, &mut html)
415        .map_err(|e| crate::error::Error::Markdown(e.to_string()))?;
416
417    Ok(html)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    #[test]
424    fn syntect_language_mapping() {
425        let ss = SyntaxSet::load_defaults_newlines();
426        // Languages we need for the docs site
427        for tok in &[
428            "js", "go", "python", "py", "cpp", "rust", "rs", "html", "css", "json", "bash", "yaml",
429            "cs",
430        ] {
431            assert!(
432                ss.find_syntax_by_token(tok).is_some(),
433                "syntect should support token '{tok}'"
434            );
435        }
436        // These are NOT in syntect defaults — we alias them
437        for tok in &["ts", "typescript", "tsx", "jsx", "toml", "csharp", "golang"] {
438            assert!(
439                ss.find_syntax_by_token(tok).is_none(),
440                "'{tok}' should NOT be in syntect defaults (we alias it)"
441            );
442        }
443    }
444
445    #[test]
446    fn inline_code_escapes_template_braces() {
447        // `{{value}}` inside inline code (e.g. in markdown tables or prose)
448        // must not be interpreted as a WebUI signal binding by the template
449        // parser — escape `{` and `}` to HTML entities.
450        let h = Highlighter::new();
451        let html =
452            render_markdown("Use `{{value}}` for escaped output.", &h, "/", "/", "/").unwrap();
453        assert!(
454            html.contains("&#123;&#123;value&#125;&#125;"),
455            "inline code braces should be escaped: {html}"
456        );
457        assert!(
458            !html.contains("{{value}}"),
459            "raw `{{{{value}}}}` must not survive: {html}"
460        );
461    }
462
463    #[test]
464    fn heading_inline_code_survives_custom_anchor_rendering() {
465        let h = Highlighter::new();
466        let html = match render_markdown("# `<for>` Loop Directive\n", &h, "/", "/", "/") {
467            Ok(html) => html,
468            Err(e) => panic!("render_markdown should succeed: {e}"),
469        };
470
471        assert!(
472            html.contains(
473                r##"<h1 id="for-loop-directive"><code>&lt;for&gt;</code> Loop Directive"##
474            ),
475            "heading inline code should render as code: {html}"
476        );
477    }
478
479    #[test]
480    fn in_page_anchors_are_absolute_to_the_page() {
481        // With a `<base href>` set on every page, bare `#slug` fragment links
482        // resolve against the site root and navigate away from the page.
483        // Heading anchors and `[text](#section)` links must be emitted as
484        // absolute paths to the current page.
485        let h = Highlighter::new();
486        let html = render_markdown(
487            "# Hello\n\nSee [below](#hello).\n",
488            &h,
489            "/webui/",
490            "/webui/guide/",
491            "/webui/guide/",
492        )
493        .unwrap();
494
495        assert!(
496            html.contains(r##"href="/webui/guide/#hello""##),
497            "heading anchor should be absolute to the page: {html}"
498        );
499        assert!(
500            html.contains(r##"aria-label="Link to Hello""##),
501            "heading anchor should have a descriptive accessible name: {html}"
502        );
503        assert!(
504            html.contains(r##"<a href="/webui/guide/#hello">below</a>"##),
505            "in-content fragment link should be absolute to the page: {html}"
506        );
507    }
508
509    #[test]
510    fn relative_links_are_absolute_to_the_markdown_source_directory() {
511        let h = Highlighter::new();
512        let html = render_markdown(
513            "[Sibling](./sibling) [Parent](../parent) [Bare](other?tab=1#details)",
514            &h,
515            "/webui/",
516            "/webui/guide/current/",
517            "/webui/guide/",
518        )
519        .unwrap();
520
521        assert!(
522            html.contains(r#"<a href="/webui/guide/sibling">Sibling</a>"#),
523            "dot-relative link should use the source directory: {html}"
524        );
525        assert!(
526            html.contains(r#"<a href="/webui/parent">Parent</a>"#),
527            "parent-relative link should normalize dot segments: {html}"
528        );
529        assert!(
530            html.contains(r#"<a href="/webui/guide/other?tab=1#details">Bare</a>"#),
531            "bare relative link should preserve its query and fragment: {html}"
532        );
533    }
534
535    #[test]
536    fn relative_images_are_absolute_to_the_markdown_source_directory() {
537        let h = Highlighter::new();
538        let html = render_markdown(
539            "![Diagram](./images/diagram.png)",
540            &h,
541            "/webui/",
542            "/webui/guide/current/",
543            "/webui/guide/",
544        )
545        .unwrap();
546
547        assert!(
548            html.contains(r#"<img src="/webui/guide/images/diagram.png" alt="Diagram" />"#),
549            "relative image should use the source directory: {html}"
550        );
551    }
552
553    #[test]
554    fn relative_links_cannot_escape_the_base_path() {
555        let h = Highlighter::new();
556        let html = match render_markdown(
557            "[Clamped](../../x)",
558            &h,
559            "/webui/",
560            "/webui/guide/",
561            "/webui/guide/",
562        ) {
563            Ok(html) => html,
564            Err(e) => panic!("render_markdown should succeed: {e}"),
565        };
566
567        assert!(
568            html.contains(r#"<a href="/webui/x">Clamped</a>"#),
569            "parent segments should stop at the base path: {html}"
570        );
571    }
572
573    #[test]
574    fn empty_link_destination_targets_the_current_page() {
575        let h = Highlighter::new();
576        let html = match render_markdown(
577            "[]()",
578            &h,
579            "/webui/",
580            "/webui/guide/current/",
581            "/webui/guide/",
582        ) {
583            Ok(html) => html,
584            Err(e) => panic!("render_markdown should succeed: {e}"),
585        };
586
587        assert!(
588            html.contains(r#"<a href="/webui/guide/current/"></a>"#),
589            "empty destination should target the current page: {html}"
590        );
591    }
592
593    #[test]
594    fn absolute_internal_links_respect_base_path_boundaries() {
595        let h = Highlighter::new();
596        for base_path in ["/webui/", "/webui"] {
597            let html = render_markdown(
598                "[Root](/webui) [Nested](/webui/guide) [Query](/webui?tab=1) [Sibling](/webui-press)",
599                &h,
600                base_path,
601                "/webui/guide/current/",
602                "/webui/guide/",
603            )
604            .unwrap();
605
606            assert!(
607                html.contains(r#"<a href="/webui">Root</a>"#),
608                "base root should not be double-prefixed: {html}"
609            );
610            assert!(
611                html.contains(r#"<a href="/webui/guide">Nested</a>"#),
612                "nested base path should not be double-prefixed: {html}"
613            );
614            assert!(
615                html.contains(r#"<a href="/webui?tab=1">Query</a>"#),
616                "query on the base root should not be double-prefixed: {html}"
617            );
618            assert!(
619                html.contains(r#"<a href="/webui/webui-press">Sibling</a>"#),
620                "a similarly named path should still receive basePath: {html}"
621            );
622        }
623    }
624
625    #[test]
626    fn non_relative_links_keep_their_existing_semantics() {
627        let h = Highlighter::new();
628        let html = render_markdown(
629            "[External](https://example.com/docs) [Network](//cdn.example.com/docs) [Query](?tab=1)",
630            &h,
631            "/webui/",
632            "/webui/guide/current/",
633            "/webui/guide/",
634        )
635        .unwrap();
636
637        assert!(
638            html.contains(r#"<a href="https://example.com/docs">External</a>"#),
639            "absolute external link should remain unchanged: {html}"
640        );
641        assert!(
642            html.contains(r#"<a href="//cdn.example.com/docs">Network</a>"#),
643            "protocol-relative link should remain unchanged: {html}"
644        );
645        assert!(
646            html.contains(r#"<a href="/webui/guide/current/?tab=1">Query</a>"#),
647            "query-only link should target the current page: {html}"
648        );
649    }
650
651    #[test]
652    fn code_block_escapes_template_braces() {
653        // Fenced code blocks must also escape braces so example template
654        // snippets render literally instead of being parsed as bindings.
655        let h = Highlighter::new();
656        let html = render_markdown("```html\n<p>{{name}}</p>\n```\n", &h, "/", "/", "/").unwrap();
657        assert!(
658            html.contains("&#123;&#123;name&#125;&#125;"),
659            "code block braces should be escaped: {html}"
660        );
661    }
662}