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

Publish.pudu

Pudu153 lines5.5 KB

GitHub ↗
1/** @Docgen.Docset.Publish — confined, incremental writing of a validated plan */2module PuduLangDocgen.Docset.Publish34import Std.Bytes as Bytes5import Std.Crypto as Crypto6import Std.Fs as Fs7import Std.Io as Io8import Std.Json as Json9import Std.Map as Map10import Std.Path as Path11import PuduLangDocgen.Constants.Codes as Codes12import PuduLangDocgen as Docgen13import PuduLangDocgen.Meta as Meta1415/// Writes a plan under an output folder. Files whose content matches the previous build's16/// record are left untouched unless `force` is set; files the previous build wrote that this17/// plan no longer has are removed. The record is kept in the `state` file.18export fn publish(root: Str, plan: &Docgen.Plan, state: Str, force: Bool) -> Result[Docgen.Report, Array[Docgen.Diagnostic]] {19  let folder = safe(root) ?20  let previous = recorded(state)21  var hashes: Array[(Str, Docgen.Meta)] = []22  var written: Array[Str] = []23  var unchanged: Array[Str] = []24  var planned: Array[Str] = []25  for artifact in plan.artifacts {26    let hash = Crypto.sha256Hex(artifact.content)27    let target = safe(Path.join(folder, artifact.path)) ?28    planned = planned.push(artifact.path)29    hashes = hashes.push((artifact.path, Docgen.Text(hash)))30    if !force && Map.getOr(&previous, artifact.path, "") == hash && Io.exists(target) {31      unchanged = unchanged.push(artifact.path)32      continue33    }34    place(target, artifact.content) ?35    written = written.push(artifact.path)36  }37  for resource in plan.resources {38    let hash = Bytes.encodeHex(&Crypto.sha512(&resource.content))39    let target = safe(Path.join(folder, resource.path)) ?40    planned = planned.push(resource.path)41    hashes = hashes.push((resource.path, Docgen.Text(hash)))42    if !force && Map.getOr(&previous, resource.path, "") == hash && Io.exists(target) {43      unchanged = unchanged.push(resource.path)44      continue45    }46    placeBytes(target, &resource.content) ?47    written = written.push(resource.path)48  }49  var removed: Array[Str] = []50  for path in Map.keys(&previous) {51    if planned.contains(path) { continue }52    let target = match safe(Path.join(folder, path)) {53      case Ok(found) => found54      case Err(_) => { continue }55    }56    if Io.exists(target) {57      match Io.remove(target) {58        case Ok(_) => { removed = removed.push(path) }59        case Err(_) => {}60      }61    }62  }63  let record = Json.encodePretty(&Json.object(&[("files", Meta.toJson(&Docgen.Fields(hashes)))]))64  place(state, record) ?65  Ok(Docgen.Report{written: written, unchanged: unchanged, removed: removed, diagnostics: plan.diagnostics})66}6768/// A text file replaced in one step: written beside its target with the usual file mode, then69/// renamed over it, so readers never see a partial file. The folder is created when missing.70export fn place(path: Str, text: Str) -> Result[(), Array[Docgen.Diagnostic]] {71  ready(path) ?72  let staging = path + ".docgen-partial"73  match Io.write(staging, text) {74    case Ok(_) => {}75    case Err(reason) => { return Err([Docgen.error(Codes.PUBLISH_FAILED, path, 1, reason)]) }76  }77  settle(staging, path)78}7980/// A binary file replaced in one step, like `place`.81export fn placeBytes(path: Str, content: &Bytes) -> Result[(), Array[Docgen.Diagnostic]] {82  ready(path) ?83  let staging = path + ".docgen-partial"84  match Io.writeAllBytes(staging, content) {85    case Ok(_) => {}86    case Err(reason) => { return Err([Docgen.error(Codes.PUBLISH_FAILED, path, 1, show(reason))]) }87  }88  settle(staging, path)89}9091/// A staged file moved over its target.92fn settle(staging: Str, path: Str) -> Result[(), Array[Docgen.Diagnostic]] {93  match Fs.rename(staging, path) {94    case Ok(_) => Ok(())95    case Err(reason) => {96      let _cleaned = Io.remove(staging)97      Err([Docgen.error(Codes.PUBLISH_FAILED, path, 1, show(reason))])98    }99  }100}101102/// Hashes recorded by the previous build; a missing or unreadable record means none.103fn recorded(state: Str) -> Map[Str, Str] {104  let text = match Io.read(state) {105    case Ok(found) => found106    case Err(_) => { return mapOf([]) }107  }108  match Json.decode(text) {109    case Ok(found) => {110      var pairs: Array[(Str, Str)] = []111      match Meta.fromJson(&found) {112        case Docgen.Fields(fields) => {113          for (path, value) in Meta.fieldsOf(&fields, "files") {114            match value {115              case Docgen.Text(hash) => { pairs = pairs.push((path, hash)) }116              case _ => {}117            }118          }119        }120        case _ => {}121      }122      mapOf(pairs)123    }124    case Err(_) => mapOf([])125  }126}127128/// A path whose existing ancestors are folders and none of them symbolic links.129fn safe(path: Str) -> Result[Str, Array[Docgen.Diagnostic]] {130  let normalized = Path.normalize(path)131  let chain = Path.ancestors(normalized).reverse().push(normalized)132  for part in chain {133    if Io.exists(part) {134      match Fs.metadata(part) {135        case Ok(details) => {136          if details.isSymbolicLink { return Err([Docgen.error(Codes.PUBLISH_FAILED, part, 1, "symbolic links are not followed when publishing")]) }137          if part != normalized && !details.isDirectory { return Err([Docgen.error(Codes.PUBLISH_FAILED, part, 1, "a file stands where a folder is needed")]) }138        }139        case Err(reason) => { return Err([Docgen.error(Codes.PUBLISH_FAILED, part, 1, show(reason))]) }140      }141    }142  }143  Ok(normalized)144}145146/// The folder of a file created when missing.147fn ready(path: Str) -> Result[(), Array[Docgen.Diagnostic]] {148  match Io.makeDirectory(Path.directoryOf(path)) {149    case Ok(_) => Ok(())150    case Err(reason) => Err([Docgen.error(Codes.PUBLISH_FAILED, path, 1, reason)])151  }152}153