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

Build.pudu

Pudu307 lines16.4 KB

GitHub ↗
1/** @Docgen.Build.Module — a complete, validated site plan from loaded project content */2module PuduLangDocgen.Build34import Std.Json as Json5import Std.List as List6import Std.Map as Map7import PuduLangDocgen.Api.Catalog as Catalog8import PuduLangDocgen.Api.Model as Model9import PuduLangDocgen.Api.Pages as ApiPages10import PuduLangDocgen.Api.Sheet as Sheet11import PuduLangDocgen.Build.Checks as Checks12import PuduLangDocgen.Build.Overwrite as Overwrite13import PuduLangDocgen.Build.Site as Site14import PuduLangDocgen.Build.Sources as Sources15import PuduLangDocgen.Configuration as Configuration16import PuduLangDocgen.Constants.Codes as Codes17import PuduLangDocgen as Docgen18import PuduLangDocgen.Inline as Inline19import PuduLangDocgen.Markdown.Phrase as Phrase20import PuduLangDocgen.Markdown.Render as Render21import PuduLangDocgen.Markdown.Syntax as Syntax22import PuduLangDocgen.Meta as Meta23import PuduLangDocgen.Navigation as Navigation24import PuduLangDocgen.Navigation.Resolve as Resolve25import PuduLangDocgen.Paths as Paths26import PuduLangDocgen.References as References27import PuduLangDocgen.Rest.OpenApi as OpenApi28import PuduLangDocgen.Rest.Pages as RestPages29import PuduLangDocgen.Site.Format as Format30import PuduLangDocgen.Site.Gallery as Gallery31import PuduLangDocgen.Site.Landing as Landing32import PuduLangDocgen.Theme as Theme3334/** @Docgen.Build.Input — everything a build reads, already loaded from the project */35export type Input = {36  config: Configuration.Config,37  paths: Array[Str],38  files: Map[Str, Str],39  resources: Array[(Str, Docgen.Resource)],40  units: Array[Array[Model.Unit]],41  external: Array[Docgen.Reference],42  template: Array[(Str, Str)],43  assets: Array[Docgen.Resource],44  history: Map[Str, Str],45  repository: Configuration.Contribution,46  diagrams: Map[Str, Str],47  generator: Str,48  today: Str49}5051/** @Docgen.Build.Extensions — caller transforms applied to pages and to the finished outputs */52export type Extensions = { pages: Array[fn(Docgen.Page) -> Docgen.Page], artifacts: Array[fn(Array[Docgen.Artifact]) -> Array[Docgen.Artifact]] }5354/// Extensions that change nothing.55export fn extensions() -> Extensions { Extensions{pages: [], artifacts: []} }5657/** @Docgen.Build.Built — the validated plan with the pages and navigation it came from */58export type Built = { plan: Docgen.Plan, pages: Array[Docgen.Page], books: Array[Site.Book] }5960/// The site plan, or every error found. Warnings travel with a successful plan; nothing is61/// planned for output unless the whole site validates.62export fn plan(input: &Input, extending: &Extensions) -> Result[Docgen.Plan, Array[Docgen.Diagnostic]] {63  let built = site(input, extending) ?64  Ok(built.plan)65}6667/// The site plan with its pages and resolved tables of contents, for work that continues68/// from a built site such as printable documents.69export fn site(input: &Input, extending: &Extensions) -> Result[Built, Array[Docgen.Diagnostic]] {70  let config = input.config71  var problems: Array[Docgen.Diagnostic] = []72  let content = Sources.mapped(&config.content, &input.paths, &[config.output])73  if content.isEmpty() && config.metadata.isEmpty() { return Err([Docgen.error(Codes.NOTHING_TO_BUILD, "docgen.json", 1, "no content or metadata is configured")]) }74  let (declared, rules, metadataProblems) = Sources.metadata(&config, &input.files)75  problems = problems.concat(metadataProblems)76  let global = Meta.merge(&declared, &templateMeta(&input.template, &input.assets))77  var kinds: Array[Str] = []78  for (kind, _style) in config.alerts { kinds = kinds.push(kind) }79  let read = Sources.read(&content, &input.files, &global, &rules, &kinds, &Sources.groupMeta(&config.content, &input.paths, &[config.output]))80  problems = problems.concat(read.diagnostics)81  var sections: Array[Overwrite.Section] = []82  for (source, _output) in Sources.mapped(&config.overwrite, &input.paths, &[config.output]) {83    let (found, overwriteProblems) = Overwrite.parse(source, Map.getOr(&input.files, source, ""))84    sections = sections.concat(found)85    problems = problems.concat(overwriteProblems)86  }87  var units: Array[(Catalog.Options, Array[Model.Unit])] = []88  var index = 089  for source in config.metadata {90    let loaded = if index < input.units.length() { input.units[index] } else { [] }91    units = units.push((source.options, Overwrite.units(&Catalog.select(&loaded, &source.options), &sections)))92    index = index + 193  }94  var outputs: Array[(Str, Str)] = input.resources.map(fn(entry: (Str, Docgen.Resource)) -> (Str, Str) {95      let (source, resource) = entry96      (source, resource.path)97    })98  var own: Array[Docgen.Reference] = []99  for entry in read.entries {100    outputs = outputs.push((entry.source, entry.output))101    let uid = Meta.textOr(&entry.meta, "uid", "")102    if !uid.isEmpty() { own = own.push(Docgen.Reference{uid: uid, name: titleOf(&entry), fullName: titleOf(&entry), href: entry.output, kind: "article"}) }103  }104  for (options, selected) in units { own = own.concat(Catalog.references(&selected, &options)) }105  let settings = rendering(&config, &global, &input.template, &input.diagrams)106  for entry in read.entries {107    match entry.document {108      case Sources.Service(service) => {109        let located = Phrase.Scope{page: entry.output, source: entry.source, outputs: mapOf([]), references: mapOf([]), settings: settings}110        let (_pages, found, _problems) = servicePages(&service, Meta.textOr(&entry.meta, "uid", Paths.slug(service.title)), &located, &config.templates)111        own = own.concat(found)112      }113      case _ => {}114    }115  }116  let (registry, registryProblems) = References.registry(&own, &input.external)117  problems = problems.concat(registryProblems)118  let outputMap = mapOf(outputs)119  var pages: Array[Docgen.Page] = []120  for entry in read.entries {121    let (rendered, found) = render(&entry, &outputMap, &registry, &settings, &sections, &config.templates)122    pages = pages.concat(rendered)123    problems = problems.concat(found)124  }125  for (options, selected) in units {126    let context = ApiPages.Context{options: options, references: registry, outputs: outputMap, settings: settings, units: selected}127    for unit in selected {128      let (found, apiProblems) = ApiPages.pages(&unit, &context)129      pages = pages.concat(found.map(|page: Docgen.Page| Docgen.Page{..page, meta: Meta.merge(&Meta.merge(&global, &Meta.forPath(&rules, page.source)), &Overwrite.metaFor(&sections, page.uid))}))130      problems = problems.concat(apiProblems)131    }132  }133  if pages.filter(|page: Docgen.Page| page.path.toLower() == "404.html").isEmpty() { pages = pages.push(Site.notFound(&global)) }134  for transform in extending.pages { pages = pages.map(|page: Docgen.Page| transform(page)) }135  problems = problems.concat(Checks.fragments(&pages))136  var tocs = read.tocs137  for (options, selected) in units {138    let output = if options.dest.isEmpty() { "toc.yml" } else { options.dest + "/toc.yml" }139    let taken = tocs.filter(fn(entry: (Str, Str, Navigation.Toc)) -> Bool {140        let (_source, published, _toc) = entry141        published == output142      })143    if taken.isEmpty() {144      let generated = Navigation.Toc{items: uidItems(&Catalog.toc(&selected, &options)), meta: []}145      var folders: Array[Str] = [""]146      for mapping in config.content {147        if mapping.dest.isEmpty() && !folders.contains(mapping.src) { folders = folders.push(mapping.src) }148      }149      var first = true150      for folder in folders {151        let key = if folder.isEmpty() { output } else { folder + "/" + output }152        tocs = tocs.push((key, if first { output } else { "" }, generated))153        first = false154      }155    }156  }157  if tocs.isEmpty() {158    let articles = List.sortOn(&pages.filter(|page: Docgen.Page| page.kind == "article" && !page.source.isEmpty()), |held: Docgen.Page| held.path)159    let items = articles.map(|page: Docgen.Page| Docgen.TocItem{title: page.title, href: page.source, uid: "", expanded: false, children: []})160    tocs = [("toc.yml", "toc.yml", Navigation.Toc{items: items, meta: []})]161  }162  let tocItems = mapOf(tocs.map(fn(entry: (Str, Str, Navigation.Toc)) -> (Str, Array[Docgen.TocItem]) {163        let (source, _output, toc) = entry164        (source, toc.items)165      }))166  let titles = mapOf(pages.map(|page: Docgen.Page| (page.path, page.title)))167  let links = Resolve.Links{tocs: tocItems, outputs: outputMap, references: registry, titles: titles}168  let (books, tocProblems) = Site.books(&tocs, &links)169  problems = problems.concat(tocProblems)170  let look = match Theme.load(&input.template) {171    case Ok(found) => found172    case Err(found) => { return Err(Checks.ruled(&problems.concat(found), &config.rules, config.warningsAsErrors)) }173  }174  let context = Site.Context{config: config, books: books, look: look, history: input.history, repository: input.repository, global: global, generator: input.generator, today: input.today}175  let pagesHtml = Site.rendered(&pages, &context).map(|artifact: Docgen.Artifact| if artifact.path.endsWith(".html") { Docgen.Artifact{..artifact, content: Format.html(artifact.content)} } else { artifact })176  var artifacts = Theme.assets().concat(pagesHtml).concat(Site.files(&pages, &own, &context))177  let copied = input.resources.map(fn(entry: (Str, Docgen.Resource)) -> Docgen.Resource {178      let (_source, resource) = entry179      resource180    }).concat(input.assets)181  let allPaths = artifacts.map(|artifact: Docgen.Artifact| artifact.path).concat(copied.map(|resource: Docgen.Resource| resource.path)).push("manifest.json")182  artifacts = artifacts.push(Docgen.Artifact{path: "manifest.json", content: Site.manifest(&pages, &outputs.filter(fn(entry: (Str, Str)) -> Bool {183            let (source, _output) = entry184            input.resources.filter(fn(resource: (Str, Docgen.Resource)) -> Bool {185                let (from, _held) = resource186                from == source187              }).length() > 0188          }), input.generator, &List.sorted(&allPaths)) })189  for transform in extending.artifacts { artifacts = transform(artifacts) }190  problems = problems.concat(Checks.outputs(&artifacts.map(|artifact: Docgen.Artifact| artifact.path).concat(copied.map(|resource: Docgen.Resource| resource.path))))191  let final = Checks.ruled(&problems, &config.rules, config.warningsAsErrors)192  if Docgen.failed(&final) { return Err(final) }193  let ordered = List.sortOn(&artifacts, |held: Docgen.Artifact| held.path)194  let planned = Docgen.Plan{artifacts: ordered, resources: List.sortOn(&copied, |held: Docgen.Resource| held.path), diagnostics: final}195  Ok(Built{plan: planned, pages: pages, books: books})196}197198/// Rendering choices from configuration, metadata, and template tokens.199fn rendering(config: &Configuration.Config, global: &Array[(Str, Docgen.Meta)], template: &Array[(Str, Str)], diagrams: &Map[Str, Str]) -> Phrase.Rendering {200  var titles: Array[(Str, Str)] = []201  for (path, text) in *template {202    if path == "token.json" {203      match Json.decode(text) {204        case Ok(found) => match Meta.fromJson(&found) {205          case Docgen.Fields(fields) => {206            for (key, value) in fields {207              match value {208                case Docgen.Text(written) => { titles = titles.push((key.toLower(), written)) }209                case _ => {}210              }211            }212          }213          case _ => {}214        }215        case Err(_) => {}216      }217    }218  }219  let classes = config.alerts.filter(fn(entry: (Str, Str)) -> Bool {220      let (_kind, style) = entry221      !style.isEmpty()222    })223  let newTab = Meta.flag(global, "_enableNewTab") && !Meta.flag(global, "_disableNewTab")224  Phrase.Rendering{newTab: newTab, plantUml: config.diagrams.remote + "/" + config.diagrams.format, diagrams: *diagrams, alertClasses: mapOf(classes), alertTitles: mapOf(titles)}225}226227/// Metadata a template folder contributes: its `public/main.css` and `public/main.js`.228fn templateMeta(template: &Array[(Str, Str)], assets: &Array[Docgen.Resource]) -> Array[(Str, Docgen.Meta)] {229  var result: Array[(Str, Docgen.Meta)] = []230  let paths = assets.map(|resource: Docgen.Resource| resource.path).concat(template.map(fn(entry: (Str, Str)) -> Str {231        let (path, _text) = entry232        path233      }))234  if paths.contains("public/main.css") { result = result.push(("_appStyle", Docgen.Text("public/main.css"))) }235  if paths.contains("public/main.js") { result = result.push(("_appScript", Docgen.Text("public/main.js"))) }236  result237}238239/// The title of a content entry: its metadata title, else its first level-one heading, else240/// its file name.241fn titleOf(entry: &Sources.Entry) -> Str {242  let named = Meta.textOr(&entry.meta, "title", "")243  if !named.isEmpty() { return named }244  match entry.document {245    case Sources.Article(article) => {246      for block in article.blocks {247        match block {248          case Syntax.Heading(title) => { if title.level == 1 { return Inline.plain(&title.content).trim() } }249          case _ => {}250        }251      }252    }253    case Sources.Service(service) => { return service.title }254    case Sources.ApiPage(Docgen.Fields(fields)) => { return Meta.textOr(&fields, "title", entry.source) }255    case _ => {}256  }257  let parts = entry.source.split("/")258  Paths.output(parts[parts.length() - 1]).replace(".html", "")259}260261/// One content entry rendered as a page.262fn render(entry: &Sources.Entry, outputs: &Map[Str, Str], registry: &Map[Str, Docgen.Reference], settings: &Phrase.Rendering, sections: &Array[Overwrite.Section], templates: &Array[Str]) -> (Array[Docgen.Page], Array[Docgen.Diagnostic]) {263  let located = Phrase.Scope{page: entry.output, source: entry.source, outputs: *outputs, references: *registry, settings: *settings}264  let uid = Meta.textOr(&entry.meta, "uid", "")265  let meta = Meta.merge(&entry.meta, &Overwrite.metaFor(sections, uid))266  let base = Docgen.Page{path: entry.output, source: entry.source, kind: "article", title: titleOf(entry), uid: uid, summary: Meta.textOr(&meta, "description", ""), body: "", headings: [], links: [], meta: meta}267  match entry.document {268    case Sources.Article(article) => {269      let rendered = Render.render(&article.blocks, &located)270      let heading = if rendered.title.isEmpty() && !Meta.textOr(&meta, "title", "").isEmpty() { "<h1 id=\"" + Paths.escape(Paths.slug(base.title)) + "\">" + Paths.escape(base.title) + "</h1>\n" } else { "" }271      ([Docgen.Page{..base, summary: if base.summary.isEmpty() { rendered.summary } else { base.summary }, body: heading + rendered.html, headings: rendered.headings, links: rendered.links}], rendered.diagnostics)272    }273    case Sources.Moved(_target) => ([Docgen.Page{..base, kind: "redirect"}], [])274    case Sources.Service(service) => {275      let (found, _references, problems) = servicePages(&service, if uid.isEmpty() { Paths.slug(service.title) } else { uid }, &located, templates)276      (found.map(|page: Docgen.Page| Docgen.Page{..page, meta: meta}), problems)277    }278    case Sources.Catalog(value) => match Gallery.render(&value, &located) {279      case Ok(found) => ([Docgen.Page{..base, kind: "catalog", title: found.title, body: found.body, headings: found.headings, links: found.links}], found.diagnostics)280      case Err(reason) => ([base], [Docgen.error(Codes.CONTENT_INVALID, entry.source, 1, reason)])281    }282    case Sources.Hub(value) => match Landing.render(&value, &located) {283      case Ok(found) => ([Docgen.Page{..base, kind: "landing", title: found.title, body: found.body, headings: found.headings, links: found.links}], found.diagnostics)284      case Err(reason) => ([base], [Docgen.error(Codes.CONTENT_INVALID, entry.source, 1, reason)])285    }286    case Sources.ApiPage(value) => match Sheet.render(&value, &located) {287      case Ok(found) => ([Docgen.Page{..base, kind: "api-page", title: found.title, body: found.body, headings: found.headings, links: found.links}], found.diagnostics)288      case Err(reason) => ([base], [Docgen.error(Codes.CONTENT_INVALID, entry.source, 1, reason)])289    }290  }291}292293/// The pages of an HTTP interface: one page, or split per tag or operation when the294/// `rest.tagpage` or `rest.operationpage` template is listed.295fn servicePages(service: &OpenApi.Service, uid: Str, located: &Phrase.Scope, templates: &Array[Str]) -> (Array[Docgen.Page], Array[Docgen.Reference], Array[Docgen.Diagnostic]) {296  let byTag = templates.contains("rest.tagpage")297  let byOperation = templates.contains("rest.operationpage")298  if byTag || byOperation { return RestPages.split(service, uid, located, byTag, byOperation) }299  let (page, references, problems) = RestPages.page(service, uid, located)300  ([page], references, problems)301}302303/// Generated API navigation bound by identity rather than by path.304fn uidItems(items: &Array[Docgen.TocItem]) -> Array[Docgen.TocItem] {305  items.map(|item: Docgen.TocItem| Docgen.TocItem{..item, href: if item.uid.isEmpty() { item.href } else { "" }, children: uidItems(&item.children)})306}307