Skip to main content

brief/emit/
html.rs

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            // HTML emit ignores `attrs` — minification is exclusively an
76            // LLM-mode concern (per design §3 and §7.4).
77            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    // The parser rejects align values outside this set, but the renderer
146    // must stay safe on any AST it's handed: these strings land in a style
147    // attribute, so only whitelisted values may pass.
148    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            // The display text for @ref lives in args["title"] after resolve
222            // (bind_positional moves positional arg 1 → keyword["title"]).
223            // Before resolve (third-test scenario), it stays in positional[0].
224            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                // No project context — emit display text only, no anchor.
251                let _ = write!(out, "{}", escape_html(display_text));
252            }
253        }
254        Inline::Shortcode {
255            name,
256            args,
257            content,
258            ..
259        } => {
260            // Footnote refs are auto-numbered during the main render pass.
261            // Inside a footnote body we degrade nested footnotes to plain
262            // bracketed text so they don't disturb the document-level
263            // numbering scheme.
264            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        // `@code` never reaches the emitter as a shortcode: the parser turns
392        // it into a `Block::CodeBlock` so the body stays verbatim.
393        _ => {
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                // Don't recurse into the body: nested footnote refs inside
478                // a footnote body render as plain text and are not numbered.
479                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                // Literal text since the last placeholder is copied as a
526                // slice, which keeps multibyte UTF-8 intact (a byte-at-a-
527                // time `push(b as char)` would mangle it).
528                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
548/// Escape `s` directly into `out`: unescaped stretches are copied in bulk
549/// instead of char-by-char, and no intermediate String is allocated. The
550/// escaped bytes are all ASCII, so scanning bytes is UTF-8 safe.
551fn 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'&' => "&amp;",
557            b'<' => "&lt;",
558            b'>' => "&gt;",
559            b'"' => "&quot;",
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, &reg)
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, &reg, Some(&p));
632        let html = render(&doc, &reg);
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, &reg, Some(&p));
655        let html = render(&doc, &reg);
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        // No resolve_with_project call → resolved_refs stays empty.
666        let doc = parse_doc("See @ref[any.brf](fallback).\n");
667        let reg = crate::shortcode::Registry::with_builtins();
668        let html = render(&doc, &reg);
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, &reg);
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}