\n",
)
out.write_string("
")
out.write_string(body)
if options.footer.trim().to_owned() != "" {
out.write_string("\n")
}
out.write_string("
")
out.write_string(toc)
out.write_string("///|
/// A source page in a MoonDocKit documentation site.
pub(all) struct DocPage {
title : String
slug : String
source : String
}
///|
/// A rendered page that can be written to disk by a CLI package.
pub(all) struct RenderedPage {
path : String
html : String
}
///|
/// A generated output file ready for a CLI to write to disk.
pub(all) struct OutputFile {
path : String
content : String
}
///|
/// File metadata derived from a generated output manifest.
pub(all) struct OutputFileInfo {
path : String
kind : String
byte_count : Int
}
///|
/// Aggregate report for generated output files.
pub(all) struct BuildReport {
file_count : Int
html_count : Int
data_count : Int
total_bytes : Int
files : Array[OutputFileInfo]
}
///|
/// One quality gate check result.
pub(all) struct QualityCheck {
name : String
passed : Bool
message : String
}
///|
/// Aggregate quality gate result for a documentation site.
pub(all) struct QualityGate {
passed : Bool
score : Int
checks : Array[QualityCheck]
}
///|
/// Searchable text metadata for one route.
pub(all) struct SearchEntry {
title : String
path : String
text : String
tags : Array[String]
}
///|
/// One public declaration extracted from a generated MoonBit interface.
pub(all) struct ApiSymbol {
kind : String
name : String
signature : String
arity : Int
return_type : String
}
///|
/// Public package information extracted from a `.mbti` interface.
pub(all) struct ApiReference {
package_name : String
symbols : Array[ApiSymbol]
}
///|
/// Aggregate metadata for a documentation site.
pub(all) struct SiteSummary {
page_count : Int
output_count : Int
search_entry_count : Int
tag_count : Int
}
///|
/// Content metrics for one parsed documentation page.
pub(all) struct DocumentMetrics {
heading_count : Int
paragraph_count : Int
list_item_count : Int
code_block_count : Int
word_count : Int
reading_minutes : Int
}
///|
/// Aggregate content metrics for a documentation site.
pub(all) struct SiteMetrics {
page_count : Int
heading_count : Int
code_block_count : Int
word_count : Int
reading_minutes : Int
}
///|
/// Visual theme values used by generated HTML pages.
pub(all) struct SiteTheme {
background : String
surface : String
text : String
accent : String
border : String
code_background : String
code_text : String
sidebar_width_px : Int
content_width_px : Int
}
///|
/// Page template options used by generated HTML pages.
pub(all) struct SiteOptions {
language : String
description : String
site_url : String
footer : String
}
///|
/// Severity level for site validation diagnostics.
pub(all) enum DiagnosticLevel {
DiagError
DiagWarning
}
///|
/// A validation finding for a site or one source page.
pub(all) struct SiteDiagnostic {
level : DiagnosticLevel
code : String
message : String
page : String?
}
///|
/// Site validation result returned before rendering or publishing.
pub(all) struct ValidationReport {
diagnostics : Array[SiteDiagnostic]
}
///|
/// Planned route metadata for one documentation page.
pub(all) struct RouteEntry {
title : String
slug : String
path : String
order : Int?
tags : Array[String]
}
///|
/// Parsed page metadata from a leading front matter block.
pub(all) struct FrontMatter {
title : String?
order : Int?
tags : Array[String]
fields : Array[(String, String)]
}
///|
/// Markdown source split into metadata and body blocks.
pub(all) struct ParsedDocument {
front_matter : FrontMatter
blocks : Array[MarkdownBlock]
}
///|
/// A heading entry extracted from a source page.
pub(all) struct TocItem {
level : Int
title : String
anchor : String
}
///|
/// A heading with a page-unique anchor.
pub(all) struct AnchoredHeading {
level : Int
title : String
anchor : String
}
///|
/// Table column alignment parsed from a GFM separator row.
pub(all) enum TableAlign {
AlignDefault
AlignLeft
AlignCenter
AlignRight
}
///|
/// Whether a list is bulleted or numbered.
pub(all) enum ListKind {
Bullet
Number
}
///|
/// A single list entry; may carry a checkbox state and a nested sublist.
pub(all) enum ListItem {
ListItem(String, Bool?, ListBlock?)
}
///|
/// A rendered list (root or nested) that owns its marker style.
pub(all) enum ListBlock {
ListBlock(ListKind, Array[ListItem])
}
///|
/// Block-level Markdown nodes used by the documentation pipeline.
pub(all) enum MarkdownBlock {
Heading(Int, String)
Paragraph(String)
List(ListBlock)
BlockQuote(String)
CodeBlock(String, String)
Table(Array[String], Array[TableAlign], Array[Array[String]])
}
///|
/// Return empty front matter metadata.
pub fn empty_front_matter() -> FrontMatter {
{ title: None, order: None, tags: [], fields: [], }
}
///|
/// Parse a generated MoonBit interface into public API declarations.
pub fn parse_mbti(source : String) -> ApiReference {
let symbols : Array[ApiSymbol] = []
let mut package_name = ""
let lines = source.split("\n").collect()
let mut i = 0
while i < lines.length() {
let line = lines[i].to_owned().trim().to_owned()
if line.has_prefix("package \"") && line.has_suffix("\"") {
package_name = line[9:line.length() - 1].to_owned()
} else if line.has_prefix("pub fn ") {
symbols.push({
kind: "function",
name: declaration_name(line, "pub fn "),
signature: line,
arity: function_arity(line),
return_type: return_type(line),
})
} else if line.has_prefix("pub(all) struct ") {
let captured = capture_interface_block(lines, i)
symbols.push({
kind: "struct",
name: declaration_name(line, "pub(all) struct "),
signature: captured.0,
arity: 0,
return_type: "",
})
i = captured.1
} else if line.has_prefix("pub(all) enum ") {
let captured = capture_interface_block(lines, i)
symbols.push({
kind: "enum",
name: declaration_name(line, "pub(all) enum "),
signature: captured.0,
arity: 0,
return_type: "",
})
i = captured.1
} else if line.has_prefix("pub trait ") {
let captured = capture_interface_block(lines, i)
symbols.push({
kind: "trait",
name: declaration_name(line, "pub trait "),
signature: captured.0,
arity: 0,
return_type: "",
})
i = captured.1
}
i = i + 1
}
{ package_name, symbols, }
}
///|
/// Convert a generated MoonBit interface into a documentation page.
pub fn mbti_to_page(source : String) -> DocPage {
let api = parse_mbti(source)
let title = if api.package_name == "" {
"MoonBit API"
} else {
api.package_name + " API"
}
let out = StringBuilder()
out.write_string(
"---\ntitle: MoonBit API\norder: 900\ntags: [api, moonbit]\n---\n",
)
out.write_string("# MoonBit API\n\n")
if api.package_name != "" {
out.write_string("Package `")
out.write_string(api.package_name)
out.write_string("` exposes ")
out.write_string(api.symbols.length().to_string())
out.write_string(" public declarations.\n\n")
}
if api.symbols.length() > 0 {
out.write_string("## API Summary\n\n")
let kinds = ["function", "struct", "enum", "trait"]
for kind in kinds {
let count = api.symbols.filter(symbol => symbol.kind == kind).length()
if count > 0 {
out.write_string("- ")
out.write_string(api_section_title(kind))
out.write_string(": ")
out.write_string(count.to_string())
out.write_string("\n")
}
}
out.write_string("\n")
out.write_string("## Review Notes\n\n")
out.write_string(
"- Source: generated `.mbti` package interface from `moon info`.\n",
)
out.write_string(
"- Navigation: symbol index links point to each generated declaration.\n",
)
out.write_string(
"- Function entries include parameter counts and return types when available.\n\n",
)
out.write_string("## Symbol Index\n\n")
for kind in kinds {
let matching = api.symbols.filter(symbol => symbol.kind == kind)
if matching.length() > 0 {
out.write_string("### ")
out.write_string(api_section_title(kind))
out.write_string("\n\n")
for symbol in matching {
out.write_string("- [")
out.write_string(symbol.name)
out.write_string("](#")
out.write_string(slugify(symbol.name))
out.write_string(")\n")
}
out.write_string("\n")
}
}
}
let kinds = ["function", "struct", "enum", "trait"]
for kind in kinds {
let matching = api.symbols.filter(symbol => symbol.kind == kind)
if matching.length() > 0 {
out.write_string("## ")
out.write_string(api_section_title(kind))
out.write_string("\n\n")
for symbol in matching {
out.write_string("### ")
out.write_string(symbol.name)
out.write_string("\n\n")
if symbol.kind == "function" {
out.write_string("- Parameters: ")
out.write_string(symbol.arity.to_string())
if symbol.return_type != "" {
out.write_string("\n- Returns: `")
out.write_string(symbol.return_type)
out.write_string("`")
}
}
out.write_string("\n\n```mbt\n")
out.write_string(symbol.signature)
out.write_string("\n```\n\n")
}
}
}
{ title, slug: "api-reference", source: out.to_string(), }
}
///|
fn declaration_name(line : String, prefix : String) -> String {
let rest = line[prefix.length():].to_owned()
let mut end = rest.length()
for index, ch in rest {
if ch == '(' || ch == '[' || ch == ' ' || ch == '{' {
end = index
break
}
}
rest[0:end].to_owned()
}
///|
fn function_arity(signature : String) -> Int {
let start = find_char(signature, '(')
let finish = find_matching_paren(signature, start)
if start < 0 || finish <= start + 1 {
return 0
}
let args = signature[start + 1:finish].to_owned().trim().to_owned()
if args == "" {
0
} else {
count_top_level_commas(args) + 1
}
}
///|
fn return_type(signature : String) -> String {
match signature.split_once(" -> ") {
Some((_, result)) => result.trim().to_owned()
None => ""
}
}
///|
fn find_char(input : String, target : Char) -> Int {
for index, ch in input {
if ch == target {
return index
}
}
-1
}
///|
fn find_matching_paren(input : String, start : Int) -> Int {
if start < 0 {
return -1
}
let mut depth = 0
for index, ch in input {
if index < start {
continue
}
if ch == '(' {
depth = depth + 1
} else if ch == ')' {
depth = depth - 1
if depth == 0 {
return index
}
}
}
-1
}
///|
fn count_top_level_commas(input : String) -> Int {
let mut count = 0
let mut depth = 0
for _, ch in input {
if ch == '(' || ch == '[' || ch == '{' {
depth = depth + 1
} else if ch == ')' || ch == ']' || ch == '}' {
if depth > 0 {
depth = depth - 1
}
} else if ch == ',' && depth == 0 {
count = count + 1
}
}
count
}
///|
fn capture_interface_block(
lines : Array[StringView],
start : Int,
) -> (String, Int) {
let out = StringBuilder()
let mut i = start
while i < lines.length() {
let line = lines[i].to_owned()
if i > start {
out.write_char('\n')
}
out.write_string(line)
if line.trim().to_owned() == "}" {
return (out.to_string(), i)
}
i = i + 1
}
(out.to_string(), i - 1)
}
///|
fn api_section_title(kind : String) -> String {
match kind {
"function" => "Functions"
"struct" => "Structs"
"enum" => "Enums"
"trait" => "Traits"
_ => "Declarations"
}
}
///|
/// Return the default MoonDocKit page theme.
pub fn default_theme() -> SiteTheme {
{
background: "#faf8f3",
surface: "#fffdf8",
text: "#1a1923",
accent: "#14635a",
border: "#e6dfd2",
code_background: "#191b22",
code_text: "#ece7dc",
sidebar_width_px: 272,
content_width_px: 760,
}
}
///|
/// Return a warm documentation theme for package guide sites.
pub fn package_theme() -> SiteTheme {
{
background: "#f8faf7",
surface: "#fffdf9",
text: "#18231c",
accent: "#2f7d57",
border: "#e0e8df",
code_background: "#161d19",
code_text: "#e6f0e8",
sidebar_width_px: 268,
content_width_px: 780,
}
}
///|
/// Return a compact theme tuned for API reference-heavy sites.
pub fn api_reference_theme() -> SiteTheme {
{
background: "#f8fafc",
surface: "#fffdfd",
text: "#16202e",
accent: "#5b5f97",
border: "#dde3ea",
code_background: "#161a24",
code_text: "#e9edf7",
sidebar_width_px: 260,
content_width_px: 900,
}
}
///|
/// Return default generated page metadata and footer options.
pub fn default_site_options() -> SiteOptions {
{
language: "en",
description: "",
site_url: "",
footer: "Generated by MoonDocKit",
}
}
///|
/// Site metadata and pages.
pub(all) struct DocSite {
title : String
pages : Array[DocPage]
}
///|
/// Build a stable route table for all pages in a site.
pub fn plan_routes(site : DocSite) -> Array[RouteEntry] {
let routes : Array[RouteEntry] = []
for page in site.pages {
routes.push(route_for_page(page))
}
routes.sort_by(compare_routes)
routes
}
///|
fn compare_routes(left : RouteEntry, right : RouteEntry) -> Int {
match (left.order, right.order) {
(Some(a), Some(b)) =>
if a != b {
a - b
} else {
compare_route_title(left, right)
}
(Some(_), None) => -1
(None, Some(_)) => 1
(None, None) => compare_route_title(left, right)
}
}
///|
fn compare_route_title(left : RouteEntry, right : RouteEntry) -> Int {
let by_title = left.title.compare(right.title)
if by_title != 0 {
by_title
} else {
left.slug.compare(right.slug)
}
}
///|
/// Escape text for safe HTML output.
pub fn html_escape(input : String) -> String {
let out = StringBuilder(size_hint=input.length())
for _, ch in input {
match ch {
'&' => out.write_string("&")
'<' => out.write_string("<")
'>' => out.write_string(">")
'"' => out.write_string(""")
'\'' => out.write_string("'")
_ => out.write_char(ch)
}
}
out.to_string()
}
///|
/// Convert a heading or page title into a stable URL slug.
pub fn slugify(title : String) -> String {
let out = StringBuilder(size_hint=title.length())
let mut pending_dash = false
let mut wrote = false
for _, ch in title {
let lower = ch.to_ascii_lowercase()
if lower is ('a'..='z') || lower is ('0'..='9') {
if pending_dash && wrote {
out.write_char('-')
}
out.write_char(lower)
pending_dash = false
wrote = true
} else {
pending_dash = wrote
}
}
out.to_string()
}
///|
/// Extract level-1 and level-2 headings from Markdown source.
pub fn extract_toc(markdown : String) -> Array[TocItem] {
blocks_to_toc(parse_blocks(markdown))
}
///|
/// Extract heading entries from parsed Markdown blocks.
pub fn blocks_to_toc(blocks : Array[MarkdownBlock]) -> Array[TocItem] {
extract_headings(blocks).map(heading => {
level: heading.level,
title: heading.title,
anchor: heading.anchor,
})
}
///|
/// Extract headings and assign page-unique anchors.
pub fn extract_headings(
blocks : Array[MarkdownBlock],
) -> Array[AnchoredHeading] {
let headings : Array[AnchoredHeading] = []
let seen : Array[String] = []
for block in blocks {
match block {
Heading(level, title) => {
let base = slugify(title)
let anchor = unique_anchor(base, seen)
seen.push(anchor)
headings.push({ level, title, anchor, })
}
_ => ()
}
}
headings
}
///|
fn unique_anchor(base : String, seen : Array[String]) -> String {
let stable_base = if base == "" { "section" } else { base }
let mut count = 1
for item in seen {
if item == stable_base || item.has_prefix(stable_base + "-") {
count = count + 1
}
}
if count == 1 {
stable_base
} else {
stable_base + "-" + count.to_string()
}
}
///|
/// Parse a useful Markdown subset into block-level nodes.
pub fn parse_blocks(markdown : String) -> Array[MarkdownBlock] {
parse_document(markdown).blocks
}
///|
/// Parse optional front matter and Markdown blocks.
pub fn parse_document(markdown : String) -> ParsedDocument {
let (front_matter, body) = split_front_matter(markdown)
{ front_matter, blocks: parse_body_blocks(body), }
}
///|
fn split_front_matter(markdown : String) -> (FrontMatter, String) {
let lines = markdown.split("\n").map(line => line.to_owned()).to_array()
if lines.length() == 0 || lines[0].trim().to_owned() != "---" {
return (empty_front_matter(), markdown)
}
let meta_lines : Array[String] = []
let body_lines : Array[String] = []
let mut in_meta = true
let mut closed = false
for i in 1.. FrontMatter {
let fields : Array[(String, String)] = []
let tags : Array[String] = []
let mut title : String? = None
let mut order : Int? = None
for raw in lines {
let line = raw.trim().to_owned()
if line == "" || line.has_prefix("#") {
continue
}
match line.split_once(":") {
Some((key_view, value_view)) => {
let key = key_view.to_owned().trim().to_owned()
let value = value_view.to_owned().trim().to_owned()
fields.push((key, value))
if key == "title" {
title = Some(unquote(value))
} else if key == "order" {
order = parse_positive_int(value)
} else if key == "tags" {
for tag in parse_tags(value) {
tags.push(tag)
}
}
}
None => ()
}
}
{ title, order, tags, fields, }
}
///|
fn parse_tags(value : String) -> Array[String] {
let inner = strip_brackets(value)
let tags : Array[String] = []
for item in inner.split(",") {
let tag = unquote(item.to_owned().trim().to_owned())
if tag != "" {
tags.push(tag)
}
}
tags
}
///|
fn strip_brackets(value : String) -> String {
let trimmed = value.trim().to_owned()
if trimmed.length() >= 2 && trimmed.has_prefix("[") && trimmed.has_suffix("]") {
trimmed[1:trimmed.length() - 1].to_owned()
} else {
trimmed
}
}
///|
fn unquote(value : String) -> String {
let trimmed = value.trim().to_owned()
if trimmed.length() >= 2 {
let first = trimmed[0]
let last = trimmed[trimmed.length() - 1]
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
return trimmed[1:trimmed.length() - 1].to_owned()
}
}
trimmed
}
///|
fn parse_positive_int(value : String) -> Int? {
let trimmed = value.trim().to_owned()
if trimmed == "" {
return None
}
let mut result = 0
for _, ch in trimmed {
if !(ch is ('0'..='9')) {
return None
}
result = result * 10 + (ch.to_int() - '0'.to_int())
}
Some(result)
}
///|
fn parse_body_blocks(markdown : String) -> Array[MarkdownBlock] {
let blocks : Array[MarkdownBlock] = []
let lines = markdown.split("\n").to_array()
let mut idx = 0
let mut in_code = false
let mut code_language = ""
let code = StringBuilder()
let mut in_list = false
let mut list_ordered = false
let list_raw : Array[(Int, ListKind, String, Bool?)] = []
let paragraph = StringBuilder()
let mut in_table = false
let table_rows : Array[String] = []
while idx < lines.length() {
let raw = lines[idx].to_owned()
let trimmed = raw.trim().to_owned()
if trimmed.has_prefix("```") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
if in_table {
finalize_table(blocks, table_rows)
in_table = false
table_rows.clear()
}
if in_code {
blocks.push(CodeBlock(code_language, code.to_string()))
code.reset()
code_language = ""
} else {
code.reset()
code_language = trimmed[3:].to_owned().trim().to_owned()
}
in_code = !in_code
idx = idx + 1
continue
}
if in_code {
code.write_string(raw)
code.write_char('\n')
idx = idx + 1
continue
}
if trimmed == "" {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
if in_table {
finalize_table(blocks, table_rows)
in_table = false
table_rows.clear()
}
idx = idx + 1
continue
}
if in_table {
if is_table_row(trimmed) {
table_rows.push(trimmed)
idx = idx + 1
continue
} else {
finalize_table(blocks, table_rows)
in_table = false
table_rows.clear()
}
}
let has_next = idx + 1 < lines.length()
let table_next = if has_next {
lines[idx + 1].trim().to_owned()
} else {
""
}
if is_table_start(trimmed, has_next, table_next) {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
in_table = true
table_rows.clear()
table_rows.push(trimmed)
idx = idx + 1
continue
}
if trimmed.has_prefix("# ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
blocks.push(Heading(1, trimmed[2:].to_owned()))
} else if trimmed.has_prefix("## ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
blocks.push(Heading(2, trimmed[3:].to_owned()))
} else if trimmed.has_prefix("### ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
blocks.push(Heading(3, trimmed[4:].to_owned()))
} else if trimmed.has_prefix("#### ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
blocks.push(Heading(4, trimmed[5:].to_owned()))
} else if trimmed.has_prefix("##### ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
blocks.push(Heading(5, trimmed[6:].to_owned()))
} else if trimmed.has_prefix("###### ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
blocks.push(Heading(6, trimmed[7:].to_owned()))
} else if trimmed.has_prefix("- ") {
close_pending_paragraph(blocks, paragraph)
if in_list && list_ordered {
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
}
in_list = true
list_ordered = false
let (text, checked) = parse_task_marker(trimmed[2:].to_owned())
list_raw.push((indent_of(raw), Bullet, text, checked))
} else if ordered_marker_len(trimmed) > 0 {
close_pending_paragraph(blocks, paragraph)
if in_list && !list_ordered {
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
}
in_list = true
list_ordered = true
let (text, checked) = parse_task_marker(
trimmed[ordered_marker_len(trimmed):].to_owned(),
)
list_raw.push((indent_of(raw), Number, text, checked))
} else if trimmed.has_prefix("> ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
blocks.push(BlockQuote(trimmed[2:].to_owned()))
} else {
if in_list {
close_pending_list(blocks, list_raw, in_list, list_ordered)
in_list = false
list_ordered = false
}
append_paragraph_line(paragraph, trimmed)
}
idx = idx + 1
}
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_raw, in_list, list_ordered)
if in_table {
finalize_table(blocks, table_rows)
}
if in_code {
blocks.push(CodeBlock(code_language, code.to_string()))
}
blocks
}
///|
fn append_paragraph_line(paragraph : StringBuilder, line : String) -> Unit {
if !paragraph.is_empty() {
paragraph.write_char(' ')
}
paragraph.write_string(line)
}
///|
fn close_pending_paragraph(
blocks : Array[MarkdownBlock],
paragraph : StringBuilder,
) -> Unit {
if !paragraph.is_empty() {
blocks.push(Paragraph(paragraph.to_string()))
paragraph.reset()
}
}
///|
/// Length of an ordered-list marker prefix ("1. ", "10. ", ...) or 0 when the
/// line does not start with a numeric list marker.
fn ordered_marker_len(line : String) -> Int {
let mut i = 0
if line.length() == 0 {
return 0
}
while i < line.length() && line[i] >= '0' && line[i] <= '9' {
i = i + 1
}
if i > 0 && i + 1 < line.length() && line[i] == '.' && line[i + 1] == ' ' {
i + 2
} else {
0
}
}
///|
fn close_pending_list(
blocks : Array[MarkdownBlock],
list_raw : Array[(Int, ListKind, String, Bool?)],
is_open : Bool,
_is_ordered : Bool,
) -> Unit {
if is_open {
blocks.push(List(build_list_block(list_raw)))
list_raw.clear()
}
}
///|
fn indent_of(raw : String) -> Int {
let mut n = 0
while n < raw.length() && raw[n] == ' ' {
n = n + 1
}
n
}
///|
fn parse_task_marker(text : String) -> (String, Bool?) {
if text.has_prefix("[ ] ") {
(text[4:].to_owned(), Some(false))
} else if text.has_prefix("[x] ") || text.has_prefix("[X] ") {
(text[4:].to_owned(), Some(true))
} else {
(text, None)
}
}
///|
///| True when `trimmed` looks like a table row and the following line is a
///|
/// GFM separator row (so the two together open a table).
fn is_table_start(trimmed : String, has_next : Bool, next : String) -> Bool {
has_next && is_table_row(trimmed) && is_table_separator(next)
}
///|
fn is_table_row(trimmed : String) -> Bool {
if !trimmed.contains("|") {
return false
}
if trimmed.has_prefix("#") {
return false
}
if trimmed.has_prefix("> ") {
return false
}
if trimmed.has_prefix("```") {
return false
}
trimmed != ""
}
///|
fn is_table_separator(line : String) -> Bool {
if !line.contains("|") {
return false
}
let cells = split_table_row(line)
if cells.length() == 0 {
return false
}
for c in cells {
let t = c.trim().to_owned()
if t == "" {
return false
}
let mut has_dash = false
for _, ch in t {
if ch == '-' {
has_dash = true
} else if ch == ':' {
continue
} else if ch == ' ' {
continue
} else {
return false
}
}
if !has_dash {
return false
}
}
true
}
///|
fn split_table_row(line : String) -> Array[String] {
let mut s = line.trim().to_owned()
if s.has_prefix("|") {
s = s[1:].to_owned()
}
if s.length() > 0 && s[s.length() - 1] == '|' {
s = s[:s.length() - 1].to_owned()
}
s.split("|").to_array().map(fn(x) { x.to_owned() })
}
///|
fn parse_table_row(line : String) -> Array[String] {
let cells = split_table_row(line)
let out : Array[String] = []
for c in cells {
out.push(c.trim().to_owned())
}
out
}
///|
fn parse_table_aligns(line : String) -> Array[TableAlign] {
let cells = split_table_row(line)
let out : Array[TableAlign] = []
for c in cells {
let t = c.trim().to_owned()
let left = t.has_prefix(":")
let right = t.length() > 0 && t[t.length() - 1] == ':'
if left && right {
out.push(AlignCenter)
} else if left {
out.push(AlignLeft)
} else if right {
out.push(AlignRight)
} else {
out.push(AlignDefault)
}
}
out
}
///|
fn finalize_table(
blocks : Array[MarkdownBlock],
table_rows : Array[String],
) -> Unit {
if table_rows.length() < 2 {
return
}
let header = parse_table_row(table_rows[0])
let aligns = parse_table_aligns(table_rows[1])
let body : Array[Array[String]] = []
let mut r = 2
while r < table_rows.length() {
body.push(parse_table_row(table_rows[r]))
r = r + 1
}
blocks.push(Table(header, aligns, body))
}
///|
/// Build a nested list tree from flat (indent, kind, text, checked) entries.
fn build_list_block(raw : Array[(Int, ListKind, String, Bool?)]) -> ListBlock {
let n = raw.length()
let child_idx : Array[Array[Int]] = []
let mut k = 0
while k < n {
child_idx.push([])
k = k + 1
}
let mut i = 1
while i < n {
let cur = raw[i].0
let mut j = i - 1
while j >= 0 {
if raw[j].0 < cur {
let mut ok = true
let mut m = j + 1
while m < i {
if raw[m].0 > raw[j].0 && raw[m].0 < cur {
ok = false
}
m = m + 1
}
if ok {
child_idx[j].push(i)
break
}
}
j = j - 1
}
i = i + 1
}
let root_items : Array[ListItem] = []
let mut p = 0
while p < n {
let mut is_root = true
let mut j = p - 1
while j >= 0 {
if raw[j].0 < raw[p].0 {
let mut ok = true
let mut m = j + 1
while m < p {
if raw[m].0 > raw[j].0 && raw[m].0 < raw[p].0 {
ok = false
}
m = m + 1
}
if ok {
is_root = false
break
}
}
j = j - 1
}
if is_root {
root_items.push(build_item(p, child_idx, raw))
}
p = p + 1
}
ListBlock(raw[0].1, root_items)
}
///|
fn build_item(
i : Int,
child_idx : Array[Array[Int]],
raw : Array[(Int, ListKind, String, Bool?)],
) -> ListItem {
if child_idx[i].length() == 0 {
ListItem(raw[i].2, raw[i].3, None)
} else {
let child_items : Array[ListItem] = []
for c in child_idx[i] {
child_items.push(build_item(c, child_idx, raw))
}
let child_kind = raw[child_idx[i][0]].1
ListItem(raw[i].2, raw[i].3, Some(ListBlock(child_kind, child_items)))
}
}
///|
/// Render parsed Markdown blocks to HTML.
pub fn render_blocks(blocks : Array[MarkdownBlock]) -> String {
let out = StringBuilder()
let headings = extract_headings(blocks)
let mut heading_index = 0
for block in blocks {
match block {
Heading(level, text) => {
let anchor = headings[heading_index].anchor
heading_index = heading_index + 1
out.write_string("")
out.write_string(html_escape(text))
out.write_string(" \n")
}
Paragraph(text) => {
out.write_string("")
out.write_string(render_inline(text))
out.write_string("
\n")
}
List(list) => render_list_block(out, list)
Table(header, aligns, body) => {
out.write_string("\n")
for i, h in header {
out.write_string("")
out.write_string(html_escape(h))
out.write_string(" ")
}
out.write_string(" \n")
for row in body {
out.write_string("")
for i, c in row {
out.write_string("")
out.write_string(render_inline(c))
out.write_string(" ")
}
out.write_string(" \n")
}
out.write_string("
\n")
}
BlockQuote(text) => {
out.write_string("")
out.write_string(render_inline(text))
out.write_string("
\n")
}
CodeBlock(language, code) => {
if language == "" {
out.write_string("")
} else {
out.write_string("")
}
out.write_string(html_escape(code))
out.write_string("
\n")
}
}
}
out.to_string()
}
///|
fn render_list_block(out : StringBuilder, list : ListBlock) -> Unit {
match list {
ListBlock(kind, items) => {
match kind {
Bullet => out.write_string("\n")
Number => out.write_string("\n")
}
for item in items {
render_list_item(out, item)
}
match kind {
Bullet => out.write_string("
\n")
Number => out.write_string("\n")
}
}
}
}
///|
fn render_list_item(out : StringBuilder, item : ListItem) -> Unit {
out.write_string("")
match item {
ListItem(text, checked, children) => {
match checked {
Some(true) =>
out.write_string(" ")
Some(false) => out.write_string(" ")
None => ()
}
out.write_string(render_inline(text))
match children {
Some(child) => {
out.write_char('\n')
render_list_block(out, child)
}
None => ()
}
}
}
out.write_string(" \n")
}
///|
fn write_align(out : StringBuilder, align : TableAlign) -> Unit {
match align {
AlignLeft => out.write_string(" align=\"left\"")
AlignCenter => out.write_string(" align=\"center\"")
AlignRight => out.write_string(" align=\"right\"")
AlignDefault => ()
}
}
///|
fn align_for(aligns : Array[TableAlign], i : Int) -> TableAlign {
if i >= 0 && i < aligns.length() {
aligns[i]
} else {
AlignDefault
}
}
///|
/// Render a useful subset of Markdown into HTML.
pub fn render_markdown(markdown : String) -> String {
render_blocks(parse_blocks(markdown))
}
///|
fn render_inline(input : String) -> String {
let out = StringBuilder(size_hint=input.length())
let mut i = 0
while i < input.length() {
if starts_with_at(input, i, "`") {
match find_char_from(input, i + 1, '`') {
Some(close) => {
out.write_string("")
out.write_string(html_escape(input[i + 1:close].to_owned()))
out.write_string("")
i = close + 1
}
None => {
out.write_string("`")
i = i + 1
}
}
} else if starts_with_at(input, i, "**") {
match find_marker_from(input, i + 2, "**") {
Some(close) => {
out.write_string("")
out.write_string(render_inline(input[i + 2:close].to_owned()))
out.write_string("")
i = close + 2
}
None => {
out.write_string("**")
i = i + 2
}
}
} else if starts_with_at(input, i, "~~") {
match find_marker_from(input, i + 2, "~~") {
Some(close) => {
out.write_string("")
out.write_string(render_inline(input[i + 2:close].to_owned()))
out.write_string("")
i = close + 2
}
None => {
out.write_string("~~")
i = i + 2
}
}
} else if starts_with_at(input, i, "*") {
match find_char_from(input, i + 1, '*') {
Some(close) => {
out.write_string("")
out.write_string(render_inline(input[i + 1:close].to_owned()))
out.write_string("")
i = close + 1
}
None => {
out.write_string("*")
i = i + 1
}
}
} else if starts_with_at(input, i, "![") {
match parse_link(input, i + 1) {
Some((close, label, src)) => {
out.write_string("
")
i = close + 1
}
None => {
out.write_string("!")
i = i + 1
}
}
} else if input[i] == '<' {
match parse_autolink(input, i) {
Some((close, href)) => {
out.write_string("")
out.write_string(html_escape(href))
out.write_string("")
i = close + 1
}
None => {
out.write_string("<")
i = i + 1
}
}
} else if input[i] == '[' {
match parse_link(input, i) {
Some((close, label, href)) => {
out.write_string("")
out.write_string(render_inline(label))
out.write_string("")
i = close + 1
}
None => {
out.write_string(html_escape(input[i:i + 1].to_owned()))
i = i + 1
}
}
// Emit a full code point so UTF-16 surrogate pairs (e.g. emoji) are
// never sliced into a lone surrogate, which would break downstream
// code-point iteration in html_escape.
} else if input[i] >= 0xD800 && input[i] <= 0xDBFF && i + 1 < input.length() {
out.write_string(html_escape(input[i:i + 2].to_owned()))
i = i + 2
} else {
out.write_string(html_escape(input[i:i + 1].to_owned()))
i = i + 1
}
}
out.to_string()
}
///|
fn starts_with_at(input : String, index : Int, marker : String) -> Bool {
let mlen = marker.length()
if index < 0 || index + mlen > input.length() {
return false
}
// Compare code units directly instead of slicing: a single-code-unit slice
// that lands on a UTF-16 surrogate half would create an invalid string and
// panic the runtime (e.g. an emoji in the line being tested for a "`" prefix).
let mut k = 0
while k < mlen {
if input[index + k] != marker[k] {
return false
}
k = k + 1
}
true
}
///|
fn find_char_from(input : String, start : Int, target : UInt16) -> Int? {
let mut i = start
while i < input.length() {
if input[i] == target {
return Some(i)
}
i = i + 1
}
None
}
///|
fn find_marker_from(input : String, start : Int, marker : String) -> Int? {
let mut i = start
while i + marker.length() <= input.length() {
if starts_with_at(input, i, marker) {
return Some(i)
}
i = i + 1
}
None
}
///|
fn parse_link(input : String, start : Int) -> (Int, String, String)? {
match find_char_from(input, start + 1, ']') {
Some(label_end) =>
if label_end + 1 < input.length() && input[label_end + 1] == '(' {
match find_char_from(input, label_end + 2, ')') {
Some(href_end) =>
Some(
(
href_end,
input[start + 1:label_end].to_owned(),
input[label_end + 2:href_end].to_owned(),
),
)
None => None
}
} else {
None
}
None => None
}
}
///|
fn parse_autolink(input : String, start : Int) -> (Int, String)? {
match find_char_from(input, start + 1, '>') {
Some(close) => {
let href = input[start + 1:close].to_owned().trim().to_owned()
if (href.has_prefix("http://") || href.has_prefix("https://")) &&
safe_href(href) == href {
Some((close, href))
} else {
None
}
}
None => None
}
}
///|
fn safe_href(href : String) -> String {
let value = href.trim().to_owned()
if value.has_prefix("http://") ||
value.has_prefix("https://") ||
value.has_prefix("#") ||
value.has_prefix("./") ||
value.has_prefix("../") ||
!value.contains(":") {
value
} else {
"#"
}
}
///|
/// Render a complete HTML page with navigation.
pub fn render_page(site : DocSite, page : DocPage) -> RenderedPage {
render_page_with_options(site, page, default_theme(), default_site_options())
}
///|
/// Render a complete HTML page with navigation and a custom theme.
pub fn render_page_with_theme(
site : DocSite,
page : DocPage,
theme : SiteTheme,
) -> RenderedPage {
render_page_with_options(site, page, theme, default_site_options())
}
///|
/// Render a complete HTML page with custom theme and template options.
pub fn render_page_with_options(
site : DocSite,
page : DocPage,
theme : SiteTheme,
options : SiteOptions,
) -> RenderedPage {
let route = route_for_page(page)
let body = render_markdown(page.source)
let routes = plan_routes(site)
let locale = locale_for(options.language)
let i18n = i18n_for(locale)
let toc = render_toc(extract_toc(page.source), locale)
let out = StringBuilder(size_hint=body.length() + 1024)
out.write_string("\n\n")
out.write_string(
"\n",
)
out.write_string(render_theme_bootstrap())
if options.description.trim().to_owned() != "" {
out.write_string("\n")
}
out.write_string("")
out.write_string(html_escape(route.title))
out.write_string(" - ")
out.write_string(html_escape(site.title))
out.write_string(" \n")
write_social_metadata(out, site, route, options)
out.write_string(render_theme_style(theme))
out.write_string(
"",
)
out.write_string(html_escape(i18n.skip_link))
out.write_string("\n",
)
out.write_string("")
out.write_string(body)
if options.footer.trim().to_owned() != "" {
out.write_string("\n")
}
out.write_string("")
out.write_string(toc)
out.write_string(" ")
out.write_string(render_search_script())
out.write_string(render_i18n_script(locale))
out.write_string(render_ui_script())
out.write_string("\n")
{ path: route.path, html: out.to_string(), }
}
///|
fn write_social_metadata(
out : StringBuilder,
site : DocSite,
route : RouteEntry,
options : SiteOptions,
) -> Unit {
let title = route.title + " - " + site.title
out.write_string("\n")
if options.description.trim().to_owned() != "" {
out.write_string("\n")
}
match canonical_url(options.site_url, route.path) {
Some(url) => {
out.write_string("\n")
out.write_string("\n")
}
None => ()
}
}
///|
fn canonical_url(site_url : String, path : String) -> String? {
let base = site_url.trim().to_owned()
if base == "" {
return None
}
if !(base.has_prefix("http://") || base.has_prefix("https://")) {
return None
}
let clean_base = if base.has_suffix("/") {
base[0:base.length() - 1].to_owned()
} else {
base
}
let clean_path = if path.has_prefix("/") { path[1:].to_owned() } else { path }
Some(clean_base + "/" + clean_path)
}
///|
fn render_search_script() -> String {
(
#|
#|
)
}
///|
/// Emit an inline script that defines the UI string dictionary for the current
/// and alternate locales and wires the in-page language switcher. Content language
/// is the author's responsibility; this only localizes the generated UI chrome.
fn render_i18n_script(locale : Locale) -> String {
let lc = match locale {
Zh => "zh"
En => "en"
}
let out = StringBuilder()
out.write_string("")
out.to_string()
}
///|
/// Emit an inline script that resolves the stored theme before first paint.
fn render_theme_bootstrap() -> String {
(
#|
#|
)
}
///|
/// Emit an inline script for theme switching, code copying and reading progress.
fn render_ui_script() -> String {
(
#|
#|
)
}
///|
/// Render all pages in a documentation site.
pub fn render_site(site : DocSite) -> Array[RenderedPage] {
render_site_with_theme(site, default_theme())
}
///|
/// Render all pages in a documentation site with a custom theme.
pub fn render_site_with_theme(
site : DocSite,
theme : SiteTheme,
) -> Array[RenderedPage] {
render_site_with_options(site, theme, default_site_options())
}
///|
/// Render all pages in a documentation site with custom theme and options.
pub fn render_site_with_options(
site : DocSite,
theme : SiteTheme,
options : SiteOptions,
) -> Array[RenderedPage] {
site.pages.map(page => render_page_with_options(site, page, theme, options))
}
///|
/// Build all static output files for a site without touching the filesystem.
pub fn build_site_manifest(site : DocSite) -> Array[OutputFile] {
build_site_manifest_with_theme(site, default_theme())
}
///|
/// Build all static output files for a site using a custom theme.
pub fn build_site_manifest_with_theme(
site : DocSite,
theme : SiteTheme,
) -> Array[OutputFile] {
build_site_manifest_with_options(site, theme, default_site_options())
}
///|
/// Build all static output files using custom theme and template options.
pub fn build_site_manifest_with_options(
site : DocSite,
theme : SiteTheme,
options : SiteOptions,
) -> Array[OutputFile] {
let files : Array[OutputFile] = []
for page in site.pages {
let rendered = render_page_with_options(site, page, theme, options)
files.push({ path: rendered.path, content: rendered.html, })
}
match build_index_page(site) {
Some(content) => files.push({ path: "index.html", content, })
None => ()
}
files.push({ path: "search-index.json", content: build_search_index(site), })
files.push({
path: "quality-report.json",
content: build_quality_report_json(site),
})
files.push({ path: "sitemap.xml", content: build_sitemap(site), })
files.push({ path: "robots.txt", content: build_robots_txt(), })
files.push({
path: "site-manifest.json",
content: build_output_manifest_json(files),
})
files.sort_by((left, right) => left.path.compare(right.path))
files
}
///|
/// Build a lightweight root page that directs visitors to the first route.
pub fn build_index_page(site : DocSite) -> String? {
let routes = plan_routes(site)
if routes.length() == 0 {
return None
}
let first = routes[0]
let title = html_escape(site.title)
let path = html_escape(first.path)
Some(
"\n\n" +
"\n" +
"\n" +
title +
" \nOpen " +
title +
".
\n\n",
)
}
///|
/// Build a robots.txt file that points crawlers to the generated sitemap.
pub fn build_robots_txt() -> String {
"User-agent: *\nAllow: /\nSitemap: sitemap.xml\n"
}
///|
/// Build a machine-readable manifest for generated output files.
pub fn build_output_manifest_json(files : Array[OutputFile]) -> String {
let report = inspect_manifest(files)
let out = StringBuilder()
out.write_string("{\n")
out.write_string(" \"file_count\": ")
out.write_string(report.file_count.to_string())
out.write_string(",\n \"html_count\": ")
out.write_string(report.html_count.to_string())
out.write_string(",\n \"data_count\": ")
out.write_string(report.data_count.to_string())
out.write_string(",\n \"total_bytes\": ")
out.write_string(report.total_bytes.to_string())
out.write_string(",\n \"files\": [\n")
for i, file in report.files {
out.write_string(" {\"path\":\"")
out.write_string(json_escape(file.path))
out.write_string("\",\"kind\":\"")
out.write_string(json_escape(file.kind))
out.write_string("\",\"bytes\":")
out.write_string(file.byte_count.to_string())
out.write_string("}")
if i + 1 < report.files.length() {
out.write_string(",")
}
out.write_string("\n")
}
out.write_string(" ]\n}\n")
out.to_string()
}
///|
/// Build a machine-readable quality report for review and CI artifacts.
pub fn build_quality_report_json(site : DocSite) -> String {
let gate = evaluate_quality(site)
let metrics = measure_site(site)
let validation = validate_site(site)
let out = StringBuilder()
out.write_string("{\n")
out.write_string(" \"site_title\": \"")
out.write_string(json_escape(site.title))
out.write_string("\",\n \"passed\": ")
out.write_string(json_bool(gate.passed))
out.write_string(",\n \"score\": ")
out.write_string(gate.score.to_string())
out.write_string(",\n \"checks\": [\n")
for i, check in gate.checks {
out.write_string(" {\"name\":\"")
out.write_string(json_escape(check.name))
out.write_string("\",\"passed\":")
out.write_string(json_bool(check.passed))
out.write_string(",\"message\":\"")
out.write_string(json_escape(check.message))
out.write_string("\"}")
if i + 1 < gate.checks.length() {
out.write_string(",")
}
out.write_string("\n")
}
out.write_string(" ],\n \"metrics\": {")
out.write_string("\"page_count\":")
out.write_string(metrics.page_count.to_string())
out.write_string(",\"heading_count\":")
out.write_string(metrics.heading_count.to_string())
out.write_string(",\"code_block_count\":")
out.write_string(metrics.code_block_count.to_string())
out.write_string(",\"word_count\":")
out.write_string(metrics.word_count.to_string())
out.write_string(",\"reading_minutes\":")
out.write_string(metrics.reading_minutes.to_string())
out.write_string("},\n \"diagnostics\": [\n")
for i, diagnostic in validation.diagnostics {
out.write_string(" {\"level\":\"")
out.write_string(diagnostic_level_name(diagnostic.level))
out.write_string("\",\"code\":\"")
out.write_string(json_escape(diagnostic.code))
out.write_string("\",\"message\":\"")
out.write_string(json_escape(diagnostic.message))
out.write_string("\",\"page\":")
match diagnostic.page {
Some(page) => {
out.write_string("\"")
out.write_string(json_escape(page))
out.write_string("\"")
}
None => out.write_string("null")
}
out.write_string("}")
if i + 1 < validation.diagnostics.length() {
out.write_string(",")
}
out.write_string("\n")
}
out.write_string(" ]\n}\n")
out.to_string()
}
///|
/// Build a minimal XML sitemap for generated HTML pages.
pub fn build_sitemap(site : DocSite) -> String {
let routes = plan_routes(site)
let out = StringBuilder()
out.write_string("\n")
out.write_string(
"\n",
)
for route in routes {
out.write_string(" ")
out.write_string(xml_escape(route.path))
out.write_string(" \n")
}
out.write_string(" \n")
out.to_string()
}
///|
/// Inspect generated output files and return a deterministic build report.
pub fn inspect_manifest(files : Array[OutputFile]) -> BuildReport {
let infos : Array[OutputFileInfo] = []
let mut html_count = 0
let mut data_count = 0
let mut total_bytes = 0
for file in files {
let kind = classify_output(file.path)
if kind == "html" {
html_count = html_count + 1
} else if kind == "data" {
data_count = data_count + 1
}
total_bytes = total_bytes + file.content.length()
infos.push({ path: file.path, kind, byte_count: file.content.length(), })
}
infos.sort_by((left, right) => left.path.compare(right.path))
{
file_count: infos.length(),
html_count,
data_count,
total_bytes,
files: infos,
}
}
///|
/// Build a report for the default generated site manifest.
pub fn build_site_report(site : DocSite) -> BuildReport {
inspect_manifest(build_site_manifest(site))
}
///|
/// Evaluate a documentation site against basic publish-readiness checks.
pub fn evaluate_quality(site : DocSite) -> QualityGate {
let checks : Array[QualityCheck] = []
let validation = validate_site(site)
let metrics = measure_site(site)
let generated_html_count = if metrics.page_count == 0 {
0
} else {
metrics.page_count + 1
}
let generated_data_count = 4
let max_page_reading_minutes = max_page_reading_minutes(site)
checks.push({
name: "validation",
passed: !has_validation_errors(validation),
message: if has_validation_errors(validation) {
"site has validation errors"
} else {
"site validation passed"
},
})
checks.push({
name: "content",
passed: metrics.word_count > 0 && metrics.heading_count > 0,
message: if metrics.word_count > 0 && metrics.heading_count > 0 {
"site has measurable documentation content"
} else {
"site needs headings and body content"
},
})
checks.push({
name: "outputs",
passed: generated_html_count == metrics.page_count + 1 &&
generated_data_count >= 3,
message: if generated_html_count == metrics.page_count + 1 &&
generated_data_count >= 3 {
"manifest includes html pages and data outputs"
} else {
"manifest output mix is incomplete"
},
})
checks.push({
name: "readability",
passed: max_page_reading_minutes <= 30,
message: if max_page_reading_minutes <= 30 {
"every page is within the baseline reading threshold"
} else {
"one or more pages may need splitting into smaller pages"
},
})
let mut passed_count = 0
for check in checks {
if check.passed {
passed_count = passed_count + 1
}
}
{
passed: passed_count == checks.length(),
score: if checks.length() == 0 {
0
} else {
passed_count * 100 / checks.length()
},
checks,
}
}
///|
fn max_page_reading_minutes(site : DocSite) -> Int {
let mut maximum = 0
for page in site.pages {
let minutes = measure_document(page.source).reading_minutes
if minutes > maximum {
maximum = minutes
}
}
maximum
}
///|
/// Build a small JSON search index for the rendered site.
pub fn build_search_index(site : DocSite) -> String {
let entries = collect_search_entries(site)
let out = StringBuilder()
out.write_string("[")
for i, entry in entries {
if i > 0 {
out.write_string(",")
}
out.write_string("{\"title\":\"")
out.write_string(json_escape(entry.title))
out.write_string("\",\"path\":\"")
out.write_string(json_escape(entry.path))
out.write_string("\",\"text\":\"")
out.write_string(json_escape(entry.text))
out.write_string("\",\"tags\":[")
for j, tag in entry.tags {
if j > 0 {
out.write_string(",")
}
out.write_string("\"")
out.write_string(json_escape(tag))
out.write_string("\"")
}
out.write_string("]}")
}
out.write_string("]")
out.to_string()
}
///|
/// Collect structured search entries before JSON serialization.
pub fn collect_search_entries(site : DocSite) -> Array[SearchEntry] {
let entries : Array[SearchEntry] = []
for page in site.pages {
let route = route_for_page(page)
let doc = parse_document(page.source)
entries.push({
title: route.title,
path: route.path,
text: blocks_to_plain_text(doc.blocks),
tags: route.tags,
})
}
entries.sort_by((left, right) => left.path.compare(right.path))
entries
}
///|
/// Summarize the generated site for demos, logs and validation.
pub fn summarize_site(site : DocSite) -> SiteSummary {
let routes = plan_routes(site)
let manifest = build_site_manifest(site)
let search_entries = collect_search_entries(site)
let tags : Array[String] = []
for route in routes {
for tag in route.tags {
if !tags.contains(tag) {
tags.push(tag)
}
}
}
{
page_count: routes.length(),
output_count: manifest.length(),
search_entry_count: search_entries.length(),
tag_count: tags.length(),
}
}
///|
/// Measure content size and structure for one Markdown document.
pub fn measure_document(markdown : String) -> DocumentMetrics {
measure_blocks(parse_blocks(markdown))
}
///|
/// Measure content size and structure for parsed Markdown blocks.
pub fn measure_blocks(blocks : Array[MarkdownBlock]) -> DocumentMetrics {
let mut heading_count = 0
let mut paragraph_count = 0
let mut list_item_count = 0
let mut code_block_count = 0
let mut word_count = 0
for block in blocks {
match block {
Heading(_, title) => {
heading_count = heading_count + 1
word_count = word_count + count_words(title)
}
Paragraph(text) | BlockQuote(text) => {
paragraph_count = paragraph_count + 1
word_count = word_count + count_words(text)
}
List(list) => {
let (ic, wc) = measure_list_block(list)
list_item_count = list_item_count + ic
word_count = word_count + wc
}
Table(header, _aligns, body) => {
for h in header {
word_count = word_count + count_words(h)
}
for row in body {
for c in row {
word_count = word_count + count_words(c)
}
}
}
CodeBlock(_, _) => code_block_count = code_block_count + 1
}
}
{
heading_count,
paragraph_count,
list_item_count,
code_block_count,
word_count,
reading_minutes: reading_minutes_for_words(word_count),
}
}
///|
fn measure_list_block(list : ListBlock) -> (Int, Int) {
match list {
ListBlock(_, items) => {
let mut ic = 0
let mut wc = 0
for item in items {
match item {
ListItem(text, _, children) => {
ic = ic + 1
wc = wc + count_words(text)
match children {
Some(child) => {
let (cic, cwc) = measure_list_block(child)
ic = ic + cic
wc = wc + cwc
}
None => ()
}
}
}
}
(ic, wc)
}
}
}
///|
/// Measure aggregate content size and structure for all site pages.
pub fn measure_site(site : DocSite) -> SiteMetrics {
let mut heading_count = 0
let mut code_block_count = 0
let mut word_count = 0
for page in site.pages {
let metrics = measure_document(page.source)
heading_count = heading_count + metrics.heading_count
code_block_count = code_block_count + metrics.code_block_count
word_count = word_count + metrics.word_count
}
{
page_count: site.pages.length(),
heading_count,
code_block_count,
word_count,
reading_minutes: reading_minutes_for_words(word_count),
}
}
///|
/// Validate a site before rendering or publishing.
pub fn validate_site(site : DocSite) -> ValidationReport {
let diagnostics : Array[SiteDiagnostic] = []
if site.title.trim().to_owned() == "" {
diagnostics.push({
level: DiagWarning,
code: "empty-site-title",
message: "site title is empty",
page: None,
})
}
if site.pages.length() == 0 {
diagnostics.push({
level: DiagError,
code: "empty-site",
message: "site has no pages",
page: None,
})
}
let paths : Array[String] = []
for page in site.pages {
validate_page(page, paths, diagnostics)
}
{ diagnostics, }
}
///|
fn validate_page(
page : DocPage,
paths : Array[String],
diagnostics : Array[SiteDiagnostic],
) -> Unit {
let route = route_for_page(page)
let page_name = if page.title.trim().to_owned() == "" {
page.slug
} else {
page.title
}
if route.title.trim().to_owned() == "" {
diagnostics.push({
level: DiagError,
code: "empty-page-title",
message: "page title is empty",
page: Some(page_name),
})
}
if route.slug.trim().to_owned() == "" {
diagnostics.push({
level: DiagError,
code: "empty-route",
message: "page route slug is empty",
page: Some(page_name),
})
} else if paths.contains(route.path) {
diagnostics.push({
level: DiagError,
code: "duplicate-route",
message: "more than one page renders to " + route.path,
page: Some(page_name),
})
} else {
paths.push(route.path)
}
if page.source.trim().to_owned() == "" {
diagnostics.push({
level: DiagWarning,
code: "empty-source",
message: "page source is empty",
page: Some(page_name),
})
}
}
///|
/// Return true when a validation report contains at least one error.
pub fn has_validation_errors(report : ValidationReport) -> Bool {
for diagnostic in report.diagnostics {
match diagnostic.level {
DiagError => return true
DiagWarning => ()
}
}
false
}
///|
/// Render diagnostics as stable text for CLI output and logs.
pub fn diagnostics_to_text(report : ValidationReport) -> String {
if report.diagnostics.length() == 0 {
return "ok"
}
let out = StringBuilder()
for diagnostic in report.diagnostics {
match diagnostic.level {
DiagError => out.write_string("error")
DiagWarning => out.write_string("warning")
}
out.write_string(" ")
out.write_string(diagnostic.code)
match diagnostic.page {
Some(page) => {
out.write_string(" [")
out.write_string(page)
out.write_string("]")
}
None => ()
}
out.write_string(": ")
out.write_string(diagnostic.message)
out.write_char('\n')
}
out.to_string()
}
///|
fn blocks_to_plain_text(blocks : Array[MarkdownBlock]) -> String {
let out = StringBuilder()
for block in blocks {
match block {
Heading(_, text) | Paragraph(text) | BlockQuote(text) =>
append_search_text(out, text)
List(list) => plain_list_block(out, list)
Table(header, _aligns, body) => {
for h in header {
append_search_text(out, h)
}
for row in body {
for c in row {
append_search_text(out, c)
}
}
}
CodeBlock(_, code) => append_search_text(out, code)
}
}
out.to_string()
}
///|
fn plain_list_block(out : StringBuilder, list : ListBlock) -> Unit {
match list {
ListBlock(_, items) =>
for item in items {
match item {
ListItem(text, _, children) => {
append_search_text(out, text)
match children {
Some(child) => plain_list_block(out, child)
None => ()
}
}
}
}
}
}
///|
fn append_search_text(out : StringBuilder, text : String) -> Unit {
if !out.is_empty() {
out.write_char(' ')
}
out.write_string(text.trim().to_owned())
}
///|
fn count_words(text : String) -> Int {
let mut count = 0
let mut in_word = false
for _, ch in text {
let lower = ch.to_ascii_lowercase()
let is_word = lower is ('a'..='z') || lower is ('0'..='9')
if is_word && !in_word {
count = count + 1
}
in_word = is_word
}
count
}
///|
fn reading_minutes_for_words(word_count : Int) -> Int {
if word_count == 0 {
0
} else {
(word_count + 199) / 200
}
}
///|
fn json_escape(input : String) -> String {
let out = StringBuilder(size_hint=input.length())
for _, ch in input {
match ch {
'"' => out.write_string("\\\"")
'\\' => out.write_string("\\\\")
'\n' => out.write_string("\\n")
'\r' => out.write_string("\\r")
'\t' => out.write_string("\\t")
_ => out.write_char(ch)
}
}
out.to_string()
}
///|
fn json_bool(value : Bool) -> String {
if value {
"true"
} else {
"false"
}
}
///|
fn diagnostic_level_name(level : DiagnosticLevel) -> String {
match level {
DiagError => "error"
DiagWarning => "warning"
}
}
///|
fn xml_escape(input : String) -> String {
let out = StringBuilder(size_hint=input.length())
for _, ch in input {
match ch {
'&' => out.write_string("&")
'<' => out.write_string("<")
'>' => out.write_string(">")
'"' => out.write_string(""")
'\'' => out.write_string("'")
_ => out.write_char(ch)
}
}
out.to_string()
}
///|
fn classify_output(path : String) -> String {
if path.has_suffix(".html") || path.has_suffix(".htm") {
"html"
} else if path.has_suffix(".json") || path.has_suffix(".xml") {
"data"
} else {
"asset"
}
}
///|
fn route_for_page(page : DocPage) -> RouteEntry {
let doc = parse_document(page.source)
let title = match doc.front_matter.title {
Some(value) => value
None => page.title
}
let slug = if page.slug == "" { slugify(title) } else { page.slug }
{
title,
slug,
path: slug + ".html",
order: doc.front_matter.order,
tags: doc.front_matter.tags,
}
}
///|
fn render_theme_style(theme : SiteTheme) -> String {
let sidebar_width = positive_or_default(theme.sidebar_width_px, 260)
let content_width = positive_or_default(theme.content_width_px, 820)
let rail_break = sidebar_width + 900
let narrow = sidebar_width + content_width + 60
let out = StringBuilder()
out.write_string("\n")
out.to_string()
}
///|
fn positive_or_default(value : Int, fallback : Int) -> Int {
if value > 0 {
value
} else {
fallback
}
}
///|
/// UI locale for generated page chrome. Internal; not part of the public API.
priv enum Locale {
En
Zh
}
///|
/// Localized UI strings for the generated documentation chrome.
pub(all) struct I18n {
search_label : String
search_placeholder : String
theme_toggle : String
skip_link : String
toc_title : String
no_results : String
lang_toggle : String
}
///|
/// Map a site language code to an internal locale (zh-CN / zh / cn -> Zh).
fn locale_for(language : String) -> Locale {
let value = language.trim().to_owned()
if value == "" {
return En
}
let first = value[0:2].to_owned()
if first == "zh" || first == "cn" {
return Zh
}
En
}
///|
/// Return the localized chrome string set for a locale.
fn i18n_for(locale : Locale) -> I18n {
match locale {
En =>
I18n::{
search_label: "Search documentation",
search_placeholder: "Search docs",
theme_toggle: "Nightfall",
skip_link: "Skip to content",
toc_title: "On this page",
no_results: "No matching pages",
lang_toggle: "中文",
}
Zh =>
I18n::{
search_label: "搜索文档",
search_placeholder: "搜索文档",
theme_toggle: "夜间模式",
skip_link: "跳到正文",
toc_title: "本页目录",
no_results: "无匹配结果",
lang_toggle: "EN",
}
}
}
///|
fn safe_language(language : String) -> String {
let value = language.trim().to_owned()
if value == "" {
return "en"
}
for _, ch in value {
let lower = ch.to_ascii_lowercase()
if !(lower is ('a'..='z')) && !(lower is ('0'..='9')) && ch != '-' {
return "en"
}
}
value
}
///|
fn render_toc(items : Array[TocItem], locale : Locale) -> String {
if items.length() == 0 {
return ""
}
let out = StringBuilder()
out.write_string(
"",
)
out.write_string(html_escape(i18n_for(locale).toc_title))
out.write_string("\n")
for item in items {
out.write_string("")
out.write_string(html_escape(item.title))
out.write_string("\n")
}
out.write_string(" \n")
out.to_string()
}
///|
/// One runnable MoonBit example extracted from a fenced code block.
pub(all) struct DocExample {
index : Int
code : String
}
///|
fn first_token(text : String) -> String {
let out = StringBuilder()
for ch in text {
if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' {
break
}
out.write_char(ch)
}
out.to_string()
}
///|
fn info_has_flag(language : String, flag : String) -> Bool {
let mut found = false
for word in language.split(" ") {
if word == flag {
found = true
}
}
found
}
///|
/// Report whether a fenced code block info string marks a runnable MoonBit
/// example. Blocks tagged `nocheck` are skipped, matching the MoonBit
/// documentation convention, so illustrative snippets stay illustrative.
pub fn is_runnable_moonbit_example(language : String) -> Bool {
if info_has_flag(language, "nocheck") {
return false
}
let tag = first_token(language)
tag == "mbt" || tag == "moonbit"
}
///|
/// Extract runnable MoonBit examples from one Markdown document.
pub fn extract_doc_examples(markdown : String) -> Array[DocExample] {
let out : Array[DocExample] = []
for block in parse_body_blocks(markdown) {
match block {
CodeBlock(language, code) =>
if is_runnable_moonbit_example(language) {
out.push({ index: out.length(), code, })
}
_ => ()
}
}
out
}
///|
fn trim_end_newlines(text : String) -> String {
let chars : Array[Char] = []
for ch in text {
chars.push(ch)
}
let mut end = chars.length()
while end > 0 && (chars[end - 1] == '\n' || chars[end - 1] == '\r') {
end = end - 1
}
let out = StringBuilder()
let mut i = 0
while i < end {
out.write_char(chars[i])
i = i + 1
}
out.to_string()
}
///|
fn strip_doc_markers(code : String) -> String {
let out = StringBuilder()
for raw in code.split("\n") {
let line = raw.to_owned()
if !line.trim().to_owned().has_prefix("///|") {
out.write_string(line)
out.write_char('\n')
}
}
out.to_string()
}
///|
/// Render extracted examples as MoonBit test source, one `test` block per
/// example.
///
/// MoonBit only accepts imports in `moon.pkg`, so `import_package` is emitted
/// as a comment telling the caller what the target package must import, rather
/// than as a declaration that would fail to compile.
pub fn render_doc_test_file(
examples : Array[DocExample],
import_package : String,
) -> String {
let out = StringBuilder()
out.write_string(
"// Generated by MoonDocKit from documented examples. Do not edit.\n",
)
if import_package != "" {
out.write_string(
"// The target package moon.pkg must import this package:\n",
)
out.write_string("// import { \"")
out.write_string(import_package)
out.write_string("\" }\n")
}
out.write_char('\n')
for example in examples {
out.write_string("///|\n")
out.write_string("test \"doc example ")
out.write_string(example.index.to_string())
out.write_string("\" {\n")
if example.code != "" {
let body = trim_end_newlines(strip_doc_markers(example.code))
if body != "" {
out.write_string(body)
out.write_char('\n')
}
}
out.write_string("}\n\n")
}
out.to_string()
}
///|
/// Count runnable MoonBit examples in one Markdown document.
pub fn count_doc_examples(markdown : String) -> Int {
extract_doc_examples(markdown).length()
}