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

Phrase.pudu

Pudu202 lines10.5 KB

GitHub ↗
1/** @Docgen.Markdown.Phrase — inline HTML with checked links, images, and cross references */2module PuduLangDocgen.Markdown.Phrase34import Std.Map as Map5import PuduLangDocgen.Constants.Codes as Codes6import PuduLangDocgen as Docgen7import PuduLangDocgen.Markdown.Syntax as Syntax8import PuduLangDocgen.Paths as Paths9import PuduLangDocgen.References as References1011/** @Docgen.Markdown.Rendering — site-wide choices about how article content renders */12export type Rendering = { newTab: Bool, plantUml: Str, diagrams: Map[Str, Str], alertClasses: Map[Str, Str], alertTitles: Map[Str, Str] }1314/** @Docgen.Markdown.Scope — where a page is published and what its links may reach */15export type Scope = { page: Str, source: Str, outputs: Map[Str, Str], references: Map[Str, Docgen.Reference], settings: Rendering }1617/** @Docgen.Markdown.Out — rendered pieces and facts gathered while rendering one page */18export type Out = {19  pieces: Array[Str],20  headings: Array[Docgen.Heading],21  ids: Array[Str],22  links: Array[Docgen.Link],23  diagnostics: Array[Docgen.Diagnostic],24  features: Array[Str],25  groups: Int,26  notes: Array[Str]27}2829/// Server that draws PlantUML diagrams unless configured otherwise.30const PLANT_UML: Str = "https://www.plantuml.com/plantuml/svg"3132/// File extensions played as video when an image names them.33const VIDEO_FILES: Array[Str] = [".mp4", ".webm", ".ogg", ".ogv", ".mov"]3435/// Rendering choices used when a site configures none.36export fn rendering() -> Rendering {37  Rendering{newTab: false, plantUml: PLANT_UML, diagrams: mapOf([]), alertClasses: mapOf([]), alertTitles: mapOf([])}38}3940/// An empty accumulator.41export fn start() -> Out {42  Out{pieces: [], headings: [], ids: [], links: [], diagnostics: [], features: [], groups: 0, notes: []}43}4445/// The accumulator with text appended.46export fn emit(out: Out, text: Str) -> Out { Out{..out, pieces: out.pieces.push(text)} }4748/// The accumulator with a diagnostic recorded.49export fn report(out: Out, found: Docgen.Diagnostic) -> Out { Out{..out, diagnostics: out.diagnostics.push(found)} }5051/// The accumulator noting that a page needs a client feature such as `math` or `mermaid`.52export fn need(out: Out, feature: Str) -> Out {53  if out.features.contains(feature) { out } else { Out{..out, features: out.features.push(feature)} }54}5556/// Inline nodes appended as HTML; `origin` is the file the nodes were written in.57export fn inlines(out: Out, nodes: &Array[Syntax.Inline], context: &Scope, origin: Str) -> Out {58  var held = out59  for node in *nodes { held = inline(held, &node, context, origin) }60  held61}6263/// The browser destination of a written link, recording local targets for validation.64/// Unsafe and unknown destinations produce diagnostics and answer the empty text.65export fn destination(out: Out, target: &Syntax.Target, context: &Scope, origin: Str, image: Bool) -> (Out, Str) {66  let written = target.destination67  if !Paths.href(written) {68    return (report(out, Docgen.warning(Codes.UNSAFE_DESTINATION, origin, target.line, "unsafe destination is not linked: " + written)), "")69  }70  if Paths.remote(written) { return (out, written) }71  let local = Out{..out, links: out.links}72  if written.startsWith("#") {73    return (Out{..local, links: local.links.push(Docgen.Link{target: context.page + written, line: target.line})}, written)74  }75  let path = Paths.pathOf(written)76  let suffix = Paths.suffixOf(written)77  let resolved = match Paths.join(Paths.directoryOf(origin), path) {78    case Ok(found) => found79    case Err(reason) => { return (report(out, Docgen.warning(Codes.DESTINATION_ESCAPES, origin, target.line, reason + ": " + written)), "") }80  }81  let published = match Map.get(&context.outputs, resolved) {82    case Some(found) => found83    case None => {84      let code = if image { Codes.IMAGE_MISSING } else { Codes.LINK_BROKEN }85      let noun = if image { "image" } else { "link target" }86      return (report(out, Docgen.warning(code, origin, target.line, noun + " not found: " + resolved)), written)87    }88  }89  let recorded = Out{..out, links: out.links.push(Docgen.Link{target: published + fragmentOf(suffix), line: target.line})}90  (recorded, Paths.between(context.page, published) + suffix)91}9293/// The fragment part of a query and fragment suffix, including `#`.94fn fragmentOf(suffix: Str) -> Str {95  let at = suffix.indexOf("#")96  if at < 0 { "" } else { suffix.drop(at) }97}9899/// A browser destination to a reference, relative to the page unless remote.100export fn referenceHref(reference: &Docgen.Reference, context: &Scope) -> Str {101  if Paths.remote(reference.href) { reference.href } else { Paths.between(context.page, reference.href) }102}103104/// One inline node appended as HTML.105fn inline(out: Out, node: &Syntax.Inline, context: &Scope, origin: Str) -> Out {106  match node {107    case Syntax.Text(written) => emit(out, Paths.escape(written))108    case Syntax.Code(written) => emit(out, "<code>" + Paths.escape(written) + "</code>")109    case Syntax.Emphasis(children) => emit(inlines(emit(out, "<em>"), &children, context, origin), "</em>")110    case Syntax.Strong(children) => emit(inlines(emit(out, "<strong>"), &children, context, origin), "</strong>")111    case Syntax.Strike(children) => emit(inlines(emit(out, "<del>"), &children, context, origin), "</del>")112    case Syntax.Subscript(children) => emit(inlines(emit(out, "<sub>"), &children, context, origin), "</sub>")113    case Syntax.Superscript(children) => emit(inlines(emit(out, "<sup>"), &children, context, origin), "</sup>")114    case Syntax.Inserted(children) => emit(inlines(emit(out, "<ins>"), &children, context, origin), "</ins>")115    case Syntax.Marked(children) => emit(inlines(emit(out, "<mark>"), &children, context, origin), "</mark>")116    case Syntax.Note(label) => note(out, label)117    case Syntax.Anchor(target, children) => anchor(out, &target, &children, context, origin)118    case Syntax.Picture(target, alt) => picture(out, &target, alt, context, origin)119    case Syntax.Xref(reference) => xref(out, &reference, context, origin)120    case Syntax.Math(written) => emit(need(out, "math"), "<span class=\"math\">\\(" + Paths.escape(written) + "\\)</span>")121    case Syntax.Markup(written) => emit(out, written)122    case Syntax.LineBreak => emit(out, "<br>\n")123    case Syntax.SoftBreak => emit(out, "\n")124    case Syntax.Fragment(located, children) => inlines(out, &children, context, located)125  }126}127128/// A link; remote links carry the `external` class and may open a new tab.129fn anchor(out: Out, target: &Syntax.Target, children: &Array[Syntax.Inline], context: &Scope, origin: Str) -> Out {130  let (checked, href) = destination(out, target, context, origin, false)131  if href.isEmpty() { return inlines(checked, children, context, origin) }132  let title = if target.title.isEmpty() { "" } else { " title=\"" + Paths.escape(target.title) + "\"" }133  let external = if Paths.remote(href) && !href.toLower().startsWith("mailto:") {134    if context.settings.newTab { " class=\"external\" target=\"_blank\" rel=\"noopener noreferrer\"" } else { " class=\"external\"" }135  } else { "" }136  let opened = emit(checked, "<a href=\"" + Paths.escape(href) + "\"" + title + external + ">")137  emit(inlines(opened, children, context, origin), "</a>")138}139140/// A numbered reference to a footnote; the first reference of a label carries its anchor.141fn note(out: Out, label: Str) -> Out {142  let id = Paths.slug(label)143  let seen = out.notes.contains(label)144  let recorded = if seen { out } else { Out{..out, notes: out.notes.push(label)} }145  var number = 1146  for held in recorded.notes {147    if held == label { break }148    number = number + 1149  }150  let marker = if seen { "" } else { " id=\"fnref-" + id + "\"" }151  emit(recorded, "<sup class=\"footnote-ref\"><a href=\"#fn-" + id + "\"" + marker + ">" + show(number) + "</a></sup>")152}153154/// An embedded player for an address on a known video host or naming a video file, or the155/// empty text for other addresses.156fn media(address: Str, alt: Str) -> Str {157  let lowered = address.toLower()158  let title = Paths.escape(if alt.isEmpty() { "Video" } else { alt })159  var embed = ""160  if lowered.startsWith("https://www.youtube.com/watch?v=") || lowered.startsWith("https://youtube.com/watch?v=") {161    embed = "https://www.youtube-nocookie.com/embed/" + address.split("v=")[1].split("&")[0]162  } else if lowered.startsWith("https://youtu.be/") {163    embed = "https://www.youtube-nocookie.com/embed/" + address.drop(17).split("?")[0]164  } else if lowered.startsWith("https://vimeo.com/") {165    embed = "https://player.vimeo.com/video/" + address.drop(18).split("?")[0]166  }167  if !embed.isEmpty() { return "<span class=\"video\"><iframe src=\"" + Paths.escape(embed) + "\" title=\"" + title + "\" loading=\"lazy\" allowfullscreen></iframe></span>" }168  for suffix in VIDEO_FILES {169    if Paths.pathOf(lowered).endsWith(suffix) { return "<video controls preload=\"metadata\" src=\"" + Paths.escape(address) + "\" title=\"" + title + "\"></video>" }170  }171  ""172}173174/// An image loaded lazily with alternate text, or a player when it names a video.175fn picture(out: Out, target: &Syntax.Target, alt: Str, context: &Scope, origin: Str) -> Out {176  let player = if Paths.href(target.destination) && Paths.remote(target.destination) { media(target.destination, alt) } else { "" }177  if !player.isEmpty() { return emit(out, player) }178  let (checked, src) = destination(out, target, context, origin, true)179  if src.isEmpty() { return emit(checked, Paths.escape(alt)) }180  let title = if target.title.isEmpty() { "" } else { " title=\"" + Paths.escape(target.title) + "\"" }181  emit(checked, "<img src=\"" + Paths.escape(src) + "\" alt=\"" + Paths.escape(alt) + "\"" + title + " loading=\"lazy\">")182}183184/// A cross reference linked to its target, or marked unresolved with a warning.185fn xref(out: Out, span: &Syntax.XrefSpan, context: &Scope, origin: Str) -> Out {186  match Map.get(&context.references, span.uid) {187    case Some(reference) => {188      let opened = emit(out, "<a class=\"xref\" href=\"" + Paths.escape(referenceHref(&reference, context)) + "\">")189      let named = if span.text.isEmpty() {190        emit(opened, Paths.escape(if span.display == "fullName" && !reference.fullName.isEmpty() { reference.fullName } else { reference.name }))191      } else { inlines(opened, &span.text, context, origin) }192      emit(named, "</a>")193    }194    case None => {195      let warned = report(out, Docgen.warning(Codes.XREF_UNRESOLVED, origin, span.line, References.UNRESOLVED + span.uid))196      let opened = emit(warned, "<span class=\"xref unresolved\">")197      let named = if span.text.isEmpty() { emit(opened, Paths.escape(if span.optional { "@" + span.uid } else { span.uid })) } else { inlines(opened, &span.text, context, origin) }198      emit(named, "</span>")199    }200  }201}202