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

Resolve.pudu

Pudu211 lines9.3 KB

GitHub ↗
1/** @Docgen.Navigation.Resolve — tables of contents bound to published pages */2module PuduLangDocgen.Navigation.Resolve34import Std.Map as Map5import PuduLangDocgen.Constants.Codes as Codes6import PuduLangDocgen as Docgen7import PuduLangDocgen.Navigation as Navigation8import PuduLangDocgen.Paths as Paths910/** @Docgen.Navigation.Links — where a table of contents may point */11export type Links = { tocs: Map[Str, Array[Docgen.TocItem]], outputs: Map[Str, Str], references: Map[Str, Docgen.Reference], titles: Map[Str, Str] }1213/** @Docgen.Navigation.Candidate — a published table of contents competing to govern pages */14export type Candidate = { output: Str, order: Int, pages: Array[Str] }1516/// Deepest chain of tables of contents that include one another.17const MAX_NESTING: Int = 161819/// File names tried, in order, when an item links to a folder.20const TOC_NAMES: Array[Str] = ["toc.yml", "toc.yaml", "toc.json", "toc.md"]2122/// Items of the table of contents at a project-relative path with every destination made23/// root-relative to the published site. Folder links and links to other tables of contents24/// bring in those items as children.25export fn resolve(path: Str, links: &Links) -> (Array[Docgen.TocItem], Array[Docgen.Diagnostic]) {26  let written = Map.getOr(&links.tocs, path, [])27  entries(path, &written, links, [path])28}2930/// The published path of a table of contents: its source path ending in `toc.json`.31export fn outputOf(path: Str) -> Str {32  let directory = Paths.directoryOf(path)33  if directory.isEmpty() { "toc.json" } else { directory + "/toc.json" }34}3536/// The table of contents governing a page, or the empty text. Tables that link the page win,37/// lower `order` first and then the nearest folder; otherwise the nearest table in a folder38/// holding the page is used, again preferring lower `order`.39export fn governing(page: Str, candidates: &Array[Candidate]) -> Str {40  var best = ""41  var bestRank = (2, 0, 0)42  for candidate in *candidates {43    let directory = Paths.directoryOf(candidate.output)44    let linked = candidate.pages.contains(page)45    let contains = directory.isEmpty() || page.startsWith(directory + "/")46    if !linked && !contains { continue }47    let jumps = distance(Paths.directoryOf(page), directory)48    let rank = (if linked { 0 } else { 1 }, candidate.order, jumps)49    if best.isEmpty() || before(rank, bestRank) {50      best = candidate.output51      bestRank = rank52    }53  }54  best55}5657/// Whether one ranking comes before another, comparing each part in turn.58fn before(left: (Int, Int, Int), right: (Int, Int, Int)) -> Bool {59  let (a, b, c) = left60  let (x, y, z) = right61  a < x || (a == x && (b < y || (b == y && c < z)))62}6364/// Folder steps between two directories through their common ancestor.65fn distance(from: Str, to: Str) -> Int {66  let left = if from.isEmpty() { [] } else { from.split("/") }67  let right = if to.isEmpty() { [] } else { to.split("/") }68  var common = 069  while common < left.length() && common < right.length() && left[common] == right[common] { common = common + 1 }70  left.length() + right.length() - 2 * common71}7273/// Local pages a tree links, without fragments.74export fn pagesOf(items: &Array[Docgen.TocItem]) -> Array[Str] {75  readingOrder(items).map(|item: Docgen.TocItem| Paths.pathOf(item.href))76}7778/// The items from the top of a tree down to the deepest one linking a page; a group that79/// links its first page yields to that page's own item.80export fn trail(items: &Array[Docgen.TocItem], page: Str) -> Array[Docgen.TocItem] {81  for item in *items {82    let below = trail(&item.children, page)83    if !below.isEmpty() { return [item].concat(below) }84    if Paths.pathOf(item.href) == page { return [item] }85  }86  []87}8889/// The pages before and after a page in reading order, when it is listed.90export fn neighbors(items: &Array[Docgen.TocItem], page: Str) -> (Option[Docgen.TocItem], Option[Docgen.TocItem]) {91  let order = readingOrder(items)92  var index = 093  for item in order {94    if Paths.pathOf(item.href) == page {95      let previous = if index > 0 { Some(order[index - 1]) } else { None }96      let next = if index + 1 < order.length() { Some(order[index + 1]) } else { None }97      return (previous, next)98    }99    index = index + 1100  }101  (None, None)102}103104/// Local pages in the order a reader meets them, each listed once.105export fn readingOrder(items: &Array[Docgen.TocItem]) -> Array[Docgen.TocItem] {106  var result: Array[Docgen.TocItem] = []107  var seen: Array[Str] = []108  for item in flattened(items) {109    let path = Paths.pathOf(item.href)110    if !path.isEmpty() && !Paths.remote(item.href) && !seen.contains(path) {111      result = result.push(item)112      seen = seen.push(path)113    }114  }115  result116}117118/// Items in depth-first order.119fn flattened(items: &Array[Docgen.TocItem]) -> Array[Docgen.TocItem] {120  var result: Array[Docgen.TocItem] = []121  for item in *items { result = result.push(item).concat(flattened(&item.children)) }122  result123}124125/// Items of one table of contents resolved against the published docset.126fn entries(path: Str, items: &Array[Docgen.TocItem], links: &Links, trailOf: Array[Str]) -> (Array[Docgen.TocItem], Array[Docgen.Diagnostic]) {127  var result: Array[Docgen.TocItem] = []128  var problems: Array[Docgen.Diagnostic] = []129  for item in *items {130    let (resolved, found) = entry(path, &item, links, trailOf)131    problems = problems.concat(found)132    match resolved {133      case Some(held) => { result = result.push(held) }134      case None => {}135    }136  }137  (result, problems)138}139140/// One item resolved; a link-only placeholder for a nested table dissolves into its items.141fn entry(path: Str, item: &Docgen.TocItem, links: &Links, trailOf: Array[Str]) -> (Option[Docgen.TocItem], Array[Docgen.Diagnostic]) {142  var problems: Array[Docgen.Diagnostic] = []143  var title = item.title144  var href = ""145  var children: Array[Docgen.TocItem] = []146  if !item.uid.isEmpty() {147    match Map.get(&links.references, item.uid) {148      case Some(reference) => {149        href = reference.href150        if title.isEmpty() { title = reference.name }151      }152      case None => { problems = problems.push(Docgen.warning(Codes.TOC_UID_UNKNOWN, path, 1, "table of contents names an unknown uid: " + item.uid)) }153    }154  }155  var local: Array[Docgen.TocItem] = []156  for child in item.children {157    if child.title.isEmpty() && child.uid.isEmpty() && (child.href.endsWith("/") || Navigation.isToc(Paths.pathOf(child.href))) {158      let (nested, found) = nestedItems(path, child.href, links, trailOf)159      local = local.concat(nested)160      problems = problems.concat(found)161    } else { local = local.push(child) }162  }163  let (resolvedChildren, childProblems) = entries(path, &local, links, trailOf)164  children = resolvedChildren165  problems = problems.concat(childProblems)166  if href.isEmpty() && !item.href.isEmpty() {167    if Paths.remote(item.href) { href = item.href } else if item.href.endsWith("/") || Navigation.isToc(Paths.pathOf(item.href)) {168      let (nested, found) = nestedItems(path, item.href, links, trailOf)169      problems = problems.concat(found)170      if children.isEmpty() { children = nested }171      href = firstPage(&nested)172    } else {173      match Paths.join(Paths.directoryOf(path), Paths.pathOf(item.href)) {174        case Ok(target) => match Map.get(&links.outputs, target) {175          case Some(published) => { href = published + Paths.suffixOf(item.href) }176          case None => { problems = problems.push(Docgen.warning(Codes.TOC_TARGET_MISSING, path, 1, "table of contents target not found: " + target)) }177        }178        case Err(reason) => { problems = problems.push(Docgen.warning(Codes.TOC_TARGET_MISSING, path, 1, reason + ": " + item.href)) }179      }180    }181  }182  if title.isEmpty() && !href.isEmpty() { title = Map.getOr(&links.titles, Paths.pathOf(href), "") }183  if title.isEmpty() || (href.isEmpty() && children.isEmpty() && (!item.href.isEmpty() || !item.uid.isEmpty())) { return (None, problems) }184  (Some(Docgen.TocItem{title: title, href: href, uid: item.uid, expanded: item.expanded, children: children}), problems)185}186187/// Items of the table of contents a folder or file link names, guarded against cycles.188fn nestedItems(path: Str, written: Str, links: &Links, trailOf: Array[Str]) -> (Array[Docgen.TocItem], Array[Docgen.Diagnostic]) {189  let target = match Paths.join(Paths.directoryOf(path), Paths.pathOf(written)) {190    case Ok(found) => found191    case Err(reason) => { return ([], [Docgen.warning(Codes.TOC_TARGET_MISSING, path, 1, reason + ": " + written)]) }192  }193  var located = ""194  if written.endsWith("/") {195    for name in TOC_NAMES {196      if located.isEmpty() && Map.containsKey(&links.tocs, target + "/" + name) { located = target + "/" + name }197    }198  } else if Map.containsKey(&links.tocs, target) { located = target }199  if located.isEmpty() { return ([], [Docgen.warning(Codes.TOC_TARGET_MISSING, path, 1, "no table of contents found at " + written)]) }200  if trailOf.contains(located) { return ([], [Docgen.error(Codes.TOC_CYCLE, path, 1, "tables of contents include each other: " + trailOf.push(located).join(" -> "))]) }201  if trailOf.length() >= MAX_NESTING { return ([], [Docgen.error(Codes.TOC_CYCLE, path, 1, "tables of contents nest deeper than " + show(MAX_NESTING))]) }202  let items = Map.getOr(&links.tocs, located, [])203  entries(located, &items, links, trailOf.push(located))204}205206/// The first local page a tree links, or the empty text.207fn firstPage(items: &Array[Docgen.TocItem]) -> Str {208  let order = readingOrder(items)209  if order.isEmpty() { "" } else { order[0].href }210}211