
Resolve.pudu
Pudu211 lines9.3 KB
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] }151617const MAX_NESTING: Int = 16181920const TOC_NAMES: Array[Str] = ["toc.yml", "toc.yaml", "toc.json", "toc.md"]2122232425export 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}293031export fn outputOf(path: Str) -> Str {32 let directory = Paths.directoryOf(path)33 if directory.isEmpty() { "toc.json" } else { directory + "/toc.json" }34}3536373839export 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}565758fn 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}636465fn 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}727374export fn pagesOf(items: &Array[Docgen.TocItem]) -> Array[Str] {75 readingOrder(items).map(|item: Docgen.TocItem| Paths.pathOf(item.href))76}77787980export 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}888990export 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}103104105export 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}117118119fn 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}124125126fn 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}139140141fn 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}186187188fn 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}205206207fn firstPage(items: &Array[Docgen.TocItem]) -> Str {208 let order = readingOrder(items)209 if order.isEmpty() { "" } else { order[0].href }210}211