mod images;
mod nunjucks;
mod openapi;
mod renderer;
mod sitemap;
pub mod svg;
mod template;
use crate::parser::{
self, apply_glossary, parse_front_matter, BookConfig, Glossary, Language, Summary, SummaryItem,
};
use anyhow::{Context, Result};
use regex::Regex;
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::Instant;
static IMPORT_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"<!--\s*@import\s*\(\s*"([^"]+)"\s*\)\s*-->"#).unwrap());
pub use renderer::{
extract_headings, extract_headings_from_asciidoc, render_asciidoc, render_asciidoc_with_path,
render_markdown, render_markdown_with_hardbreaks, render_markdown_with_path, TocItem,
};
pub use template::Templates;
pub fn is_asciidoc_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|s| s.to_str()),
Some("adoc") | Some("asciidoc")
)
}
#[derive(Serialize)]
struct SearchEntry {
title: String,
path: String,
content: String,
}
#[derive(Default)]
struct BuildStats {
pages: usize,
assets: usize,
}
const GITBOOK_CSS: &str = include_str!("../../templates/gitbook.css");
const GITBOOK_JS: &str = include_str!("../../templates/gitbook.js");
const COLLAPSIBLE_JS: &str = include_str!("../../templates/collapsible.js");
const FONTSETTINGS_JS: &str = include_str!("../../templates/fontsettings.js");
const SEARCH_JS: &str = include_str!("../../templates/search.js");
pub fn build(source: &Path, output: &Path) -> Result<()> {
build_with_options(source, output, false)
}
pub fn build_with_options(source: &Path, output: &Path, skip_search_index: bool) -> Result<()> {
let start_time = Instant::now();
let source = source
.canonicalize()
.context("Source directory not found")?;
println!("Loading book configuration...");
let config = BookConfig::load(&source)?;
println!(
" Title: {}",
if config.title.is_empty() {
"(untitled)"
} else {
&config.title
}
);
let languages = parser::langs::parse_langs(&source)?;
if !skip_search_index {
clean_output_dir(output, &source)?;
}
let stats = if languages.is_empty() {
println!("Building single-language book...");
build_single_book(&source, output, &config, skip_search_index, None)?
} else {
println!(
"Building multi-language book with {} languages:",
languages.len()
);
for lang in &languages {
println!(" - {} ({})", lang.title, lang.code);
}
build_multi_lang_book(&source, output, &config, &languages, skip_search_index)?
};
if let Some(openapi_config) = &config.openapi {
openapi::generate_swagger_ui(&source, output, openapi_config)?;
}
let elapsed = start_time.elapsed();
let elapsed_secs = elapsed.as_secs_f64();
println!();
println!(
">> generation finished with success in {:.1}s !",
elapsed_secs
);
println!(
" {} pages built, {} asset files copied",
stats.pages, stats.assets
);
Ok(())
}
fn build_single_book(
source: &Path,
output: &Path,
config: &BookConfig,
skip_search_index: bool,
lang_prefix: Option<&str>,
) -> Result<BuildStats> {
let summary = Summary::parse(source)?;
let templates = Templates::new(config)?;
let mut stats = BuildStats::default();
let glossary = Glossary::load(source)?;
if !glossary.is_empty() {
println!(" Loaded glossary with {} terms", glossary.entries.len());
}
fs::create_dir_all(output)?;
write_static_assets(output, config)?;
stats.assets += copy_assets(source, output)?;
if let Some(style_path) = config.get_website_style() {
let src_style = source.join(style_path);
if src_style.exists() {
let dest_style = output.join("gitbook/style.css");
fs::create_dir_all(dest_style.parent().unwrap())?;
fs::copy(&src_style, &dest_style)?;
}
}
stats.pages += build_chapters(
source,
output,
&summary.items,
config,
&templates,
&summary,
&glossary,
lang_prefix,
)?;
let readme_path = source.join("README.md");
if readme_path.exists() {
let raw_content = read_text_lossy(&readme_path)?;
let parsed = parse_front_matter(&raw_content);
let front_matter = parsed.front_matter;
let imported_content = process_imports_for_file(&parsed.content, &readme_path)?;
let content = nunjucks::process_nunjucks_templates(&imported_content, config)
.unwrap_or_else(|e| {
eprintln!(" Warning: Template error in README.md: {}", e);
imported_content.clone()
});
let html_content = render_markdown_with_hardbreaks(&content, config.hardbreaks);
let html_content = apply_glossary(&html_content, &glossary);
let html_content = sitemap::process_sitemap_directives(&html_content, &summary);
let toc_items = extract_headings(&content);
let page_title = front_matter
.as_ref()
.and_then(|fm| fm.title.as_deref())
.unwrap_or(&config.title);
let page_html = templates.render_page_with_meta(
page_title,
&html_content,
"./",
config,
&summary,
Some("index.html"),
&toc_items,
front_matter.as_ref(),
lang_prefix,
)?;
let page_html = apply_svg_processing(page_html, output, "./", config)?;
fs::write(output.join("index.html"), page_html)?;
stats.pages += 1;
}
if !skip_search_index {
generate_search_index(source, output, &summary, config)?;
}
if config.fetch_remote_images {
println!("Downloading remote images...");
let downloaded = process_remote_images(output)?;
if downloaded > 0 {
println!(" Downloaded {} remote images", downloaded);
}
}
Ok(stats)
}
fn write_static_assets(output: &Path, config: &BookConfig) -> Result<()> {
let gitbook_dir = output.join("gitbook");
fs::create_dir_all(&gitbook_dir)?;
let css_content = format!("{}\n{}", GITBOOK_CSS, sitemap::get_sitemap_css());
fs::write(gitbook_dir.join("gitbook.css"), css_content)?;
fs::write(gitbook_dir.join("gitbook.js"), GITBOOK_JS)?;
if config.is_plugin_enabled("collapsible-chapters") {
fs::write(gitbook_dir.join("collapsible.js"), COLLAPSIBLE_JS)?;
}
if config.is_plugin_enabled("fontsettings") {
fs::write(gitbook_dir.join("fontsettings.js"), FONTSETTINGS_JS)?;
}
fs::write(gitbook_dir.join("search.js"), SEARCH_JS)?;
write_favicon_assets(&gitbook_dir)?;
Ok(())
}
fn build_multi_lang_book(
source: &Path,
output: &Path,
config: &BookConfig,
languages: &[Language],
skip_search_index: bool,
) -> Result<BuildStats> {
let mut stats = BuildStats::default();
fs::create_dir_all(output)?;
generate_lang_index(output, languages, config)?;
for lang in languages {
println!("\nBuilding {} ({})...", lang.title, lang.code);
let lang_source = source.join(&lang.code);
let lang_output = output.join(&lang.code);
let lang_config_path = lang_source.join("book.json");
let lang_config = if lang_config_path.exists() {
BookConfig::load(&lang_source)?
} else {
config.clone()
};
let lang_stats = build_single_book(
&lang_source,
&lang_output,
&lang_config,
skip_search_index,
Some(&lang.code),
)?;
stats.pages += lang_stats.pages;
stats.assets += lang_stats.assets;
}
let assets_dir = source.join("assets");
if assets_dir.exists() {
stats.assets += copy_dir_recursive_count(&assets_dir, &output.join("assets"))?;
}
Ok(stats)
}
#[allow(clippy::too_many_arguments)]
fn build_chapters(
source: &Path,
output: &Path,
items: &[SummaryItem],
config: &BookConfig,
templates: &Templates,
summary: &Summary,
glossary: &Glossary,
lang_prefix: Option<&str>,
) -> Result<usize> {
let mut built_files: std::collections::HashSet<String> = std::collections::HashSet::new();
build_chapters_inner(
source,
output,
items,
config,
templates,
summary,
glossary,
&mut built_files,
lang_prefix,
)
}
#[allow(clippy::too_many_arguments)]
fn build_chapters_inner(
source: &Path,
output: &Path,
items: &[SummaryItem],
config: &BookConfig,
templates: &Templates,
summary: &Summary,
glossary: &Glossary,
built_files: &mut std::collections::HashSet<String>,
lang_prefix: Option<&str>,
) -> Result<usize> {
let mut count = 0;
for item in items {
if let SummaryItem::Link {
title,
path,
children,
} = item
{
if let Some(md_path) = path {
let base_path = if let Some(hash_pos) = md_path.find('#') {
md_path[..hash_pos].trim_start_matches('/')
} else {
md_path.trim_start_matches('/')
};
if base_path.is_empty() || built_files.contains(base_path) {
if !children.is_empty() {
count += build_chapters_inner(
source,
output,
children,
config,
templates,
summary,
glossary,
built_files,
lang_prefix,
)?;
}
continue;
}
let src_file = source.join(base_path);
if src_file.exists() {
built_files.insert(base_path.to_string());
let raw_content = read_text_lossy(&src_file)?;
let parsed = parse_front_matter(&raw_content);
let front_matter = parsed.front_matter;
let is_asciidoc = is_asciidoc_file(&src_file);
let full_path = match lang_prefix {
Some(prefix) => format!("{}/{}", prefix, base_path),
None => base_path.to_string(),
};
let (html_content, toc_items) = if is_asciidoc {
let html = render_asciidoc_with_path(&parsed.content, Some(&full_path));
let toc = extract_headings_from_asciidoc(&parsed.content);
(html, toc)
} else {
let imported_content =
process_imports_for_file(&parsed.content, &src_file)?;
let content =
nunjucks::process_nunjucks_templates(&imported_content, config)
.unwrap_or_else(|e| {
eprintln!(" Warning: Template error in {}: {}", base_path, e);
imported_content.clone()
});
let html = render_markdown_with_path(
&content,
Some(&full_path),
config.hardbreaks,
);
let toc = extract_headings(&content);
(html, toc)
};
let html_content = apply_glossary(&html_content, glossary);
let html_content = sitemap::process_sitemap_directives(&html_content, summary);
let html_path = template::source_path_to_html(base_path);
let dest_file = output.join(&html_path);
let depth = html_path.matches('/').count();
let root_path = if depth > 0 {
"../".repeat(depth)
} else {
"./".to_string()
};
let page_title = front_matter
.as_ref()
.and_then(|fm| fm.title.as_deref())
.unwrap_or(title);
let page_html = templates.render_page_with_meta(
page_title,
&html_content,
&root_path,
config,
summary,
Some(&html_path),
&toc_items,
front_matter.as_ref(),
lang_prefix,
)?;
let page_html = apply_svg_processing(page_html, output, &root_path, config)?;
if let Some(parent) = dest_file.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dest_file, page_html)?;
count += 1;
} else {
println!(" Warning: {} not found", base_path);
}
}
if !children.is_empty() {
count += build_chapters_inner(
source,
output,
children,
config,
templates,
summary,
glossary,
built_files,
lang_prefix,
)?;
}
}
}
Ok(count)
}
fn clean_output_dir(output: &Path, source: &Path) -> Result<()> {
if !output.exists() {
return Ok(());
}
let (Ok(out_canonical), Ok(src_canonical)) = (output.canonicalize(), source.canonicalize())
else {
return Ok(());
};
if src_canonical.starts_with(&out_canonical) {
return Ok(());
}
for entry in fs::read_dir(&out_canonical)? {
let entry = entry?;
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
let path = entry.path();
if entry.file_type()?.is_dir() {
fs::remove_dir_all(&path)?;
} else {
fs::remove_file(&path)?;
}
}
Ok(())
}
fn read_text_lossy(path: &Path) -> Result<String> {
let bytes = fs::read(path)?;
match String::from_utf8(bytes) {
Ok(s) => Ok(s),
Err(e) => {
eprintln!(
" Warning: {} is not valid UTF-8; replacing invalid sequences",
path.display()
);
Ok(String::from_utf8_lossy(e.as_bytes()).into_owned())
}
}
}
fn copy_assets(source: &Path, output: &Path) -> Result<usize> {
let mut count = 0;
let asset_dir_names: &[&str] = &["assets", "images", "image", "img"];
let output_canonical = output.canonicalize().ok();
for dir_name in asset_dir_names {
let src_dir = source.join(dir_name);
if src_dir.exists() {
let dest_dir = output.join(dir_name);
count += copy_dir_recursive_count(&src_dir, &dest_dir)?;
}
}
for entry in walkdir::WalkDir::new(source).into_iter().filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if (e.depth() == 1 && asset_dir_names.contains(&name.as_ref()))
|| name == "_book"
|| name == "node_modules"
{
return false;
}
if e.file_type().is_dir() {
if let (Some(out), Ok(entry_canonical)) =
(output_canonical.as_ref(), e.path().canonicalize())
{
if &entry_canonical == out {
return false;
}
}
}
true
}) {
let entry = entry?;
if entry.file_type().is_dir() {
let name = entry.file_name().to_string_lossy();
if asset_dir_names.contains(&name.as_ref()) {
let relative = entry.path().strip_prefix(source)?;
let dest_dir = output.join(relative);
count += copy_dir_recursive_count(entry.path(), &dest_dir)?;
}
}
}
Ok(count)
}
fn copy_dir_recursive_count(src: &Path, dest: &Path) -> Result<usize> {
fs::create_dir_all(dest)?;
let mut count = 0;
for entry in walkdir::WalkDir::new(src) {
let entry = entry?;
let relative = entry.path().strip_prefix(src)?;
let dest_path = dest.join(relative);
if entry.file_type().is_dir() {
fs::create_dir_all(&dest_path)?;
continue;
}
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
let src_meta = match entry.path().metadata() {
Ok(m) => m,
Err(e) => {
eprintln!(
" Warning: skipping asset {} ({})",
entry.path().display(),
e
);
continue;
}
};
if let Ok(dest_meta) = dest_path.symlink_metadata() {
if dest_meta.file_type().is_symlink() {
fs::remove_file(&dest_path)?;
} else {
let up_to_date = dest_meta.len() == src_meta.len()
&& match (dest_meta.modified(), src_meta.modified()) {
(Ok(d), Ok(s)) => d >= s,
_ => false,
};
if up_to_date {
continue;
}
}
}
if let Err(e) = fs::copy(entry.path(), &dest_path) {
eprintln!(
" Warning: failed to copy asset {}: {}",
entry.path().display(),
e
);
continue;
}
count += 1;
}
Ok(count)
}
fn generate_lang_index(output: &Path, languages: &[Language], config: &BookConfig) -> Result<()> {
let title = if config.title.is_empty() {
"Select Language"
} else {
&config.title
};
let mut lang_links = String::new();
for lang in languages {
lang_links.push_str(&format!(
r#"
<li>
<a href="{}/">{}</a>
</li>
"#,
lang.code, lang.title
));
}
let html = format!(
r#"<!DOCTYPE HTML>
<html lang="" data-guidebook>
<head>
<meta charset="UTF-8">
<title>Choose a language · {}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="description" content="">
<meta name="generator" content="guidebook">
<link rel="stylesheet" href="gitbook/style.css">
<meta name="HandheldFriendly" content="true"/>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="gitbook/images/apple-touch-icon-precomposed-152.png">
<link rel="shortcut icon" href="gitbook/images/favicon.ico" type="image/x-icon">
</head>
<body>
<div class="book-langs-index" role="navigation">
<div class="inner">
<h3>Choose a language</h3>
<ul class="languages">
{}
</ul>
</div>
</div>
</body>
</html>"#,
title, lang_links
);
fs::write(output.join("index.html"), html)?;
copy_gitbook_static_to_root(output)?;
Ok(())
}
fn strip_html_tags(html: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for c in html.chars() {
if c == '<' {
in_tag = true;
} else if c == '>' {
in_tag = false;
} else if !in_tag {
result.push(c);
}
}
result.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn collect_search_entries(
source: &Path,
items: &[SummaryItem],
config: &BookConfig,
seen_paths: &mut HashSet<String>,
entries: &mut Vec<SearchEntry>,
) -> Result<()> {
for item in items {
if let SummaryItem::Link {
title,
path,
children,
} = item
{
if let Some(file_path) = path {
let file_path = file_path.trim_start_matches('/');
let file_only = file_path.split('#').next().unwrap_or(file_path);
let src_file = source.join(file_only);
if src_file.exists() {
let html_path = template::source_path_to_html(file_only);
if seen_paths.insert(html_path.clone()) {
let raw = read_text_lossy(&src_file)?;
let parsed = parse_front_matter(&raw);
let imported = process_imports_for_file(&parsed.content, &src_file)?;
let content = nunjucks::process_nunjucks_templates(&imported, config)
.unwrap_or_else(|_| imported.clone());
let html_content = if is_asciidoc_file(&src_file) {
render_asciidoc(&content)
} else {
render_markdown(&content)
};
let text_content = strip_html_tags(&html_content);
entries.push(SearchEntry {
title: title.clone(),
path: html_path,
content: text_content,
});
}
}
}
if !children.is_empty() {
collect_search_entries(source, children, config, seen_paths, entries)?;
}
}
}
Ok(())
}
fn generate_search_index(
source: &Path,
output: &Path,
summary: &Summary,
config: &BookConfig,
) -> Result<()> {
let mut entries = Vec::new();
let mut seen_paths = HashSet::new();
let readme_path = source.join("README.md");
if readme_path.exists() {
let raw = read_text_lossy(&readme_path)?;
let parsed = parse_front_matter(&raw);
let imported = process_imports_for_file(&parsed.content, &readme_path)?;
let content = nunjucks::process_nunjucks_templates(&imported, config)
.unwrap_or_else(|_| imported.clone());
let html_content = render_markdown(&content);
let text_content = strip_html_tags(&html_content);
entries.push(SearchEntry {
title: "Home".to_string(),
path: "index.html".to_string(),
content: text_content,
});
}
collect_search_entries(
source,
&summary.items,
config,
&mut seen_paths,
&mut entries,
)?;
let json = serde_json::to_string(&entries)?;
fs::write(output.join("search_index.json"), json)?;
Ok(())
}
fn process_remote_images(output: &Path) -> Result<usize> {
use images::ImageDownloader;
let mut downloader = ImageDownloader::new(output);
for entry in walkdir::WalkDir::new(output) {
let entry = entry?;
if entry.file_type().is_file() {
if let Some(ext) = entry.path().extension() {
if ext == "html" {
let html = fs::read_to_string(entry.path())?;
let depth = entry
.path()
.strip_prefix(output)
.map(|rel| rel.components().count().saturating_sub(1))
.unwrap_or(0);
match downloader.process_html(&html, depth) {
Ok(processed_html) => {
if processed_html != html {
fs::write(entry.path(), processed_html)?;
}
}
Err(e) => {
eprintln!(
" Warning: Failed to process {}: {}",
entry.path().display(),
e
);
}
}
}
}
}
}
let (downloaded, _) = downloader.stats();
Ok(downloaded)
}
fn process_imports(
content: &str,
base_path: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<String> {
let mut result = content.to_string();
let mut offset: i64 = 0;
for caps in IMPORT_REGEX.captures_iter(content) {
let full_match = caps.get(0).unwrap();
let import_path = &caps[1];
let resolved_path = base_path.join(import_path);
let canonical_path = match resolved_path.canonicalize() {
Ok(p) => p,
Err(_) => {
eprintln!(
" Warning: @import file not found: {}",
resolved_path.display()
);
continue;
}
};
if visited.contains(&canonical_path) {
eprintln!(
" Warning: Circular @import detected, skipping: {}",
canonical_path.display()
);
continue;
}
let imported_content = match read_text_lossy(&canonical_path) {
Ok(c) => {
c.strip_prefix('\u{FEFF}').unwrap_or(&c).to_string()
}
Err(e) => {
eprintln!(
" Warning: Failed to read @import file {}: {}",
canonical_path.display(),
e
);
continue;
}
};
visited.insert(canonical_path.clone());
let import_base_path = canonical_path.parent().unwrap_or(base_path);
let processed_content = process_imports(&imported_content, import_base_path, visited)?;
visited.remove(&canonical_path);
let start = (full_match.start() as i64 + offset) as usize;
let end = (full_match.end() as i64 + offset) as usize;
result.replace_range(start..end, &processed_content);
offset += processed_content.len() as i64 - (full_match.end() - full_match.start()) as i64;
}
Ok(result)
}
fn process_imports_for_file(content: &str, file_path: &Path) -> Result<String> {
let mut visited = HashSet::new();
if let Ok(canonical) = file_path.canonicalize() {
visited.insert(canonical);
}
let base_path = file_path.parent().unwrap_or(Path::new("."));
process_imports(content, base_path, &mut visited)
}
fn apply_svg_processing(
html: String,
output_dir: &Path,
root_prefix: &str,
config: &BookConfig,
) -> Result<String> {
let mut result = html;
if config.externalize_svg == Some(true) {
result = svg::externalize_inline_svg(&result, output_dir, root_prefix)?;
}
if config.inline_svg == Some(true) {
result = svg::inline_svg_files(&result, output_dir)?;
}
Ok(result)
}
#[cfg(test)]
static VAR_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{\s*book\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}").unwrap());
#[cfg(test)]
static FENCED_CODE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)```[^\n]*\n.*?```").unwrap());
#[cfg(test)]
static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`\n]+`").unwrap());
#[cfg(test)]
fn expand_variables(content: &str, config: &BookConfig) -> String {
if config.variables.is_empty() {
return content.to_string();
}
let protected_regions = find_protected_regions(content);
let mut result = String::new();
let mut last_end = 0;
for caps in VAR_REGEX.captures_iter(content) {
let full_match = caps.get(0).unwrap();
let start = full_match.start();
let end = full_match.end();
let is_protected = protected_regions
.iter()
.any(|(region_start, region_end)| start >= *region_start && end <= *region_end);
result.push_str(&content[last_end..start]);
if is_protected {
result.push_str(&content[start..end]);
} else {
let var_name = &caps[1];
if let Some(value) = config.variables.get(var_name) {
let replacement = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => value.to_string(),
};
result.push_str(&replacement);
} else {
result.push_str(&content[start..end]);
}
}
last_end = end;
}
result.push_str(&content[last_end..]);
result
}
#[cfg(test)]
fn find_protected_regions(content: &str) -> Vec<(usize, usize)> {
let mut regions = Vec::new();
for m in FENCED_CODE_REGEX.find_iter(content) {
regions.push((m.start(), m.end()));
}
for m in INLINE_CODE_REGEX.find_iter(content) {
let overlaps = regions
.iter()
.any(|(start, end)| m.start() >= *start && m.end() <= *end);
if !overlaps {
regions.push((m.start(), m.end()));
}
}
regions
}
fn copy_gitbook_static_to_root(output: &Path) -> Result<()> {
let gitbook_dir = output.join("gitbook");
fs::create_dir_all(&gitbook_dir)?;
let style_css = r#"
.book-langs-index {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.book-langs-index .inner {
text-align: center;
}
.book-langs-index h3 {
color: #333;
font-size: 1.5em;
margin-bottom: 1em;
}
.book-langs-index .languages {
list-style: none;
padding: 0;
margin: 0;
}
.book-langs-index .languages li {
margin: 0.5em 0;
}
.book-langs-index .languages a {
color: #4183c4;
text-decoration: none;
font-size: 1.2em;
}
.book-langs-index .languages a:hover {
text-decoration: underline;
}
"#;
fs::write(gitbook_dir.join("style.css"), style_css)?;
write_favicon_assets(&gitbook_dir)?;
Ok(())
}
fn write_favicon_assets(gitbook_dir: &Path) -> Result<()> {
let images_dir = gitbook_dir.join("images");
fs::create_dir_all(&images_dir)?;
fs::write(
images_dir.join("favicon.ico"),
include_bytes!("../../assets/favicon.ico"),
)?;
fs::write(
images_dir.join("apple-touch-icon-precomposed-152.png"),
include_bytes!("../../assets/apple-touch-icon-precomposed-152.png"),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn create_test_config(variables: HashMap<String, serde_json::Value>) -> BookConfig {
BookConfig {
variables,
..Default::default()
}
}
#[test]
fn test_expand_variables_basic() {
let mut vars = HashMap::new();
vars.insert("version".to_string(), serde_json::json!("1.0.0"));
vars.insert("author".to_string(), serde_json::json!("Guide Inc"));
let config = create_test_config(vars);
let content = "Version: {{ book.version }}\nAuthor: {{ book.author }}";
let result = expand_variables(content, &config);
assert_eq!(result, "Version: 1.0.0\nAuthor: Guide Inc");
}
#[test]
fn test_expand_variables_no_spaces() {
let mut vars = HashMap::new();
vars.insert("version".to_string(), serde_json::json!("2.0.0"));
let config = create_test_config(vars);
let content = "Version: {{book.version}}";
let result = expand_variables(content, &config);
assert_eq!(result, "Version: 2.0.0");
}
#[test]
fn test_expand_variables_with_extra_spaces() {
let mut vars = HashMap::new();
vars.insert("name".to_string(), serde_json::json!("Test"));
let config = create_test_config(vars);
let content = "Name: {{ book.name }}";
let result = expand_variables(content, &config);
assert_eq!(result, "Name: Test");
}
#[test]
fn test_expand_variables_number() {
let mut vars = HashMap::new();
vars.insert("year".to_string(), serde_json::json!(2024));
let config = create_test_config(vars);
let content = "Year: {{ book.year }}";
let result = expand_variables(content, &config);
assert_eq!(result, "Year: 2024");
}
#[test]
fn test_expand_variables_boolean() {
let mut vars = HashMap::new();
vars.insert("published".to_string(), serde_json::json!(true));
let config = create_test_config(vars);
let content = "Published: {{ book.published }}";
let result = expand_variables(content, &config);
assert_eq!(result, "Published: true");
}
#[test]
fn test_expand_variables_unknown_variable() {
let mut vars = HashMap::new();
vars.insert("known".to_string(), serde_json::json!("value"));
let config = create_test_config(vars);
let content = "Known: {{ book.known }}, Unknown: {{ book.unknown }}";
let result = expand_variables(content, &config);
assert_eq!(result, "Known: value, Unknown: {{ book.unknown }}");
}
#[test]
fn test_expand_variables_empty_config() {
let config = create_test_config(HashMap::new());
let content = "No variables: {{ book.test }}";
let result = expand_variables(content, &config);
assert_eq!(result, "No variables: {{ book.test }}");
}
#[test]
fn test_expand_variables_in_markdown() {
let mut vars = HashMap::new();
vars.insert("version".to_string(), serde_json::json!("1.0.0"));
let config = create_test_config(vars);
let content = "# Version {{ book.version }}\n\nThis is version {{ book.version }}.";
let result = expand_variables(content, &config);
assert_eq!(result, "# Version 1.0.0\n\nThis is version 1.0.0.");
}
#[test]
fn test_expand_variables_preserves_code_blocks() {
let mut vars = HashMap::new();
vars.insert("version".to_string(), serde_json::json!("1.0.0"));
let config = create_test_config(vars);
let content = r#"Version: {{ book.version }}
```javascript
// This should not be expanded
const version = "{{ book.version }}";
console.log(version);
```
After code block: {{ book.version }}"#;
let result = expand_variables(content, &config);
assert!(result.contains("Version: 1.0.0"));
assert!(result.contains("After code block: 1.0.0"));
assert!(result.contains(r#"const version = "{{ book.version }}";"#));
}
#[test]
fn test_expand_variables_preserves_inline_code() {
let mut vars = HashMap::new();
vars.insert("var".to_string(), serde_json::json!("value"));
let config = create_test_config(vars);
let content = "Normal: {{ book.var }}, inline: `{{ book.var }}`, after: {{ book.var }}";
let result = expand_variables(content, &config);
assert_eq!(
result,
"Normal: value, inline: `{{ book.var }}`, after: value"
);
}
#[test]
fn test_expand_variables_multiple_code_blocks() {
let mut vars = HashMap::new();
vars.insert("x".to_string(), serde_json::json!("X"));
let config = create_test_config(vars);
let content = r#"{{ book.x }}
```
{{ book.x }}
```
{{ book.x }}
```rust
{{ book.x }}
```
{{ book.x }}"#;
let result = expand_variables(content, &config);
let x_count = result.matches("X").count();
let template_count = result.matches("{{ book.x }}").count();
assert_eq!(x_count, 3);
assert_eq!(template_count, 2);
}
#[test]
fn test_find_protected_regions_fenced_code() {
let content = "text\n```\ncode\n```\nmore text";
let regions = find_protected_regions(content);
assert_eq!(regions.len(), 1);
let (start, end) = regions[0];
assert!(content[start..end].starts_with("```"));
assert!(content[start..end].ends_with("```"));
}
#[test]
fn test_find_protected_regions_inline_code() {
let content = "text `inline` more text";
let regions = find_protected_regions(content);
assert_eq!(regions.len(), 1);
let (start, end) = regions[0];
assert_eq!(&content[start..end], "`inline`");
}
#[test]
fn test_find_protected_regions_multiple() {
let content = "`a` text `b` more\n```\nblock\n```\nend";
let regions = find_protected_regions(content);
assert_eq!(regions.len(), 3);
}
#[test]
fn test_process_imports_regex_pattern() {
let re = Regex::new(r#"<!--\s*@import\s*\(\s*"([^"]+)"\s*\)\s*-->"#).unwrap();
assert!(re.is_match(r#"<!-- @import("file.md") -->"#));
assert!(re.is_match(r#"<!--@import("file.md")-->"#));
assert!(re.is_match(r#"<!-- @import( "file.md" ) -->"#));
assert!(re.is_match(r#"<!-- @import("path/to/file.md") -->"#));
assert!(!re.is_match(r#"@import("file.md")"#)); assert!(!re.is_match(r#"<!-- @import('file.md') -->"#)); }
}