1use comrak::nodes::{NodeHtmlBlock, NodeValue};
8use comrak::{parse_document, Arena, Options};
9use syntect::parsing::SyntaxSet;
10use syntect::util::LinesWithEndings;
11
12use crate::error::Result;
13
14pub 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 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 fn highlight_code(&self, code: &str, lang: &str) -> String {
47 use syntect::easy::HighlightLines;
48
49 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", 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 ®ions {
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 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'&' => "&",
100 b'<' => "<",
101 b'>' => ">",
102 b'{' => "{",
103 b'}' => "}",
104 _ => continue,
105 };
106 buf.push_str(&s[start..i]);
108 buf.push_str(esc);
109 start = i + 1;
110 }
111 buf.push_str(&s[start..]);
112}
113
114fn 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 0xa71d5d => "keyword",
122 0x183691 => "string",
124 0x969896 => "comment",
126 0xed6a43 => "number",
128 0x63a35c => "tag",
130 0x795da3 => "attr",
132 0x0086b3 => "property",
134 0x6f42c1 => "function",
136 0x333333 => "variable",
138 _ => "",
139 }
140}
141
142fn 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
155fn 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 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 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
283pub 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; let root = parse_document(&arena, content, &options);
311 let base_prefix = base_path.trim_end_matches('/');
312
313 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 link.url = format!("{page_url}{}", link.url);
328 } else if link.url.starts_with('?') {
329 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 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 link.url = resolved;
343 }
344 }
345 }
346
347 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 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 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 data.value = NodeValue::HtmlBlock(NodeHtmlBlock {
396 block_type: 6,
397 literal: highlighted,
398 });
399 }
400 NodeValue::Code(ref code) => {
401 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 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 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 let h = Highlighter::new();
451 let html =
452 render_markdown("Use `{{value}}` for escaped output.", &h, "/", "/", "/").unwrap();
453 assert!(
454 html.contains("{{value}}"),
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><for></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 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 "",
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 let h = Highlighter::new();
656 let html = render_markdown("```html\n<p>{{name}}</p>\n```\n", &h, "/", "/", "/").unwrap();
657 assert!(
658 html.contains("{{name}}"),
659 "code block braces should be escaped: {html}"
660 );
661 }
662}