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

Directives.pudu

Pudu207 lines8.0 KB

GitHub ↗
1/** @Docgen.Markdown.Directives — authoring extension syntax recognized per line */2module PuduLangDocgen.Markdown.Directives34import Std.Char as Char5import PuduLangDocgen.Markdown.FrontMatter as FrontMatter67/** @Docgen.Markdown.Directive — a triple-colon container line with named attributes */8export type Directive = { name: Str, attributes: Array[(Str, Str)], closed: Bool }910/** @Docgen.Markdown.TabHeading — a heading that opens one tab of a tab group */11export type TabHeading = { level: Int, title: Str, id: Str, condition: Str }1213/** @Docgen.Markdown.Excerpt — a code excerpt line naming language, file, and caption */14export type Excerpt = { language: Str, name: Str, destination: Str, title: Str }1516/// A `:::name key="value" flag:::` line; `closed` tells whether it ends on the same line.17export fn colon(text: Str) -> Option[Directive] {18  let body = text.trim()19  if !body.startsWith(":::") { return None }20  var inner = body.drop(3).trim()21  let closed = inner.endsWith(":::")22  if closed { inner = inner.take(inner.length() - 3).trim() }23  if inner.isEmpty() { return None }24  var nameEnd = 025  let cs = inner.chars()26  while nameEnd < cs.length() && (Char.isAlphanumeric(cs[nameEnd]) || cs[nameEnd] == '-') { nameEnd = nameEnd + 1 }27  if nameEnd == 0 { return None }28  Some(Directive{name: inner.take(nameEnd).toLower(), attributes: attributes(inner.drop(nameEnd)), closed: closed})29}3031/// The value of an attribute, or the empty text.32export fn value(found: &Directive, key: Str) -> Str {33  for (name, held) in found.attributes {34    if name == key { return held }35  }36  ""37}3839/// `key="value"` pairs and bare flags, in written order.40export fn attributes(text: Str) -> Array[(Str, Str)] {41  var result: Array[(Str, Str)] = []42  let cs = text.chars()43  var index = 044  while index < cs.length() {45    while index < cs.length() && Char.isWhitespace(cs[index]) { index = index + 1 }46    let start = index47    while index < cs.length() && !Char.isWhitespace(cs[index]) && cs[index] != '=' { index = index + 1 }48    let key = slice(&cs, start, index)49    if key.isEmpty() { index = index + 150      continue }51    if index < cs.length() && cs[index] == '=' {52      index = index + 153      let quote = if index < cs.length() { cs[index] } else { ' ' }54      if quote == '"' || quote == '\'' {55        let valueStart = index + 156        index = valueStart57        while index < cs.length() && cs[index] != quote { index = index + 1 }58        result = result.push((key, slice(&cs, valueStart, index)))59        index = index + 160      } else {61        let valueStart = index62        while index < cs.length() && !Char.isWhitespace(cs[index]) { index = index + 1 }63        result = result.push((key, slice(&cs, valueStart, index)))64      }65    } else { result = result.push((key, "true")) }66  }67  result68}6970/// The alert kind of a quote's first line `[!NOTE]`, uppercased, with any text after it.71export fn alert(text: Str) -> Option[(Str, Str)] {72  let body = text.trim()73  if !body.startsWith("[!") { return None }74  let close = body.indexOf("]")75  if close < 3 { return None }76  let kind = body.drop(2).take(close - 2)77  for character in kind.chars() {78    if !Char.isLetter(character) { return None }79  }80  if kind.toLower() == "video" || kind.toLower() == "include" { return None }81  Some((kind.toUpper(), body.drop(close + 1).trim()))82}8384/// The address of a `[!Video address]` line.85export fn video(text: Str) -> Option[Str] {86  let body = text.trim()87  if !body.toLower().startsWith("[!video ") || !body.endsWith("]") { return None }88  let address = body.drop(8).take(body.length() - 9).trim()89  if address.isEmpty() { None } else { Some(address) }90}9192/// A tab heading `# [Title](#tab/id/condition)`.93export fn tab(text: Str) -> Option[TabHeading] {94  let body = text.trim()95  var level = 096  for character in body.chars() {97    if character != '#' { break }98    level = level + 199  }100  if level < 1 || level > 6 { return None }101  let rest = body.drop(level).trim()102  if !rest.startsWith("[") || !rest.endsWith(")") { return None }103  let middle = rest.indexOf("](#tab/")104  if middle < 1 { return None }105  let path = rest.drop(middle + 7).take(rest.length() - middle - 8).split("/")106  if path[0].isEmpty() || path.length() > 2 { return None }107  Some(TabHeading{level: level, title: rest.drop(1).take(middle - 1), id: path[0], condition: if path.length() == 2 { path[1] } else { "" }})108}109110/// The label and first line of a footnote definition `[^label]: text`.111export fn footnote(text: Str) -> Option[(Str, Str)] {112  let body = text.trim()113  if !body.startsWith("[^") { return None }114  let close = body.indexOf("]:")115  if close < 3 || body.take(close).contains(" ") { return None }116  Some((body.drop(2).take(close - 2), body.drop(close + 2).trim()))117}118119/// The title and path of a whole-line `[!INCLUDE[title](path)]`.120export fn include(text: Str) -> Option[(Str, Str)] {121  let body = text.trim()122  if !body.toUpper().startsWith("[!INCLUDE") || !body.endsWith(")]") { return None }123  let rest = body.drop(9).trim()124  if !rest.startsWith("[") { return None }125  let middle = rest.indexOf("](")126  if middle < 0 { return None }127  let path = rest.drop(middle + 2).take(rest.length() - middle - 4).trim()128  if path.isEmpty() { None } else { Some((rest.drop(1).take(middle - 1), unquoted(path))) }129}130131/// A whole-line `[!code-language[name](path "title")]` excerpt.132export fn excerpt(text: Str) -> Option[Excerpt] {133  let body = text.trim()134  if !body.startsWith("[!code") || !body.endsWith(")]") { return None }135  let inner = body.drop(1).indexOf("[")136  if inner < 0 { return None }137  let open = inner + 1138  let head = body.drop(6).take(open - 6)139  let language = if head.startsWith("-") { head.drop(1) } else { head }140  if !head.isEmpty() && !head.startsWith("-") { return None }141  let middle = body.indexOf("](")142  if middle < open { return None }143  let target = body.drop(middle + 2).take(body.length() - middle - 4).trim()144  let space = target.indexOf(" \"")145  let destination = if space < 0 { target } else { target.take(space) }146  let title = if space < 0 { "" } else { target.drop(space + 2).replace("\"", "") }147  if destination.isEmpty() { return None }148  Some(Excerpt{language: language, name: body.drop(open + 1).take(middle - open - 1), destination: destination, title: title})149}150151/// The index of the `:::name-end:::` line closing a container opened at an index, or -1.152/// Containers of the same name nest.153export fn extent(lines: &Array[FrontMatter.Line], start: Int, name: Str) -> Int {154  var level = 0155  for index in start..lines.length() {156    match colon(lines[index].text) {157      case Some(found) => {158        if found.name == name { level = level + 1 }159        if found.name == name + "-end" {160          level = level - 1161          if level == 0 { return index }162        }163      }164      case None => {}165    }166  }167  -1168}169170/// The columns of a row body: each `:::column span="n":::` up to its `:::column-end:::`.171/// Lines outside any column are ignored.172export fn columns(lines: &Array[FrontMatter.Line]) -> Array[(Int, Array[FrontMatter.Line])] {173  var result: Array[(Int, Array[FrontMatter.Line])] = []174  var index = 0175  while index < lines.length() {176    match colon(lines[index].text) {177      case Some(found) => {178        if found.name == "column" {179          let close = extent(lines, index, "column")180          let end = if close < 0 { lines.length() } else { close }181          let written = value(&found, "span")182          let span = match written.toInt() {183            case Some(count) => if count < 1 { 1 } else { count }184            case None => 1185          }186          result = result.push((span, lines.slice(index + 1, end)))187          index = end + 1188        } else { index = index + 1 }189      }190      case None => { index = index + 1 }191    }192  }193  result194}195196/// A path with one pair of surrounding angle brackets removed.197fn unquoted(path: Str) -> Str {198  if path.startsWith("<") && path.endsWith(">") { path.drop(1).take(path.length() - 2) } else { path }199}200201/// Text of the characters in a half-open range.202fn slice(cs: &Array[Char], from: Int, to: Int) -> Str {203  var pieces: Array[Str] = []204  for index in from..to { pieces = pieces.push(cs[index].toText()) }205  pieces.join("")206}207