
Navigation.pudu
Pudu159 lines7.8 KB
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)] }141516const MAX_LEVELS: Int = 3217181920const FIELDS: Array[Str] = ["name", "displayName", "title", "href", "topicHref", "uid", "topicUid", "items", "expanded"]212223export 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}2728293031export 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}62636465export 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}939495export 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}99100101export 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}110111112fn 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}124125126fn 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}132133134fn 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