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

Docset.pudu

Pudu320 lines15.0 KB

GitHub ↗
1/** @Docgen.Docset.Seam — a project folder loaded, built, and published */2module PuduLangDocgen.Docset34import Std.Bytes as Bytes5import Std.Crypto as Crypto6import Std.Fs as Fs7import Std.Io as Io8import Std.Map as Map9import Std.Path as Path10import Std.Time as Time11import PuduLangDocgen.Api.Catalog as Catalog12import PuduLangDocgen.Api.Export as Export13import PuduLangDocgen.Api.Model as Model14import PuduLangDocgen.Api.Parser as Parser15import PuduLangDocgen.Build as Build16import PuduLangDocgen.Build.Site as Site17import PuduLangDocgen.Build.Sources as Sources18import PuduLangDocgen.Configuration as Configuration19import PuduLangDocgen.Constants.Codes as Codes20import PuduLangDocgen.Constants.Package as Package21import PuduLangDocgen as Docgen22import PuduLangDocgen.Docset.Publish as Publish23import PuduLangDocgen.Docset.Tasks as Tasks24import PuduLangDocgen.Docset.Tools as Tools25import PuduLangDocgen.Docset.Walk as Walk26import PuduLangDocgen.Markdown as Markdown27import PuduLangDocgen.Meta as Meta28import PuduLangDocgen.References as References29import PuduLangDocgen.Yaml as Yaml3031/** @Docgen.Docset.Options — command-line choices layered over the configuration */32export type Options = {33  output: Str,34  metadata: Array[(Str, Docgen.Meta)],35  xref: Array[Str],36  templates: Array[Str],37  warningsAsErrors: Bool,38  dryRun: Bool,39  disableGitFeatures: Bool,40  force: Bool,41  exportRawModel: Bool,42  exportViewModel: Bool43}4445/** @Docgen.Docset.Loaded — a project's folder and everything a build reads from it */46export type Loaded = { base: Str, input: Build.Input, diagnostics: Array[Docgen.Diagnostic] }4748/// Options that leave the configuration as written.49export fn options() -> Options {50  Options{output: "", metadata: [], xref: [], templates: [], warningsAsErrors: false, dryRun: false, disableGitFeatures: false, force: false, exportRawModel: false, exportViewModel: false}51}5253/// A project read from its configuration file with options applied.54export fn load(configFile: Str, chosen: &Options) -> Result[Loaded, Array[Docgen.Diagnostic]] {55  let text = match Io.read(configFile) {56    case Ok(found) => found57    case Err(reason) => { return Err([Docgen.error(Codes.CONFIG_UNREADABLE, configFile, 1, reason)]) }58  }59  let written = Configuration.parse(Path.nameOf(configFile), text) ?60  let base = match Fs.canonical(Path.directoryOf(configFile)) {61    case Ok(found) => found62    case Err(reason) => { return Err([Docgen.error(Codes.CONFIG_UNREADABLE, configFile, 1, show(reason))]) }63  }64  let config = overridden(&written, chosen)65  var problems: Array[Docgen.Diagnostic] = []66  let paths = Walk.files(base, &[config.output]) ?67  let skip = [config.output]68  let content = Sources.mapped(&config.content, &paths, &skip)69  let overwrite = Sources.mapped(&config.overwrite, &paths, &skip)70  let resources = Sources.mapped(&config.resource, &paths, &skip)71  var wanted: Array[Str] = []72  for (source, _output) in content.concat(overwrite) { wanted = wanted.push(source) }73  wanted = wanted.concat(config.globalMetadataFiles).concat(config.fileMetadataFiles)74  let (files, textProblems) = closure(base, &wanted)75  problems = problems.concat(textProblems)76  var units: Array[Array[Model.Unit]] = []77  var sources: Array[Configuration.ApiSource] = []78  for source in config.metadata {79    var chosenRules: Array[Catalog.Rule] = []80    if !source.filterFile.isEmpty() {81      match Walk.text(base, source.filterFile) {82        case Ok(held) => match Yaml.decode(held) {83          case Ok(value) => match Catalog.rules(&value) {84            case Ok(found) => { chosenRules = found }85            case Err(reason) => { problems = problems.push(Docgen.error(Codes.CONFIG_INVALID, source.filterFile, 1, reason)) }86          }87          case Err(reason) => { problems = problems.push(Docgen.error(Codes.CONFIG_UNREADABLE, source.filterFile, 1, reason)) }88        }89        case Err(problem) => { problems = problems.push(problem) }90      }91    }92    sources = sources.push(Configuration.ApiSource{..source, options: Catalog.Options{..source.options, rules: chosenRules}})93    var parsed: Array[Model.Unit] = []94    for mapping in source.mappings {95      let folder = Path.normalize(Path.join(base, mapping.src))96      let found = match Walk.files(folder, &[]) {97        case Ok(held) => held98        case Err(more) => {99          problems = problems.concat(more)100          []101        }102      }103      for (path, _output) in Sources.mapped(&[Configuration.Mapping{..mapping, src: ""}], &found, &[]) {104        match Walk.text(folder, path) {105          case Ok(held) => { parsed = parsed.push(Parser.parse(path, held)) }106          case Err(problem) => { problems = problems.push(problem) }107        }108      }109    }110    units = units.push(parsed)111  }112  var copied: Array[(Str, Docgen.Resource)] = []113  for (source, output) in resources {114    match Walk.bytes(base, source) {115      case Ok(held) => { copied = copied.push((source, Docgen.Resource{path: output, content: held})) }116      case Err(problem) => { problems = problems.push(problem) }117    }118  }119  let (template, assets, templateProblems) = templates(base, &config.templates)120  problems = problems.concat(templateProblems)121  let (external, xrefProblems) = Tools.references(base, &config.xref)122  problems = problems.concat(xrefProblems)123  var diagrams: Map[Str, Str] = mapOf([])124  if config.diagrams.local {125    var drawings: Array[Str] = []126    for (path, held) in Map.pairs(&files) {127      if path.toLower().endsWith(".md") { drawings = drawings.concat(Markdown.diagrams(&Markdown.parse(path, held, &files, &[]).blocks)) }128    }129    let (drawn, diagramProblems) = Tools.diagrams(&drawings, &config.diagrams)130    diagrams = drawn131    problems = problems.concat(diagramProblems)132  }133  let history = if config.gitFeatures { Tools.history(base) } else { mapOf([]) }134  let repository = if config.gitFeatures { Tools.repository(base, &config.contribution) } else { config.contribution }135  let today = match Time.format(&Time.currentInstant(), "%Y-%m-%d") {136    case Ok(found) => found137    case Err(_) => ""138  }139  if Docgen.failed(&problems) { return Err(problems) }140  let input = Build.Input{config: Configuration.Config{..config, metadata: sources}, paths: paths, files: files, resources: copied, units: units, external: external, template: template, assets: assets, history: history, repository: repository, diagrams: diagrams, generator: Package.VERSION, today: today}141  if config.xrefService.isEmpty() { return Ok(Loaded{base: base, input: input, diagnostics: problems}) }142  let (served, serviceProblems) = Tools.consult(&config.xrefService, &References.unresolved(&trial(&input)))143  Ok(Loaded{base: base, input: Build.Input{..input, external: external.concat(served)}, diagnostics: problems.concat(serviceProblems)})144}145146/// Every diagnostic of a build of the input, whether or not it succeeds.147fn trial(input: &Build.Input) -> Array[Docgen.Diagnostic] {148  match Build.plan(input, &Build.extensions()) {149    case Ok(planned) => planned.diagnostics150    case Err(problems) => problems151  }152}153154/// A project built and, unless the run is dry, published incrementally.155export fn build(configFile: Str, chosen: &Options, extending: &Build.Extensions) -> Result[Docgen.Report, Array[Docgen.Diagnostic]] {156  let loaded = load(configFile, chosen) ?157  let plan = match Build.plan(&loaded.input, extending) {158    case Ok(found) => found159    case Err(problems) => { return Err(loaded.diagnostics.concat(problems)) }160  }161  let all = Docgen.Plan{..plan, diagnostics: loaded.diagnostics.concat(plan.diagnostics)}162  if loaded.input.config.dryRun {163    let planned = all.artifacts.map(|artifact: Docgen.Artifact| artifact.path).concat(all.resources.map(|resource: Docgen.Resource| resource.path))164    return Ok(Docgen.Report{written: [], unchanged: planned, removed: [], diagnostics: all.diagnostics})165  }166  let state = Path.join(Path.join(loaded.base, Package.STATE_FOLDER), "build.json")167  Publish.publish(Path.join(loaded.base, loaded.input.config.output), &all, state, chosen.force)168}169170/// The site built and published, then a PDF for every table of contents that sets `pdf`. Unlike171/// `pdf`, a missing renderer is a warning here, so sites build on machines without a browser.172export fn buildAll(configFile: Str, chosen: &Options) -> Result[(Docgen.Report, Array[Str]), Array[Docgen.Diagnostic]] {173  let loaded = load(configFile, chosen) ?174  let built = match Build.site(&loaded.input, &Build.extensions()) {175    case Ok(found) => found176    case Err(problems) => { return Err(loaded.diagnostics.concat(problems)) }177  }178  let all = Docgen.Plan{..built.plan, diagnostics: loaded.diagnostics.concat(built.plan.diagnostics)}179  if loaded.input.config.dryRun {180    let planned = all.artifacts.map(|artifact: Docgen.Artifact| artifact.path).concat(all.resources.map(|resource: Docgen.Resource| resource.path))181    return Ok((Docgen.Report{written: [], unchanged: planned, removed: [], diagnostics: all.diagnostics}, []))182  }183  let state = Path.join(Path.join(loaded.base, Package.STATE_FOLDER), "build.json")184  let report = Publish.publish(Path.join(loaded.base, loaded.input.config.output), &all, state, chosen.force) ?185  if built.books.filter(|book: Site.Book| Meta.flag(&book.meta, "pdf")).isEmpty() { return Ok((report, [])) }186  let renderer = match loaded.input.config.pdf {187    case Some(settings) => settings.renderer188    case None => []189  }190  match Tasks.pdf(loaded.base, &Build.Built{..built, plan: all}, loaded.input.config.output, &renderer) {191    case Ok(produced) => Ok((report, produced))192    case Err(problems) => Ok((Docgen.Report{..report, diagnostics: report.diagnostics.concat(problems.map(|problem: Docgen.Diagnostic| Docgen.Diagnostic{..problem, severity: Docgen.Warning}))}, []))193  }194}195196/// The site built and published, then a PDF for every table of contents that sets `pdf`.197export fn pdf(configFile: Str, chosen: &Options) -> Result[Array[Str], Array[Docgen.Diagnostic]] {198  let loaded = load(configFile, chosen) ?199  let built = match Build.site(&loaded.input, &Build.extensions()) {200    case Ok(found) => found201    case Err(problems) => { return Err(loaded.diagnostics.concat(problems)) }202  }203  let state = Path.join(Path.join(loaded.base, Package.STATE_FOLDER), "build.json")204  let _report = Publish.publish(Path.join(loaded.base, loaded.input.config.output), &built.plan, state, chosen.force) ?205  let renderer = match loaded.input.config.pdf {206    case Some(settings) => settings.renderer207    case None => []208  }209  Tasks.pdf(loaded.base, &built, loaded.input.config.output, &renderer)210}211212/// API metadata written as files beside the configuration, in each source's format.213export fn metadata(configFile: Str, chosen: &Options) -> Result[Array[Str], Array[Docgen.Diagnostic]] {214  let loaded = load(configFile, chosen) ?215  var written: Array[Str] = []216  var index = 0217  for source in loaded.input.config.metadata {218    let units = if index < loaded.input.units.length() { loaded.input.units[index] } else { [] }219    let selected = Catalog.select(&units, &source.options)220    let folder = if chosen.output.isEmpty() { source.options.dest } else { chosen.output }221    for (path, text) in Export.files(&selected, source.format) {222      let relative = if folder.isEmpty() { path } else { folder + "/" + path }223      let target = Path.join(loaded.base, relative)224      Publish.place(target, text) ?225      written = written.push(relative)226    }227    index = index + 1228  }229  Ok(written)230}231232/// A digest of every project file's path and content outside the output folder, which233/// changes whenever a source the build reads changes.234export fn fingerprint(configFile: Str, output: Str) -> Str {235  let base = Path.directoryOf(configFile)236  let paths = match Walk.files(if base.isEmpty() { "." } else { base }, &[output]) {237    case Ok(found) => found238    case Err(_) => []239  }240  var pieces: Array[Str] = []241  for path in paths {242    let content = match Walk.bytes(if base.isEmpty() { "." } else { base }, path) {243      case Ok(held) => Bytes.encodeHex(&Crypto.sha512(&held))244      case Err(_) => ""245    }246    pieces = pieces.push(path + "=" + content)247  }248  Crypto.sha256Hex(pieces.join("\n"))249}250251/// The configuration with command-line options applied.252fn overridden(config: &Configuration.Config, chosen: &Options) -> Configuration.Config {253  var result = *config254  if !chosen.output.isEmpty() { result = Configuration.Config{..result, output: chosen.output} }255  if chosen.warningsAsErrors { result = Configuration.Config{..result, warningsAsErrors: true} }256  if chosen.dryRun { result = Configuration.Config{..result, dryRun: true} }257  if chosen.disableGitFeatures { result = Configuration.Config{..result, gitFeatures: false} }258  if chosen.exportRawModel && result.exportRawModel.isEmpty() { result = Configuration.Config{..result, exportRawModel: result.output} }259  if chosen.exportViewModel && result.exportViewModel.isEmpty() { result = Configuration.Config{..result, exportViewModel: result.output} }260  Configuration.Config{..result,261    globalMetadata: Meta.merge(&result.globalMetadata, &chosen.metadata),262    xref: result.xref.concat(chosen.xref),263    templates: result.templates.concat(chosen.templates) }264}265266/// The text of files and of every file their Markdown includes or excerpts, transitively.267fn closure(base: Str, wanted: &Array[Str]) -> (Map[Str, Str], Array[Docgen.Diagnostic]) {268  var found: Array[(Str, Str)] = []269  var seen: Array[Str] = []270  var queue = *wanted271  var problems: Array[Docgen.Diagnostic] = []272  while !queue.isEmpty() {273    let path = queue[0]274    queue = queue.slice(1, queue.length())275    if seen.contains(path) { continue }276    seen = seen.push(path)277    match Walk.text(base, path) {278      case Ok(held) => {279        found = found.push((path, held))280        if path.toLower().endsWith(".md") || path.toLower().endsWith(".markdown") {281          queue = queue.concat(Markdown.dependencies(path, held).filter(|next: Str| !seen.contains(next)))282        }283      }284      case Err(problem) => { if wanted.contains(path) { problems = problems.push(problem) } }285    }286  }287  (mapOf(found), problems)288}289290/// Template overrides and public assets from each template folder in order; later folders291/// replace files of earlier ones. Built-in template names add no files.292fn templates(base: Str, names: &Array[Str]) -> (Array[(Str, Str)], Array[Docgen.Resource], Array[Docgen.Diagnostic]) {293  var texts: Array[(Str, Str)] = []294  var assets: Array[Docgen.Resource] = []295  var problems: Array[Docgen.Diagnostic] = []296  for name in *names {297    if Package.BUILT_IN_TEMPLATES.contains(name) { continue }298    let folder = Path.join(base, name)299    match Walk.files(folder, &[]) {300      case Ok(paths) => {301        for path in paths {302          if path.startsWith("public/") {303            match Walk.bytes(folder, path) {304              case Ok(held) => { assets = assets.filter(|asset: Docgen.Resource| asset.path != path).push(Docgen.Resource{path: path, content: held}) }305              case Err(problem) => { problems = problems.push(problem) }306            }307          } else if path.endsWith(".html") || path == "token.json" {308            match Walk.text(folder, path) {309              case Ok(held) => { texts = texts.push((path, held)) }310              case Err(problem) => { problems = problems.push(problem) }311            }312          }313        }314      }315      case Err(found) => { problems = problems.concat(found) }316    }317  }318  (texts, assets, problems)319}320