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

Paths.pudu

Pudu143 lines6.2 KB

GitHub ↗
1/** @Docgen.Paths.Module — publication paths and browser destinations share one policy */2module PuduLangDocgen.Paths34import Std.Char as Char5import Std.Set as Set67/// Device names that no portable output segment may use, whatever its extension.8const RESERVED: Set[Str] = setOf([9    "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",10    "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"11  ])1213/// Characters that no portable output path may contain.14const FORBIDDEN: Str = ":%?#<>\"|*"1516/// Whether a path names a confined, portable relative output.17export fn relative(path: Str) -> Bool {18  if path.isEmpty() || path.startsWith("/") || path.contains("\\") { return false }19  for character in path.chars() {20    if character < ' ' || character == '\u{7f}' || FORBIDDEN.contains(character.toText()) { return false }21  }22  for part in path.split("/") {23    if part.isEmpty() || part == "." || part == ".." || part.endsWith(".") || part.endsWith(" ") { return false }24    if RESERVED.contains(part.split(".")[0].toLower()) { return false }25  }26  true27}2829/// Whether a destination leaves the site: HTTP, HTTPS, or mail.30export fn remote(destination: Str) -> Bool {31  let lowered = destination.toLower()32  lowered.startsWith("https://") || lowered.startsWith("http://") || lowered.startsWith("mailto:")33}3435/// Whether a browser destination avoids executable schemes and ambiguous separators.36export fn href(destination: Str) -> Bool {37  if destination.isEmpty() || destination.startsWith("//") || destination.contains("\\") { return false }38  for character in destination.chars() {39    if character <= ' ' || character == '\u{7f}' { return false }40  }41  let lowered = destination.toLower()42  if lowered.startsWith("https://") || lowered.startsWith("http://") {43    let rest = destination.drop(if lowered.startsWith("https://") { 8 } else { 7 })44    return !rest.isEmpty() && !rest.startsWith("/") && !rest.startsWith("?") && !rest.startsWith("#") && !rest.split("/")[0].contains("@")45  }46  if lowered.startsWith("mailto:") { return destination.length() > 7 }47  !pathOf(destination).contains(":")48}4950/// The path of a destination without its query and fragment.51export fn pathOf(destination: Str) -> Str { destination.split("#")[0].split("?")[0] }5253/// The query and fragment a destination carries after its path, including the separator.54export fn suffixOf(destination: Str) -> Str { destination.drop(pathOf(destination).length()) }5556/// A stable lowercase fragment; text with no letters or digits answers `section`.57export fn slug(text: Str) -> Str {58  var pieces: Array[Str] = []59  var dash = false60  for character in text.toLower().chars() {61    if Char.isAlphanumeric(character) || character > '\u{7f}' || character == '_' {62      if dash && !pieces.isEmpty() { pieces = pieces.push("-") }63      pieces = pieces.push(character.toText())64      dash = false65    } else { dash = true }66  }67  if pieces.isEmpty() { "section" } else { pieces.join("") }68}6970/// The published path of a source: Markdown and model files become pages, others keep their path.71export fn output(source: Str) -> Str {72  let lowered = source.toLower()73  if lowered.endsWith(".markdown") { return source.take(source.length() - 9) + ".html" }74  if lowered.endsWith(".md") { return source.take(source.length() - 3) + ".html" }75  if lowered.endsWith(".yml") { return source.take(source.length() - 4) + ".html" }76  if lowered.endsWith(".yaml") { return source.take(source.length() - 5) + ".html" }77  if lowered.endsWith(".json") { return source.take(source.length() - 5) + ".html" }78  source79}8081/// A local destination resolved from a source file to a root-relative published path.82/// Remote destinations pass through; escaping the root or naming an unsafe path is refused.83export fn resolve(source: Str, destination: Str) -> Result[Str, Str] {84  if !relative(source) { return Err("invalid source path") }85  if !href(destination) { return Err("unsafe destination") }86  if remote(destination) { return Ok(destination) }87  let path = pathOf(destination)88  let suffix = suffixOf(destination)89  if path.isEmpty() { return Ok(output(source) + suffix) }90  if path.contains("%") { return Err("encoded local path is not supported") }91  let resolved = join(directoryOf(source), path) ?92  if !relative(resolved) { return Err("invalid local destination") }93  Ok(output(resolved) + suffix)94}9596/// The directory part of a relative path; a top-level file answers the empty text.97export fn directoryOf(path: Str) -> Str {98  let parts = path.split("/")99  parts.slice(0, parts.length() - 1).join("/")100}101102/// A relative path joined under a directory, collapsing dot segments without leaving the root.103export fn join(directory: Str, path: Str) -> Result[Str, Str] {104  var parts: Array[Str] = if directory.isEmpty() { [] } else { directory.split("/") }105  let segments = path.split("/")106  var index = 0107  for part in segments {108    if part == ".." {109      if parts.isEmpty() { return Err("destination escapes docset root") }110      parts = parts.slice(0, parts.length() - 1)111    } else if part.isEmpty() {112      if index != segments.length() - 1 { return Err("empty destination segment") }113    } else if part != "." { parts = parts.push(part) }114    index = index + 1115  }116  if parts.isEmpty() { return Err("destination names the docset root") }117  Ok(parts.join("/"))118}119120/// The browser path from one published page to another root-relative destination.121export fn between(from: Str, to: Str) -> Str {122  if remote(to) || to.startsWith("#") { return to }123  let origin = from.split("/")124  let target = to.split("/")125  var common = 0126  while common < origin.length() - 1 && common < target.length() - 1 && origin[common] == target[common] { common = common + 1 }127  var parts: Array[Str] = []128  for _step in common..(origin.length() - 1) { parts = parts.push("..") }129  parts.concat(target.slice(common, target.length())).join("/")130}131132/// The `../` prefix leading from a published page back to the site root.133export fn rootOf(page: Str) -> Str {134  var parts: Array[Str] = []135  for _step in 1..page.split("/").length() { parts = parts.push("../") }136  parts.join("")137}138139/// Text escaped for HTML content and quoted attributes.140export fn escape(text: Str) -> Str {141  text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;")142}143