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            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            // The display text for @ref lives in args["title"] after resolve
219            // (bind_positional moves positional arg 1 → keyword["title"]).
220            // Before resolve (third-test scenario), it stays in positional[0].
221            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                // No project context — emit display text only, no anchor.
248                let _ = write!(out, "{}", escape_html(display_text));
249            }
250        }
251        Inline::Shortcode {
252            name,
253            args,
254            content,
255            ..
256        } => {
257            // Footnote refs are auto-numbered during the main render pass.
258            // Inside a footnote body we degrade nested footnotes to plain
259            // bracketed text so they don't disturb the document-level
260            // numbering scheme.
261            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                // Don't recurse into the body: nested footnote refs inside
491                // a footnote body render as plain text and are not numbered.
492                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("&amp;"),
560            '<' => o.push_str("&lt;"),
561            '>' => o.push_str("&gt;"),
562            '"' => o.push_str("&quot;"),
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, &reg)
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, &reg, Some(&p));
626        let html = render(&doc, &reg);
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, &reg, Some(&p));
649        let html = render(&doc, &reg);
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        // No resolve_with_project call → resolved_refs stays empty.
660        let doc = parse_doc("See @ref[any.brf](fallback).\n");
661        let reg = crate::shortcode::Registry::with_builtins();
662        let html = render(&doc, &reg);
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, &reg);
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}