
Render.pudu
Pudu269 lines14.0 KB
1/** @Docgen.Markdown.Render — article blocks rendered as accessible HTML */2module PuduLangDocgen.Markdown.Render34import Std.Bytes as Bytes5import Std.Map as Map6import PuduLangDocgen.Constants.Codes as Codes7import PuduLangDocgen as Docgen8import PuduLangDocgen.Inline as Inline9import PuduLangDocgen.Markdown.Highlight as Highlight10import PuduLangDocgen.Markdown.Languages as Languages11import PuduLangDocgen.Markdown.Phrase as Phrase12import PuduLangDocgen.Markdown.Sanitize as Sanitize13import PuduLangDocgen.Markdown.Syntax as Syntax14import PuduLangDocgen.Paths as Paths1516/** @Docgen.Markdown.Rendered — page HTML with its outline, links, and needs */17export type Rendered = {18 html: Str,19 title: Str,20 summary: Str,21 headings: Array[Docgen.Heading],22 links: Array[Docgen.Link],23 features: Array[Str],24 diagnostics: Array[Docgen.Diagnostic]25}262728const SUMMARY_LIMIT: Int = 200293031export fn render(blocks: &Array[Syntax.Block], context: &Phrase.Scope) -> Rendered {32 var notes: Array[(Str, Array[Syntax.Block])] = []33 var body: Array[Syntax.Block] = []34 for item in *blocks {35 match item {36 case Syntax.Footnote(label, content) => { notes = notes.push((label, content)) }37 case other => { body = body.push(other) }38 }39 }40 let out = footnotes(sequence(Phrase.start(), &body, context, context.source, false), ¬es, context)41 var title = ""42 for found in out.headings {43 if title.isEmpty() && found.level == 1 { title = found.title }44 }45 Rendered{html: out.pieces.join(""), title: title, summary: summaryOf(blocks), headings: out.headings, links: out.links, features: out.features, diagnostics: out.diagnostics}46}474849fn footnotes(out: Phrase.Out, notes: &Array[(Str, Array[Syntax.Block])], context: &Phrase.Scope) -> Phrase.Out {50 if out.notes.isEmpty() { return out }51 var held = Phrase.emit(out, "<section class=\"footnotes\" role=\"doc-endnotes\">\n<hr>\n<ol>\n")52 var index = 053 while index < held.notes.length() {54 let label = held.notes[index]55 let id = Paths.slug(label)56 var content: Array[Syntax.Block] = []57 for (name, blocks) in *notes {58 if name == label { content = blocks }59 }60 held = sequence(Phrase.emit(held, "<li id=\"fn-" + id + "\">\n"), &content, context, context.source, false)61 held = Phrase.emit(held, "<a href=\"#fnref-" + id + "\" class=\"footnote-back\" aria-label=\"Back to reference\">↩</a></li>\n")62 index = index + 163 }64 Phrase.emit(held, "</ol>\n</section>\n")65}666768export fn summaryOf(blocks: &Array[Syntax.Block]) -> Str {69 for held in *blocks {70 match held {71 case Syntax.Paragraph(children) => {72 let text = Inline.plain(&children).replace("\n", " ").trim()73 if text.length() <= SUMMARY_LIMIT { return text }74 let cut = text.take(SUMMARY_LIMIT)75 let space = cut.split(" ")76 return space.slice(0, if space.length() > 1 { space.length() - 1 } else { 1 }).join(" ") + "…"77 }78 case Syntax.Included(_path, children) => {79 let found = summaryOf(&children)80 if !found.isEmpty() { return found }81 }82 case _ => {}83 }84 }85 ""86}878889fn sequence(out: Phrase.Out, blocks: &Array[Syntax.Block], context: &Phrase.Scope, origin: Str, tight: Bool) -> Phrase.Out {90 var held = out91 for item in *blocks { held = block(held, &item, context, origin, tight) }92 held93}949596fn block(out: Phrase.Out, item: &Syntax.Block, context: &Phrase.Scope, origin: Str, tight: Bool) -> Phrase.Out {97 match item {98 case Syntax.Heading(title) => heading(out, &title, context, origin)99 case Syntax.Paragraph(children) => {100 if tight { Phrase.inlines(out, &children, context, origin) } else { Phrase.emit(Phrase.inlines(Phrase.emit(out, "<p>"), &children, context, origin), "</p>\n") }101 }102 case Syntax.CodeBlock(sample) => code(out, &sample, context)103 case Syntax.Quote(children) => Phrase.emit(sequence(Phrase.emit(out, "<blockquote>\n"), &children, context, origin, false), "</blockquote>\n")104 case Syntax.ListBlock(listing) => list(out, &listing, context, origin)105 case Syntax.TableBlock(held) => table(out, &held, context, origin)106 case Syntax.Rule => Phrase.emit(out, "<hr>\n")107 case Syntax.HtmlBlock(raw) => Phrase.emit(out, Sanitize.clean(raw) + "\n")108 case Syntax.Alert(notice) => alert(out, ¬ice, context, origin)109 case Syntax.TabGroup(group) => tabs(out, &group, context, origin)110 case Syntax.Included(located, children) => sequence(out, &children, context, located, tight)111 case Syntax.Video(address) => video(out, address, origin)112 case Syntax.MathBlock(written) => Phrase.emit(Phrase.need(out, "math"), "<div class=\"math display\">\\[" + Paths.escape(written) + "\\]</div>\n")113 case Syntax.Grid(columns) => grid(out, &columns, context, origin)114 case Syntax.Image(figure) => image(out, &figure, context, origin)115 case Syntax.Footnote(_label, _content) => out116 }117}118119120fn heading(out: Phrase.Out, title: &Syntax.Title, context: &Phrase.Scope, origin: Str) -> Phrase.Out {121 let text = Inline.plain(&title.content).trim()122 let base = if title.id.isEmpty() { Paths.slug(text) } else { title.id }123 var id = base124 var count = 1125 while out.ids.contains(id) {126 count = count + 1127 id = base + "-" + show(count)128 }129 let tag = "h" + show(title.level)130 let recorded = Phrase.Out{..out, ids: out.ids.push(id), headings: out.headings.push(Docgen.Heading{id: id, title: text, level: title.level})}131 let opened = Phrase.emit(recorded, "<" + tag + " id=\"" + Paths.escape(id) + "\">")132 let filled = Phrase.inlines(opened, &title.content, context, origin)133 let link = if title.level == 1 { "" } else { "<a class=\"anchor\" href=\"#" + Paths.escape(id) + "\" aria-label=\"Link to this section\"></a>" }134 Phrase.emit(filled, link + "</" + tag + ">\n")135}136137138fn code(out: Phrase.Out, sample: &Syntax.Sample, context: &Phrase.Scope) -> Phrase.Out {139 let language = sample.language.toLower()140 if language == "plantuml" || language == "puml" {141 let address = Map.getOr(&context.settings.diagrams, sample.text, context.settings.plantUml + "/~h" + Bytes.encodeHex(&Bytes.fromText(sample.text)))142 return Phrase.emit(out, "<p class=\"diagram\"><img class=\"plantuml\" src=\"" + Paths.escape(address) + "\" alt=\"" + Paths.escape(if sample.title.isEmpty() { "Diagram" } else { sample.title }) + "\" loading=\"lazy\"></p>\n")143 }144 if language == "mermaid" {145 return Phrase.emit(Phrase.need(out, "mermaid"), "<pre class=\"mermaid\">" + Paths.escape(sample.text) + "</pre>\n")146 }147 var lines: Array[Str] = []148 var number = 1149 for line in Highlight.lines(language, sample.text) {150 lines = lines.push(if sample.highlight.contains(number) { "<span class=\"line highlight\">" + line + "</span>" } else { line })151 number = number + 1152 }153 let label = if sample.title.isEmpty() { Paths.escape(if language.isEmpty() { "Code" } else { Languages.title(Languages.identify(language)) }) } else { Paths.escape(sample.title) }154 let attribute = if language.isEmpty() { "" } else { " class=\"lang-" + Paths.escape(language) + "\"" }155 Phrase.emit(out, "<div class=\"code-block\"><div class=\"code-header\"><span class=\"code-label\">" + label + "</span><button type=\"button\" class=\"copy\" aria-label=\"Copy code\">Copy</button></div><pre tabindex=\"0\"><code" + attribute + ">" + lines.join("\n") + "</code></pre></div>\n")156}157158159fn list(out: Phrase.Out, listing: &Syntax.Listing, context: &Phrase.Scope, origin: Str) -> Phrase.Out {160 let tag = if listing.ordered { "ol" } else { "ul" }161 let start = if listing.ordered && listing.start != 1 { " start=\"" + show(listing.start) + "\"" } else { "" }162 let tasks = listing.items.filter(|entry: Syntax.Item| entry.checked >= 0).length() > 0163 var held = Phrase.emit(out, "<" + tag + start + (if tasks { " class=\"tasks\"" } else { "" }) + ">\n")164 for entry in listing.items {165 let box = if entry.checked == 1 { "<input type=\"checkbox\" disabled checked> " } else if entry.checked == 0 { "<input type=\"checkbox\" disabled> " } else { "" }166 held = Phrase.emit(sequence(Phrase.emit(held, "<li>" + box), &entry.blocks, context, origin, listing.tight), "</li>\n")167 }168 Phrase.emit(held, "</" + tag + ">\n")169}170171172fn table(out: Phrase.Out, held: &Syntax.Table, context: &Phrase.Scope, origin: Str) -> Phrase.Out {173 var result = Phrase.emit(out, "<div class=\"table-wrapper\"><table>\n<thead><tr>")174 var column = 0175 for cell in held.header {176 result = Phrase.emit(Phrase.inlines(Phrase.emit(result, "<th" + aligned(&held.align, column) + ">"), &cell, context, origin), "</th>")177 column = column + 1178 }179 result = Phrase.emit(result, "</tr></thead>\n<tbody>\n")180 for row in held.rows {181 result = Phrase.emit(result, "<tr>")182 column = 0183 for cell in row {184 result = Phrase.emit(Phrase.inlines(Phrase.emit(result, "<td" + aligned(&held.align, column) + ">"), &cell, context, origin), "</td>")185 column = column + 1186 }187 result = Phrase.emit(result, "</tr>\n")188 }189 Phrase.emit(result, "</tbody></table></div>\n")190}191192193fn aligned(align: &Array[Str], column: Int) -> Str {194 if column < align.length() && !align[column].isEmpty() { " class=\"align-" + align[column] + "\"" } else { "" }195}196197198fn alert(out: Phrase.Out, notice: &Syntax.Notice, context: &Phrase.Scope, origin: Str) -> Phrase.Out {199 let lowered = notice.kind.toLower()200 let title = Map.getOr(&context.settings.alertTitles, lowered, notice.kind.take(1) + lowered.drop(1))201 let classes = Map.getOr(&context.settings.alertClasses, notice.kind, "alert alert-" + lowered)202 let opened = Phrase.emit(out, "<div class=\"" + Paths.escape(classes) + "\" role=\"note\"><p class=\"alert-title\">" + Paths.escape(title) + "</p>\n")203 Phrase.emit(sequence(opened, ¬ice.blocks, context, origin, false), "</div>\n")204}205206207208fn tabs(out: Phrase.Out, group: &Array[Syntax.Tab], context: &Phrase.Scope, origin: Str) -> Phrase.Out {209 let number = out.groups + 1210 var held = Phrase.need(Phrase.Out{..out, groups: number}, "tabs")211 held = Phrase.emit(held, "<div class=\"tabs\"><div class=\"tab-list\" role=\"tablist\">")212 var ids: Array[Str] = []213 for tab in *group {214 if ids.contains(tab.id) { continue }215 let key = "tab-" + show(number) + "-" + show(ids.length())216 held = Phrase.emit(held, "<button type=\"button\" role=\"tab\" id=\"" + key + "\" aria-selected=\"" + (if ids.isEmpty() { "true" } else { "false" }) + "\" data-tab=\"" + Paths.escape(tab.id) + "\">" + Paths.escape(tab.title) + "</button>")217 ids = ids.push(tab.id)218 }219 held = Phrase.emit(held, "</div>\n")220 var first: Array[Str] = []221 for tab in *group {222 let position = ids.indexOf(tab.id)223 let key = "tab-" + show(number) + "-" + show(position)224 let shown = position == 0 && !first.contains(tab.id)225 if shown { first = first.push(tab.id) }226 let condition = if tab.condition.isEmpty() { "" } else { " data-condition=\"" + Paths.escape(tab.condition) + "\"" }227 held = Phrase.emit(held, "<section class=\"tab-panel\" role=\"tabpanel\" aria-labelledby=\"" + key + "\" data-tab=\"" + Paths.escape(tab.id) + "\"" + condition + (if shown { "" } else { " hidden" }) + ">\n")228 held = Phrase.emit(sequence(held, &tab.blocks, context, origin, false), "</section>\n")229 }230 Phrase.emit(held, "</div>\n")231}232233234fn video(out: Phrase.Out, address: Str, origin: Str) -> Phrase.Out {235 if !Paths.href(address) || !address.toLower().startsWith("https://") {236 return Phrase.report(out, Docgen.warning(Codes.VIDEO_INSECURE, origin, 1, "video address must use HTTPS: " + address))237 }238 Phrase.emit(out, "<div class=\"video\"><iframe src=\"" + Paths.escape(address) + "\" title=\"Video\" loading=\"lazy\" allowfullscreen></iframe></div>\n")239}240241242fn grid(out: Phrase.Out, columns: &Array[Syntax.Column], context: &Phrase.Scope, origin: Str) -> Phrase.Out {243 var held = Phrase.emit(out, "<div class=\"row\">\n")244 for column in *columns {245 let opened = Phrase.emit(held, "<div class=\"column\" style=\"flex-grow: " + show(column.span) + "\">\n")246 held = Phrase.emit(sequence(opened, &column.blocks, context, origin, false), "</div>\n")247 }248 Phrase.emit(held, "</div>\n")249}250251252fn image(out: Phrase.Out, figure: &Syntax.Figure, context: &Phrase.Scope, origin: Str) -> Phrase.Out {253 let (checked, src) = Phrase.destination(out, &Syntax.Target{destination: figure.source, title: "", line: figure.line}, context, origin, true)254 if src.isEmpty() { return checked }255 let alt = if figure.kind == "icon" { "" } else { figure.alt }256 let picture = "<img src=\"" + Paths.escape(src) + "\" alt=\"" + Paths.escape(alt) + "\" loading=\"lazy\">"257 var held = Phrase.emit(checked, "<figure class=\"image image-" + Paths.escape(figure.kind) + "\">")258 if figure.lightbox.isEmpty() { held = Phrase.emit(held, picture) } else {259 let (linked, large) = Phrase.destination(held, &Syntax.Target{destination: figure.lightbox, title: "", line: figure.line}, context, origin, true)260 held = if large.isEmpty() { Phrase.emit(linked, picture) } else { Phrase.emit(linked, "<a href=\"" + Paths.escape(large) + "\">" + picture + "</a>") }261 }262 if !figure.caption.isEmpty() {263 let tag = if figure.kind == "complex" { "details" } else { "figcaption" }264 let lead = if figure.kind == "complex" { "<details><summary>Description</summary>\n" } else { "<figcaption>\n" }265 held = Phrase.emit(sequence(Phrase.emit(held, lead), &figure.caption, context, origin, false), "</" + tag + ">")266 }267 Phrase.emit(held, "</figure>\n")268}269