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

Sources.pudu

Pudu179 lines9.2 KB

GitHub ↗
1/** @Docgen.Build.Sources — project files mapped to outputs and read into typed documents */2module PuduLangDocgen.Build.Sources34import Std.Glob as Glob5import Std.Json as Json6import Std.List as List7import Std.Map as Map8import PuduLangDocgen.Configuration as Configuration9import PuduLangDocgen.Constants.Codes as Codes10import PuduLangDocgen as Docgen11import PuduLangDocgen.Markdown as Markdown12import PuduLangDocgen.Meta as Meta13import PuduLangDocgen.Navigation as Navigation14import PuduLangDocgen.Paths as Paths15import PuduLangDocgen.Rest.OpenApi as OpenApi16import PuduLangDocgen.Site.Gallery as Gallery17import PuduLangDocgen.Site.Landing as Landing18import PuduLangDocgen.Yaml as Yaml1920/** @Docgen.Build.Document — one content file read into the form its kind needs */21export type Document = Article(Markdown.Article) | Service(OpenApi.Service) | ApiPage(Docgen.Meta) | Catalog(Docgen.Meta) | Hub(Docgen.Meta) | Moved(Str)2223/** @Docgen.Build.Entry — a content file, where it publishes, and its merged metadata */24export type Entry = { source: Str, output: Str, meta: Array[(Str, Docgen.Meta)], document: Document }2526/** @Docgen.Build.Read — every content file read, with tables of contents apart */27export type Read = { entries: Array[Entry], tocs: Array[(Str, Str, Navigation.Toc)], diagnostics: Array[Docgen.Diagnostic] }2829/// Marker on the first line of a structured API page file.30const API_PAGE: Str = "#YamlMime:ApiPage"3132/// Project files selected by mappings, each paired with its output path. A file matches when33/// it lies under the mapping's source folder, a `files` glob matches its path inside that34/// folder, and no `exclude` glob does. The output keeps that inner path under `dest`. Files35/// under `skip` folders are never selected; the first mapping to select a file wins.36export fn mapped(mappings: &Array[Configuration.Mapping], paths: &Array[Str], skip: &Array[Str]) -> Array[(Str, Str)] {37  selected(mappings, paths, skip).map(fn(entry: (Str, Str, Array[(Str, Docgen.Meta)])) -> (Str, Str) {38      let (source, output, _meta) = entry39      (source, output)40    })41}4243/// Metadata that the group of a file's mapping gives it, by source path.44export fn groupMeta(mappings: &Array[Configuration.Mapping], paths: &Array[Str], skip: &Array[Str]) -> Map[Str, Array[(Str, Docgen.Meta)]] {45  mapOf(selected(mappings, paths, skip).map(fn(entry: (Str, Str, Array[(Str, Docgen.Meta)])) -> (Str, Array[(Str, Docgen.Meta)]) {46        let (source, _output, meta) = entry47        (source, meta)48      }))49}5051/// Selected files with their outputs and group metadata.52fn selected(mappings: &Array[Configuration.Mapping], paths: &Array[Str], skip: &Array[Str]) -> Array[(Str, Str, Array[(Str, Docgen.Meta)])] {53  var result: Array[(Str, Str, Array[(Str, Docgen.Meta)])] = []54  var taken: Array[Str] = []55  for mapping in *mappings {56    for path in List.sorted(paths) {57      if taken.contains(path) || skip.filter(|folder: Str| path.startsWith(folder + "/")).length() > 0 { continue }58      let inner = if mapping.src.isEmpty() { path } else if path.startsWith(mapping.src + "/") { path.drop(mapping.src.length() + 1) } else { "" }59      if inner.isEmpty() || !Glob.matchesAny(&mapping.files, inner) || Glob.matchesAny(&mapping.exclude, inner) { continue }60      result = result.push((path, if mapping.dest.isEmpty() { inner } else { mapping.dest + "/" + inner }, mapping.meta))61      taken = taken.push(path)62    }63  }64  result65}6667/// Global metadata and file metadata rules from the configuration and the metadata files it68/// names; later files override earlier values.69export fn metadata(config: &Configuration.Config, files: &Map[Str, Str]) -> (Array[(Str, Docgen.Meta)], Array[Meta.Rule], Array[Docgen.Diagnostic]) {70  var global = config.globalMetadata71  var rules = config.fileMetadata72  var problems: Array[Docgen.Diagnostic] = []73  for path in config.globalMetadataFiles {74    match object(path, files) {75      case Ok(fields) => { global = Meta.merge(&global, &fields) }76      case Err(problem) => { problems = problems.push(problem) }77    }78  }79  for path in config.fileMetadataFiles {80    match object(path, files) {81      case Ok(fields) => match Meta.rulesOf(&fields) {82        case Ok(found) => { rules = rules.concat(found) }83        case Err(reason) => { problems = problems.push(Docgen.error(Codes.CONFIG_INVALID, path, 1, reason)) }84      }85      case Err(problem) => { problems = problems.push(problem) }86    }87  }88  (Meta.merge(&global, &config.globalMetadata), rules, problems)89}9091/// Content files read by kind: Markdown articles and redirects, HTTP interface descriptions,92/// structured API pages, and tables of contents. Metadata merges global values, then file93/// metadata of the file's group, then file rules, then the file's own header, which wins.94export fn read(content: &Array[(Str, Str)], files: &Map[Str, Str], global: &Array[(Str, Docgen.Meta)], rules: &Array[Meta.Rule], alerts: &Array[Str], groups: &Map[Str, Array[(Str, Docgen.Meta)]]) -> Read {95  var entries: Array[Entry] = []96  var tocs: Array[(Str, Str, Navigation.Toc)] = []97  var problems: Array[Docgen.Diagnostic] = []98  for (source, output) in *content {99    let text = match Map.get(files, source) {100      case Some(found) => found101      case None => {102        problems = problems.push(Docgen.error(Codes.CONTENT_UNREADABLE, source, 1, "content file could not be read"))103        continue104      }105    }106    let inherited = Meta.merge(&Meta.merge(global, &Map.getOr(groups, source, [])), &Meta.forPath(rules, source))107    let lowered = source.toLower()108    if Navigation.isToc(source) {109      match Navigation.parse(source, text) {110        case Ok(found) => { tocs = tocs.push((source, output, Navigation.Toc{..found, meta: Meta.merge(&Meta.merge(global, &found.meta), &Meta.forPath(rules, source))})) }111        case Err(found) => { problems = problems.concat(found) }112      }113    } else if lowered.endsWith(".md") || lowered.endsWith(".markdown") {114      let article = Markdown.parse(source, text, files, alerts)115      problems = problems.concat(article.diagnostics)116      let meta = Meta.merge(&inherited, &article.meta)117      let target = Meta.textOr(&meta, "redirect_url", "")118      let document = if target.isEmpty() { Article(article) } else { Moved(target) }119      entries = entries.push(Entry{source: source, output: Paths.output(output), meta: meta, document: document})120    } else if lowered.endsWith(".yml") || lowered.endsWith(".yaml") || lowered.endsWith(".json") {121      match structured(source, text) {122        case Ok(value) => {123          if text.split("\n")[0].contains(Gallery.MARKER) {124            entries = entries.push(Entry{source: source, output: Paths.output(output), meta: inherited, document: Catalog(value)})125          } else if text.split("\n")[0].contains(Landing.MARKER) {126            entries = entries.push(Entry{source: source, output: Paths.output(output), meta: Meta.merge(&Meta.merge(&[("_layout", Docgen.Text("landing"))], &inherited), &declared(&value)), document: Hub(value)})127          } else if text.trim().startsWith(API_PAGE) {128            entries = entries.push(Entry{source: source, output: Paths.output(output), meta: inherited, document: ApiPage(value)})129          } else if OpenApi.recognizes(&value) {130            match OpenApi.read(&value) {131              case Ok(service) => { entries = entries.push(Entry{source: source, output: Paths.output(output), meta: inherited, document: Service(service)}) }132              case Err(reason) => { problems = problems.push(Docgen.error(Codes.CONTENT_INVALID, source, 1, reason)) }133            }134          } else { problems = problems.push(Docgen.warning(Codes.CONTENT_UNSUPPORTED, source, 1, "structured file is not an interface description, an API page, a catalog, or a landing page and is skipped")) }135        }136        case Err(reason) => { problems = problems.push(Docgen.error(Codes.CONTENT_INVALID, source, 1, reason)) }137      }138    } else { problems = problems.push(Docgen.warning(Codes.CONTENT_UNSUPPORTED, source, 1, "content file type is not supported; list it as a resource to copy it")) }139  }140  Read{entries: entries, tocs: tocs, diagnostics: problems}141}142143/// A JSON or YAML file decoded by its extension.144fn structured(source: Str, text: Str) -> Result[Docgen.Meta, Str] {145  if source.toLower().endsWith(".json") {146    match Json.decode(text) {147      case Ok(found) => Ok(Meta.fromJson(&found))148      case Err(problem) => Err("not valid JSON: " + Json.explain(&problem))149    }150  } else {151    let decoded = Yaml.decode(text)152    match decoded {153      case Ok(found) => Ok(found)154      case Err(reason) => Err("not valid YAML: " + reason)155    }156  }157}158159/// The object a metadata file holds.160fn object(path: Str, files: &Map[Str, Str]) -> Result[Array[(Str, Docgen.Meta)], Docgen.Diagnostic] {161  let text = match Map.get(files, path) {162    case Some(found) => found163    case None => { return Err(Docgen.error(Codes.CONFIG_UNREADABLE, path, 1, "metadata file could not be read")) }164  }165  match structured(path, text) {166    case Ok(Docgen.Fields(fields)) => Ok(fields)167    case Ok(_) => Err(Docgen.error(Codes.CONFIG_INVALID, path, 1, "metadata file must hold an object"))168    case Err(reason) => Err(Docgen.error(Codes.CONFIG_UNREADABLE, path, 1, reason))169  }170}171172/// The `metadata` object a structured page declares for itself.173fn declared(value: &Docgen.Meta) -> Array[(Str, Docgen.Meta)] {174  match value {175    case Docgen.Fields(fields) => Meta.fieldsOf(&fields, "metadata")176    case _ => []177  }178}179