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 out.push_str(&escape_html(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") {
146 a.iter()
147 .map(|v| match v {
148 ArgValue::Ident(s) | ArgValue::Str(s) => s.as_str(),
149 _ => "left",
150 })
151 .collect()
152 } else {
153 vec!["left"; header.cells.len()]
154 };
155 out.push_str("<table>\n<thead><tr>");
156 for (i, c) in header.cells.iter().enumerate() {
157 let a = aligns.get(i).copied().unwrap_or("left");
158 let _ = write!(out, "<th style=\"text-align:{}\">", a);
159 render_inline_seq(c, ctx, out);
160 out.push_str("</th>");
161 }
162 out.push_str("</tr></thead>\n<tbody>\n");
163 for r in rows {
164 out.push_str("<tr>");
165 for (i, c) in r.cells.iter().enumerate() {
166 let a = aligns.get(i).copied().unwrap_or("left");
167 let _ = write!(out, "<td style=\"text-align:{}\">", a);
168 render_inline_seq(c, ctx, out);
169 out.push_str("</td>");
170 }
171 out.push_str("</tr>\n");
172 }
173 out.push_str("</tbody>\n</table>\n");
174}
175
176fn render_inline_seq(seq: &[Inline], ctx: &mut Ctx, out: &mut String) {
177 for n in seq {
178 render_inline(n, ctx, out);
179 }
180}
181
182fn render_inline(node: &Inline, ctx: &mut Ctx, out: &mut String) {
183 match node {
184 Inline::Text { value, .. } => out.push_str(&escape_html(value)),
185 Inline::HardBreak { .. } => out.push_str("<br>"),
186 Inline::Bold { content, .. } => {
187 out.push_str("<strong>");
188 render_inline_seq(content, ctx, out);
189 out.push_str("</strong>");
190 }
191 Inline::Italic { content, .. } => {
192 out.push_str("<em>");
193 render_inline_seq(content, ctx, out);
194 out.push_str("</em>");
195 }
196 Inline::Underline { content, .. } => {
197 out.push_str("<u>");
198 render_inline_seq(content, ctx, out);
199 out.push_str("</u>");
200 }
201 Inline::Strike { content, .. } => {
202 out.push_str("<s>");
203 render_inline_seq(content, ctx, out);
204 out.push_str("</s>");
205 }
206 Inline::InlineCode { value, .. } => {
207 out.push_str("<code>");
208 out.push_str(&escape_html(value));
209 out.push_str("</code>");
210 }
211 Inline::Shortcode {
212 name,
213 args,
214 content: _,
215 span,
216 ..
217 } if name == "ref" => {
218 let title_kw = args.keyword.get("title").and_then(|v| v.as_str());
222 let title_pos = args.positional.first().and_then(|v| v.as_str());
223 let resolved = ctx.resolved_refs.get(span);
224 let display_text = resolved
225 .map(|r| r.display.as_str())
226 .or(title_kw)
227 .or(title_pos)
228 .unwrap_or("");
229 if let Some(r) = resolved {
230 let mut url = r.target_path.clone();
231 debug_assert!(
232 url.ends_with(".brf"),
233 "ResolvedRef.target_path must end in .brf"
234 );
235 url.replace_range(url.len() - 4.., ".html");
236 if let Some(a) = &r.target_anchor {
237 url.push('#');
238 url.push_str(a);
239 }
240 let _ = write!(
241 out,
242 "<a href=\"{}\">{}</a>",
243 escape_attr(&url),
244 escape_html(display_text)
245 );
246 } else {
247 let _ = write!(out, "{}", escape_html(display_text));
249 }
250 }
251 Inline::Shortcode {
252 name,
253 args,
254 content,
255 ..
256 } => {
257 if name == "footnote" {
262 if content.is_none() {
263 return;
264 }
265 if ctx.in_footnote {
266 out.push('[');
267 if let Some(c) = content {
268 render_inline_seq(c, ctx, out);
269 }
270 out.push(']');
271 return;
272 }
273 ctx.counter += 1;
274 let n = ctx.counter;
275 let _ = write!(
276 out,
277 "<sup class=\"fn-ref\"><a id=\"fn-ref-{}\" href=\"#fn-{}\">{}</a></sup>",
278 n, n, n
279 );
280 return;
281 }
282 let inner = content.as_ref().map(|c| {
283 let mut s = String::new();
284 render_inline_seq(c, ctx, &mut s);
285 s
286 });
287 render_shortcode_html(name, args, inner.as_deref(), ctx, out);
288 }
289 }
290}
291
292fn render_shortcode_html(
293 name: &str,
294 args: &ShortArgs,
295 inner: Option<&str>,
296 ctx: &mut Ctx,
297 out: &mut String,
298) {
299 if let Some(sc) = ctx.reg.get(name) {
300 if let Some(t) = &sc.template_html {
301 let r = expand_template(t, args, inner.unwrap_or(""));
302 out.push_str(&r);
303 return;
304 }
305 }
306 match name {
307 "link" => {
308 let url = args
309 .keyword
310 .get("url")
311 .and_then(|v| v.as_str())
312 .unwrap_or("#");
313 let title = args.keyword.get("title").and_then(|v| v.as_str());
314 if let Some(t) = title {
315 let _ = write!(
316 out,
317 "<a href=\"{}\" title=\"{}\">{}</a>",
318 escape_attr(url),
319 escape_attr(t),
320 inner.unwrap_or("")
321 );
322 } else {
323 let _ = write!(
324 out,
325 "<a href=\"{}\">{}</a>",
326 escape_attr(url),
327 inner.unwrap_or("")
328 );
329 }
330 }
331 "image" => {
332 let src = args
333 .keyword
334 .get("src")
335 .and_then(|v| v.as_str())
336 .unwrap_or("");
337 let alt = args
338 .keyword
339 .get("alt")
340 .and_then(|v| v.as_str())
341 .unwrap_or("");
342 let _ = write!(
343 out,
344 "<img src=\"{}\" alt=\"{}\">",
345 escape_attr(src),
346 escape_attr(alt)
347 );
348 }
349 "kbd" => {
350 let _ = write!(out, "<kbd>{}</kbd>", inner.unwrap_or(""));
351 }
352 "sub" => {
353 let _ = write!(out, "<sub>{}</sub>", inner.unwrap_or(""));
354 }
355 "sup" => {
356 let _ = write!(out, "<sup>{}</sup>", inner.unwrap_or(""));
357 }
358 "details" => {
359 let summary = args
360 .keyword
361 .get("summary")
362 .and_then(|v| v.as_str())
363 .unwrap_or("");
364 let _ = write!(
365 out,
366 "<details><summary>{}</summary>{}</details>\n",
367 escape_html(summary),
368 inner.unwrap_or("")
369 );
370 }
371 "callout" => {
372 let kind = args
373 .keyword
374 .get("kind")
375 .and_then(|v| v.as_str())
376 .unwrap_or("info");
377 let _ = write!(
378 out,
379 "<aside class=\"callout callout-{}\">{}</aside>\n",
380 escape_attr(kind),
381 inner.unwrap_or("")
382 );
383 }
384 "math" => {
385 let raw = inner.unwrap_or("");
386 let _ = write!(out, "<span class=\"math\">{}</span>", escape_html(raw));
387 }
388 "code" => {
389 let lang = args
390 .keyword
391 .get("lang")
392 .and_then(|v| v.as_str())
393 .unwrap_or("");
394 let body = inner.unwrap_or("");
395 if !lang.is_empty() {
396 let _ = write!(
397 out,
398 "<pre><code class=\"language-{}\">{}</code></pre>\n",
399 escape_attr(lang),
400 escape_html(body)
401 );
402 } else {
403 let _ = write!(out, "<pre><code>{}</code></pre>\n", escape_html(body));
404 }
405 }
406 _ => {
407 let _ = write!(
408 out,
409 "<div class=\"shortcode-{}\">{}</div>",
410 escape_attr(name),
411 inner.unwrap_or("")
412 );
413 }
414 }
415}
416
417fn collect_footnotes(doc: &Document) -> Vec<Vec<Inline>> {
418 let mut out = Vec::new();
419 for b in &doc.blocks {
420 collect_block(b, &mut out);
421 }
422 out
423}
424
425fn collect_block(b: &Block, out: &mut Vec<Vec<Inline>>) {
426 match b {
427 Block::Heading { content, .. } | Block::Paragraph { content, .. } => {
428 for n in content {
429 collect_inline(n, out);
430 }
431 }
432 Block::List { items, .. } => {
433 for it in items {
434 for n in &it.content {
435 collect_inline(n, out);
436 }
437 for c in &it.children {
438 collect_block(c, out);
439 }
440 }
441 }
442 Block::Blockquote { children, .. } | Block::BlockShortcode { children, .. } => {
443 for c in children {
444 collect_block(c, out);
445 }
446 }
447 Block::Table { header, rows, .. } => {
448 for cell in &header.cells {
449 for n in cell {
450 collect_inline(n, out);
451 }
452 }
453 for row in rows {
454 for cell in &row.cells {
455 for n in cell {
456 collect_inline(n, out);
457 }
458 }
459 }
460 }
461 Block::DefinitionList { items, .. } => {
462 for it in items {
463 for n in &it.term {
464 collect_inline(n, out);
465 }
466 for n in &it.definition {
467 collect_inline(n, out);
468 }
469 }
470 }
471 Block::CodeBlock { .. } | Block::HorizontalRule { .. } => {}
472 }
473}
474
475fn collect_inline(node: &Inline, out: &mut Vec<Vec<Inline>>) {
476 match node {
477 Inline::Bold { content, .. }
478 | Inline::Italic { content, .. }
479 | Inline::Underline { content, .. }
480 | Inline::Strike { content, .. } => {
481 for n in content {
482 collect_inline(n, out);
483 }
484 }
485 Inline::Shortcode { name, content, .. } => {
486 if name == "footnote" {
487 if let Some(c) = content {
488 out.push(c.clone());
489 }
490 return;
493 }
494 if let Some(c) = content {
495 for n in c {
496 collect_inline(n, out);
497 }
498 }
499 }
500 _ => {}
501 }
502}
503
504fn emit_footnotes_section(
505 footnotes: &[Vec<Inline>],
506 reg: &Registry,
507 resolved_refs: &std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
508 out: &mut String,
509) {
510 out.push_str("<hr class=\"footnotes-sep\">\n<ol class=\"footnotes\">\n");
511 for (i, body) in footnotes.iter().enumerate() {
512 let n = i + 1;
513 let _ = write!(out, "<li id=\"fn-{}\">", n);
514 let mut ctx = Ctx {
515 reg,
516 counter: 0,
517 in_footnote: true,
518 resolved_refs,
519 };
520 render_inline_seq(body, &mut ctx, out);
521 let _ = write!(
522 out,
523 " <a href=\"#fn-ref-{}\" class=\"fn-back\">\u{21A9}</a></li>\n",
524 n
525 );
526 }
527 out.push_str("</ol>\n");
528}
529
530fn expand_template(tpl: &str, args: &ShortArgs, content: &str) -> String {
531 let mut out = String::new();
532 let bytes = tpl.as_bytes();
533 let mut i = 0;
534 while i < bytes.len() {
535 if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') {
536 if let Some(rel) = tpl[i + 2..].find("}}") {
537 let key = tpl[i + 2..i + 2 + rel].trim();
538 if key == "content" {
539 out.push_str(content);
540 } else if let Some(rest) = key.strip_prefix("args.") {
541 if let Some(v) = args.keyword.get(rest).and_then(|v| v.as_str()) {
542 out.push_str(&escape_html(v));
543 }
544 }
545 i = i + 2 + rel + 2;
546 continue;
547 }
548 }
549 out.push(bytes[i] as char);
550 i += 1;
551 }
552 out
553}
554
555fn escape_html(s: &str) -> String {
556 let mut o = String::with_capacity(s.len());
557 for c in s.chars() {
558 match c {
559 '&' => o.push_str("&"),
560 '<' => o.push_str("<"),
561 '>' => o.push_str(">"),
562 '"' => o.push_str("""),
563 _ => o.push(c),
564 }
565 }
566 o
567}
568
569fn escape_attr(s: &str) -> String {
570 escape_html(s)
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::lexer::lex;
577 use crate::parser::parse;
578 use crate::span::SourceMap;
579
580 fn render_html(input: &str) -> String {
581 let src = SourceMap::new("d.brf", input);
582 let toks = lex(&src).unwrap();
583 let (doc, diags) = parse(toks, &src);
584 assert!(diags.is_empty(), "{:?}", diags);
585 let reg = Registry::with_builtins();
586 render(&doc, ®)
587 }
588
589 #[test]
590 fn html_does_not_emit_frontmatter() {
591 let out = render_html("+++\ntitle = \"hi\"\n+++\n# Doc\n");
592 assert!(!out.contains("+++"), "{}", out);
593 assert!(!out.contains("title"), "{}", out);
594 assert!(out.contains("<h1>Doc</h1>"));
595 }
596
597 fn parse_doc(s: &str) -> crate::ast::Document {
598 use crate::{lexer, parser, span::SourceMap};
599 let src = SourceMap::new("t.brf", s);
600 let tokens = lexer::lex(&src).expect("lex");
601 let (doc, _) = parser::parse(tokens, &src);
602 doc
603 }
604
605 #[test]
606 fn ref_lowers_to_anchor_using_resolved_refs() {
607 use crate::project::ProjectIndex;
608 use crate::resolve::{ResolveProject, resolve_with_project};
609 use std::collections::BTreeSet;
610 use std::path::PathBuf;
611
612 let src = "See @ref[other.brf#top](the top).\n".to_string();
613 let mut doc = parse_doc(&src);
614 let mut idx = ProjectIndex {
615 root: PathBuf::from("/tmp/p"),
616 ..Default::default()
617 };
618 idx.anchors
619 .insert("other.brf".to_string(), BTreeSet::from(["top".into()]));
620 let p = ResolveProject {
621 index: &idx,
622 current: &PathBuf::from("here.brf"),
623 };
624 let reg = crate::shortcode::Registry::with_builtins();
625 let _ = resolve_with_project(&mut doc, ®, Some(&p));
626 let html = render(&doc, ®);
627 assert!(
628 html.contains("<a href=\"other.html#top\">the top</a>"),
629 "got: {}",
630 html
631 );
632 }
633
634 #[test]
635 fn ref_without_anchor_lowers_to_html_with_no_fragment() {
636 use crate::project::ProjectIndex;
637 use crate::resolve::{ResolveProject, resolve_with_project};
638 use std::collections::BTreeSet;
639 use std::path::PathBuf;
640 let mut doc = parse_doc("See @ref[a/b.brf](title).\n");
641 let mut idx = ProjectIndex::default();
642 idx.anchors.insert("a/b.brf".to_string(), BTreeSet::new());
643 let p = ResolveProject {
644 index: &idx,
645 current: &PathBuf::from("here.brf"),
646 };
647 let reg = crate::shortcode::Registry::with_builtins();
648 let _ = resolve_with_project(&mut doc, ®, Some(&p));
649 let html = render(&doc, ®);
650 assert!(
651 html.contains("<a href=\"a/b.html\">title</a>"),
652 "got: {}",
653 html
654 );
655 }
656
657 #[test]
658 fn unresolved_ref_falls_back_to_display_text() {
659 let doc = parse_doc("See @ref[any.brf](fallback).\n");
661 let reg = crate::shortcode::Registry::with_builtins();
662 let html = render(&doc, ®);
663 assert!(html.contains("fallback"), "got: {}", html);
664 assert!(
665 !html.contains("<a "),
666 "must not emit a broken link: {}",
667 html
668 );
669 }
670
671 #[test]
672 fn dl_renders_as_dl_dt_dd() {
673 use crate::ast::{Block, DefinitionItem, Document, Inline, ShortArgs};
674 use crate::span::Span;
675 let doc = Document {
676 blocks: vec![Block::DefinitionList {
677 args: ShortArgs::default(),
678 items: vec![DefinitionItem {
679 term: vec![Inline::Text {
680 value: "Term".into(),
681 span: Span::DUMMY,
682 }],
683 definition: vec![Inline::Text {
684 value: "Definition.".into(),
685 span: Span::DUMMY,
686 }],
687 span: Span::DUMMY,
688 }],
689 span: Span::DUMMY,
690 }],
691 metadata: None,
692 resolved_refs: Default::default(),
693 };
694 let reg = Registry::with_builtins();
695 let out = render(&doc, ®);
696 assert!(out.contains("<dl>"), "got: {}", out);
697 assert!(out.contains("<dt>Term</dt>"), "got: {}", out);
698 assert!(out.contains("<dd>Definition.</dd>"), "got: {}", out);
699 assert!(out.contains("</dl>"), "got: {}", out);
700 }
701}