1use crate::ast::{Block, Document, Inline, ListItem, Row, ShortArgs};
2use crate::shortcode::{ArgValue, Registry};
3use std::fmt::Write;
4
5pub fn render(doc: &Document, reg: &Registry) -> String {
6 let footnotes = collect_footnotes(doc);
7 let mut ctx = Ctx {
8 reg,
9 counter: 0,
10 in_footnote: false,
11 resolved_refs: &doc.resolved_refs,
12 };
13 let mut out = String::new();
14 for b in &doc.blocks {
15 render_block(b, &mut ctx, &mut out);
16 }
17 if !footnotes.is_empty() {
18 emit_footnotes_section(&footnotes, reg, &doc.resolved_refs, &mut out);
19 }
20 out
21}
22
23struct Ctx<'a> {
24 reg: &'a Registry,
25 counter: u32,
26 in_footnote: bool,
27 resolved_refs: &'a std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
28}
29
30fn render_block(block: &Block, ctx: &mut Ctx, out: &mut String) {
31 match block {
32 Block::Heading {
33 level,
34 content,
35 anchor,
36 ..
37 } => {
38 if let Some(a) = anchor {
39 let _ = write!(out, "<h{} id=\"{}\">", level, escape_attr(a));
40 } else {
41 let _ = write!(out, "<h{}>", level);
42 }
43 render_inline_seq(content, ctx, out);
44 let _ = writeln!(out, "</h{}>", level);
45 }
46 Block::Paragraph { content, .. } => {
47 if content.is_empty() {
48 return;
49 }
50 out.push_str("<p>");
51 render_inline_seq(content, ctx, out);
52 out.push_str("</p>\n");
53 }
54 Block::List { ordered, items, .. } => {
55 let tag = if *ordered { "ol" } else { "ul" };
56 let has_tasks = items.iter().any(|it| it.task.is_some());
57 if has_tasks {
58 let _ = writeln!(out, "<{} class=\"contains-task-list\">", tag);
59 } else {
60 let _ = writeln!(out, "<{}>", tag);
61 }
62 for it in items {
63 render_item(it, ctx, out);
64 }
65 let _ = writeln!(out, "</{}>", tag);
66 }
67 Block::Blockquote { children, .. } => {
68 out.push_str("<blockquote>\n");
69 for c in children {
70 render_block(c, ctx, out);
71 }
72 out.push_str("</blockquote>\n");
73 }
74 Block::CodeBlock { lang, body, .. } => {
75 match lang {
78 Some(l) => {
79 let _ = write!(out, "<pre><code class=\"language-{}\">", escape_attr(l));
80 }
81 None => out.push_str("<pre><code>"),
82 }
83 escape_html_into(out, body);
84 out.push_str("</code></pre>\n");
85 }
86 Block::Table {
87 args, header, rows, ..
88 } => {
89 render_table(args, header, rows, ctx, out);
90 }
91 Block::DefinitionList { items, .. } => {
92 out.push_str("<dl>\n");
93 for it in items {
94 out.push_str("<dt>");
95 render_inline_seq(&it.term, ctx, out);
96 out.push_str("</dt>\n<dd>");
97 render_inline_seq(&it.definition, ctx, out);
98 out.push_str("</dd>\n");
99 }
100 out.push_str("</dl>\n");
101 }
102 Block::HorizontalRule { .. } => out.push_str("<hr>\n"),
103 Block::BlockShortcode {
104 name,
105 args,
106 children,
107 ..
108 } => {
109 let inner = {
110 let mut s = String::new();
111 for c in children {
112 render_block(c, ctx, &mut s);
113 }
114 s
115 };
116 render_shortcode_html(name, args, Some(&inner), ctx, out);
117 }
118 }
119}
120
121fn render_item(it: &ListItem, ctx: &mut Ctx, out: &mut String) {
122 use crate::ast::TaskState;
123 match it.task {
124 None => out.push_str("<li>"),
125 Some(state) => {
126 let checked = matches!(state, TaskState::Done);
127 let _ = write!(
128 out,
129 "<li class=\"task-list-item\"><input type=\"checkbox\" disabled{}> ",
130 if checked { " checked" } else { "" }
131 );
132 }
133 }
134 render_inline_seq(&it.content, ctx, out);
135 if !it.children.is_empty() {
136 out.push('\n');
137 for c in &it.children {
138 render_block(c, ctx, out);
139 }
140 }
141 out.push_str("</li>\n");
142}
143
144fn render_table(args: &ShortArgs, header: &Row, rows: &[Row], ctx: &mut Ctx, out: &mut String) {
145 let aligns: Vec<&str> = if let Some(ArgValue::Array(a)) = args.keyword.get("align") {
149 a.iter()
150 .map(|v| match v.as_str() {
151 Some(s @ ("left" | "right" | "center")) => s,
152 _ => "left",
153 })
154 .collect()
155 } else {
156 vec!["left"; header.cells.len()]
157 };
158 out.push_str("<table>\n<thead><tr>");
159 for (i, c) in header.cells.iter().enumerate() {
160 let a = aligns.get(i).copied().unwrap_or("left");
161 let _ = write!(out, "<th style=\"text-align:{}\">", a);
162 render_inline_seq(c, ctx, out);
163 out.push_str("</th>");
164 }
165 out.push_str("</tr></thead>\n<tbody>\n");
166 for r in rows {
167 out.push_str("<tr>");
168 for (i, c) in r.cells.iter().enumerate() {
169 let a = aligns.get(i).copied().unwrap_or("left");
170 let _ = write!(out, "<td style=\"text-align:{}\">", a);
171 render_inline_seq(c, ctx, out);
172 out.push_str("</td>");
173 }
174 out.push_str("</tr>\n");
175 }
176 out.push_str("</tbody>\n</table>\n");
177}
178
179fn render_inline_seq(seq: &[Inline], ctx: &mut Ctx, out: &mut String) {
180 for n in seq {
181 render_inline(n, ctx, out);
182 }
183}
184
185fn render_inline(node: &Inline, ctx: &mut Ctx, out: &mut String) {
186 match node {
187 Inline::Text { value, .. } => escape_html_into(out, value),
188 Inline::HardBreak { .. } => out.push_str("<br>"),
189 Inline::Bold { content, .. } => {
190 out.push_str("<strong>");
191 render_inline_seq(content, ctx, out);
192 out.push_str("</strong>");
193 }
194 Inline::Italic { content, .. } => {
195 out.push_str("<em>");
196 render_inline_seq(content, ctx, out);
197 out.push_str("</em>");
198 }
199 Inline::Underline { content, .. } => {
200 out.push_str("<u>");
201 render_inline_seq(content, ctx, out);
202 out.push_str("</u>");
203 }
204 Inline::Strike { content, .. } => {
205 out.push_str("<s>");
206 render_inline_seq(content, ctx, out);
207 out.push_str("</s>");
208 }
209 Inline::InlineCode { value, .. } => {
210 out.push_str("<code>");
211 escape_html_into(out, value);
212 out.push_str("</code>");
213 }
214 Inline::Shortcode {
215 name,
216 args,
217 content: _,
218 span,
219 ..
220 } if name == "ref" => {
221 let title_kw = args.keyword.get("title").and_then(|v| v.as_str());
225 let title_pos = args.positional.first().and_then(|v| v.as_str());
226 let resolved = ctx.resolved_refs.get(span);
227 let display_text = resolved
228 .map(|r| r.display.as_str())
229 .or(title_kw)
230 .or(title_pos)
231 .unwrap_or("");
232 if let Some(r) = resolved {
233 let mut url = r.target_path.clone();
234 debug_assert!(
235 url.ends_with(".brf"),
236 "ResolvedRef.target_path must end in .brf"
237 );
238 url.replace_range(url.len() - 4.., ".html");
239 if let Some(a) = &r.target_anchor {
240 url.push('#');
241 url.push_str(a);
242 }
243 let _ = write!(
244 out,
245 "<a href=\"{}\">{}</a>",
246 escape_attr(&url),
247 escape_html(display_text)
248 );
249 } else {
250 let _ = write!(out, "{}", escape_html(display_text));
252 }
253 }
254 Inline::Shortcode {
255 name,
256 args,
257 content,
258 ..
259 } => {
260 if name == "footnote" {
265 if content.is_none() {
266 return;
267 }
268 if ctx.in_footnote {
269 out.push('[');
270 if let Some(c) = content {
271 render_inline_seq(c, ctx, out);
272 }
273 out.push(']');
274 return;
275 }
276 ctx.counter += 1;
277 let n = ctx.counter;
278 let _ = write!(
279 out,
280 "<sup class=\"fn-ref\"><a id=\"fn-ref-{}\" href=\"#fn-{}\">{}</a></sup>",
281 n, n, n
282 );
283 return;
284 }
285 let inner = content.as_ref().map(|c| {
286 let mut s = String::new();
287 render_inline_seq(c, ctx, &mut s);
288 s
289 });
290 render_shortcode_html(name, args, inner.as_deref(), ctx, out);
291 }
292 }
293}
294
295fn render_shortcode_html(
296 name: &str,
297 args: &ShortArgs,
298 inner: Option<&str>,
299 ctx: &mut Ctx,
300 out: &mut String,
301) {
302 if let Some(sc) = ctx.reg.get(name) {
303 if let Some(t) = &sc.template_html {
304 let r = expand_template(t, args, inner.unwrap_or(""));
305 out.push_str(&r);
306 return;
307 }
308 }
309 match name {
310 "link" => {
311 let url = args
312 .keyword
313 .get("url")
314 .and_then(|v| v.as_str())
315 .unwrap_or("#");
316 let title = args.keyword.get("title").and_then(|v| v.as_str());
317 if let Some(t) = title {
318 let _ = write!(
319 out,
320 "<a href=\"{}\" title=\"{}\">{}</a>",
321 escape_attr(url),
322 escape_attr(t),
323 inner.unwrap_or("")
324 );
325 } else {
326 let _ = write!(
327 out,
328 "<a href=\"{}\">{}</a>",
329 escape_attr(url),
330 inner.unwrap_or("")
331 );
332 }
333 }
334 "image" => {
335 let src = args
336 .keyword
337 .get("src")
338 .and_then(|v| v.as_str())
339 .unwrap_or("");
340 let alt = args
341 .keyword
342 .get("alt")
343 .and_then(|v| v.as_str())
344 .unwrap_or("");
345 let _ = write!(
346 out,
347 "<img src=\"{}\" alt=\"{}\">",
348 escape_attr(src),
349 escape_attr(alt)
350 );
351 }
352 "kbd" => {
353 let _ = write!(out, "<kbd>{}</kbd>", inner.unwrap_or(""));
354 }
355 "sub" => {
356 let _ = write!(out, "<sub>{}</sub>", inner.unwrap_or(""));
357 }
358 "sup" => {
359 let _ = write!(out, "<sup>{}</sup>", inner.unwrap_or(""));
360 }
361 "details" => {
362 let summary = args
363 .keyword
364 .get("summary")
365 .and_then(|v| v.as_str())
366 .unwrap_or("");
367 let _ = write!(
368 out,
369 "<details><summary>{}</summary>{}</details>\n",
370 escape_html(summary),
371 inner.unwrap_or("")
372 );
373 }
374 "callout" => {
375 let kind = args
376 .keyword
377 .get("kind")
378 .and_then(|v| v.as_str())
379 .unwrap_or("info");
380 let _ = write!(
381 out,
382 "<aside class=\"callout callout-{}\">{}</aside>\n",
383 escape_attr(kind),
384 inner.unwrap_or("")
385 );
386 }
387 "math" => {
388 let raw = inner.unwrap_or("");
389 let _ = write!(out, "<span class=\"math\">{}</span>", escape_html(raw));
390 }
391 _ => {
394 let _ = write!(
395 out,
396 "<div class=\"shortcode-{}\">{}</div>",
397 escape_attr(name),
398 inner.unwrap_or("")
399 );
400 }
401 }
402}
403
404fn collect_footnotes(doc: &Document) -> Vec<Vec<Inline>> {
405 let mut out = Vec::new();
406 for b in &doc.blocks {
407 collect_block(b, &mut out);
408 }
409 out
410}
411
412fn collect_block(b: &Block, out: &mut Vec<Vec<Inline>>) {
413 match b {
414 Block::Heading { content, .. } | Block::Paragraph { content, .. } => {
415 for n in content {
416 collect_inline(n, out);
417 }
418 }
419 Block::List { items, .. } => {
420 for it in items {
421 for n in &it.content {
422 collect_inline(n, out);
423 }
424 for c in &it.children {
425 collect_block(c, out);
426 }
427 }
428 }
429 Block::Blockquote { children, .. } | Block::BlockShortcode { children, .. } => {
430 for c in children {
431 collect_block(c, out);
432 }
433 }
434 Block::Table { header, rows, .. } => {
435 for cell in &header.cells {
436 for n in cell {
437 collect_inline(n, out);
438 }
439 }
440 for row in rows {
441 for cell in &row.cells {
442 for n in cell {
443 collect_inline(n, out);
444 }
445 }
446 }
447 }
448 Block::DefinitionList { items, .. } => {
449 for it in items {
450 for n in &it.term {
451 collect_inline(n, out);
452 }
453 for n in &it.definition {
454 collect_inline(n, out);
455 }
456 }
457 }
458 Block::CodeBlock { .. } | Block::HorizontalRule { .. } => {}
459 }
460}
461
462fn collect_inline(node: &Inline, out: &mut Vec<Vec<Inline>>) {
463 match node {
464 Inline::Bold { content, .. }
465 | Inline::Italic { content, .. }
466 | Inline::Underline { content, .. }
467 | Inline::Strike { content, .. } => {
468 for n in content {
469 collect_inline(n, out);
470 }
471 }
472 Inline::Shortcode { name, content, .. } => {
473 if name == "footnote" {
474 if let Some(c) = content {
475 out.push(c.clone());
476 }
477 return;
480 }
481 if let Some(c) = content {
482 for n in c {
483 collect_inline(n, out);
484 }
485 }
486 }
487 _ => {}
488 }
489}
490
491fn emit_footnotes_section(
492 footnotes: &[Vec<Inline>],
493 reg: &Registry,
494 resolved_refs: &std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
495 out: &mut String,
496) {
497 out.push_str("<hr class=\"footnotes-sep\">\n<ol class=\"footnotes\">\n");
498 for (i, body) in footnotes.iter().enumerate() {
499 let n = i + 1;
500 let _ = write!(out, "<li id=\"fn-{}\">", n);
501 let mut ctx = Ctx {
502 reg,
503 counter: 0,
504 in_footnote: true,
505 resolved_refs,
506 };
507 render_inline_seq(body, &mut ctx, out);
508 let _ = write!(
509 out,
510 " <a href=\"#fn-ref-{}\" class=\"fn-back\">\u{21A9}</a></li>\n",
511 n
512 );
513 }
514 out.push_str("</ol>\n");
515}
516
517fn expand_template(tpl: &str, args: &ShortArgs, content: &str) -> String {
518 let mut out = String::new();
519 let bytes = tpl.as_bytes();
520 let mut start = 0;
521 let mut i = 0;
522 while i < bytes.len() {
523 if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') {
524 if let Some(rel) = tpl[i + 2..].find("}}") {
525 out.push_str(&tpl[start..i]);
529 let key = tpl[i + 2..i + 2 + rel].trim();
530 if key == "content" {
531 out.push_str(content);
532 } else if let Some(rest) = key.strip_prefix("args.") {
533 if let Some(v) = args.keyword.get(rest).and_then(|v| v.as_str()) {
534 escape_html_into(&mut out, v);
535 }
536 }
537 i = i + 2 + rel + 2;
538 start = i;
539 continue;
540 }
541 }
542 i += 1;
543 }
544 out.push_str(&tpl[start..]);
545 out
546}
547
548fn escape_html_into(out: &mut String, s: &str) {
552 let bytes = s.as_bytes();
553 let mut start = 0;
554 for (i, &b) in bytes.iter().enumerate() {
555 let rep = match b {
556 b'&' => "&",
557 b'<' => "<",
558 b'>' => ">",
559 b'"' => """,
560 _ => continue,
561 };
562 out.push_str(&s[start..i]);
563 out.push_str(rep);
564 start = i + 1;
565 }
566 out.push_str(&s[start..]);
567}
568
569fn escape_html(s: &str) -> String {
570 let mut o = String::with_capacity(s.len());
571 escape_html_into(&mut o, s);
572 o
573}
574
575fn escape_attr(s: &str) -> String {
576 escape_html(s)
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use crate::lexer::lex;
583 use crate::parser::parse;
584 use crate::span::SourceMap;
585
586 fn render_html(input: &str) -> String {
587 let src = SourceMap::new("d.brf", input);
588 let toks = lex(&src).unwrap();
589 let (doc, diags) = parse(toks, &src);
590 assert!(diags.is_empty(), "{:?}", diags);
591 let reg = Registry::with_builtins();
592 render(&doc, ®)
593 }
594
595 #[test]
596 fn html_does_not_emit_frontmatter() {
597 let out = render_html("+++\ntitle = \"hi\"\n+++\n# Doc\n");
598 assert!(!out.contains("+++"), "{}", out);
599 assert!(!out.contains("title"), "{}", out);
600 assert!(out.contains("<h1>Doc</h1>"));
601 }
602
603 fn parse_doc(s: &str) -> crate::ast::Document {
604 use crate::{lexer, parser, span::SourceMap};
605 let src = SourceMap::new("t.brf", s);
606 let tokens = lexer::lex(&src).expect("lex");
607 let (doc, _) = parser::parse(tokens, &src);
608 doc
609 }
610
611 #[test]
612 fn ref_lowers_to_anchor_using_resolved_refs() {
613 use crate::project::ProjectIndex;
614 use crate::resolve::{ResolveProject, resolve_with_project};
615 use std::collections::BTreeSet;
616 use std::path::PathBuf;
617
618 let src = "See @ref[other.brf#top](the top).\n".to_string();
619 let mut doc = parse_doc(&src);
620 let mut idx = ProjectIndex {
621 root: PathBuf::from("/tmp/p"),
622 ..Default::default()
623 };
624 idx.anchors
625 .insert("other.brf".to_string(), BTreeSet::from(["top".into()]));
626 let p = ResolveProject {
627 index: &idx,
628 current: &PathBuf::from("here.brf"),
629 };
630 let reg = crate::shortcode::Registry::with_builtins();
631 let _ = resolve_with_project(&mut doc, ®, Some(&p));
632 let html = render(&doc, ®);
633 assert!(
634 html.contains("<a href=\"other.html#top\">the top</a>"),
635 "got: {}",
636 html
637 );
638 }
639
640 #[test]
641 fn ref_without_anchor_lowers_to_html_with_no_fragment() {
642 use crate::project::ProjectIndex;
643 use crate::resolve::{ResolveProject, resolve_with_project};
644 use std::collections::BTreeSet;
645 use std::path::PathBuf;
646 let mut doc = parse_doc("See @ref[a/b.brf](title).\n");
647 let mut idx = ProjectIndex::default();
648 idx.anchors.insert("a/b.brf".to_string(), BTreeSet::new());
649 let p = ResolveProject {
650 index: &idx,
651 current: &PathBuf::from("here.brf"),
652 };
653 let reg = crate::shortcode::Registry::with_builtins();
654 let _ = resolve_with_project(&mut doc, ®, Some(&p));
655 let html = render(&doc, ®);
656 assert!(
657 html.contains("<a href=\"a/b.html\">title</a>"),
658 "got: {}",
659 html
660 );
661 }
662
663 #[test]
664 fn unresolved_ref_falls_back_to_display_text() {
665 let doc = parse_doc("See @ref[any.brf](fallback).\n");
667 let reg = crate::shortcode::Registry::with_builtins();
668 let html = render(&doc, ®);
669 assert!(html.contains("fallback"), "got: {}", html);
670 assert!(
671 !html.contains("<a "),
672 "must not emit a broken link: {}",
673 html
674 );
675 }
676
677 #[test]
678 fn dl_renders_as_dl_dt_dd() {
679 use crate::ast::{Block, DefinitionItem, Document, Inline, ShortArgs};
680 use crate::span::Span;
681 let doc = Document {
682 blocks: vec![Block::DefinitionList {
683 args: ShortArgs::default(),
684 items: vec![DefinitionItem {
685 term: vec![Inline::Text {
686 value: "Term".into(),
687 span: Span::DUMMY,
688 }],
689 definition: vec![Inline::Text {
690 value: "Definition.".into(),
691 span: Span::DUMMY,
692 }],
693 span: Span::DUMMY,
694 }],
695 span: Span::DUMMY,
696 }],
697 metadata: None,
698 resolved_refs: Default::default(),
699 };
700 let reg = Registry::with_builtins();
701 let out = render(&doc, ®);
702 assert!(out.contains("<dl>"), "got: {}", out);
703 assert!(out.contains("<dt>Term</dt>"), "got: {}", out);
704 assert!(out.contains("<dd>Definition.</dd>"), "got: {}", out);
705 assert!(out.contains("</dl>"), "got: {}", out);
706 }
707}