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

Navigation.pudu

Pudu159 lines7.8 KB

GitHub ↗
1/** @Docgen.Navigation.Module — table of contents files read into nested items */2module PuduLangDocgen.Navigation34import Std.Json as Json5import Std.List as List6import PuduLangDocgen.Constants.Codes as Codes7import PuduLangDocgen as Docgen8import PuduLangDocgen.Meta as Meta9import PuduLangDocgen.Paths as Paths10import PuduLangDocgen.Yaml as Yaml1112/** @Docgen.Navigation.Toc — items of one table of contents with its own metadata */13export type Toc = { items: Array[Docgen.TocItem], meta: Array[(Str, Docgen.Meta)] }1415/// Deepest item nesting a table of contents may declare.16const MAX_LEVELS: Int = 321718/// Fields an item may declare; `name` and `displayName` title it, `topicHref` and `topicUid`19/// give a group its own page.20const FIELDS: Array[Str] = ["name", "displayName", "title", "href", "topicHref", "uid", "topicUid", "items", "expanded"]2122/// Whether a project-relative path names a table of contents file.23export fn isToc(path: Str) -> Bool {24  let name = path.split("/")[path.split("/").length() - 1].toLower()25  name == "toc.yml" || name == "toc.yaml" || name == "toc.json" || name == "toc.md"26}2728/// A table of contents written as YAML, JSON, or Markdown headings. An object form holds29/// `items` beside metadata such as `order` or `pdf`. Hrefs are kept as written; resolution30/// happens against the published docset.31export fn parse(path: Str, text: Str) -> Result[Toc, Array[Docgen.Diagnostic]] {32  let lowered = path.toLower()33  if lowered.endsWith(".md") { return Ok(Toc{items: markdown(path, text) ?, meta: []}) }34  let value = if lowered.endsWith(".json") {35    match Json.decode(text) {36      case Ok(found) => Meta.fromJson(&found)37      case Err(problem) => { return Err([Docgen.error(Codes.TOC_SYNTAX, path, 1, "table of contents is not valid JSON: " + Json.explain(&problem))]) }38    }39  } else {40    match Yaml.decode(text) {41      case Ok(found) => found42      case Err(reason) => { return Err([Docgen.error(Codes.TOC_SYNTAX, path, 1, "table of contents is not valid YAML: " + reason)]) }43    }44  }45  var meta: Array[(Str, Docgen.Meta)] = []46  let listed = match value {47    case Docgen.Items(entries) => entries48    case Docgen.Fields(fields) => match Meta.get(&fields, "items") {49      case Some(Docgen.Items(entries)) => {50        for (key, held) in fields {51          if key != "items" { meta = meta.push((key, held)) }52        }53        entries54      }55      case _ => { return Err([Docgen.error(Codes.TOC_SHAPE, path, 1, "table of contents object needs an items list")]) }56    }57    case Docgen.Nothing => []58    case _ => { return Err([Docgen.error(Codes.TOC_SHAPE, path, 1, "table of contents must be a list of items")]) }59  }60  Ok(Toc{items: items(path, &listed, 1) ?, meta: meta})61}6263/// Items of a Markdown table of contents: each heading level nests one deeper.64/// `# [Title](href)` links an item; `# Title` makes a group.65export fn markdown(path: Str, text: Str) -> Result[Array[Docgen.TocItem], Array[Docgen.Diagnostic]] {66  var entries: Array[(Int, Docgen.TocItem, Int)] = []67  var number = 068  for line in text.replace("\r\n", "\n").split("\n") {69    number = number + 170    let body = line.trim()71    if body.startsWith("#") {72      var level = 073      for character in body.chars() {74        if character != '#' { break }75        level = level + 176      }77      let content = body.drop(level).trim()78      if content.isEmpty() { return Err([Docgen.error(Codes.TOC_HEADING_EMPTY, path, number, "table of contents heading has no title")]) }79      var title = content80      var href = ""81      if content.startsWith("[") && content.endsWith(")") && content.contains("](") {82        let middle = content.indexOf("](")83        title = content.drop(1).take(middle - 1)84        href = content.drop(middle + 2).take(content.length() - middle - 3).trim()85      }86      if !href.isEmpty() && !Paths.href(href) { return Err([Docgen.error(Codes.TOC_UNSAFE, path, number, "unsafe table of contents destination: " + href)]) }87      entries = entries.push((level, Docgen.TocItem{title: title, href: href, uid: "", expanded: false, children: []}, number))88    }89  }90  let (tree, _next) = nest(&entries, 0, 1)91  Ok(tree)92}9394/// Items for published pages when no table of contents exists, ordered by path.95export fn forPages(pages: &Array[Docgen.Page]) -> Array[Docgen.TocItem] {96  let ordered = List.sortOn(pages, |held: Docgen.Page| held.path)97  ordered.filter(|page: Docgen.Page| page.kind != "redirect").map(|page: Docgen.Page| Docgen.TocItem{title: page.title, href: page.path, uid: "", expanded: false, children: []})98}99100/// Items as JSON with `name`, `href`, `expanded`, and nested `items`.101export fn toJson(tree: &Array[Docgen.TocItem]) -> Json.Json {102  Json.list(&tree.map(fn(entry: Docgen.TocItem) -> Json.Json {103        var fields = [("name", Json.Text(entry.title))]104        if !entry.href.isEmpty() { fields = fields.push(("href", Json.Text(entry.href))) }105        if entry.expanded { fields = fields.push(("expanded", Json.Boolean(true))) }106        if !entry.children.isEmpty() { fields = fields.push(("items", toJson(&entry.children))) }107        Json.object(&fields)108      }))109}110111/// Headings from an index grouped under the nearest shallower heading.112fn nest(entries: &Array[(Int, Docgen.TocItem, Int)], from: Int, level: Int) -> (Array[Docgen.TocItem], Int) {113  var result: Array[Docgen.TocItem] = []114  var index = from115  while index < entries.length() {116    let (depth, heading, _line) = entries[index]117    if depth < level { break }118    let (children, next) = nest(entries, index + 1, depth + 1)119    result = result.push(Docgen.TocItem{..heading, children: children})120    index = next121  }122  (result, index)123}124125/// Items of a list, bounded in depth.126fn items(path: Str, listed: &Array[Docgen.Meta], level: Int) -> Result[Array[Docgen.TocItem], Array[Docgen.Diagnostic]] {127  if level > MAX_LEVELS { return Err([Docgen.error(Codes.TOC_TOO_DEEP, path, 1, "table of contents nests deeper than " + show(MAX_LEVELS) + " levels")]) }128  var result: Array[Docgen.TocItem] = []129  for held in *listed { result = result.push(item(path, &held, level) ?) }130  Ok(result)131}132133/// One item with checked fields and destination.134fn item(path: Str, held: &Docgen.Meta, level: Int) -> Result[Docgen.TocItem, Array[Docgen.Diagnostic]] {135  let fields = match held {136    case Docgen.Fields(found) => found137    case _ => { return Err([Docgen.error(Codes.TOC_SHAPE, path, 1, "table of contents item must be a mapping")]) }138  }139  for (key, _value) in fields {140    if !FIELDS.contains(key) { return Err([Docgen.error(Codes.TOC_UNKNOWN_FIELD, path, 1, "unknown table of contents field: " + key)]) }141  }142  let title = Meta.textOr(&fields, "displayName", Meta.textOr(&fields, "name", Meta.textOr(&fields, "title", "")))143  let href = Meta.textOr(&fields, "topicHref", Meta.textOr(&fields, "href", ""))144  let nestedHref = if Meta.text(&fields, "topicHref") != None { Meta.textOr(&fields, "href", "") } else { "" }145  let uid = Meta.textOr(&fields, "topicUid", Meta.textOr(&fields, "uid", ""))146  let children = match Meta.get(&fields, "items") {147    case Some(Docgen.Items(listed)) => items(path, &listed, level + 1) ?148    case Some(Docgen.Nothing) => []149    case None => []150    case _ => { return Err([Docgen.error(Codes.TOC_SHAPE, path, 1, "items of " + title + " must be a list")]) }151  }152  if title.trim().isEmpty() && uid.isEmpty() && href.isEmpty() { return Err([Docgen.error(Codes.TOC_NAMELESS, path, 1, "table of contents item needs a name, uid, or href")]) }153  for written in [href, nestedHref] {154    if !written.isEmpty() && !Paths.href(written) { return Err([Docgen.error(Codes.TOC_UNSAFE, path, 1, "unsafe table of contents destination: " + written)]) }155  }156  let nested: Array[Docgen.TocItem] = if nestedHref.isEmpty() { [] } else { [Docgen.TocItem{title: "", href: nestedHref, uid: "", expanded: false, children: []}] }157  Ok(Docgen.TocItem{title: title, href: href, uid: uid, expanded: Meta.flag(&fields, "expanded"), children: nested.concat(children)})158}159