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

Blocks.pudu

Pudu443 lines21.3 KB

GitHub ↗
1/** @Docgen.Markdown.Blocks — recursive block structure with includes and excerpts */2module PuduLangDocgen.Markdown.Blocks34import Std.Map as Map5import Std.Option as Option6import Std.Set as Set7import Std.Text as Text8import PuduLangDocgen.Constants.Codes as Codes9import PuduLangDocgen as Docgen10import PuduLangDocgen.Inline as Inline11import PuduLangDocgen.Markdown.Directives as Directives12import PuduLangDocgen.Markdown.FrontMatter as FrontMatter13import PuduLangDocgen.Markdown.Languages as Languages14import PuduLangDocgen.Markdown.Layout as Layout15import PuduLangDocgen.Markdown.Leaves as Leaves16import PuduLangDocgen.Markdown.Snippet as Snippet17import PuduLangDocgen.Markdown.Syntax as Syntax18import PuduLangDocgen.Paths as Paths1920/** @Docgen.Markdown.Env — the file being parsed, loaded files, and include trail */21export type Env = { origin: Str, files: Map[Str, Str], trail: Array[Str], definitions: Map[Str, Syntax.Target], alerts: Set[Str] }2223/** @Docgen.Markdown.Parsed — blocks with the diagnostics found while reading them */24export type Parsed = { blocks: Array[Syntax.Block], diagnostics: Array[Docgen.Diagnostic] }2526/// Deepest nesting of containers and includes read before content is kept as text.27const MAX_DEPTH: Int = 242829/// A parsing environment for a project-relative file with extra alert kinds.30export fn environment(origin: Str, files: &Map[Str, Str], alerts: &Set[Str]) -> Env {31  Env{origin: origin, files: *files, trail: [origin], definitions: mapOf([]), alerts: *alerts}32}3334/// Blocks of numbered lines.35export fn parse(lines: &Array[FrontMatter.Line], env: &Env, depth: Int) -> Parsed {36  var blocks: Array[Syntax.Block] = []37  var diagnostics: Array[Docgen.Diagnostic] = []38  var index = 039  while index < lines.length() {40    if Layout.blank(lines[index].text) {41      index = index + 142      continue43    }44    let step = if depth > MAX_DEPTH { deep(lines, index, env) } else { block(lines, index, env, depth) }45    blocks = blocks.concat(step.blocks)46    diagnostics = diagnostics.concat(step.diagnostics)47    index = if step.next > index { step.next } else { index + 1 }48  }49  Parsed{blocks: blocks, diagnostics: diagnostics}50}5152/// Inline content of text starting at a line, with inline includes resolved.53export fn inlines(text: Str, line: Int, env: &Env, depth: Int) -> (Array[Syntax.Inline], Array[Docgen.Diagnostic]) {54  resolved(Inline.parse(text, &env.definitions, line), line, env, depth)55}5657/// Remaining lines kept as one paragraph once nesting is too deep.58fn deep(lines: &Array[FrontMatter.Line], index: Int, env: &Env) -> Leaves.Step {59  let text = lines.slice(index, lines.length()).map(|line: FrontMatter.Line| line.text).join("\n")60  let problem = Docgen.warning(Codes.NESTING_TOO_DEEP, env.origin, lines[index].number, "content nests deeper than " + show(MAX_DEPTH) + " levels and is shown as text")61  Leaves.Step{blocks: [Syntax.Paragraph([Syntax.Text(text)])], next: lines.length(), diagnostics: [problem]}62}6364/// The block starting at a nonblank line.65fn block(lines: &Array[FrontMatter.Line], index: Int, env: &Env, depth: Int) -> Leaves.Step {66  let line = lines[index]67  let text = line.text68  match Layout.fence(text) {69    case Some(opened) => { return Leaves.fenced(lines, index, &opened, env.origin) }70    case None => {}71  }72  if Layout.indent(text) >= 4 { return Leaves.indented(lines, index) }73  if text.trim().startsWith("$$") { return Leaves.mathBlock(lines, index, env.origin) }74  match Directives.colon(text) {75    case Some(found) => { return directive(lines, index, &found, env, depth) }76    case None => {}77  }78  match Directives.include(text) {79    case Some((_title, written)) => { return included(written, line.number, index + 1, env, depth) }80    case None => {}81  }82  match Directives.excerpt(text) {83    case Some(found) => { return excerpt(found.destination, found.language, found.title, line.number, index + 1, env) }84    case None => {}85  }86  match Directives.tab(text) {87    case Some(opened) => { return tabs(lines, index, opened.level, env, depth) }88    case None => {}89  }90  match Directives.footnote(text) {91    case Some((label, first)) => { return footnote(lines, index, label, first, env, depth) }92    case None => {}93  }94  match Layout.atx(text) {95    case Some((level, content, id)) => {96      let (children, problems) = inlines(content, line.number, env, depth)97      return Leaves.Step{blocks: [Syntax.Heading(Syntax.Title{level: level, content: children, id: id, line: line.number})], next: index + 1, diagnostics: problems}98    }99    case None => {}100  }101  if Layout.thematic(text) { return Leaves.Step{blocks: [Syntax.Rule], next: index + 1, diagnostics: []} }102  if Option.isSome(&Layout.quote(text)) { return quoted(lines, index, env, depth) }103  match Layout.marker(text) {104    case Some(first) => { return listed(lines, index, &first, env, depth) }105    case None => {}106  }107  if Layout.html(text) { return Leaves.markup(lines, index) }108  if index + 1 < lines.length() && text.contains("|") {109    match Layout.alignments(lines[index + 1].text) {110      case Some(align) => {111        if Layout.cells(text).length() == align.length() { return table(lines, index, &align, env, depth) }112      }113      case None => {}114    }115  }116  paragraph(lines, index, env, depth)117}118119/// A triple-colon container: code, image, video, or row layout.120fn directive(lines: &Array[FrontMatter.Line], index: Int, found: &Directives.Directive, env: &Env, depth: Int) -> Leaves.Step {121  let number = lines[index].number122  if found.name == "code" {123    let region = Directives.value(found, "id")124    let written = Directives.value(found, "source")125    var query: Array[Str] = []126    for key in ["range", "highlight"] {127      let held = Directives.value(found, key)128      if !held.isEmpty() { query = query.push(key + "=" + held) }129    }130    let destination = written + (if region.isEmpty() { "" } else { "#" + region }) + (if query.isEmpty() { "" } else { "?" + query.join("&") })131    return excerpt(destination, Directives.value(found, "language"), Directives.value(found, "title"), number, index + 1, env)132  }133  if found.name == "video" {134    return Leaves.Step{blocks: [Syntax.Video(Directives.value(found, "source"))], next: index + 1, diagnostics: []}135  }136  let container = !found.closed || found.name == "row" || found.name == "column" || (found.name == "image" && Directives.value(found, "type") == "complex")137  let close = if container { Directives.extent(lines, index, found.name) } else { index }138  if close < 0 {139    return Leaves.Step{blocks: [], next: index + 1, diagnostics: [Docgen.error(Codes.DIRECTIVE_UNCLOSED, env.origin, number, "directive " + found.name + " has no :::" + found.name + "-end::: line")]}140  }141  let body = lines.slice(index + 1, close)142  if found.name == "image" {143    let caption = parse(&body, env, depth + 1)144    let kind = Directives.value(found, "type")145    let figure = Syntax.Figure{source: Directives.value(found, "source"), alt: Directives.value(found, "alt-text"), kind: if kind.isEmpty() { "content" } else { kind }, lightbox: Directives.value(found, "lightbox"), caption: caption.blocks, line: number}146    let problems = if figure.source.isEmpty() { [Docgen.error(Codes.DIRECTIVE_INVALID, env.origin, number, "image directive needs a source")] } else { [] }147    return Leaves.Step{blocks: [Syntax.Image(figure)], next: close + 1, diagnostics: problems.concat(caption.diagnostics)}148  }149  if found.name == "row" {150    var columns: Array[Syntax.Column] = []151    var problems: Array[Docgen.Diagnostic] = []152    for (span, content) in Directives.columns(&body) {153      let parsed = parse(&content, env, depth + 1)154      columns = columns.push(Syntax.Column{span: span, blocks: parsed.blocks})155      problems = problems.concat(parsed.diagnostics)156    }157    return Leaves.Step{blocks: [Syntax.Grid(columns)], next: close + 1, diagnostics: problems}158  }159  let parsed = parse(&body, env, depth + 1)160  let problem = Docgen.warning(Codes.DIRECTIVE_INVALID, env.origin, number, "unsupported directive " + found.name + "; its content is shown without it")161  Leaves.Step{blocks: parsed.blocks, next: close + 1, diagnostics: parsed.diagnostics.push(problem)}162}163164/// Blocks of another file placed where a whole-line include stands.165fn included(written: Str, number: Int, next: Int, env: &Env, depth: Int) -> Leaves.Step {166  let problem = |code: Str, message: Str| Leaves.Step{blocks: [], next: next, diagnostics: [Docgen.error(code, env.origin, number, message)]}167  let located = match Layout.locate(env.origin, written) {168    case Ok(found) => found169    case Err(reason) => { return problem(Codes.PATH_INVALID, reason) }170  }171  if env.trail.contains(located) { return problem(Codes.INCLUDE_CYCLE, "include cycle: " + env.trail.push(located).join(" -> ")) }172  let text = match Map.get(&env.files, located) {173    case Some(found) => found174    case None => { return problem(Codes.INCLUDE_MISSING, "included file not found: " + located) }175  }176  let header = match FrontMatter.split(text) {177    case Ok(found) => found178    case Err((line, message)) => { return Leaves.Step{blocks: [], next: next, diagnostics: [Docgen.error(Codes.FRONT_MATTER_INVALID, located, line, message)]} }179  }180  let (found, rest) = Layout.definitions(&header.lines)181  let child = Env{..*env, origin: located, trail: env.trail.push(located), definitions: Map.merge(&env.definitions, &found)}182  let parsed = parse(&rest, &child, depth + 1)183  Leaves.Step{blocks: [Syntax.Included(located, parsed.blocks)], next: next, diagnostics: parsed.diagnostics}184}185186/// A code block excerpted from a loaded file.187fn excerpt(destination: Str, language: Str, title: Str, number: Int, next: Int, env: &Env) -> Leaves.Step {188  let problem = |code: Str, message: Str| Leaves.Step{blocks: [], next: next, diagnostics: [Docgen.error(code, env.origin, number, message)]}189  let chosen = match Snippet.selection(destination) {190    case Ok(found) => found191    case Err(reason) => { return problem(Codes.EXCERPT_INVALID, reason) }192  }193  let located = match Layout.locate(env.origin, chosen.path) {194    case Ok(found) => found195    case Err(reason) => { return problem(Codes.PATH_INVALID, reason) }196  }197  let text = match Map.get(&env.files, located) {198    case Some(found) => found199    case None => { return problem(Codes.EXCERPT_MISSING, "excerpted file not found: " + located) }200  }201  let (code, highlight) = match Snippet.extract(text, &chosen) {202    case Ok(found) => found203    case Err(reason) => { return problem(Codes.EXCERPT_INVALID, reason + " in " + located) }204  }205  let named = if language.isEmpty() { Languages.ofPath(located) } else { language }206  Leaves.Step{blocks: [Syntax.CodeBlock(Syntax.Sample{language: named, title: title, text: code, highlight: highlight, line: number})], next: next, diagnostics: []}207}208209/// A tab group: consecutive tab headings of one level, ended by a `---` line.210fn tabs(lines: &Array[FrontMatter.Line], index: Int, level: Int, env: &Env, depth: Int) -> Leaves.Step {211  var group: Array[Syntax.Tab] = []212  var problems: Array[Docgen.Diagnostic] = []213  var cursor = index214  var ended = false215  while cursor < lines.length() && !ended {216    let heading = match Directives.tab(lines[cursor].text) {217      case Some(found) => found218      case None => { break }219    }220    if heading.level != level { break }221    var end = cursor + 1222    while end < lines.length() && !ended {223      let text = lines[end].text224      if text.trim() == "---" || text.trim() == "***" { ended = true } else {225        match Directives.tab(text) {226          case Some(other) => { if other.level == level { break } }227          case None => {}228        }229        match Layout.atx(text) {230          case Some((found, _content, _id)) => { if found <= level && Option.isNone(&Directives.tab(text)) { break } }231          case None => {}232        }233        end = end + 1234      }235    }236    let body = lines.slice(cursor + 1, end)237    let parsed = parse(&body, env, depth + 1)238    group = group.push(Syntax.Tab{id: heading.id, title: heading.title, condition: heading.condition, blocks: parsed.blocks})239    problems = problems.concat(parsed.diagnostics)240    cursor = if ended { end + 1 } else { end }241  }242  Leaves.Step{blocks: [Syntax.TabGroup(group)], next: cursor, diagnostics: problems}243}244245/// A footnote definition: its first line and every following line indented four columns,246/// with lazy continuation of a paragraph.247fn footnote(lines: &Array[FrontMatter.Line], index: Int, label: Str, first: Str, env: &Env, depth: Int) -> Leaves.Step {248  var body = [FrontMatter.Line{text: first, number: lines[index].number}]249  var end = index + 1250  while end < lines.length() {251    let text = lines[end].text252    if Layout.blank(text) {253      var ahead = end254      while ahead < lines.length() && Layout.blank(lines[ahead].text) { ahead = ahead + 1 }255      if ahead >= lines.length() || Layout.indent(lines[ahead].text) < 4 { break }256      for gap in end..ahead { body = body.push(FrontMatter.Line{text: "", number: lines[gap].number}) }257      end = ahead258    } else if Layout.indent(text) >= 4 {259      body = body.push(FrontMatter.Line{text: Layout.strip(text, 4), number: lines[end].number})260      end = end + 1261    } else if !Layout.interrupts(text) && !Layout.blank(body[body.length() - 1].text) && Option.isNone(&Directives.footnote(text)) {262      body = body.push(lines[end])263      end = end + 1264    } else { break }265  }266  let parsed = parse(&body, env, depth + 1)267  Leaves.Step{blocks: [Syntax.Footnote(label, parsed.blocks)], next: end, diagnostics: parsed.diagnostics}268}269270/// A block quote, an alert when its first line names a kind, or an embedded video.271fn quoted(lines: &Array[FrontMatter.Line], index: Int, env: &Env, depth: Int) -> Leaves.Step {272  var body: Array[FrontMatter.Line] = []273  var end = index274  while end < lines.length() {275    let text = lines[end].text276    match Layout.quote(text) {277      case Some(inner) => { body = body.push(FrontMatter.Line{text: inner, number: lines[end].number}) }278      case None => {279        let lazy = !body.isEmpty() && !Layout.blank(body[body.length() - 1].text) && !Layout.interrupts(text)280        if !lazy { break }281        body = body.push(lines[end])282      }283    }284    end = end + 1285  }286  if body.length() == 1 {287    match Directives.video(body[0].text) {288      case Some(address) => { return Leaves.Step{blocks: [Syntax.Video(address)], next: end, diagnostics: []} }289      case None => {}290    }291  }292  match Directives.alert(body[0].text) {293    case Some((kind, rest)) => {294      if Set.contains(&env.alerts, kind) {295        let remaining = if rest.isEmpty() { body.slice(1, body.length()) } else { [FrontMatter.Line{text: rest, number: body[0].number}].concat(body.slice(1, body.length())) }296        let parsed = parse(&remaining, env, depth + 1)297        return Leaves.Step{blocks: [Syntax.Alert(Syntax.Notice{kind: kind, blocks: parsed.blocks})], next: end, diagnostics: parsed.diagnostics}298      }299      let parsed = parse(&body, env, depth + 1)300      let problem = Docgen.warning(Codes.UNKNOWN_ALERT, env.origin, body[0].number, "unknown alert kind " + kind + "; shown as a quote")301      return Leaves.Step{blocks: [Syntax.Quote(parsed.blocks)], next: end, diagnostics: parsed.diagnostics.push(problem)}302    }303    case None => {}304  }305  let parsed = parse(&body, env, depth + 1)306  Leaves.Step{blocks: [Syntax.Quote(parsed.blocks)], next: end, diagnostics: parsed.diagnostics}307}308309/// Consecutive items sharing a marker kind; blank lines between items make the list loose.310fn listed(lines: &Array[FrontMatter.Line], index: Int, first: &Layout.Marker, env: &Env, depth: Int) -> Leaves.Step {311  var items: Array[Syntax.Item] = []312  var problems: Array[Docgen.Diagnostic] = []313  var loose = false314  var cursor = index315  while cursor < lines.length() {316    if Layout.thematic(lines[cursor].text) { break }317    let found = match Layout.marker(lines[cursor].text) {318      case Some(held) => held319      case None => { break }320    }321    if found.ordered != first.ordered || found.symbol != first.symbol { break }322    var body = [FrontMatter.Line{text: found.rest, number: lines[cursor].number}]323    var end = cursor + 1324    while end < lines.length() {325      let text = lines[end].text326      if Layout.blank(text) {327        var ahead = end328        while ahead < lines.length() && Layout.blank(lines[ahead].text) { ahead = ahead + 1 }329        if ahead >= lines.length() || Layout.indent(lines[ahead].text) < found.width { break }330        for gap in end..ahead { body = body.push(FrontMatter.Line{text: "", number: lines[gap].number}) }331        loose = loose || !Layout.blank(body[0].text)332        end = ahead333      } else if Layout.indent(text) >= found.width {334        body = body.push(FrontMatter.Line{text: Layout.strip(text, found.width), number: lines[end].number})335        end = end + 1336      } else if !Layout.interrupts(text) && !Layout.blank(body[body.length() - 1].text) && Option.isNone(&Layout.marker(text)) {337        body = body.push(FrontMatter.Line{text: Text.trimStart(text), number: lines[end].number})338        end = end + 1339      } else { break }340    }341    var checked = -1342    let lead = body[0].text343    if lead.startsWith("[ ] ") || lead == "[ ]" { checked = 0 } else if lead.startsWith("[x] ") || lead.startsWith("[X] ") || lead == "[x]" || lead == "[X]" { checked = 1 }344    if checked >= 0 { body = [FrontMatter.Line{text: lead.drop(4), number: body[0].number}].concat(body.slice(1, body.length())) }345    let parsed = parse(&body, env, depth + 1)346    items = items.push(Syntax.Item{checked: checked, blocks: parsed.blocks})347    problems = problems.concat(parsed.diagnostics)348    var after = end349    while after < lines.length() && Layout.blank(lines[after].text) { after = after + 1 }350    let continues = after < lines.length() && !Layout.thematic(lines[after].text) && match Layout.marker(lines[after].text) {351      case Some(next) => next.ordered == first.ordered && next.symbol == first.symbol352      case None => false353    }354    if continues && after > end { loose = true }355    cursor = if continues { after } else { end }356    if !continues { break }357  }358  let listing = Syntax.Listing{ordered: first.ordered, start: first.start, tight: !loose, items: items}359  Leaves.Step{blocks: [Syntax.ListBlock(listing)], next: cursor, diagnostics: problems}360}361362/// A pipe table; rows are padded or cut to the header width.363fn table(lines: &Array[FrontMatter.Line], index: Int, align: &Array[Str], env: &Env, depth: Int) -> Leaves.Step {364  var problems: Array[Docgen.Diagnostic] = []365  var header: Array[Array[Syntax.Inline]] = []366  for cell in Layout.cells(lines[index].text) {367    let (content, found) = inlines(cell, lines[index].number, env, depth)368    header = header.push(content)369    problems = problems.concat(found)370  }371  var rows: Array[Array[Array[Syntax.Inline]]] = []372  var end = index + 2373  while end < lines.length() && !Layout.blank(lines[end].text) && lines[end].text.contains("|") && !Layout.interrupts(lines[end].text) {374    let written = Layout.cells(lines[end].text)375    var row: Array[Array[Syntax.Inline]] = []376    for column in 0..header.length() {377      let cell = if column < written.length() { written[column] } else { "" }378      let (content, found) = inlines(cell, lines[end].number, env, depth)379      row = row.push(content)380      problems = problems.concat(found)381    }382    rows = rows.push(row)383    end = end + 1384  }385  Leaves.Step{blocks: [Syntax.TableBlock(Syntax.Table{align: *align, header: header, rows: rows})], next: end, diagnostics: problems}386}387388/// A paragraph, or a setext heading when an underline follows its lines.389fn paragraph(lines: &Array[FrontMatter.Line], index: Int, env: &Env, depth: Int) -> Leaves.Step {390  var collected = [Text.trimStart(lines[index].text)]391  var end = index + 1392  var level = 0393  while end < lines.length() {394    let text = lines[end].text395    level = Layout.setext(text)396    if level > 0 { break }397    if Layout.interrupts(text) || Option.isSome(&Directives.include(text)) || Option.isSome(&Directives.excerpt(text)) { break }398    if end + 1 < lines.length() && text.contains("|") && Option.isSome(&Layout.alignments(lines[end + 1].text)) { break }399    collected = collected.push(Text.trimStart(text))400    end = end + 1401  }402  let text = collected.join("\n")403  let number = lines[index].number404  if level > 0 {405    let (content, problems) = inlines(text.trim(), number, env, depth)406    return Leaves.Step{blocks: [Syntax.Heading(Syntax.Title{level: level, content: content, id: "", line: number})], next: end + 1, diagnostics: problems}407  }408  let trimmed = if text.endsWith(" ") { text.trim() } else { text }409  let (content, problems) = inlines(trimmed, number, env, depth)410  Leaves.Step{blocks: [Syntax.Paragraph(content)], next: end, diagnostics: problems}411}412413/// Inline includes replaced by the first paragraph of the named file.414fn resolved(nodes: Array[Syntax.Inline], line: Int, env: &Env, depth: Int) -> (Array[Syntax.Inline], Array[Docgen.Diagnostic]) {415  var result: Array[Syntax.Inline] = []416  var problems: Array[Docgen.Diagnostic] = []417  for node in nodes {418    match node {419      case Syntax.Fragment(written, _title) => {420        let step = included(written, line, 0, env, depth)421        problems = problems.concat(step.diagnostics)422        for held in step.blocks {423          match held {424            case Syntax.Included(located, blocks) => {425              let content = match blocks.isEmpty() {426                case true => []427                case false => match blocks[0] {428                  case Syntax.Paragraph(children) => children429                  case _ => []430                }431              }432              result = result.push(Syntax.Fragment(located, content))433            }434            case _ => {}435          }436        }437      }438      case other => { result = result.push(other) }439    }440  }441  (result, problems)442}443