
Docset.pudu
Pudu320 lines15.0 KB
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] }474849export fn options() -> Options {50 Options{output: "", metadata: [], xref: [], templates: [], warningsAsErrors: false, dryRun: false, disableGitFeatures: false, force: false, exportRawModel: false, exportViewModel: false}51}525354export 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}145146147fn 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}153154155export 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}169170171172export 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}195196197export 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}211212213export 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}231232233234export 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}250251252fn 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}265266267fn 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}289290291292fn 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