Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-docgen

Documentation publishing for Pudu: articles, API references, navigation, search, and static websites

0.1.0Apache-2.01

InstallClose

Render.pudu

Pudu269 lines14.0 KB

GitHub ↗
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}2627/// Longest summary taken from the first paragraph.28const SUMMARY_LIMIT: Int = 2002930/// Blocks rendered for a page; the title is the first level-one heading.31export 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), &notes, 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}4748/// The notes a page referenced, in reference order, each linking back to its first reference.49fn 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}6667/// Plain text of the first paragraph, cut at a word near the summary limit.68export 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}8788/// Blocks in order; `tight` renders paragraphs without wrapping elements.89fn 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}9495/// One block as HTML.96fn 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, &notice, 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}118119/// A heading with a unique fragment and a permalink.120fn 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}136137/// A code sample with a caption bar, copy control, and emphasized lines.138fn 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}157158/// A bullet, numbered, or task list.159fn 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}170171/// A table inside a scrollable region with per-column alignment.172fn 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}191192/// The alignment class of a column.193fn aligned(align: &Array[Str], column: Int) -> Str {194  if column < align.length() && !align[column].isEmpty() { " class=\"align-" + align[column] + "\"" } else { "" }195}196197/// An alert box titled by its kind.198fn 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, &notice.blocks, context, origin, false), "</div>\n")204}205206/// A tab group with one button per tab id; tabs with the same id across groups switch207/// together, and a tab with a condition shows only while its condition's id is selected.208fn 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}232233/// An embedded HTTPS video.234fn 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}240241/// A row of columns sized by their spans.242fn 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}250251/// A figure with optional lightbox link and caption.252fn 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