fabrica

hanna/fabrica

16407 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Markdown rendering for READMEs and bios.
6//!
7//! `comrak` with the GFM extensions renders the source; a
8//! [`SyntaxHighlighterAdapter`] runs fenced code blocks through the `highlight`
9//! crate so they get the same `hl-*` theme classes as the blob viewer; then
10//! `ammonia` sanitizes the result, stripping raw HTML and scripting while
11//! permitting only our highlight classes, so a README can never inject markup.
12
13use std::borrow::Cow;
14use std::collections::HashMap;
15use std::fmt::{self, Write as _};
16use std::path::Path as FsPath;
17
18use comrak::adapters::SyntaxHighlighterAdapter;
19use maud::{Markup, PreEscaped};
20
21/// Render `source` markdown to sanitized, syntax-highlighted HTML.
22#[must_use]
23pub fn render(source: &str) -> Markup {
24 PreEscaped(render_html(source, None))
25}
26
27/// Like [`render`], but also linkifies `#123` issue/PR references against
28/// `repo_base` (e.g. `/owner/repo`). Used for issue/PR descriptions and comments.
29#[must_use]
30pub fn render_with_refs(source: &str, repo_base: &str) -> Markup {
31 PreEscaped(linkify_refs(&render_html(source, None), repo_base))
32}
33
34/// Render a README or in-repo markdown file, rewriting **relative** links and
35/// images so they point into the repository at `ref_name`. `base` is
36/// `/owner/repo`; `dir` is the directory the document lives in (empty for the
37/// repo root), used to resolve `./` and `../` paths. Also linkifies `#123`
38/// references. Absolute URLs, `//host`, `mailto:`, and `#anchor` are left alone.
39#[must_use]
40pub fn render_repo(source: &str, base: &str, ref_name: &str, dir: &str) -> Markup {
41 let repo = RepoUrls {
42 base,
43 ref_name,
44 dir,
45 };
46 PreEscaped(linkify_refs(&render_html(source, Some(&repo)), base))
47}
48
49/// The context for rewriting a repository document's relative URLs.
50struct RepoUrls<'a> {
51 base: &'a str,
52 ref_name: &'a str,
53 dir: &'a str,
54}
55
56impl RepoUrls<'_> {
57 /// Rewrite `url` to point into the repo, or `None` to leave it unchanged.
58 /// Links target the blob view; images target the raw view.
59 fn resolve(&self, url: &str, image: bool) -> Option<String> {
60 let url = url.trim();
61 if url.is_empty()
62 || url.starts_with('#')
63 || url.starts_with("//")
64 || url.starts_with("mailto:")
65 {
66 return None;
67 }
68 // A scheme (`https:`, `data:`, …) marks an absolute URL — leave it be.
69 if let Some(idx) = url.find(':')
70 && url[..idx]
71 .chars()
72 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
73 && !url[..idx].is_empty()
74 {
75 return None;
76 }
77 // Split off a trailing `?query` / `#fragment` to reattach after resolving.
78 let (path, suffix) = url
79 .find(['?', '#'])
80 .map_or((url, ""), |i| (&url[..i], &url[i..]));
81 let joined = if let Some(root_rel) = path.strip_prefix('/') {
82 root_rel.to_string()
83 } else if self.dir.is_empty() {
84 path.to_string()
85 } else {
86 format!("{}/{path}", self.dir)
87 };
88 let resolved = normalize_path(&joined);
89 let view = if image { "raw" } else { "blob" };
90 Some(format!(
91 "{}/-/{view}/{}/{resolved}{suffix}",
92 self.base, self.ref_name
93 ))
94 }
95}
96
97/// Resolve `.`/`..`/empty segments in a slash path (no leading slash on output).
98fn normalize_path(path: &str) -> String {
99 let mut out: Vec<&str> = Vec::new();
100 for seg in path.split('/') {
101 match seg {
102 "" | "." => {}
103 ".." => {
104 out.pop();
105 }
106 other => out.push(other),
107 }
108 }
109 out.join("/")
110}
111
112/// Rewrite every relative link and image URL in the AST subtree.
113fn rewrite_urls<'a>(node: &'a comrak::nodes::AstNode<'a>, repo: &RepoUrls) {
114 for child in node.children() {
115 rewrite_urls(child, repo);
116 }
117 let mut data = node.data.borrow_mut();
118 match &mut data.value {
119 comrak::nodes::NodeValue::Link(link) => {
120 if let Some(url) = repo.resolve(&link.url, false) {
121 link.url = url;
122 }
123 }
124 comrak::nodes::NodeValue::Image(link) => {
125 if let Some(url) = repo.resolve(&link.url, true) {
126 link.url = url;
127 }
128 }
129 _ => {}
130 }
131}
132
133/// The shared comrak + ammonia pipeline, producing sanitized HTML. When `repo`
134/// is set, relative links/images are rewritten into the repository first.
135fn render_html(source: &str, repo: Option<&RepoUrls>) -> String {
136 let mut options = comrak::Options::default();
137 options.extension.table = true;
138 options.extension.strikethrough = true;
139 options.extension.tasklist = true;
140 options.extension.autolink = true;
141 options.extension.footnotes = true;
142
143 let adapter = Highlighter;
144 let mut plugins = comrak::options::Plugins::default();
145 plugins.render.codefence_syntax_highlighter = Some(&adapter);
146
147 let unsafe_html = match repo {
148 None => comrak::markdown_to_html_with_plugins(source, &options, &plugins),
149 Some(repo) => {
150 let arena = comrak::Arena::new();
151 let root = comrak::parse_document(&arena, source, &options);
152 rewrite_urls(root, repo);
153 let mut out = String::new();
154 let _ = comrak::format_html_with_plugins(root, &options, &mut out, &plugins);
155 out
156 }
157 };
158 sanitizer().clean(&unsafe_html).to_string()
159}
160
161/// Turn `#123` into a link to the issue/PR, but only in ordinary text — never
162/// inside a tag, an attribute, or a `<code>`/`<pre>` block. Runs on already
163/// sanitized HTML, so the injected anchors are trusted. The link targets the
164/// issues path; the issue view redirects to the PR view when the number is a PR.
165fn linkify_refs(html: &str, base: &str) -> String {
166 let bytes = html.as_bytes();
167 // Build as bytes so multibyte UTF-8 is copied verbatim (only ASCII markers
168 // are special-cased).
169 let mut out: Vec<u8> = Vec::with_capacity(html.len() + 32);
170 let mut i = 0;
171 let mut in_tag = false;
172 let mut code_depth: usize = 0;
173 let mut a_depth: usize = 0; // inside an existing <a> (or email autolink)
174 while i < bytes.len() {
175 let c = bytes[i];
176 if in_tag {
177 out.push(c);
178 if c == b'>' {
179 in_tag = false;
180 }
181 i += 1;
182 continue;
183 }
184 if c == b'<' {
185 let rest = &html[i..];
186 if rest.starts_with("<code") || rest.starts_with("<pre") {
187 code_depth += 1;
188 } else if rest.starts_with("</code") || rest.starts_with("</pre") {
189 code_depth = code_depth.saturating_sub(1);
190 } else if rest.starts_with("<a") && !rest.starts_with("<abbr") {
191 a_depth += 1;
192 } else if rest.starts_with("</a") {
193 a_depth = a_depth.saturating_sub(1);
194 }
195 in_tag = true;
196 out.push(b'<');
197 i += 1;
198 continue;
199 }
200 let plain = code_depth == 0 && a_depth == 0;
201 let boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
202 // `#123` → issue/PR link.
203 if plain && boundary && c == b'#' {
204 let mut j = i + 1;
205 while j < bytes.len() && bytes[j].is_ascii_digit() {
206 j += 1;
207 }
208 if j > i + 1 {
209 let num = &html[i + 1..j];
210 out.extend_from_slice(
211 format!("<a class=\"issue-ref\" href=\"{base}/-/issues/{num}\">#{num}</a>")
212 .as_bytes(),
213 );
214 i = j;
215 continue;
216 }
217 }
218 // `@name` → user profile link (boundary avoids matching email local parts).
219 if plain && boundary && c == b'@' {
220 let mut j = i + 1;
221 while j < bytes.len() && is_username_byte(bytes[j]) {
222 j += 1;
223 }
224 if j > i + 1 {
225 let name = &html[i + 1..j];
226 out.extend_from_slice(
227 format!("<a class=\"user-ref\" href=\"/{name}\">@{name}</a>").as_bytes(),
228 );
229 i = j;
230 continue;
231 }
232 }
233 out.push(c);
234 i += 1;
235 }
236 String::from_utf8(out).unwrap_or_else(|_| html.to_string())
237}
238
239/// Bytes allowed in a username (matches `model::validate_name`'s character set).
240fn is_username_byte(b: u8) -> bool {
241 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
242}
243
244/// Extract `@username` mentions from raw markdown text, deduplicated. Used to
245/// create mention notifications (over-matching inside code is acceptable).
246#[must_use]
247pub fn mentions(text: &str) -> Vec<String> {
248 let bytes = text.as_bytes();
249 let mut out: Vec<String> = Vec::new();
250 let mut i = 0;
251 while i < bytes.len() {
252 let boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
253 if boundary && bytes[i] == b'@' {
254 let mut j = i + 1;
255 while j < bytes.len() && is_username_byte(bytes[j]) {
256 j += 1;
257 }
258 if j > i + 1 {
259 let name = text[i + 1..j].to_string();
260 if !out.contains(&name) {
261 out.push(name);
262 }
263 i = j;
264 continue;
265 }
266 }
267 i += 1;
268 }
269 out
270}
271
272/// The ammonia sanitizer, extended to allow the highlighter's `<span class="hl-*">`
273/// runs (and only those classes) inside code blocks.
274fn sanitizer() -> ammonia::Builder<'static> {
275 let mut builder = ammonia::Builder::default();
276 builder.add_tags(["span"]);
277 builder.add_allowed_classes("span", highlight::HL_CLASSES.iter().copied());
278 builder.add_allowed_classes("code", ["highlight"]);
279 builder
280}
281
282/// Bridges comrak's code-fence rendering to the `highlight` crate.
283struct Highlighter;
284
285impl SyntaxHighlighterAdapter for Highlighter {
286 fn write_highlighted(
287 &self,
288 output: &mut dyn fmt::Write,
289 lang: Option<&str>,
290 code: &str,
291 ) -> fmt::Result {
292 // The info string is a language hint (like a `.gitattributes` override);
293 // resolve it, then highlight. Unknown/plain langs fall back to escaped text.
294 let hint = lang.map(str::trim).filter(|l| !l.is_empty());
295 let resolved = highlight::resolve(FsPath::new(""), code.as_bytes(), hint);
296 let lines = highlight::highlight_document(&resolved, code);
297 for (i, line) in lines.iter().enumerate() {
298 if i > 0 {
299 output.write_char('\n')?;
300 }
301 for span in &line.spans {
302 match span.class {
303 Some(class) => {
304 write!(
305 output,
306 "<span class=\"{class}\">{}</span>",
307 Escape(&span.text)
308 )?;
309 }
310 None => write!(output, "{}", Escape(&span.text))?,
311 }
312 }
313 }
314 Ok(())
315 }
316
317 fn write_pre_tag(
318 &self,
319 output: &mut dyn fmt::Write,
320 _attributes: HashMap<&'static str, Cow<'_, str>>,
321 ) -> fmt::Result {
322 output.write_str("<pre>")
323 }
324
325 fn write_code_tag(
326 &self,
327 output: &mut dyn fmt::Write,
328 _attributes: HashMap<&'static str, Cow<'_, str>>,
329 ) -> fmt::Result {
330 output.write_str("<code class=\"highlight\">")
331 }
332}
333
334/// A `Display` wrapper that HTML-escapes text as it is written.
335struct Escape<'a>(&'a str);
336
337impl fmt::Display for Escape<'_> {
338 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339 for c in self.0.chars() {
340 match c {
341 '&' => f.write_str("&amp;")?,
342 '<' => f.write_str("&lt;")?,
343 '>' => f.write_str("&gt;")?,
344 '"' => f.write_str("&quot;")?,
345 _ => f.write_char(c)?,
346 }
347 }
348 Ok(())
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn renders_gfm_and_strips_scripts() {
358 let html =
359 render("# Title\n\n- [x] done\n\n<script>alert(1)</script>\n\n`code`").into_string();
360 assert!(html.contains("<h1>"));
361 assert!(html.contains("<code>code</code>"));
362 assert!(
363 !html.contains("<script"),
364 "scripts must be stripped: {html}"
365 );
366 }
367
368 #[test]
369 fn renders_tables() {
370 let html = render("| a | b |\n| - | - |\n| 1 | 2 |").into_string();
371 assert!(html.contains("<table>"));
372 }
373
374 #[test]
375 fn linkifies_issue_refs_outside_code() {
376 let html = render_with_refs("see #12 and `#34` here", "/o/r").into_string();
377 assert!(
378 html.contains(r#"<a class="issue-ref" href="/o/r/-/issues/12">#12</a>"#),
379 "plain #12 links: {html}"
380 );
381 // The `#34` inside a code span is left alone.
382 assert!(html.contains("#34</code>"), "code #34 untouched: {html}");
383 assert!(
384 !html.contains("issues/34"),
385 "code #34 must not link: {html}"
386 );
387 }
388
389 #[test]
390 fn does_not_link_word_hash() {
391 let html = render_with_refs("color abc#5 def", "/o/r").into_string();
392 assert!(!html.contains("issues/5"), "abc#5 is not a ref: {html}");
393 }
394
395 #[test]
396 fn linkifies_mentions_but_not_emails() {
397 let html = render_with_refs("hi @alice, mail me@example.com", "/o/r").into_string();
398 assert!(
399 html.contains(r#"<a class="user-ref" href="/alice">@alice</a>"#),
400 "@alice links: {html}"
401 );
402 // The email's `@example` is not a mention (local part is alphanumeric).
403 assert!(
404 !html.contains(r#"href="/example""#),
405 "email untouched: {html}"
406 );
407 }
408
409 #[test]
410 fn extracts_mentions() {
411 assert_eq!(mentions("hey @bob and @carol-x!"), vec!["bob", "carol-x"]);
412 assert_eq!(mentions("foo@bar.com only"), Vec::<String>::new());
413 // Deduplicated.
414 assert_eq!(mentions("@a @a @b"), vec!["a", "b"]);
415 }
416
417 #[test]
418 fn rewrites_relative_repo_links_and_images() {
419 let src = "[cfg](fabrica.example.toml) ![logo](docs/logo.png) \
420 [abs](https://x.test/y) [anchor](#s)";
421 let html = render_repo(src, "/hanna/fabrica", "main", "").into_string();
422 // A relative link points at the blob view on the current ref.
423 assert!(
424 html.contains(r#"href="/hanna/fabrica/-/blob/main/fabrica.example.toml""#),
425 "relative link rewritten: {html}"
426 );
427 // A relative image points at the raw view.
428 assert!(
429 html.contains(r#"src="/hanna/fabrica/-/raw/main/docs/logo.png""#),
430 "relative image rewritten: {html}"
431 );
432 // Absolute URLs and bare anchors are left untouched.
433 assert!(
434 html.contains(r#"href="https://x.test/y""#),
435 "abs kept: {html}"
436 );
437 assert!(html.contains(r##"href="#s""##), "anchor kept: {html}");
438 }
439
440 #[test]
441 fn resolves_relative_paths_from_the_document_directory() {
442 // A link in docs/guide.md to ../README.md resolves to the repo root.
443 let html = render_repo("[up](../README.md)", "/o/r", "v1.0", "docs").into_string();
444 assert!(
445 html.contains(r#"href="/o/r/-/blob/v1.0/README.md""#),
446 "../ resolved against dir: {html}"
447 );
448 }
449
450 #[test]
451 fn highlights_fenced_code() {
452 let html = render("```rust\nfn main() {}\n```").into_string();
453 // A keyword span survives sanitizing with its highlight class.
454 assert!(
455 html.contains("hl-keyword"),
456 "fenced rust should be highlighted: {html}"
457 );
458 assert!(!html.contains("<script"));
459 }
460
461 #[test]
462 fn plain_fence_is_escaped_not_highlighted() {
463 let html = render("```\n<b>x</b> & y\n```").into_string();
464 assert!(html.contains("&lt;b&gt;"), "code is escaped: {html}");
465 }
466}