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

Layout.pudu

Pudu321 lines12.4 KB

GitHub ↗
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 Paths1213/// Elements that open an HTML block wherever a line starts with them.14const 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 }2627/// Leading indentation in columns, with tabs advancing to the next multiple of four.28export 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}3536/// Text with up to the given columns of leading indentation removed.37export 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}5556/// Whether a line holds only white space.57export fn blank(text: Str) -> Bool { text.trim().isEmpty() }5859/// An opening fence of three or more backticks or tildes indented at most three columns.60export 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}7677/// Whether a line closes a fence: the same character, at least as long, nothing after it.78export 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}8788/// Whether a line is a thematic break of three or more `-`, `*`, or `_`.89export 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}100101/// An ATX heading as level, content, and explicit `{#id}`.102export 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}127128/// The level of a setext underline: 1 for `=`, 2 for `-`, 0 for neither.129export 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}140141/// A bullet or ordered list marker and the content column after it.142export 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}167168/// The content of a block quote line without its `>` marker.169export 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}177178/// Cells of a pipe table row; escaped pipes and pipes inside code spans stay in their cell.179export 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}204205/// Column alignments of a table delimiter row, or none when the line is not one.206export 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}219220/// Whether a line opens an HTML block: a comment, a block-level element, or a line holding221/// exactly one tag.222export 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}236237/// Whether a line ends a paragraph by starting another block.238export fn interrupts(text: Str) -> Bool {239  if blank(text) || thematic(text) || Option.isSome(&atx(text)) || Option.isSome(&fence(text)) || Option.isSome(&quote(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}246247/// A link reference definition `[label]: destination "title"`.248export 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}270271/// Link reference definitions outside code, and the lines that remain.272export 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}301302/// The project-relative path a file names from the file that mentions it.303export 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}310311/// Line numbers of ranges limited to the lines a sample has.312export 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