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
198pub fn render_markdown(
208 content: &str,
209 highlighter: &Highlighter,
210 base_path: &str,
211 page_url: &str,
212) -> Result<String> {
213 let arena = Arena::new();
214 let mut options = Options::default();
215 options.extension.table = true;
216 options.extension.strikethrough = true;
217 options.extension.autolink = true;
218 options.render.r#unsafe = true; let root = parse_document(&arena, content, &options);
221
222 for node in root.descendants() {
229 let mut data = node.data.borrow_mut();
230 if let NodeValue::Link(ref mut link) = data.value {
231 if link.url.starts_with('#') {
232 link.url = format!("{page_url}{}", link.url);
235 } else if link.url.starts_with('/')
236 && !link.url.starts_with(base_path)
237 && base_path != "/"
238 {
239 link.url = format!("{}{}", base_path.trim_end_matches('/'), &link.url);
241 }
242 }
243 }
244
245 let headings: Vec<_> = root
247 .descendants()
248 .filter(|node| matches!(node.data.borrow().value, NodeValue::Heading(_)))
249 .collect();
250
251 for node in headings {
252 let (level, plain_text, html_text) = {
253 let data = node.data.borrow();
254 if let NodeValue::Heading(ref h) = data.value {
255 let (plain, html) = collect_node_text(node);
256 (h.level, plain, html)
257 } else {
258 continue;
259 }
260 };
261 let slug = slugify(&plain_text);
262 let html = format!(
263 "<h{level} id=\"{slug}\">{html_text} <a class=\"header-anchor\" href=\"{page_url}#{slug}\">#</a></h{level}>"
264 );
265 while let Some(child) = node.first_child() {
270 child.detach();
271 }
272 node.data.borrow_mut().value = NodeValue::HtmlBlock(NodeHtmlBlock {
273 block_type: 6,
274 literal: html,
275 });
276 }
277
278 for node in root.descendants() {
281 let mut data = node.data.borrow_mut();
282 match &mut data.value {
283 NodeValue::CodeBlock(ref mut block) => {
284 let lang = if block.info.is_empty() {
285 "text"
286 } else {
287 block.info.split_whitespace().next().unwrap_or("text")
288 };
289 let highlighted = highlighter.highlight_code(&block.literal, lang);
290 data.value = NodeValue::HtmlBlock(NodeHtmlBlock {
293 block_type: 6,
294 literal: highlighted,
295 });
296 }
297 NodeValue::Code(ref code) => {
298 let mut html = String::with_capacity(code.literal.len() + 13);
301 html.push_str("<code>");
302 emit_escaped(&mut html, &code.literal);
303 html.push_str("</code>");
304 data.value = NodeValue::HtmlInline(html);
305 }
306 _ => {}
307 }
308 }
309
310 let mut html = String::with_capacity(content.len());
311 comrak::format_html(root, &options, &mut html)
312 .map_err(|e| crate::error::Error::Markdown(e.to_string()))?;
313
314 Ok(html)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 #[test]
321 fn syntect_language_mapping() {
322 let ss = SyntaxSet::load_defaults_newlines();
323 for tok in &[
325 "js", "go", "python", "py", "cpp", "rust", "rs", "html", "css", "json", "bash", "yaml",
326 "cs",
327 ] {
328 assert!(
329 ss.find_syntax_by_token(tok).is_some(),
330 "syntect should support token '{tok}'"
331 );
332 }
333 for tok in &["ts", "typescript", "tsx", "jsx", "toml", "csharp", "golang"] {
335 assert!(
336 ss.find_syntax_by_token(tok).is_none(),
337 "'{tok}' should NOT be in syntect defaults (we alias it)"
338 );
339 }
340 }
341
342 #[test]
343 fn inline_code_escapes_template_braces() {
344 let h = Highlighter::new();
348 let html = render_markdown("Use `{{value}}` for escaped output.", &h, "/", "/").unwrap();
349 assert!(
350 html.contains("{{value}}"),
351 "inline code braces should be escaped: {html}"
352 );
353 assert!(
354 !html.contains("{{value}}"),
355 "raw `{{{{value}}}}` must not survive: {html}"
356 );
357 }
358
359 #[test]
360 fn heading_inline_code_survives_custom_anchor_rendering() {
361 let h = Highlighter::new();
362 let html = match render_markdown("# `<for>` Loop Directive\n", &h, "/", "/") {
363 Ok(html) => html,
364 Err(e) => panic!("render_markdown should succeed: {e}"),
365 };
366
367 assert!(
368 html.contains(
369 r##"<h1 id="for-loop-directive"><code><for></code> Loop Directive"##
370 ),
371 "heading inline code should render as code: {html}"
372 );
373 }
374
375 #[test]
376 fn in_page_anchors_are_absolute_to_the_page() {
377 let h = Highlighter::new();
382 let html = render_markdown(
383 "# Hello\n\nSee [below](#hello).\n",
384 &h,
385 "/webui/",
386 "/webui/guide/",
387 )
388 .unwrap();
389
390 assert!(
391 html.contains(r##"href="/webui/guide/#hello""##),
392 "heading anchor should be absolute to the page: {html}"
393 );
394 assert!(
395 html.contains(r##"<a href="/webui/guide/#hello">below</a>"##),
396 "in-content fragment link should be absolute to the page: {html}"
397 );
398 }
399
400 #[test]
401 fn code_block_escapes_template_braces() {
402 let h = Highlighter::new();
405 let html = render_markdown("```html\n<p>{{name}}</p>\n```\n", &h, "/", "/").unwrap();
406 assert!(
407 html.contains("{{name}}"),
408 "code block braces should be escaped: {html}"
409 );
410 }
411}