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

Site.pudu

Pudu204 lines11.5 KB

GitHub ↗
1/** @Docgen.Build.Site — navigation, page frames, templated pages, and site-wide files */2module PuduLangDocgen.Build.Site34import Std.Json as Json5import Std.Map as Map6import PuduLangDocgen.Configuration as Configuration7import PuduLangDocgen as Docgen8import PuduLangDocgen.Meta as Meta9import PuduLangDocgen.Navigation as Navigation10import PuduLangDocgen.Navigation.Resolve as Resolve11import PuduLangDocgen.Paths as Paths12import PuduLangDocgen.References as References13import PuduLangDocgen.Site.Redirect as Redirect14import PuduLangDocgen.Site.Search as Search15import PuduLangDocgen.Site.Sitemap as Sitemap16import PuduLangDocgen.Site.View as View17import PuduLangDocgen.Theme as Theme1819/** @Docgen.Build.Book — a resolved table of contents with where it publishes and its metadata */20export type Book = { output: Str, items: Array[Docgen.TocItem], meta: Array[(Str, Docgen.Meta)], nested: Bool }2122/** @Docgen.Build.Context — what every page of the site is framed with */23export type Context = {24  config: Configuration.Config,25  books: Array[Book],26  look: Theme.Look,27  history: Map[Str, Str],28  repository: Configuration.Contribution,29  global: Array[(Str, Docgen.Meta)],30  generator: Str,31  today: Str32}3334/// Edit link patterns by host family; `\{repo\}`, `\{branch\}`, and `\{path\}` are filled in.35const EDIT_PATTERNS: Array[(Str, Str)] = [36  ("github", "\{repo\}/blob/\{branch\}/\{path\}"), ("gitlab", "\{repo\}/-/blob/\{branch\}/\{path\}"),37  ("vso", "\{repo\}?path=/\{path\}&version=GB\{branch\}"), ("azure", "\{repo\}?path=/\{path\}&version=GB\{branch\}"),38  ("bitbucket", "\{repo\}/src/\{branch\}/\{path\}")39]4041/// Tables of contents resolved against the site; entries without an output only serve as42/// targets of other tables. Tables another table links are nested and rank after tables of43/// their own folder unless they declare an `order`.44export fn books(tocs: &Array[(Str, Str, Navigation.Toc)], links: &Resolve.Links) -> (Array[Book], Array[Docgen.Diagnostic]) {45  var referenced: Array[Str] = []46  for (_source, _output, toc) in *tocs {47    for item in flattened(&toc.items) {48      if Navigation.isToc(Paths.pathOf(item.href)) || item.href.endsWith("/") { referenced = referenced.push(item.href) }49    }50  }51  var result: Array[Book] = []52  var problems: Array[Docgen.Diagnostic] = []53  for (source, output, toc) in *tocs {54    if output.isEmpty() { continue }55    let (items, found) = Resolve.resolve(source, links)56    problems = problems.concat(found)57    let name = source.split("/")[source.split("/").length() - 1]58    let folder = Paths.directoryOf(source).split("/")59    let nested = referenced.filter(|href: Str| href.endsWith(name) || href.endsWith(folder[folder.length() - 1] + "/")).length() > 060    result = result.push(Book{output: Resolve.outputOf(output), items: items, meta: toc.meta, nested: nested})61  }62  (result, problems)63}6465/// Every page rendered through the layout, with redirect pages standing alone.66export fn rendered(pages: &Array[Docgen.Page], context: &Context) -> Array[Docgen.Artifact] {67  let root = context.books.filter(|book: Book| book.output == "toc.json")68  let navbar = if root.isEmpty() { [] } else { root[0].items.map(|item: Docgen.TocItem| Docgen.TocItem{..item, children: []}) }69  let rootItems = if root.isEmpty() { [] } else { root[0].items }70  let candidates = context.books.map(|book: Book| Resolve.Candidate{output: book.output, order: orderOf(&book), pages: Resolve.pagesOf(&book.items)})71  var result: Array[Docgen.Artifact] = []72  for page in *pages {73    if page.kind == "redirect" {74      result = result.push(Docgen.Artifact{path: page.path, content: Redirect.page(page.title, Meta.textOr(&page.meta, "redirect_url", "index.html"))})75      continue76    }77    let governing = Resolve.governing(page.path, &candidates)78    let book = context.books.filter(|held: Book| held.output == governing)79    let items = if book.isEmpty() { [] } else { book[0].items }80    let fromRoot = Resolve.trail(&rootItems, page.path)81    let (previous, next) = Resolve.neighbors(&items, page.path)82    let features = ["math", "mermaid"].filter(|feature: Str| page.body.contains("class=\"" + feature))83    let pdf = if book.isEmpty() || !Meta.flag(&book[0].meta, "pdf") { "" } else { pdfOf(&book[0]) }84    let frame = View.Frame {85      navbar: navbar, toc: items, trail: if fromRoot.isEmpty() { Resolve.trail(&items, page.path) } else { fromRoot },86      previous: previous, next: next, editUrl: editUrl(&page, context), updated: Map.getOr(&context.history, page.source, ""),87      features: features, root: if page.kind == "not-found" { basePath(&page.meta) } else { "" }, pdf: pdf, year: context.today.take(4), tocPath: governing,88      navPath: if root.isEmpty() { "" } else { "toc.json" }89    }90    let view = View.view(&page, &frame)91    result = result.push(Docgen.Artifact{path: page.path, content: Theme.page(&context.look, &view)})92    if !context.config.exportViewModel.isEmpty() { result = result.push(Docgen.Artifact{path: modelPath(context.config.exportViewModel, context.config.output, page.path, ".view.json"), content: Json.encodePretty(&Meta.toJson(&view))}) }93    if !context.config.exportRawModel.isEmpty() { result = result.push(Docgen.Artifact{path: modelPath(context.config.exportRawModel, context.config.output, page.path, ".raw.json"), content: Json.encodePretty(&raw(&page))}) }94  }95  result96}9798/// Site-wide files: tables of contents as JSON, the search index, the sitemap, and the99/// cross-reference map.100export fn files(pages: &Array[Docgen.Page], references: &Array[Docgen.Reference], context: &Context) -> Array[Docgen.Artifact] {101  var result: Array[Docgen.Artifact] = []102  for book in context.books { result = result.push(Docgen.Artifact{path: book.output, content: Json.encodePretty(&Navigation.toJson(&relative(&book.items, book.output)))}) }103  if Meta.textOr(&context.global, "_enableSearch", "true") != "false" { result = result.push(Docgen.Artifact{path: "index.json", content: Search.index(pages)}) }104  let base = Meta.textOr(&context.global, "_baseUrl", "")105  match context.config.sitemap {106    case Some(options) => {107      let modified = pages.map(|page: Docgen.Page| (page.path, Map.getOr(&context.history, page.source, context.today)))108      result = result.push(Docgen.Artifact{path: "sitemap.xml", content: Sitemap.render(pages, &options, &modified)})109    }110    case None => {}111  }112  let mapBase = match context.config.sitemap {113    case Some(options) => options.baseUrl114    case None => base115  }116  result.push(Docgen.Artifact{path: "xrefmap.yml", content: References.toYaml(references, mapBase)})117}118119/// A generated page for addresses the site does not have.120export fn notFound(global: &Array[(Str, Docgen.Meta)]) -> Docgen.Page {121  let body = "<h1 id=\"page-not-found\">Page not found</h1>\n<p>The page you requested does not exist or has moved.</p>\n<p><a href=\"index.html\">Go to the home page</a></p>\n"122  Docgen.Page{path: "404.html", source: "", kind: "not-found", title: "Page not found", uid: "", summary: "", body: body, headings: [], links: [], meta: Meta.merge(global, &[("_noindex", Docgen.Flag(true)), ("_disableToc", Docgen.Flag(true)), ("_disableBreadcrumb", Docgen.Flag(true))])}123}124125/// The manifest listing every output with its source and kind, and the generator version.126export fn manifest(pages: &Array[Docgen.Page], resources: &Array[(Str, Str)], generator: Str, outputs: &Array[Str]) -> Str {127  let entries = pages.map(|page: Docgen.Page| Json.object(&[("type", Json.Text(page.kind)), ("source", Json.Text(page.source)), ("output", Json.Text(page.path))])).concat(resources.map(fn(entry: (Str, Str)) -> Json.Json {128        let (source, output) = entry129        Json.object(&[("type", Json.Text("resource")), ("source", Json.Text(source)), ("output", Json.Text(output))])130      }))131  Json.encodePretty(&Json.object(&[("generator", Json.Text("pudu-lang-docgen")), ("version", Json.Text(generator)), ("files", Json.list(&entries)), ("outputs", Json.list(&outputs.map(|path: Str| Json.Text(path))))]))132}133134/// The `order` a table declares, or 100 for nested tables and 0 otherwise.135fn orderOf(book: &Book) -> Int {136  match Meta.get(&book.meta, "order") {137    case Some(Docgen.Whole(value)) => value138    case _ => if book.nested { 100 } else { 0 }139  }140}141142/// The PDF a table publishes: `pdfFileName` beside the table, `toc.pdf` by default.143export fn pdfOf(book: &Book) -> Str {144  let name = Meta.textOr(&book.meta, "pdfFileName", "toc.pdf")145  let directory = Paths.directoryOf(book.output)146  if directory.isEmpty() { name } else { directory + "/" + name }147}148149/// Items in depth-first order.150fn flattened(items: &Array[Docgen.TocItem]) -> Array[Docgen.TocItem] {151  var result: Array[Docgen.TocItem] = []152  for item in *items { result = result.push(item).concat(flattened(&item.children)) }153  result154}155156/// Items with destinations made relative to a published file.157fn relative(items: &Array[Docgen.TocItem], from: Str) -> Array[Docgen.TocItem] {158  items.map(|item: Docgen.TocItem| Docgen.TocItem{..item, href: if item.href.isEmpty() || Paths.remote(item.href) { item.href } else { Paths.between(from, item.href) }, children: relative(&item.children, from)})159}160161/// The edit link of a page's source file, or the empty text without a repository.162fn editUrl(page: &Docgen.Page, context: &Context) -> Str {163  if page.source.isEmpty() { return "" }164  let contribute = Meta.fieldsOf(&page.meta, "_gitContribute")165  let repo = Meta.textOr(&contribute, "repo", context.repository.repo)166  if repo.isEmpty() { return "" }167  let branch = Meta.textOr(&contribute, "branch", context.repository.branch)168  let prefix = Meta.textOr(&contribute, "path", context.repository.path)169  let path = if prefix.isEmpty() { page.source } else { prefix + "/" + page.source }170  let written = Meta.textOr(&page.meta, "_gitUrlPattern", if repo.contains("gitlab") { "gitlab" } else if repo.contains("dev.azure.com") || repo.contains("visualstudio.com") { "vso" } else if repo.contains("bitbucket") { "bitbucket" } else { "github" })171  var pattern = written172  for (name, template) in EDIT_PATTERNS {173    if name == written { pattern = template }174  }175  let trimmed = if repo.endsWith("/") { repo.take(repo.length() - 1) } else if repo.endsWith(".git") { repo.take(repo.length() - 4) } else { repo }176  pattern.replace("\{repo\}", trimmed).replace("\{branch\}", branch).replace("\{path\}", path)177}178179/// The path prefix of the site's base address, or `/` when none is known.180fn basePath(meta: &Array[(Str, Docgen.Meta)]) -> Str {181  let base = Meta.textOr(meta, "_baseUrl", "")182  if base.isEmpty() { return "/" }183  let afterScheme = if base.contains("://") { base.split("://")[1] } else { base }184  let slash = afterScheme.indexOf("/")185  let path = if slash < 0 { "/" } else { afterScheme.drop(slash) }186  if path.endsWith("/") { path } else { path + "/" }187}188189/// Where a model export of a page is written, keeping its path inside the export folder.190fn modelPath(folder: Str, output: Str, page: Str, suffix: Str) -> Str {191  let inner = if folder == output { "" } else if folder.startsWith(output + "/") { folder.drop(output.length() + 1) + "/" } else { "_models/" }192  inner + page + suffix193}194195/// A page as its data model.196fn raw(page: &Docgen.Page) -> Json.Json {197  Json.object(&[198      ("path", Json.Text(page.path)), ("source", Json.Text(page.source)), ("kind", Json.Text(page.kind)), ("title", Json.Text(page.title)),199      ("uid", Json.Text(page.uid)), ("summary", Json.Text(page.summary)), ("body", Json.Text(page.body)),200      ("headings", Json.list(&page.headings.map(|heading: Docgen.Heading| Json.object(&[("id", Json.Text(heading.id)), ("title", Json.Text(heading.title)), ("level", Json.Number(heading.level))])))),201      ("metadata", Meta.toJson(&Docgen.Fields(page.meta)))202    ])203}204