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

Catalog.pudu

Pudu186 lines9.1 KB

GitHub ↗
1/** @Docgen.Api.Catalog — selected declarations, their pages, identities, and navigation */2module PuduLangDocgen.Api.Catalog34import Std.Glob as Glob5import Std.List as List6import Std.Regex as Regex7import PuduLangDocgen.Api.Model as Model8import PuduLangDocgen as Docgen9import PuduLangDocgen.Meta as Meta1011/** @Docgen.Api.Rule — an include or exclude decision for identities a pattern matches */12export type Rule = { include: Bool, pattern: Regex.Regex, kind: Str }1314/** @Docgen.Api.Options — where reference pages go and which declarations they show */15export type Options = {16  dest: Str,17  includePrivate: Bool,18  include: Array[Str],19  exclude: Array[Str],20  rules: Array[Rule],21  sourceUrl: Str,22  sourceExclude: Array[Str],23  nested: Bool,24  separate: Bool,25  skipMarkup: Bool,26  alphabetic: Bool,27  categories: Str28}2930/// Options that show exported declarations under `api` with default layouts.31export fn defaults() -> Options {32  Options{dest: "api", includePrivate: false, include: [], exclude: [], rules: [], sourceUrl: "", sourceExclude: [], nested: false, separate: false, skipMarkup: false, alphabetic: false, categories: "none"}33}3435/// Category headings of module navigation and the member kinds each holds.36const CATEGORIES: Array[(Str, Array[Str])] = [37  ("Types", ["record", "union", "alias", "opaque"]), ("Traits", ["trait"]), ("Functions", ["function"]), ("Constants", ["constant"])38]3940/// Rules of a filter file: `apiRules` listing `include` or `exclude` entries, each with a41/// `uidRegex` and an optional `type` of `Module`, `Type`, `Function`, `Constant`, or `Member`.42export fn rules(value: &Docgen.Meta) -> Result[Array[Rule], Str] {43  let fields = match value {44    case Docgen.Fields(found) => found45    case _ => { return Err("filter must be an object with apiRules") }46  }47  let listed = match Meta.get(&fields, "apiRules") {48    case Some(Docgen.Items(found)) => found49    case _ => { return Err("filter needs an apiRules list") }50  }51  var result: Array[Rule] = []52  for held in listed {53    let entry = match held {54      case Docgen.Fields(found) => found55      case _ => { return Err("each api rule must be an object") }56    }57    let include = Meta.get(&entry, "include") != None58    let body = Meta.fieldsOf(&entry, if include { "include" } else { "exclude" })59    let written = Meta.textOr(&body, "uidRegex", "")60    if written.isEmpty() { return Err("each api rule needs a uidRegex under include or exclude") }61    let pattern = match Regex.compile(written) {62      case Ok(found) => found63      case Err(problem) => { return Err("uidRegex " + written + ": " + Regex.explain(&problem)) }64    }65    result = result.push(Rule{include: include, pattern: pattern, kind: Meta.textOr(&body, "type", "").toLower()})66  }67  Ok(result)68}6970/// Declarations kept for publication: exported ones unless private ones are asked for, whose71/// identity matches an include pattern when any is given and no exclude pattern.72export fn select(units: &Array[Model.Unit], options: &Options) -> Array[Model.Unit] {73  var result: Array[Model.Unit] = []74  for unit in List.sortOn(units, |held: Model.Unit| held.uid) {75    if !wanted(options, unit.uid) { continue }76    if !ruled(options, unit.uid, "module") { continue }77    let members = unit.members.filter(|member: Model.Member| (member.exported || options.includePrivate) && wanted(options, member.uid) && ruled(options, member.uid, member.kind))78    let kept = Model.Unit{..unit, members: members.map(fn(member: Model.Member) -> Model.Member {79          let inner = member.members.filter(|held: Model.Member| wanted(options, held.uid) && ruled(options, held.uid, held.kind))80          let ordered = if options.alphabetic && member.kind == "union" { List.sortOn(&inner, |held: Model.Member| held.name) } else { inner }81          Model.Member{..member, members: ordered}82        }) }83    if !kept.members.isEmpty() || !kept.doc.isEmpty() { result = result.push(kept) }84  }85  result86}8788/// The published path of a module or type page.89export fn pageOf(options: &Options, uid: Str) -> Str {90  if options.dest.isEmpty() { uid + ".html" } else { options.dest + "/" + uid + ".html" }91}9293/// Identities of every module, declaration, field, variant, and method with its destination.94/// Modules and types have pages; functions and constants are sections of their module page,95/// and fields, variants, and methods sections of their type page.96export fn references(units: &Array[Model.Unit], options: &Options) -> Array[Docgen.Reference] {97  var result: Array[Docgen.Reference] = []98  for unit in *units {99    let modulePage = pageOf(options, unit.uid)100    result = result.push(Docgen.Reference{uid: unit.uid, name: lastName(unit.uid), fullName: unit.uid, href: modulePage, kind: "module"})101    for member in unit.members {102      let typed = Model.isType(member.kind)103      let href = if typed || options.separate { pageOf(options, member.uid) } else { modulePage + "#" + member.name }104      result = result.push(Docgen.Reference{uid: member.uid, name: member.name, fullName: member.uid, href: href, kind: member.kind})105      if typed {106        for inner in member.members {107          result = result.push(Docgen.Reference{uid: inner.uid, name: inner.name, fullName: inner.uid, href: pageOf(options, member.uid) + "#" + inner.name, kind: inner.kind})108        }109      }110    }111  }112  result113}114115/// Navigation of modules, each listing its types and traits, and its functions and constants116/// when they have pages of their own. Items carry the identity they link to. Nested navigation groups modules under the modules117/// their names extend.118export fn toc(units: &Array[Model.Unit], options: &Options) -> Array[Docgen.TocItem] {119  let flat = units.map(fn(unit: Model.Unit) -> Docgen.TocItem {120      let shown = unit.members.filter(|member: Model.Member| Model.isType(member.kind) || options.separate)121      let ordered = List.sortOn(&shown, |held: Model.Member| held.name)122      let entry = |member: Model.Member| Docgen.TocItem{title: member.name, href: pageOf(options, member.uid), uid: member.uid, expanded: false, children: []}123      var children: Array[Docgen.TocItem] = []124      if options.categories == "none" { children = ordered.map(entry) } else {125        for (heading, kinds) in CATEGORIES {126          let group = ordered.filter(|member: Model.Member| kinds.contains(member.kind)).map(entry)127          if !group.isEmpty() {128            children = if options.categories == "nested" { children.push(Docgen.TocItem{title: heading, href: "", uid: "", expanded: false, children: group}) } else { children.push(Docgen.TocItem{title: heading, href: "", uid: "", expanded: false, children: []}).concat(group) }129          }130        }131      }132      Docgen.TocItem{title: unit.uid, href: pageOf(options, unit.uid), uid: unit.uid, expanded: false, children: children}133    })134  if options.nested { nest(&flat, "") } else { flat }135}136137/// Module items placed under the item of the longest module name they extend, titled by138/// their last segment; prefixes with no module of their own become groups.139fn nest(flat: &Array[Docgen.TocItem], prefix: Str) -> Array[Docgen.TocItem] {140  var heads: Array[Str] = []141  for item in *flat {142    let rest = if prefix.isEmpty() { item.uid } else if item.uid.startsWith(prefix + ".") { item.uid.drop(prefix.length() + 1) } else { "" }143    if !rest.isEmpty() && !heads.contains(rest.split(".")[0]) { heads = heads.push(rest.split(".")[0]) }144  }145  heads.map(fn(head: Str) -> Docgen.TocItem {146      let full = if prefix.isEmpty() { head } else { prefix + "." + head }147      let below = nest(flat, full)148      var own = Docgen.TocItem{title: head, href: "", uid: "", expanded: false, children: []}149      for item in *flat {150        if item.uid == full { own = Docgen.TocItem{..item, title: head} }151      }152      Docgen.TocItem{..own, children: below.concat(own.children)}153    })154}155156/// A browser link to a declaration's source line, or the empty text without a pattern.157/// The pattern names `\{path\}` and `\{line\}`.158export fn sourceLink(options: &Options, path: Str, line: Int) -> Str {159  if options.sourceUrl.isEmpty() || Glob.matchesAny(&options.sourceExclude, path) { "" } else { options.sourceUrl.replace("\{path\}", path).replace("\{line\}", show(line)) }160}161162/// The last dotted segment of an identity.163export fn lastName(uid: Str) -> Str {164  let parts = uid.split(".")165  parts[parts.length() - 1]166}167168/// Whether the first filter rule matching an identity and kind includes it; an identity no169/// rule matches is included.170fn ruled(options: &Options, uid: Str, kind: Str) -> Bool {171  let family = if kind == "module" { "module" } else if Model.isType(kind) { "type" } else { kind }172  for rule in options.rules {173    let fits = rule.kind.isEmpty() || rule.kind == family || (rule.kind == "member" && family != "module" && family != "type")174    if fits && Regex.isMatch(&rule.pattern, uid) { return rule.include }175  }176  true177}178179/// Whether an identity passes the include and exclude patterns; a module is kept while any180/// include pattern reaches inside it.181fn wanted(options: &Options, uid: Str) -> Bool {182  let inside = !options.include.filter(|pattern: Str| pattern.startsWith(uid + ".")).isEmpty()183  if !options.include.isEmpty() && !Glob.matchesAny(&options.include, uid) && !inside { return false }184  !Glob.matchesAny(&options.exclude, uid)185}186