\n",
)
out.write_string(toc)
out.write_string(body)
if options.footer.trim().to_owned() != "" {
out.write_string("\n")
}
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
}
///|
/// Block-level Markdown nodes used by the documentation pipeline.
pub(all) enum MarkdownBlock {
Heading(Int, String)
Paragraph(String)
UnorderedList(Array[String])
BlockQuote(String)
CodeBlock(String, 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("## 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: "#f7f8fb",
surface: "#ffffff",
text: "#172033",
accent: "#2457c5",
border: "#dde3ee",
code_background: "#101828",
code_text: "#eef4ff",
sidebar_width_px: 240,
content_width_px: 820,
}
}
///|
/// 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 mut in_code = false
let mut code_language = ""
let code = StringBuilder()
let mut in_list = false
let list_items : Array[String] = []
let paragraph = StringBuilder()
for raw in markdown.split("\n") {
let line = raw.to_owned()
let trimmed = line.trim().to_owned()
if trimmed.has_prefix("```") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_items, in_list)
in_list = false
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
continue
}
if in_code {
code.write_string(line)
code.write_char('\n')
continue
}
if trimmed == "" {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_items, in_list)
in_list = false
continue
}
if trimmed.has_prefix("# ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_items, in_list)
in_list = false
blocks.push(Heading(1, trimmed[2:].to_owned()))
} else if trimmed.has_prefix("## ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_items, in_list)
in_list = false
blocks.push(Heading(2, trimmed[3:].to_owned()))
} else if trimmed.has_prefix("### ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_items, in_list)
in_list = false
blocks.push(Heading(3, trimmed[4:].to_owned()))
} else if trimmed.has_prefix("- ") {
close_pending_paragraph(blocks, paragraph)
in_list = true
list_items.push(trimmed[2:].to_owned())
} else if trimmed.has_prefix("> ") {
close_pending_paragraph(blocks, paragraph)
close_pending_list(blocks, list_items, in_list)
in_list = false
blocks.push(BlockQuote(trimmed[2:].to_owned()))
} else {
if in_list {
close_pending_list(blocks, list_items, in_list)
in_list = false
}
append_paragraph_line(paragraph, trimmed)
}
}
close_pending_paragraph(blocks, paragraph)
if in_list {
blocks.push(UnorderedList(list_items.copy()))
}
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()
}
}
///|
fn close_pending_list(
blocks : Array[MarkdownBlock],
list_items : Array[String],
is_open : Bool,
) -> Unit {
if is_open {
blocks.push(UnorderedList(list_items.copy()))
list_items.clear()
}
}
///|
/// 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")
}
UnorderedList(items) => {
out.write_string("\n")
for item in items {
out.write_string("- ")
out.write_string(render_inline(item))
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()
}
///|
/// 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_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
}
}
} 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 {
index + marker.length() <= input.length() &&
input[index:index + marker.length()].to_owned() == marker
}
///|
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 toc = render_toc(extract_toc(page.source))
let routes = plan_routes(site)
let out = StringBuilder(size_hint=body.length() + 1024)
out.write_string("\n\n")
out.write_string(
"\n",
)
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(
"Skip to content\n",
)
out.write_string(toc)
out.write_string(body)
if options.footer.trim().to_owned() != "" {
out.write_string("\n")
}
out.write_string(" ")
out.write_string(render_search_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 {
(
#|
#|
)
}
///|
/// 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: "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 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 build = build_site_report(site)
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: build.html_count == metrics.page_count + 1 && build.data_count >= 2,
message: if build.html_count == metrics.page_count + 1 &&
build.data_count >= 2 {
"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)
}
UnorderedList(items) =>
for item in items {
list_item_count = list_item_count + 1
word_count = word_count + count_words(item)
}
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),
}
}
///|
/// 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)
UnorderedList(items) =>
for item in items {
append_search_text(out, item)
}
CodeBlock(_, code) => append_search_text(out, code)
}
}
out.to_string()
}
///|
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 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, 240)
let content_width = positive_or_default(theme.content_width_px, 820)
let main_margin = sidebar_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
}
}
///|
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]) -> String {
if items.length() == 0 {
return ""
}
let out = StringBuilder()
out.write_string(
"On this page\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()
}