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

Configuration.pudu

Pudu249 lines14.3 KB

GitHub ↗
1/** @Docgen.Configuration.Module — the typed project configuration of a documentation site */2module PuduLangDocgen.Configuration34import Std.Json as Json5import PuduLangDocgen.Api.Catalog as Catalog6import PuduLangDocgen.Api.Export as Export7import PuduLangDocgen.Configuration.Fields as Fields8import PuduLangDocgen.Configuration.Rules as Rules9import PuduLangDocgen.Constants.Codes as Codes10import PuduLangDocgen as Docgen11import PuduLangDocgen.Meta as Meta12import PuduLangDocgen.Site.Sitemap as Sitemap13import PuduLangDocgen.Yaml as Yaml1415/** @Docgen.Configuration.Mapping — files matched by globs under a source folder, published under a destination */16export type Mapping = { files: Array[Str], exclude: Array[Str], src: Str, dest: Str, meta: Array[(Str, Docgen.Meta)] }1718/** @Docgen.Configuration.ApiSource — Pudu sources documented as reference pages */19export type ApiSource = { mappings: Array[Mapping], options: Catalog.Options, format: Str, filterFile: Str }2021/** @Docgen.Configuration.Contribution — the repository behind edit links */22export type Contribution = { repo: Str, branch: Str, path: Str }2324/** @Docgen.Configuration.Diagrams — how PlantUML diagrams are drawn */25export type Diagrams = { remote: Str, format: Str, local: Bool, jar: Str, java: Str }2627/** @Docgen.Configuration.Pdf — the command that turns printable documents into PDF files */28export type Pdf = { renderer: Array[Str] }2930/** @Docgen.Configuration.Config — everything a build reads from the project configuration */31export type Config = {32  metadata: Array[ApiSource],33  content: Array[Mapping],34  resource: Array[Mapping],35  overwrite: Array[Mapping],36  output: Str,37  globalMetadata: Array[(Str, Docgen.Meta)],38  globalMetadataFiles: Array[Str],39  fileMetadata: Array[Meta.Rule],40  fileMetadataFiles: Array[Str],41  templates: Array[Str],42  xref: Array[Str],43  xrefService: Array[Str],44  sitemap: Option[Sitemap.Options],45  gitFeatures: Bool,46  contribution: Contribution,47  warningsAsErrors: Bool,48  rules: Array[(Str, Str)],49  alerts: Array[(Str, Str)],50  diagrams: Diagrams,51  dryRun: Bool,52  exportRawModel: Str,53  exportViewModel: Str,54  pdf: Option[Pdf]55}5657/// Levels a rule may assign to a diagnostic code.58export const LEVELS: Array[Str] = ["error", "warning", "info", "off"]5960/// Settings used when a configuration names nothing else.61export fn defaults() -> Config {62  Config {63    metadata: [], content: [], resource: [], overwrite: [], output: "_site", globalMetadata: [], globalMetadataFiles: [],64    fileMetadata: [], fileMetadataFiles: [], templates: ["default"], xref: [], xrefService: [], sitemap: None, gitFeatures: true,65    contribution: Contribution{repo: "", branch: "main", path: ""}, warningsAsErrors: false, rules: [], alerts: [],66    diagrams: Diagrams{remote: "https://www.plantuml.com/plantuml", format: "svg", local: false, jar: "plantuml.jar", java: "java"},67    dryRun: false, exportRawModel: "", exportViewModel: "", pdf: None68  }69}7071/// A configuration read from JSON or YAML text; the file name decides the format and72/// appears in every diagnostic.73export fn parse(file: Str, text: Str) -> Result[Config, Array[Docgen.Diagnostic]] {74  let value = if file.toLower().endsWith(".json") {75    match Json.decode(text) {76      case Ok(found) => Meta.fromJson(&found)77      case Err(problem) => { return Err([Docgen.error(Codes.CONFIG_UNREADABLE, file, 1, "configuration is not valid JSON: " + Json.explain(&problem))]) }78    }79  } else {80    match Yaml.decode(text) {81      case Ok(found) => found82      case Err(reason) => { return Err([Docgen.error(Codes.CONFIG_UNREADABLE, file, 1, "configuration is not valid YAML: " + reason)]) }83    }84  }85  let fields = match value {86    case Docgen.Fields(found) => found87    case _ => { return Err([Docgen.error(Codes.CONFIG_INVALID, file, 1, "configuration must be an object")]) }88  }89  let config = match read(&Fields.Reader{file: file, at: "", fields: fields}) {90    case Ok(found) => found91    case Err(problem) => { return Err([problem]) }92  }93  let problems = Rules.check(file, &judged(&config))94  if problems.isEmpty() { Ok(config) } else { Err(problems) }95}9697/// The values the cross-field rules judge.98fn judged(config: &Config) -> Rules.Settings {99  let (mapped, base, priority, frequency) = match config.sitemap {100    case Some(found) => (true, found.baseUrl, found.priority, found.changefreq)101    case None => (false, "", "", "")102  }103  Rules.Settings {104    output: config.output, sitemap: mapped, baseUrl: base, priority: priority, changefreq: frequency, xref: config.xref, services: config.xrefService,105    destinations: config.metadata.map(|source: ApiSource| source.options.dest), repository: config.contribution.repo,106    renderer: match config.pdf { case Some(found) => found.renderer case None => [] }107  }108}109110/// The whole configuration.111fn read(top: &Fields.Reader) -> Result[Config, Docgen.Diagnostic] {112  Fields.only(top, &["metadata", "build", "pdf"]) ?113  var config = defaults()114  var sources: Array[ApiSource] = []115  for held in Fields.objects(top, "metadata") ? { sources = sources.push(apiSource(&held) ?) }116  config = Config{..config, metadata: sources}117  let build = Fields.nested(top, "build") ?118  Fields.only(&build, &["content", "resource", "overwrite", "output", "dest", "globalMetadata", "globalMetadataFiles", "fileMetadata", "fileMetadataFiles", "template", "xref", "xrefService", "sitemap", "disableGitFeatures", "gitContribute", "warningsAsErrors", "rules", "markdownEngineProperties", "dryRun", "groups", "theme", "exportRawModel", "rawModelOutputFolder", "exportViewModel", "viewModelOutputFolder"]) ?119  let global = Fields.nested(&build, "globalMetadata") ?120  let files = Fields.nested(&build, "fileMetadata") ?121  let rules = match Meta.rulesOf(&files.fields) {122    case Ok(found) => found123    case Err(reason) => { return Err(Fields.failure(&build, "fileMetadata", reason)) }124  }125  let output = Fields.directory(&build, "output", Fields.directory(&build, "dest", "_site") ?) ?126  let chosen = Fields.texts(&build, "template") ?127  let templates = (if chosen.isEmpty() { ["default"] } else { chosen }).concat(Fields.texts(&build, "theme") ?)128  config = Config{..config,129    content: mappings(&build, "content", false) ?, resource: mappings(&build, "resource", false) ?, overwrite: mappings(&build, "overwrite", false) ?,130    output: output, globalMetadata: global.fields, globalMetadataFiles: Fields.texts(&build, "globalMetadataFiles") ?,131    fileMetadata: rules, fileMetadataFiles: Fields.texts(&build, "fileMetadataFiles") ?,132    templates: templates, xref: Fields.texts(&build, "xref") ?, xrefService: Fields.texts(&build, "xrefService") ?,133    gitFeatures: !(Fields.flag(&build, "disableGitFeatures", false) ?), warningsAsErrors: Fields.flag(&build, "warningsAsErrors", false) ?,134    dryRun: Fields.flag(&build, "dryRun", false) ?,135    exportRawModel: if Fields.flag(&build, "exportRawModel", false) ? { Fields.directory(&build, "rawModelOutputFolder", output) ? } else { "" },136    exportViewModel: if Fields.flag(&build, "exportViewModel", false) ? { Fields.directory(&build, "viewModelOutputFolder", output) ? } else { "" } }137  let contribute = Fields.nested(&build, "gitContribute") ?138  Fields.only(&contribute, &["repo", "branch", "path"]) ?139  config = Config{..config, contribution: Contribution{repo: Fields.text(&contribute, "repo", "") ?, branch: Fields.text(&contribute, "branch", "main") ?, path: Fields.directory(&contribute, "path", "") ?}}140  let ruleReader = Fields.nested(&build, "rules") ?141  var levels: Array[(Str, Str)] = []142  for (code, _level) in ruleReader.fields {143    let level = Fields.text(&ruleReader, code, "") ?144    if !LEVELS.contains(level) { return Err(Fields.failure(&ruleReader, code, "must be one of " + LEVELS.join(", "))) }145    levels = levels.push((code, level))146  }147  let engine = Fields.nested(&build, "markdownEngineProperties") ?148  Fields.only(&engine, &["alerts", "plantUml"]) ?149  let alerts = Fields.nested(&engine, "alerts") ?150  var kinds: Array[(Str, Str)] = []151  for (kind, _style) in alerts.fields { kinds = kinds.push((kind.toUpper(), Fields.text(&alerts, kind, "") ?)) }152  let plant = Fields.nested(&engine, "plantUml") ?153  Fields.only(&plant, &["remoteUrl", "outputFormat", "renderingMode", "localPlantUmlPath", "javaPath"]) ?154  let format = Fields.text(&plant, "outputFormat", "svg") ?155  if !["svg", "png", "txt"].contains(format) { return Err(Fields.failure(&plant, "outputFormat", "must be svg, png, or txt")) }156  let mode = Fields.text(&plant, "renderingMode", "remote") ?157  if mode != "remote" && mode != "local" { return Err(Fields.failure(&plant, "renderingMode", "must be remote or local")) }158  let remote = Fields.text(&plant, "remoteUrl", config.diagrams.remote) ?159  let diagrams = Diagrams{remote: if remote.endsWith("/") { remote.take(remote.length() - 1) } else { remote }, format: format, local: mode == "local", jar: Fields.text(&plant, "localPlantUmlPath", config.diagrams.jar) ?, java: Fields.text(&plant, "javaPath", config.diagrams.java) ?}160  config = Config{..config, rules: levels, alerts: kinds, diagrams: diagrams}161  let map = Fields.nested(&build, "sitemap") ?162  if !map.fields.isEmpty() { config = Config{..config, sitemap: Some(sitemap(&map) ?)} }163  let pdf = Fields.nested(top, "pdf") ?164  if !pdf.fields.isEmpty() {165    Fields.only(&pdf, &["renderer"]) ?166    config = Config{..config, pdf: Some(Pdf{renderer: Fields.texts(&pdf, "renderer") ?})}167  }168  Ok(config)169}170171/// File mappings under a key; plain text or a list of globs counts as one mapping. Only API172/// sources, marked by `above`, may name a source folder above the project.173fn mappings(reader: &Fields.Reader, key: Str, above: Bool) -> Result[Array[Mapping], Docgen.Diagnostic] {174  match Meta.get(&reader.fields, key) {175    case Some(Docgen.Text(_found)) => { return Ok([Mapping{files: Fields.texts(reader, key) ?, exclude: [], src: "", dest: "", meta: []}]) }176    case Some(Docgen.Items(listed)) => {177      let plain = listed.filter(|held: Docgen.Meta| match held { case Docgen.Text(_found) => true case _ => false })178      if plain.length() == listed.length() && !listed.isEmpty() { return Ok([Mapping{files: Fields.texts(reader, key) ?, exclude: [], src: "", dest: "", meta: []}]) }179    }180    case _ => {}181  }182  var result: Array[Mapping] = []183  for held in Fields.objects(reader, key) ? {184    Fields.only(&held, &["files", "exclude", "src", "dest", "group"]) ?185    let files = Fields.texts(&held, "files") ?186    if files.isEmpty() { return Err(Fields.failure(&held, "files", "must name at least one glob")) }187    var dest = Fields.directory(&held, "dest", "") ?188    var meta: Array[(Str, Docgen.Meta)] = []189    let group = Fields.text(&held, "group", "") ?190    if !group.isEmpty() {191      let groups = Fields.nested(reader, "groups") ?192      let settings = Fields.nested(&groups, group) ?193      if settings.fields.isEmpty() { return Err(Fields.failure(&held, "group", "names no entry of build.groups: " + group)) }194      let folder = Fields.directory(&settings, "dest", "") ?195      dest = if folder.isEmpty() { dest } else if dest.isEmpty() { folder } else { folder + "/" + dest }196      for (name, held) in settings.fields {197        if name != "dest" { meta = meta.push((name, held)) }198      }199    }200    result = result.push(Mapping{files: files, exclude: Fields.texts(&held, "exclude") ?, src: if above { Fields.sourceFolder(&held, "src", "") ? } else { Fields.directory(&held, "src", "") ? }, dest: dest, meta: meta})201  }202  Ok(result)203}204205/// One API metadata source.206fn apiSource(reader: &Fields.Reader) -> Result[ApiSource, Docgen.Diagnostic] {207  Fields.only(reader, &["src", "dest", "includePrivateMembers", "filter", "sourceUrl", "sourceLinkExclude", "outputFormat", "namespaceLayout", "memberLayout", "categoryLayout", "enumSortOrder", "shouldSkipMarkup"]) ?208  let sources = mappings(reader, "src", true) ?209  if sources.isEmpty() { return Err(Fields.failure(reader, "src", "must name the Pudu sources to document")) }210  let filterFile = match Meta.get(&reader.fields, "filter") {211    case Some(Docgen.Text(path)) => path212    case _ => ""213  }214  let filter = if filterFile.isEmpty() { Fields.nested(reader, "filter") ? } else { Fields.Reader{file: reader.file, at: reader.at + ".filter", fields: []} }215  Fields.only(&filter, &["include", "exclude"]) ?216  let format = Fields.text(reader, "outputFormat", "json") ?217  if !Export.FORMATS.contains(format) { return Err(Fields.failure(reader, "outputFormat", "must be one of " + Export.FORMATS.join(", "))) }218  let namespaces = choice(reader, "namespaceLayout", "flattened", &["flattened", "nested"]) ?219  let members = choice(reader, "memberLayout", "samePage", &["samePage", "separatePages"]) ?220  let categories = choice(reader, "categoryLayout", "flattened", &["flattened", "nested", "none"]) ?221  let order = choice(reader, "enumSortOrder", "declaringOrder", &["alphabetic", "declaringOrder"]) ?222  let options = Catalog.Options{..Catalog.defaults(),223    dest: Fields.directory(reader, "dest", "api") ?, includePrivate: Fields.flag(reader, "includePrivateMembers", false) ?,224    include: Fields.texts(&filter, "include") ?, exclude: Fields.texts(&filter, "exclude") ?, sourceUrl: Fields.text(reader, "sourceUrl", "") ?,225    sourceExclude: Fields.texts(reader, "sourceLinkExclude") ?, nested: namespaces == "nested", separate: members == "separatePages",226    skipMarkup: Fields.flag(reader, "shouldSkipMarkup", false) ?, alphabetic: order == "alphabetic", categories: categories }227  Ok(ApiSource{mappings: sources, options: options, format: format, filterFile: filterFile})228}229230/// Text under a key that must be one of the allowed values.231fn choice(reader: &Fields.Reader, key: Str, fallback: Str, allowed: &Array[Str]) -> Result[Str, Docgen.Diagnostic] {232  let written = Fields.text(reader, key, fallback) ?233  if !allowed.contains(written) { return Err(Fields.failure(reader, key, "must be one of " + allowed.join(", "))) }234  Ok(written)235}236237/// Sitemap settings with per-glob file options.238fn sitemap(reader: &Fields.Reader) -> Result[Sitemap.Options, Docgen.Diagnostic] {239  Fields.only(reader, &["baseUrl", "priority", "changefreq", "fileOptions"]) ?240  let options = Fields.nested(reader, "fileOptions") ?241  var files: Array[(Str, Array[(Str, Docgen.Meta)])] = []242  for (pattern, _settings) in options.fields {243    let settings = Fields.nested(&options, pattern) ?244    Fields.only(&settings, &["baseUrl", "priority", "changefreq"]) ?245    files = files.push((pattern, settings.fields))246  }247  Ok(Sitemap.Options{baseUrl: Fields.text(reader, "baseUrl", "") ?, priority: Fields.text(reader, "priority", "0.5") ?, changefreq: Fields.text(reader, "changefreq", "daily") ?, files: files})248}249