///|
fn json_escape(source : String) -> String {
let out = StringBuilder::new(size_hint=source.length())
for i in 0.. out.write_string("\\\"")
'\\' => out.write_string("\\\\")
'\n' => out.write_string("\\n")
'\r' => out.write_string("\\r")
'\t' => out.write_string("\\t")
ch => out.write_char(ch)
}
}
out.to_string()
}
///|
fn find_source(sources : Array[SourceFile], path : String) -> String? {
for source in sources {
if source.path == path {
return Some(source.content)
}
}
None
}
///|
fn relative_root(path : String) -> String {
let mut depth = 0
for i in 0.. String {
let out = StringBuilder::new()
out.write_string("\{escape_html(title)} ",
)
for item in items {
match item {
Chapter(chapter) => {
let active_class = if chapter.output_path == active {
" class=\"active\""
} else {
""
}
out.write_string(
"\{escape_html(chapter.number)} \{escape_html(chapter.title)} ",
)
}
PartTitle(part) =>
out.write_string("\{escape_html(part)} ")
Separator => out.write_string(" ")
}
}
out.write_string(" ")
out.to_string()
}
///|
fn extra_links(paths : Array[String], root : String, kind : String) -> String {
let out = StringBuilder::new()
for path in paths {
if kind == "css" {
out.write_string(
" ",
)
} else {
out.write_string("")
}
}
out.to_string()
}
///|
fn author_text(authors : Array[String]) -> String {
let out = StringBuilder::new()
for index, author in authors {
if index > 0 {
out.write_string("、")
}
out.write_string(author)
}
out.to_string()
}
///|
fn render_page_toc(sections : Array[Section]) -> String {
if sections.is_empty() {
return ""
}
let out = StringBuilder::new()
out.write_string(
"本页目录 ",
)
for section in sections {
out.write_string(
"\{escape_html(section.title)} ",
)
}
out.write_string(" ")
out.to_string()
}
///|
fn apply_page_template(
template : String,
language : String,
page_title : String,
book_title : String,
head : String,
sidebar : String,
breadcrumbs : String,
toc : String,
content : String,
navigation : String,
authors : String,
scripts : String,
) -> String {
template
.replace(old="{{language}}", new=language)
.replace(old="{{page_title}}", new=page_title)
.replace(old="{{book_title}}", new=book_title)
.replace(old="{{head}}", new=head)
.replace(old="{{sidebar}}", new=sidebar)
.replace(old="{{breadcrumbs}}", new=breadcrumbs)
.replace(old="{{toc}}", new=toc)
.replace(old="{{content}}", new=content)
.replace(old="{{navigation}}", new=navigation)
.replace(old="{{authors}}", new=authors)
.replace(old="{{scripts}}", new=scripts)
}
///|
pub let default_page_template : String = "{{page_title}} · {{book_title}} {{head}}{{breadcrumbs}}
{{toc}}{{content}} {{authors}}
{{navigation}} {{scripts}}"
///|
fn render_page(
config : BookConfig,
items : Array[SummaryItem],
chapters : Array[Chapter],
index : Int,
body : String,
source : String,
template : String?,
) -> String {
let chapter = chapters[index]
let root = relative_root(chapter.output_path)
let previous = if index > 0 {
"← \{escape_html(chapters[index - 1].title)} "
} else {
" "
}
let next = if index + 1 < chapters.length() {
"\{escape_html(chapters[index + 1].title)} → "
} else {
" "
}
let linked_body = body
.replace(old=".md\"", new=".html\"")
.replace(old=".md#", new=".html#")
let authors = author_text(config.authors)
let author_html = if authors.is_empty() {
""
} else {
"作者:\{escape_html(authors)}"
}
apply_page_template(
template.unwrap_or(default_page_template),
escape_html(config.language),
escape_html(chapter.title),
escape_html(config.title),
" \{extra_links(config.additional_css, root, "css")}",
render_sidebar(config.title, items, chapter.output_path, root),
"\{escape_html(config.title)} / \{escape_html(chapter.title)}",
render_page_toc(extract_sections(source)),
linked_body,
previous + next,
author_html,
"\{extra_links(config.additional_js, root, "js")}",
)
}
///|
fn markdown_links(source : String) -> Array[String] {
let links : Array[String] = []
let mut in_code = false
for raw_line in source.split("\n") {
let line = raw_line.to_owned()
if line.trim().has_prefix("```") {
in_code = !in_code
continue
}
if in_code {
continue
}
let mut pos = 0
while pos < line.length() {
let middle = find_text(line, "](", pos)
if middle < 0 {
break
}
let close = find_text(line, ")", middle + 2)
if close < 0 {
break
}
links.push(line[middle + 2:close].to_owned())
pos = close + 1
}
}
links
}
///|
fn target_parts(link : String) -> (String, String) {
let hash = find_text(link, "#", 0)
if hash < 0 {
(link, "")
} else {
(link[0:hash].to_owned(), link[hash + 1:].to_owned())
}
}
///|
fn has_heading_anchor(source : String, anchor : String) -> Bool {
for raw_line in source.split("\n") {
let line = raw_line.trim().to_owned()
if line.has_prefix("#") {
let mut level = 0
while level < line.length() &&
line.code_unit_at(level).to_int() == '#'.to_int() {
level += 1
}
if slugify(line[level:].trim().to_owned()) == anchor {
return true
}
}
}
false
}
///|
fn normalize_link(source_path : String, target : String) -> String {
let slash = find_last_char(source_path, '/')
let mut base = if slash >= 0 {
source_path[0:slash + 1].to_owned()
} else {
""
}
let mut rest = target
while rest.has_prefix("../") {
rest = rest[3:].to_owned()
let clean_base = base.trim_end(chars="/").to_owned()
let parent = find_last_char(clean_base, '/')
base = if parent >= 0 { clean_base[0:parent + 1].to_owned() } else { "" }
}
base + rest.trim_start(chars="./").to_owned()
}
///|
/// Return local non-Markdown resources referenced by a chapter.
pub fn referenced_assets(
source_path : String,
content : String,
) -> Array[String] {
let assets : Array[String] = []
for link in markdown_links(content) {
let (target, _) = target_parts(link)
if target.is_empty() ||
target.has_suffix(".md") ||
target.has_prefix("http://") ||
target.has_prefix("https://") ||
target.has_prefix("mailto:") ||
target.has_prefix("data:") {
continue
}
if target.has_prefix("/") {
assets.push(target)
} else {
assets.push(normalize_link(source_path, target))
}
}
assets
}
///|
/// Validate internal Markdown links against the declared book sources.
pub fn check_links(
chapters : Array[Chapter],
sources : Array[SourceFile],
) -> Array[Diagnostic] {
let known : Map[String, Bool] = Map([], capacity=chapters.length())
for chapter in chapters {
known[chapter.source_path] = true
}
let diagnostics : Array[Diagnostic] = []
for chapter in chapters {
match find_source(sources, chapter.source_path) {
None =>
diagnostics.push({
severity: "error",
path: chapter.source_path,
message: "SUMMARY.md 引用了不存在的章节",
})
Some(content) =>
for link in markdown_links(content) {
let (target, anchor) = target_parts(link)
if target.has_prefix("http://") ||
target.has_prefix("https://") ||
target.has_prefix("mailto:") {
continue
}
if target.is_empty() {
if !anchor.is_empty() && !has_heading_anchor(content, anchor) {
diagnostics.push({
severity: "error",
path: chapter.source_path,
message: "当前章节锚点不存在:#\{anchor}",
})
}
} else if target.has_suffix(".md") {
let normalized = normalize_link(chapter.source_path, target)
if !known.contains(normalized) {
diagnostics.push({
severity: "error",
path: chapter.source_path,
message: "内部章节链接不存在:\{target}",
})
} else if !anchor.is_empty() {
match find_source(sources, normalized) {
Some(target_content) if !has_heading_anchor(
target_content, anchor,
) =>
diagnostics.push({
severity: "error",
path: chapter.source_path,
message: "章节锚点不存在:\{link}",
})
_ => ()
}
}
}
}
}
}
diagnostics
}
///|
fn search_index(
chapters : Array[Chapter],
sources : Array[SourceFile],
) -> String {
let out = StringBuilder::new()
out.write_char('[')
for i, chapter in chapters {
if i > 0 {
out.write_char(',')
}
let text = match find_source(sources, chapter.source_path) {
Some(content) => plain_text(content)
None => ""
}
out.write_string(
"{\"title\":\"\{json_escape(chapter.title)}\",\"path\":\"\{json_escape(chapter.output_path)}\",\"body\":\"\{json_escape(text)}\"}",
)
}
out.write_char(']')
out.to_string()
}
///|
pub fn generate_search_index(
chapters : Array[Chapter],
sources : Array[SourceFile],
) -> String {
search_index(chapters, sources)
}
///|
fn needs_generation(
context : BuildContext,
path : String,
signature : String,
) -> Bool {
if context.force {
return true
}
match context.previous_hashes.get(path) {
Some(previous) => previous != signature
None => true
}
}
///|
/// Build a static book and skip rendering outputs whose input signature is unchanged.
pub fn build_site_with_context(
config_text : String,
summary_text : String,
sources : Array[SourceFile],
context : BuildContext,
) -> Result[BuildOutput, MoonBookError] {
let config = parse_config(config_text)
match parse_summary(summary_text) {
Err(error) => Err(error)
Ok(items) => {
let chapters = summary_chapters(items)
let diagnostics = check_links(chapters, sources)
for diagnostic in validate_config(config_text) {
diagnostics.push(diagnostic)
}
let css = match theme_css(config.theme) {
Some(value) => value
None => {
diagnostics.push({
severity: "error",
path: "moonbook.toml",
message: "未知主题:\{config.theme},可用主题为 light、rust、coal",
})
default_theme_css
}
}
let files : Array[GeneratedFile] = []
let hashes : Map[String, String] = Map([], capacity=chapters.length() + 3)
let mut skipped = 0
let css_hash = content_hash(css)
hashes["moonbook.css"] = css_hash
if needs_generation(context, "moonbook.css", css_hash) {
files.push({ path: "moonbook.css", content: css })
} else {
skipped += 1
}
let js_hash = content_hash(default_theme_js)
hashes["moonbook.js"] = js_hash
if needs_generation(context, "moonbook.js", js_hash) {
files.push({ path: "moonbook.js", content: default_theme_js })
} else {
skipped += 1
}
for i, chapter in chapters {
match find_source(sources, chapter.source_path) {
Some(content) => {
let signature = content_hash(
config_text +
"\n" +
summary_text +
"\n" +
content +
"\n" +
context.template.unwrap_or(""),
)
hashes[chapter.output_path] = signature
if needs_generation(context, chapter.output_path, signature) {
files.push({
path: chapter.output_path,
content: render_page(
config,
items,
chapters,
i,
render_markdown(content),
content,
context.template,
),
})
} else {
skipped += 1
}
}
None => ()
}
}
if config.search {
let index = search_index(chapters, sources)
let index_hash = content_hash(index)
hashes["search-index.json"] = index_hash
if needs_generation(context, "search-index.json", index_hash) {
files.push({ path: "search-index.json", content: index })
} else {
skipped += 1
}
}
Ok({ files, diagnostics, skipped, manifest: render_manifest(hashes) })
}
}
}
///|
/// Build a complete static book from in-memory inputs.
pub fn build_site(
config_text : String,
summary_text : String,
sources : Array[SourceFile],
) -> Result[BuildOutput, MoonBookError] {
build_site_with_context(
config_text,
summary_text,
sources,
BuildContext::default(),
)
}