
Layout.pudu
Pudu321 lines12.4 KB
1/** @Docgen.Markdown.Layout — line-level recognition of block boundaries */2module PuduLangDocgen.Markdown.Layout34import Std.Char as Char5import Std.Map as Map6import Std.Option as Option7import Std.Set as Set8import PuduLangDocgen.Inline as Inline9import PuduLangDocgen.Markdown.FrontMatter as FrontMatter10import PuduLangDocgen.Markdown.Syntax as Syntax11import PuduLangDocgen.Paths as Paths121314const BLOCK_ELEMENTS: Set[Str] = setOf([15 "address", "article", "aside", "blockquote", "center", "details", "dialog", "div", "dl", "dd", "dt",16 "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr",17 "iframe", "li", "main", "nav", "ol", "p", "pre", "script", "section", "style", "summary", "table",18 "tbody", "td", "tfoot", "th", "thead", "tr", "ul"19 ])2021/** @Docgen.Markdown.Fence — an opening code fence and its info text */22export type Fence = { marker: Char, length: Int, indent: Int, info: Str }2324/** @Docgen.Markdown.Marker — a list item marker and the content after it */25export type Marker = { ordered: Bool, start: Int, symbol: Char, width: Int, rest: Str }262728export fn indent(text: Str) -> Int {29 var columns = 030 for character in text.chars() {31 if character == ' ' { columns = columns + 1 } else if character == '\t' { columns = columns + 4 - columns % 4 } else { return columns }32 }33 columns34}353637export fn strip(text: Str, columns: Int) -> Str {38 var removed = 039 var index = 040 let cs = text.chars()41 while index < cs.length() && removed < columns {42 if cs[index] == ' ' { removed = removed + 1 } else if cs[index] == '\t' {43 let width = 4 - removed % 444 if removed + width > columns {45 var padding: Array[Str] = []46 for _extra in 0..(removed + width - columns) { padding = padding.push(" ") }47 return padding.join("") + text.drop(index + 1)48 }49 removed = removed + width50 } else { return text.drop(index) }51 index = index + 152 }53 text.drop(index)54}555657export fn blank(text: Str) -> Bool { text.trim().isEmpty() }585960export fn fence(text: Str) -> Option[Fence] {61 let depth = indent(text)62 if depth > 3 { return None }63 let body = strip(text, depth)64 let first = if body.isEmpty() { ' ' } else { body.chars()[0] }65 if first != '`' && first != '~' { return None }66 var length = 067 for character in body.chars() {68 if character != first { break }69 length = length + 170 }71 if length < 3 { return None }72 let info = body.drop(length).trim()73 if first == '`' && info.contains("`") { return None }74 Some(Fence{marker: first, length: length, indent: depth, info: info})75}767778export fn closes(text: Str, opened: &Fence) -> Bool {79 if indent(text) > 3 { return false }80 let body = text.trim()81 if body.length() < opened.length { return false }82 for character in body.chars() {83 if character != opened.marker { return false }84 }85 true86}878889export fn thematic(text: Str) -> Bool {90 if indent(text) > 3 { return false }91 let body = text.trim().replace(" ", "").replace("\t", "")92 if body.length() < 3 { return false }93 let first = body.chars()[0]94 if first != '-' && first != '*' && first != '_' { return false }95 for character in body.chars() {96 if character != first { return false }97 }98 true99}100101102export fn atx(text: Str) -> Option[(Int, Str, Str)] {103 if indent(text) > 3 { return None }104 let body = text.trim()105 var level = 0106 for character in body.chars() {107 if character != '#' { break }108 level = level + 1109 }110 if level < 1 || level > 6 { return None }111 let after = body.drop(level)112 if !after.isEmpty() && !after.startsWith(" ") && !after.startsWith("\t") { return None }113 var content = after.trim()114 var cut = content.length()115 while cut > 0 && content.chars()[cut - 1] == '#' { cut = cut - 1 }116 if cut == 0 { content = "" } else if cut < content.length() && content.chars()[cut - 1] == ' ' { content = content.take(cut).trim() }117 var id = ""118 if content.endsWith("\}") {119 let open = content.indexOf("\{#")120 if open >= 0 && !content.drop(open + 2).contains(" ") {121 id = content.drop(open + 2).take(content.length() - open - 3)122 content = content.take(open).trim()123 }124 }125 Some((level, content, id))126}127128129export fn setext(text: Str) -> Int {130 if indent(text) > 3 { return 0 }131 let body = text.trim()132 if body.isEmpty() { return 0 }133 let first = body.chars()[0]134 if first != '=' && first != '-' { return 0 }135 for character in body.chars() {136 if character != first { return 0 }137 }138 if first == '=' { 1 } else { 2 }139}140141142export fn marker(text: Str) -> Option[Marker] {143 let depth = indent(text)144 if depth > 3 { return None }145 let body = strip(text, depth)146 let cs = body.chars()147 if cs.isEmpty() { return None }148 var ordered = false149 var start = 1150 var symbol = cs[0]151 var used = 1152 if cs[0] == '-' || cs[0] == '*' || cs[0] == '+' { used = 1 } else if Char.isDigit(cs[0]) {153 var digits = 0154 while digits < cs.length() && digits < 10 && Char.isDigit(cs[digits]) { digits = digits + 1 }155 if digits > 9 || digits >= cs.length() || (cs[digits] != '.' && cs[digits] != ')') { return None }156 ordered = true157 symbol = cs[digits]158 start = body.take(digits).toInt() ?159 used = digits + 1160 } else { return None }161 let after = body.drop(used)162 if !after.isEmpty() && !after.startsWith(" ") && !after.startsWith("\t") { return None }163 let spaces = indent(after)164 let gap = if after.trim().isEmpty() { 1 } else if spaces > 4 { 1 } else { spaces }165 Some(Marker{ordered: ordered, start: start, symbol: symbol, width: depth + used + gap, rest: strip(after, gap)})166}167168169export fn quote(text: Str) -> Option[Str] {170 let depth = indent(text)171 if depth > 3 { return None }172 let body = strip(text, depth)173 if !body.startsWith(">") { return None }174 let rest = body.drop(1)175 Some(if rest.startsWith(" ") { rest.drop(1) } else if rest.startsWith("\t") { strip(rest, 1) } else { rest })176}177178179export fn cells(text: Str) -> Array[Str] {180 var held = text.trim()181 if held.startsWith("|") { held = held.drop(1) }182 if held.endsWith("|") && !held.endsWith("\\|") { held = held.take(held.length() - 1) }183 var result: Array[Str] = []184 var current: Array[Str] = []185 var code = false186 let cs = held.chars()187 var index = 0188 while index < cs.length() {189 let character = cs[index]190 if character == '\\' && index + 1 < cs.length() && cs[index + 1] == '|' {191 current = current.push("|")192 index = index + 2193 continue194 }195 if character == '`' { code = !code }196 if character == '|' && !code {197 result = result.push(current.join("").trim())198 current = []199 } else { current = current.push(character.toText()) }200 index = index + 1201 }202 result.push(current.join("").trim())203}204205206export fn alignments(text: Str) -> Option[Array[Str]] {207 if !text.contains("-") || indent(text) > 3 { return None }208 if !text.contains("|") && !text.trim().startsWith(":") { return None }209 var result: Array[Str] = []210 for cell in cells(text) {211 let core = cell.replace(":", "")212 if core.length() < 1 || core.replace("-", "").length() > 0 { return None }213 let left = cell.startsWith(":")214 let right = cell.endsWith(":")215 result = result.push(if left && right { "center" } else if right { "right" } else if left { "left" } else { "" })216 }217 Some(result)218}219220221222export fn html(text: Str) -> Bool {223 if indent(text) > 3 { return false }224 let body = text.trim()225 if body.startsWith("<!--") { return true }226 if !body.startsWith("<") || body.length() < 3 { return false }227 let rest = if body.drop(1).startsWith("/") { body.drop(2) } else { body.drop(1) }228 let cs = rest.chars()229 if cs.isEmpty() || !Char.isLetter(cs[0]) { return false }230 var index = 0231 while index < cs.length() && (Char.isAlphanumeric(cs[index]) || cs[index] == '-') { index = index + 1 }232 if index < cs.length() && cs[index] != ' ' && cs[index] != '>' && cs[index] != '/' && cs[index] != '\t' { return false }233 if Set.contains(&BLOCK_ELEMENTS, rest.take(index).toLower()) { return true }234 body.endsWith(">") && body.indexOf(">") == body.length() - 1235}236237238export fn interrupts(text: Str) -> Bool {239 if blank(text) || thematic(text) || Option.isSome(&atx(text)) || Option.isSome(&fence(text)) || Option.isSome("e(text)) || html(text) { return true }240 if text.trim().startsWith(":::") || text.trim().startsWith("$$") { return true }241 match marker(text) {242 case Some(found) => !found.rest.trim().isEmpty() && (!found.ordered || found.start == 1)243 case None => false244 }245}246247248export fn definition(text: Str, number: Int) -> Option[(Str, Syntax.Target)] {249 if indent(text) > 3 { return None }250 let body = text.trim()251 if !body.startsWith("[") || body.startsWith("[!") || body.startsWith("[^") { return None }252 let close = body.indexOf("]:")253 if close < 2 { return None }254 let name = Inline.label(body.drop(1).take(close - 1))255 if name.isEmpty() { return None }256 let rest = body.drop(close + 2).trim()257 if rest.isEmpty() { return None }258 var destination = rest.split(" ")[0]259 if destination.startsWith("<") && destination.endsWith(">") { destination = destination.drop(1).take(destination.length() - 2) }260 let after = rest.drop(rest.split(" ")[0].length()).trim()261 var title = ""262 if !after.isEmpty() {263 let quoteMark = after.take(1)264 let closing = if quoteMark == "(" { ")" } else { quoteMark }265 if (quoteMark != "\"" && quoteMark != "'" && quoteMark != "(") || !after.endsWith(closing) || after.length() < 2 { return None }266 title = after.drop(1).take(after.length() - 2)267 }268 Some((name, Syntax.Target{destination: destination, title: title, line: number}))269}270271272export fn definitions(lines: &Array[FrontMatter.Line]) -> (Map[Str, Syntax.Target], Array[FrontMatter.Line]) {273 var found: Map[Str, Syntax.Target] = mapOf([])274 var kept: Array[FrontMatter.Line] = []275 var open: Option[Fence] = None276 var previousBlank = true277 for line in *lines {278 var dropped = false279 match open {280 case Some(opened) => { if closes(line.text, &opened) { open = None } }281 case None => {282 open = fence(line.text)283 if Option.isNone(&open) && previousBlank {284 match definition(line.text, line.number) {285 case Some((name, target)) => {286 if !Map.containsKey(&found, name) { found = Map.insert(&found, name, target) }287 dropped = true288 }289 case None => {}290 }291 }292 }293 }294 if !dropped {295 kept = kept.push(line)296 previousBlank = blank(line.text)297 }298 }299 (found, kept)300}301302303export fn locate(origin: Str, written: Str) -> Result[Str, Str] {304 if written.isEmpty() || Paths.remote(written) || written.startsWith("/") || written.contains("\\") { return Err("path must be relative: " + written) }305 if written.contains("%") { return Err("encoded path is not supported: " + written) }306 let joined = Paths.join(Paths.directoryOf(origin), Paths.pathOf(written)) ?307 if !Paths.relative(joined) { return Err("path is not portable: " + written) }308 Ok(joined)309}310311312export fn expand(bounds: &Array[(Int, Int)], count: Int) -> Array[Int] {313 var result: Array[Int] = []314 for number in 1..(count + 1) {315 for (first, last) in *bounds {316 if number >= first && number <= last && !result.contains(number) { result = result.push(number) }317 }318 }319 result320}321