///|
fn portable_project_path(name : String) -> String raise {
  workspace_path(name) |> ignore
  if @utf8.encode(name[:]).length() > 4096 {
    raise InvalidReference("Export path exceeds 4096 bytes")
  }
  if name.split("/").count() > 64 {
    raise InvalidReference("Export path exceeds 64 components")
  }
  for component in name.split("/") {
    let part = component.to_owned()
    if @utf8.encode(part[:]).length() > 255 {
      raise InvalidReference("Export filename exceeds 255 UTF-8 bytes")
    }
    let base = part.split(".").next().unwrap_or("").to_owned().to_lower()
    if part.has_suffix(".") ||
      part.has_suffix(" ") ||
      part.any(c => {
        c.to_int() < 32 ||
        c.to_int() == 127 ||
        "<>:\"\\|?*".contains(c.to_string())
      }) ||
      [
        "con", "prn", "aux", "nul", "clock$", "com¹", "com²", "com³", "lpt¹",
        "lpt²", "lpt³", "conin$", "conout$", "com1", "com2", "com3", "com4", "com5",
        "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6",
        "lpt7", "lpt8", "lpt9",
      ].contains(base) {
      raise InvalidReference("Export requires portable relative filenames")
    }
  }
  name
}

///|
/// Validate all contents and portable paths before hosts create any output.
/// Only the files explicitly contained in the snapshot are exported.
pub fn project_export_files(
  snapshot : Json,
  options~ : ParseOptions = ParseOptions::default(),
) -> Map[String, String] raise {
  guard snapshot is Object(fields) &&
    fields.get("format") == Some(Json::string("moonvalve-project-v1")) &&
    fields.get("source") is Some(String(source)) &&
    fields.get("files") is Some(Object(rows)) &&
    rows.length() > 0 &&
    rows.length() <= 64 else {
    raise InvalidReference("Expected a project snapshot containing 1..64 files")
  }
  let output : Map[String, String] = Map([])
  let names : Map[String, Bool] = Map([])
  let mut total = 0
  for name, value in rows {
    portable_project_path(name) |> ignore
    let lower = name.to_lower()
    if names.contains(lower) {
      raise InvalidReference("Case-insensitive export filename collision")
    }
    names[lower] = true
    guard value is String(text) else {
      raise InvalidReference("Expected text file contents")
    }
    total += @utf8.encode(text[:]).length()
    if total > 67108864 {
      raise InvalidReference("Project export exceeds 64 MiB")
    }
    if name.to_lower().has_suffix(".kv3") ||
      text.trim_start().to_owned().has_prefix("