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

View.pudu

Pudu211 lines13.0 KB

GitHub ↗
1/** @Docgen.Site.View — everything a layout shows for one page, as template metadata */2module PuduLangDocgen.Site.View34import Std.Json as Json5import Std.Option as Option6import PuduLangDocgen as Docgen7import PuduLangDocgen.Markdown.Sanitize as Sanitize8import PuduLangDocgen.Meta as Meta9import PuduLangDocgen.Paths as Paths10import PuduLangDocgen.Site.Strings as Strings1112/** @Docgen.Site.Frame — navigation and history around a page */13export type Frame = {14  navbar: Array[Docgen.TocItem],15  toc: Array[Docgen.TocItem],16  trail: Array[Docgen.TocItem],17  previous: Option[Docgen.TocItem],18  next: Option[Docgen.TocItem],19  editUrl: Str,20  updated: Str,21  features: Array[Str],22  root: Str,23  pdf: Str,24  year: Str,25  tocPath: Str,26  navPath: Str27}2829/// Script loaded on pages with diagrams unless `_mermaidScript` names another.30const MERMAID: Str = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"3132/// Script loaded on pages with math unless `_mathScript` names another.33const MATHJAX: Str = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"3435/// The view of a page: site identity, navigation, content, and interface text.36/// Page metadata switches parts off with `_disableToc`, `_disableAffix`, `_disableBreadcrumb`,37/// `_disableNavbar`, `_disableFooter`, `_disableContribution`, `_disableNextArticle`, and38/// `_enableSearch: false`. The footer shows `_appFooter` markup, or else the `_footerLinks`39/// list and a `_copyright` line dated with the build year. Plain layouts show no page actions.40export fn view(page: &Docgen.Page, frame: &Frame) -> Docgen.Meta {41  let meta = page.meta42  let root = if frame.root.isEmpty() { Paths.rootOf(page.path) } else { frame.root }43  let local = |href: Str| if href.isEmpty() || Paths.remote(href) { href } else { root + href }44  let siteName = Meta.textOr(&meta, "_appName", Meta.textOr(&meta, "_appTitle", "Documentation"))45  let lang = Meta.textOr(&meta, "_lang", "")46  let layout = Meta.textOr(&meta, "_layout", "default")47  let plain = layout == "landing" || layout == "chromeless"48  let showToc = !plain && !Meta.flag(&meta, "_disableToc") && !frame.toc.isEmpty()49  let showAffix = !plain && !Meta.flag(&meta, "_disableAffix") && page.headings.filter(|found: Docgen.Heading| found.level == 2 || found.level == 3).length() > 150  let showNavbar = !Meta.flag(&meta, "_disableNavbar") && !frame.navbar.isEmpty()51  let editUrl = if Meta.flag(&meta, "_disableContribution") { "" } else { frame.editUrl }52  let hidePager = plain || Meta.flag(&meta, "_disableNextArticle")53  let previous = if hidePager { None } else { frame.previous }54  let next = if hidePager { None } else { frame.next }55  let baseUrl = Meta.textOr(&meta, "_baseUrl", "")56  let canonical = if baseUrl.isEmpty() { "" } else { (if baseUrl.endsWith("/") { baseUrl } else { baseUrl + "/" }) + page.path }57  let footer = Meta.textOr(&meta, "_appFooter", "")58  let footerLinks = links(&meta, "_footerLinks", root)59  let holder = Meta.textOr(&meta, "_copyright", "")60  let copyright = if holder.isEmpty() { "" } else { "© " + (if frame.year.isEmpty() { "" } else { frame.year + " " }) + holder }61  let pdfUrl = if frame.pdf.isEmpty() { "" } else { Paths.between(page.path, frame.pdf) }62  let fields: Array[(Str, Docgen.Meta)] = [63    ("lang", Docgen.Text(lang)), ("root", Docgen.Text(root)), ("kind", Docgen.Text(page.kind)),64    ("title", Docgen.Text(page.title)), ("siteTitle", Docgen.Text(Meta.textOr(&meta, "_appTitle", ""))),65    ("siteName", Docgen.Text(siteName)), ("description", Docgen.Text(Meta.textOr(&meta, "description", page.summary))),66    ("noindex", Docgen.Flag(Meta.flag(&meta, "_noindex"))), ("canonical", Docgen.Text(canonical)),67    ("favicon", Docgen.Text(local(Meta.textOr(&meta, "_appFaviconPath", "")))), ("logo", Docgen.Text(local(Meta.textOr(&meta, "_appLogoPath", "")))),68    ("customStyle", Docgen.Text(local(Meta.textOr(&meta, "_appStyle", "")))), ("customScript", Docgen.Text(local(Meta.textOr(&meta, "_appScript", "")))),69    ("searchEnabled", Docgen.Flag(Meta.textOr(&meta, "_enableSearch", "true") != "false")),70    ("showToc", Docgen.Flag(showToc)), ("showAffix", Docgen.Flag(showAffix)), ("showNavbar", Docgen.Flag(showNavbar)),71    ("showBreadcrumb", Docgen.Flag(!plain && !Meta.flag(&meta, "_disableBreadcrumb"))), ("showTocFilter", Docgen.Flag(!Meta.flag(&meta, "_disableTocFilter"))),72    ("layout", Docgen.Text(layout)), ("chromeless", Docgen.Flag(layout == "chromeless")),73    ("showFooter", Docgen.Flag(layout != "chromeless" && !Meta.flag(&meta, "_disableFooter"))),74    ("logoUrl", Docgen.Text(Meta.textOr(&meta, "_appLogoUrl", root + "index.html"))),75    ("keywords", Docgen.Text(listed(&meta, "keywords"))), ("author", Docgen.Text(Meta.textOr(&meta, "author", ""))),76    ("ogType", Docgen.Text(if layout == "landing" || page.path == "index.html" { "website" } else { "article" })),77    ("image", Docgen.Text(absolute(Meta.textOr(&meta, "image", Meta.textOr(&meta, "_appOgImagePath", "")), baseUrl, root))),78    ("twitter", Docgen.Text(Meta.textOr(&meta, "_twitterSite", ""))), ("themeColor", Docgen.Text(Meta.textOr(&meta, "_themeColor", ""))),79    ("touchIcon", Docgen.Text(local(Meta.textOr(&meta, "_appTouchIconPath", "")))),80    ("extraMeta", Docgen.Items(Meta.fieldsOf(&meta, "_meta").map(fn(entry: (Str, Docgen.Meta)) -> Docgen.Meta {81            let (name, content) = entry82            Docgen.Fields([("name", Docgen.Text(name)), ("content", content)])83          }))),84    ("structuredData", Docgen.Text(structured(page, frame, siteName, baseUrl))), ("analytics", Docgen.Text(Meta.textOr(&meta, "_googleAnalyticsTagId", ""))),85    ("pdfUrl", Docgen.Text(pdfUrl)),86    ("_rel", Docgen.Text(root)), ("_path", Docgen.Text(page.path)), ("_tocPath", Docgen.Text(frame.tocPath)),87    ("_tocRel", Docgen.Text(if frame.tocPath.isEmpty() { "" } else { Paths.rootOf(page.path) + Paths.directoryOf(frame.tocPath) })),88    ("_navPath", Docgen.Text(frame.navPath)), ("_navRel", Docgen.Text(if frame.navPath.isEmpty() { "" } else { Paths.between(page.path, frame.navPath) })),89    ("navbar", Docgen.Items(navbar(page, frame))), ("toc", Docgen.Text(if showToc { tocHtml(&frame.toc, page.path, &frame.trail) } else { "" })),90    ("breadcrumb", Docgen.Items(breadcrumb(page, frame, siteName))),91    ("hasActions", Docgen.Flag(!plain && (!editUrl.isEmpty() || !frame.updated.isEmpty() || !pdfUrl.isEmpty()))), ("editUrl", Docgen.Text(editUrl)), ("lastModified", Docgen.Text(frame.updated)),92    ("hasPager", Docgen.Flag(Option.isSome(&previous) || Option.isSome(&next))), ("previous", neighbor(&previous, page.path)), ("next", neighbor(&next, page.path)),93    ("headings", Docgen.Items(page.headings.filter(|found: Docgen.Heading| found.level == 2 || found.level == 3).map(|found: Docgen.Heading| Docgen.Fields([("id", Docgen.Text(found.id)), ("title", Docgen.Text(found.title)), ("level", Docgen.Whole(found.level))])))),94    ("body", Docgen.Text(page.body)), ("footer", Docgen.Text(Sanitize.clean(footer))),95    ("hasFooterLinks", Docgen.Flag(!footerLinks.isEmpty())), ("footerLinks", Docgen.Items(footerLinks)), ("copyright", Docgen.Text(copyright)),96    ("math", Docgen.Flag(frame.features.contains("math"))), ("mathScript", Docgen.Text(Meta.textOr(&meta, "_mathScript", MATHJAX))),97    ("mermaid", Docgen.Flag(frame.features.contains("mermaid"))), ("mermaidScript", Docgen.Text(Meta.textOr(&meta, "_mermaidScript", MERMAID))),98    ("text", Strings.forLanguage(lang, &Meta.fieldsOf(&meta, "_text")))99  ]100  Docgen.Fields(fields)101}102103/// Links listed under a key as objects with `name` and `href`; unsafe or incomplete entries are104/// left out and local destinations are made relative to the page.105fn links(meta: &Array[(Str, Docgen.Meta)], key: Str, root: Str) -> Array[Docgen.Meta] {106  var result: Array[Docgen.Meta] = []107  match Meta.get(meta, key) {108    case Some(Docgen.Items(entries)) => {109      for entry in entries {110        match entry {111          case Docgen.Fields(fields) => {112            let name = Meta.textOr(&fields, "name", "")113            let href = Meta.textOr(&fields, "href", "")114            if !name.isEmpty() && !href.isEmpty() && Paths.href(href) {115              result = result.push(Docgen.Fields([("name", Docgen.Text(name)), ("href", Docgen.Text(if Paths.remote(href) { href } else { root + href }))]))116            }117          }118          case _ => {}119        }120      }121    }122    case _ => {}123  }124  result125}126127/// A list of keywords written as text or as a list, joined by commas.128fn listed(meta: &Array[(Str, Docgen.Meta)], key: Str) -> Str {129  match Meta.get(meta, key) {130    case Some(Docgen.Items(values)) => values.map(|value: Docgen.Meta| match value { case Docgen.Text(written) => written case _ => "" }).filter(|written: Str| !written.isEmpty()).join(", ")131    case Some(Docgen.Text(written)) => written132    case _ => ""133  }134}135136/// An address made absolute against the site's base address, or page-relative without one.137fn absolute(path: Str, baseUrl: Str, root: Str) -> Str {138  if path.isEmpty() || Paths.remote(path) { return path }139  if baseUrl.isEmpty() { return root + path }140  (if baseUrl.endsWith("/") { baseUrl } else { baseUrl + "/" }) + path141}142143/// Structured breadcrumb data for search engines, when the site's base address is known.144fn structured(page: &Docgen.Page, frame: &Frame, siteName: Str, baseUrl: Str) -> Str {145  if baseUrl.isEmpty() { return "" }146  let base = if baseUrl.endsWith("/") { baseUrl } else { baseUrl + "/" }147  var items = [Json.object(&[("@type", Json.Text("ListItem")), ("position", Json.Number(1)), ("name", Json.Text(siteName)), ("item", Json.Text(base + "index.html"))])]148  var position = 2149  for item in frame.trail {150    if !item.href.isEmpty() && !Paths.remote(item.href) {151      items = items.push(Json.object(&[("@type", Json.Text("ListItem")), ("position", Json.Number(position)), ("name", Json.Text(item.title)), ("item", Json.Text(base + Paths.pathOf(item.href)))]))152      position = position + 1153    }154  }155  let data = Json.object(&[("@context", Json.Text("https://schema.org")), ("@type", Json.Text("BreadcrumbList")), ("itemListElement", Json.list(&items))])156  Json.encode(&data).replace("</", "<\\/")157}158159/// Nested navigation as HTML lists; items on the trail to the page start expanded.160export fn tocHtml(items: &Array[Docgen.TocItem], page: Str, trail: &Array[Docgen.TocItem]) -> Str {161  var pieces = ["<ul>"]162  for item in *items {163    let open = item.expanded || trail.contains(item)164    let classes = (if item.children.isEmpty() { "" } else { "has-children" }) + (if open && !item.children.isEmpty() { " expanded" } else { "" })165    pieces = pieces.push(if classes.isEmpty() { "<li>" } else { "<li class=\"" + classes + "\">" })166    if item.href.isEmpty() && item.children.isEmpty() {167      pieces = pieces.push("<span class=\"toc-heading\">" + Paths.escape(item.title) + "</span>")168    } else if item.href.isEmpty() {169      pieces = pieces.push("<button type=\"button\" class=\"toc-group\" aria-expanded=\"" + (if open { "true" } else { "false" }) + "\">" + Paths.escape(item.title) + "</button>")170    } else {171      let href = if Paths.remote(item.href) { item.href } else { Paths.between(page, item.href) }172      let current = if Paths.pathOf(item.href) == page { " class=\"active\" aria-current=\"page\"" } else { "" }173      pieces = pieces.push("<a href=\"" + Paths.escape(href) + "\"" + current + ">" + Paths.escape(item.title) + "</a>")174    }175    if !item.children.isEmpty() { pieces = pieces.push(tocHtml(&item.children, page, trail)) }176    pieces = pieces.push("</li>")177  }178  pieces.push("</ul>").join("")179}180181/// Top navigation items; the one leading to the page's section is active.182fn navbar(page: &Docgen.Page, frame: &Frame) -> Array[Docgen.Meta] {183  frame.navbar.map(fn(item: Docgen.TocItem) -> Docgen.Meta {184      let href = if Paths.remote(item.href) || item.href.isEmpty() { item.href } else { Paths.between(page.path, item.href) }185      let active = (!frame.trail.isEmpty() && frame.trail[0].title == item.title) || Paths.pathOf(item.href) == page.path186      Docgen.Fields([("name", Docgen.Text(item.title)), ("href", Docgen.Text(href)), ("active", Docgen.Flag(active))])187    })188}189190/// The site root followed by the page's navigation trail; the last entry has no link.191fn breadcrumb(page: &Docgen.Page, frame: &Frame, siteName: Str) -> Array[Docgen.Meta] {192  var entries = [Docgen.Fields([("name", Docgen.Text(siteName)), ("href", Docgen.Text(Paths.between(page.path, "index.html")))])]193  let trail = if frame.trail.isEmpty() { [Docgen.TocItem{title: page.title, href: page.path, uid: "", expanded: false, children: []}] } else { frame.trail }194  var index = 0195  for item in trail {196    let last = index == trail.length() - 1197    let href = if last || item.href.isEmpty() || Paths.remote(item.href) { "" } else { Paths.between(page.path, item.href) }198    entries = entries.push(Docgen.Fields([("name", Docgen.Text(if last { page.title } else { item.title })), ("href", Docgen.Text(href))]))199    index = index + 1200  }201  entries202}203204/// A previous or next page as template metadata.205fn neighbor(item: &Option[Docgen.TocItem], page: Str) -> Docgen.Meta {206  match item {207    case Some(found) => Docgen.Fields([("name", Docgen.Text(found.title)), ("href", Docgen.Text(Paths.between(page, found.href)))])208    case None => Docgen.Nothing209  }210}211