
Configuration.pudu
Pudu249 lines14.3 KB
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}565758export const LEVELS: Array[Str] = ["error", "warning", "info", "off"]596061export 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}70717273export 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}969798fn 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}109110111fn 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}170171172173fn 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}204205206fn 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}229230231fn 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}236237238fn 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