
Paths.pudu
Pudu143 lines6.2 KB
1/** @Docgen.Paths.Module — publication paths and browser destinations share one policy */2module PuduLangDocgen.Paths34import Std.Char as Char5import Std.Set as Set678const 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 ])121314const FORBIDDEN: Str = ":%?#<>\"|*"151617export 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}282930export fn remote(destination: Str) -> Bool {31 let lowered = destination.toLower()32 lowered.startsWith("https://") || lowered.startsWith("http://") || lowered.startsWith("mailto:")33}343536export 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}495051export fn pathOf(destination: Str) -> Str { destination.split("#")[0].split("?")[0] }525354export fn suffixOf(destination: Str) -> Str { destination.drop(pathOf(destination).length()) }555657export 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}697071export 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}80818283export 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}959697export fn directoryOf(path: Str) -> Str {98 let parts = path.split("/")99 parts.slice(0, parts.length() - 1).join("/")100}101102103export 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}119120121export 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}131132133export 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}138139140export fn escape(text: Str) -> Str {141 text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")142}143