mod renderer;
mod template;
use crate::parser::{self, BookConfig, Language, Summary, SummaryItem};
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use std::time::Instant;
pub use renderer::{render_markdown, render_markdown_with_path};
pub use template::Templates;
#[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");
pub fn build(source: &Path, output: &Path) -> 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)?;
let stats = if languages.is_empty() {
println!("Building single-language book...");
build_single_book(&source, output, &config)?
} 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)?
};
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) -> Result<BuildStats> {
let summary = Summary::parse(source)?;
let templates = Templates::new(config)?;
let mut stats = BuildStats::default();
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)?;
let readme_path = source.join("README.md");
if readme_path.exists() {
let content = fs::read_to_string(&readme_path)?;
let html_content = render_markdown(&content);
let page_html = templates.render_page(
&config.title,
&html_content,
"./",
config,
&summary,
Some("index.html"),
)?;
fs::write(output.join("index.html"), page_html)?;
stats.pages += 1;
}
Ok(stats)
}
fn write_static_assets(output: &Path, config: &BookConfig) -> Result<()> {
let gitbook_dir = output.join("gitbook");
fs::create_dir_all(&gitbook_dir)?;
fs::write(gitbook_dir.join("gitbook.css"), GITBOOK_CSS)?;
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)?;
}
Ok(())
}
fn build_multi_lang_book(
source: &Path,
output: &Path,
config: &BookConfig,
languages: &[Language],
) -> 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)?;
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)
}
fn build_chapters(
source: &Path,
output: &Path,
items: &[SummaryItem],
config: &BookConfig,
templates: &Templates,
summary: &Summary,
) -> 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 src_file = source.join(md_path);
if src_file.exists() {
let content = fs::read_to_string(&src_file)?;
let html_content = render_markdown_with_path(&content, Some(md_path));
let html_path = md_path.replace(".md", ".html");
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_html = templates.render_page(
title,
&html_content,
&root_path,
config,
summary,
Some(&html_path),
)?;
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", md_path);
}
}
if !children.is_empty() {
count += build_chapters(source, output, children, config, templates, summary)?;
}
}
}
Ok(count)
}
fn copy_assets(source: &Path, output: &Path) -> Result<usize> {
let mut count = 0;
for dir_name in &["assets", "images", "img"] {
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)?;
}
}
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)?;
} else {
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &dest_path)?;
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="" >
<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 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)?;
let images_dir = gitbook_dir.join("images");
fs::create_dir_all(&images_dir)?;
Ok(())
}