
Build.pudu
Pudu307 lines16.4 KB
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]] }535455export 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] }59606162export fn plan(input: &Input, extending: &Extensions) -> Result[Docgen.Plan, Array[Docgen.Diagnostic]] {63 let built = site(input, extending) ?64 Ok(built.plan)65}66676869export 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), §ions)))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, ®istry, &settings, §ions, &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(§ions, 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}197198199fn 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}226227228fn 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}238239240241fn 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}260261262fn 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}292293294295fn 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}302303304fn 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