
Blocks.pudu
Pudu443 lines21.3 KB
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] }252627const MAX_DEPTH: Int = 24282930export 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}333435export 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}515253export 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}565758fn 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}636465fn 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}118119120fn 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}163164165fn 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}185186187fn 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}208209210fn 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}244245246247fn 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}269270271fn 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}308309310fn 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}361362363fn 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}387388389fn 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}412413414fn 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