Build multiple books from localizations at once

Changes how the `book` module loads books. Now it is possible to load
all of the translations of a book and put them into a single output
folder. If a book is generated this way, a menu will be created in the
handlebars renderer for switching between languages.
This commit is contained in:
Ruin0x11 2020-08-28 00:24:33 -07:00
parent 96d9271d64
commit 8869c2cf06
17 changed files with 573 additions and 98 deletions

View File

@ -1,4 +1,4 @@
use std::collections::VecDeque; use std::collections::{HashMap, VecDeque};
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::{Read, Write}; use std::io::{Read, Write};
@ -14,10 +14,30 @@ pub fn load_book<P: AsRef<Path>>(
root_dir: P, root_dir: P,
cfg: &Config, cfg: &Config,
build_opts: &BuildOpts, build_opts: &BuildOpts,
) -> Result<Book> { ) -> Result<LoadedBook> {
if cfg.language.has_localized_dir_structure() {
match build_opts.language_ident {
// Build a single book's translation.
Some(_) => Ok(LoadedBook::Single(load_single_book_translation(&root_dir, cfg, &build_opts.language_ident)?)),
// Build all available translations at once.
None => {
let mut translations = HashMap::new();
for (lang_ident, _) in cfg.language.0.iter() {
let book = load_single_book_translation(&root_dir, cfg, &Some(lang_ident.clone()))?;
translations.insert(lang_ident.clone(), book);
}
Ok(LoadedBook::Localized(LocalizedBooks(translations)))
}
}
} else {
Ok(LoadedBook::Single(load_single_book_translation(&root_dir, cfg, &None)?))
}
}
fn load_single_book_translation<P: AsRef<Path>>(root_dir: P, cfg: &Config, language_ident: &Option<String>) -> Result<Book> {
let localized_src_dir = root_dir.as_ref().join( let localized_src_dir = root_dir.as_ref().join(
cfg.get_localized_src_path(build_opts.language_ident.as_ref()) cfg.get_localized_src_path(language_ident.as_ref())
.unwrap(), .unwrap(),
); );
let fallback_src_dir = root_dir.as_ref().join(cfg.get_fallback_src_path()); let fallback_src_dir = root_dir.as_ref().join(cfg.get_fallback_src_path());
@ -139,6 +159,91 @@ where
} }
} }
/// A collection of `Books`, each one a single localization.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LocalizedBooks(pub HashMap<String, Book>);
impl LocalizedBooks {
/// Get a depth-first iterator over the items in the book.
pub fn iter(&self) -> BookItems<'_> {
let mut items = VecDeque::new();
for (_, book) in self.0.iter() {
items.extend(book.iter().items);
}
BookItems {
items: items
}
}
/// Recursively apply a closure to each item in the book, allowing you to
/// mutate them.
///
/// # Note
///
/// Unlike the `iter()` method, this requires a closure instead of returning
/// an iterator. This is because using iterators can possibly allow you
/// to have iterator invalidation errors.
pub fn for_each_mut<F>(&mut self, mut func: F)
where
F: FnMut(&mut BookItem),
{
for (_, book) in self.0.iter_mut() {
book.for_each_mut(&mut func);
}
}
}
/// A book which has been loaded and is ready for rendering.
///
/// This exists because the result of loading a book directory can be multiple
/// books, each one representing a separate translation, or a single book with
/// no translations.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LoadedBook {
/// The book was loaded with all translations.
Localized(LocalizedBooks),
/// The book was loaded without any additional translations.
Single(Book),
}
impl LoadedBook {
/// Get a depth-first iterator over the items in the book.
pub fn iter(&self) -> BookItems<'_> {
match self {
LoadedBook::Localized(books) => books.iter(),
LoadedBook::Single(book) => book.iter(),
}
}
/// Recursively apply a closure to each item in the book, allowing you to
/// mutate them.
///
/// # Note
///
/// Unlike the `iter()` method, this requires a closure instead of returning
/// an iterator. This is because using iterators can possibly allow you
/// to have iterator invalidation errors.
pub fn for_each_mut<F>(&mut self, mut func: F)
where
F: FnMut(&mut BookItem),
{
match self {
LoadedBook::Localized(books) => books.for_each_mut(&mut func),
LoadedBook::Single(book) => book.for_each_mut(&mut func),
}
}
/// Returns one of the books loaded. Used for compatibility.
pub fn first(&self) -> &Book {
match self {
LoadedBook::Localized(books) => books.0.iter().next().unwrap().1,
LoadedBook::Single(book) => &book
}
}
}
/// Enum representing any type of item which can be added to a book. /// Enum representing any type of item which can be added to a book.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BookItem { pub enum BookItem {
@ -512,7 +617,7 @@ more text.
Vec::new(), Vec::new(),
&cfg, &cfg,
) )
.unwrap(); .unwrap();
assert_eq!(got, should_be); assert_eq!(got, should_be);
} }

View File

@ -10,7 +10,7 @@ mod book;
mod init; mod init;
mod summary; mod summary;
pub use self::book::{load_book, Book, BookItem, BookItems, Chapter}; pub use self::book::{load_book, BookItem, BookItems, Chapter, Book, LocalizedBooks, LoadedBook};
pub use self::init::BookBuilder; pub use self::init::BookBuilder;
pub use self::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem}; pub use self::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem};
@ -18,7 +18,9 @@ use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::string::ToString; use std::string::ToString;
use std::collections::HashMap;
use tempfile::Builder as TempFileBuilder; use tempfile::Builder as TempFileBuilder;
use tempfile::TempDir;
use toml::Value; use toml::Value;
use crate::errors::*; use crate::errors::*;
@ -37,8 +39,9 @@ pub struct MDBook {
pub root: PathBuf, pub root: PathBuf,
/// The configuration used to tweak now a book is built. /// The configuration used to tweak now a book is built.
pub config: Config, pub config: Config,
/// A representation of the book's contents in memory. /// A representation of the book's contents in memory. Can be a single book,
pub book: Book, /// or multiple books in different languages.
pub book: LoadedBook,
/// Build options passed from frontend. /// Build options passed from frontend.
pub build_opts: BuildOpts, pub build_opts: BuildOpts,
@ -131,7 +134,7 @@ impl MDBook {
); );
let fallback_src_dir = root.join(config.get_fallback_src_path()); let fallback_src_dir = root.join(config.get_fallback_src_path());
let book = let book =
book::load_book_from_disk(&summary, localized_src_dir, fallback_src_dir, &config)?; LoadedBook::Single(book::load_book_from_disk(&summary, localized_src_dir, fallback_src_dir, &config)?);
let renderers = determine_renderers(&config); let renderers = determine_renderers(&config);
let preprocessors = determine_preprocessors(&config)?; let preprocessors = determine_preprocessors(&config)?;
@ -208,7 +211,6 @@ impl MDBook {
/// Run the entire build process for a particular [`Renderer`]. /// Run the entire build process for a particular [`Renderer`].
pub fn execute_build_process(&self, renderer: &dyn Renderer) -> Result<()> { pub fn execute_build_process(&self, renderer: &dyn Renderer) -> Result<()> {
let mut preprocessed_book = self.book.clone();
let preprocess_ctx = PreprocessorContext::new( let preprocess_ctx = PreprocessorContext::new(
self.root.clone(), self.root.clone(),
self.build_opts.clone(), self.build_opts.clone(),
@ -216,19 +218,26 @@ impl MDBook {
renderer.name().to_string(), renderer.name().to_string(),
); );
for preprocessor in &self.preprocessors { let preprocessed_books = match &self.book {
if preprocessor_should_run(&**preprocessor, renderer, &self.config) { LoadedBook::Localized(ref books) => {
debug!("Running the {} preprocessor.", preprocessor.name()); let mut new_books = HashMap::new();
preprocessed_book = preprocessor.run(&preprocess_ctx, preprocessed_book)?;
} for (ident, book) in books.0.iter() {
} let preprocessed_book = self.preprocess(&preprocess_ctx, renderer, book.clone())?;
new_books.insert(ident.clone(), preprocessed_book);
}
LoadedBook::Localized(LocalizedBooks(new_books))
},
LoadedBook::Single(ref book) => LoadedBook::Single(self.preprocess(&preprocess_ctx, renderer, book.clone())?),
};
let name = renderer.name(); let name = renderer.name();
let build_dir = self.build_dir_for(name); let build_dir = self.build_dir_for(name);
let mut render_context = RenderContext::new( let mut render_context = RenderContext::new(
self.root.clone(), self.root.clone(),
preprocessed_book.clone(), preprocessed_books.clone(),
self.build_opts.clone(), self.build_opts.clone(),
self.config.clone(), self.config.clone(),
build_dir, build_dir,
@ -257,16 +266,7 @@ impl MDBook {
self self
} }
/// Run `rustdoc` tests on the book, linking against the provided libraries. fn test_book(&self, book: &Book, temp_dir: &TempDir, library_args: &Vec<&str>) -> Result<()> {
pub fn test(&mut self, library_paths: Vec<&str>) -> Result<()> {
let library_args: Vec<&str> = (0..library_paths.len())
.map(|_| "-L")
.zip(library_paths.into_iter())
.flat_map(|x| vec![x.0, x.1])
.collect();
let temp_dir = TempFileBuilder::new().prefix("mdbook-").tempdir()?;
// FIXME: Is "test" the proper renderer name to use here? // FIXME: Is "test" the proper renderer name to use here?
let preprocess_context = PreprocessorContext::new( let preprocess_context = PreprocessorContext::new(
self.root.clone(), self.root.clone(),
@ -274,8 +274,7 @@ impl MDBook {
self.config.clone(), self.config.clone(),
"test".to_string(), "test".to_string(),
); );
let book = LinkPreprocessor::new().run(&preprocess_context, book.clone())?;
let book = LinkPreprocessor::new().run(&preprocess_context, self.book.clone())?;
// Index Preprocessor is disabled so that chapter paths continue to point to the // Index Preprocessor is disabled so that chapter paths continue to point to the
// actual markdown files. // actual markdown files.
@ -296,7 +295,7 @@ impl MDBook {
tmpf.write_all(ch.content.as_bytes())?; tmpf.write_all(ch.content.as_bytes())?;
let mut cmd = Command::new("rustdoc"); let mut cmd = Command::new("rustdoc");
cmd.arg(&path).arg("--test").args(&library_args); cmd.arg(&path).arg("--test").args(library_args);
if let Some(edition) = self.config.rust.edition { if let Some(edition) = self.config.rust.edition {
match edition { match edition {

View File

@ -20,7 +20,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> {
.arg_from_usage( .arg_from_usage(
"-l, --language=[language] 'Language to render the compiled book in.{n}\ "-l, --language=[language] 'Language to render the compiled book in.{n}\
Only valid if the [languages] table in the config is not empty.{n}\ Only valid if the [languages] table in the config is not empty.{n}\
If omitted, defaults to the language with `default` set to true.'", If omitted, builds all translations and provides a menu in the generated output for switching between them.'",
) )
} }

View File

@ -21,7 +21,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> {
.arg_from_usage( .arg_from_usage(
"-l, --language=[language] 'Language to render the compiled book in.{n}\ "-l, --language=[language] 'Language to render the compiled book in.{n}\
Only valid if the [languages] table in the config is not empty.{n}\ Only valid if the [languages] table in the config is not empty.{n}\
If omitted, defaults to the language with `default` set to true.'", If omitted, builds all translations and provides a menu in the generated output for switching between them.'",
) )
} }

View File

@ -52,7 +52,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> {
.arg_from_usage( .arg_from_usage(
"-l, --language=[language] 'Language to render the compiled book in.{n}\ "-l, --language=[language] 'Language to render the compiled book in.{n}\
Only valid if the [languages] table in the config is not empty.{n}\ Only valid if the [languages] table in the config is not empty.{n}\
If omitted, defaults to the language with `default` set to true.'", If omitted, builds all translations and provides a menu in the generated output for switching between them.'",
) )
} }

View File

@ -27,7 +27,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> {
.help("A comma-separated list of directories to add to {n}the crate search path when building tests")) .help("A comma-separated list of directories to add to {n}the crate search path when building tests"))
.arg_from_usage("-l, --language=[language] 'Language to render the compiled book in.{n}\ .arg_from_usage("-l, --language=[language] 'Language to render the compiled book in.{n}\
Only valid if the [languages] table in the config is not empty.{n}\ Only valid if the [languages] table in the config is not empty.{n}\
If omitted, defaults to the language with `default` set to true.'") If omitted, builds all translations and provides a menu in the generated output for switching between them.'")
} }
// test command implementation // test command implementation

View File

@ -26,7 +26,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> {
.arg_from_usage( .arg_from_usage(
"-l, --language=[language] 'Language to render the compiled book in.{n}\ "-l, --language=[language] 'Language to render the compiled book in.{n}\
Only valid if the [languages] table in the config is not empty.{n}\ Only valid if the [languages] table in the config is not empty.{n}\
If omitted, defaults to the language with `default` set to true.'", If omitted, builds all translations and provides a menu in the generated output for switching between them.'",
) )
} }

View File

@ -776,17 +776,27 @@ impl Default for Search {
/// Configuration for localizations of this book /// Configuration for localizations of this book
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)] #[serde(transparent)]
pub struct LanguageConfig(HashMap<String, Language>); pub struct LanguageConfig(pub HashMap<String, Language>);
/// Configuration for a single localization /// Configuration for a single localization
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case")]
pub struct Language { pub struct Language {
name: String, /// Human-readable name of the language.
default: bool, pub name: String,
/// If true, this language is the default. There can only be one default
/// language in the config.
pub default: bool,
} }
impl LanguageConfig { impl LanguageConfig {
/// If true, mdBook should assume there are subdirectories under src/
/// corresponding to the localizations in the config. If false, src/ is a
/// single directory containing the summary file and the rest.
pub fn has_localized_dir_structure(&self) -> bool {
self.default_language().is_some()
}
/// Returns the default language specified in the config. /// Returns the default language specified in the config.
pub fn default_language(&self) -> Option<&String> { pub fn default_language(&self) -> Option<&String> {
self.0 self.0

View File

@ -199,11 +199,11 @@ mod tests {
); );
let mut buffer = Vec::new(); let mut buffer = Vec::new();
cmd.write_input(&mut buffer, &md.book, &ctx).unwrap(); cmd.write_input(&mut buffer, &md.book.first(), &ctx).unwrap();
let (got_ctx, got_book) = CmdPreprocessor::parse_input(buffer.as_slice()).unwrap(); let (got_ctx, got_book) = CmdPreprocessor::parse_input(buffer.as_slice()).unwrap();
assert_eq!(got_book, md.book); assert_eq!(got_book, *md.book.first());
assert_eq!(got_ctx, ctx); assert_eq!(got_ctx, ctx);
} }
} }

View File

@ -1,4 +1,4 @@
use crate::book::{Book, BookItem}; use crate::book::{Book, BookItem, LoadedBook};
use crate::config::{BookConfig, Config, HtmlConfig, Playground, RustEdition}; use crate::config::{BookConfig, Config, HtmlConfig, Playground, RustEdition};
use crate::errors::*; use crate::errors::*;
use crate::renderer::html_handlebars::helpers; use crate::renderer::html_handlebars::helpers;
@ -24,6 +24,135 @@ impl HtmlHandlebars {
HtmlHandlebars HtmlHandlebars
} }
fn render_books<'a>(
&self,
ctx: &RenderContext,
src_dir: &PathBuf,
html_config: &HtmlConfig,
handlebars: &mut Handlebars<'a>,
theme: &Theme,
) -> Result<()> {
match ctx.book {
LoadedBook::Localized(ref books) => {
for (lang_ident, book) in books.0.iter() {
let localized_src_dir = src_dir.join(lang_ident);
let localized_destination = ctx.destination.join(lang_ident);
let localized_build_dir = ctx.config.build.build_dir.join(lang_ident);
self.render_book(
ctx,
&book,
&localized_src_dir,
&localized_src_dir,
&localized_destination,
&localized_build_dir,
html_config,
handlebars,
theme,
)?;
}
}
LoadedBook::Single(ref book) => {
let extra_file_dir = match &ctx.build_opts.language_ident {
// `src_dir` points to the root source directory, not the
// subdirectory with the translation's index/summary files.
// We have to append the language identifier to prevent the
// files from the other translations from being copied in
// the final step.
Some(lang_ident) => {
let mut path = src_dir.clone();
path.push(lang_ident);
path
},
// `src_dir` is where index.html and the other extra files
// are, so use that.
None => src_dir.clone()
};
self.render_book(ctx, &book, src_dir, &extra_file_dir, &ctx.destination, &ctx.config.build.build_dir, html_config, handlebars, theme)?;
}
}
Ok(())
}
fn render_book<'a>(&self,
ctx: &RenderContext,
book: &Book,
src_dir: &PathBuf,
extra_file_dir: &PathBuf,
destination: &PathBuf,
build_dir: &PathBuf,
html_config: &HtmlConfig,
handlebars: &mut Handlebars<'a>,
theme: &Theme,
) -> Result<()> {
let build_dir = ctx.root.join(build_dir);
let mut data = make_data(&ctx.root, &book, &ctx.book, &ctx.config, &html_config, &theme)?;
// Print version
let mut print_content = String::new();
fs::create_dir_all(&destination)
.with_context(|| "Unexpected error when constructing destination path")?;
let mut is_index = true;
for item in book.iter() {
let ctx = RenderItemContext {
handlebars: &handlebars,
destination: destination.to_path_buf(),
data: data.clone(),
is_index,
html_config: html_config.clone(),
edition: ctx.config.rust.edition,
};
self.render_item(item, ctx, &mut print_content)?;
is_index = false;
}
// Render 404 page
if html_config.input_404 != Some("".to_string()) {
self.render_404(ctx, &html_config, src_dir, destination, handlebars, &mut data)?;
}
// Print version
self.configure_print_version(&mut data, &print_content);
if let Some(ref title) = ctx.config.book.title {
data.insert("title".to_owned(), json!(title));
}
// Render the handlebars template with the data
debug!("Render template");
let rendered = handlebars.render("index", &data)?;
let rendered = self.post_process(rendered, &html_config.playground, ctx.config.rust.edition);
utils::fs::write_file(&destination, "print.html", rendered.as_bytes())?;
debug!("Creating print.html ✓");
debug!("Copy static files");
self.copy_static_files(&destination, &theme, &html_config)
.with_context(|| "Unable to copy across static files")?;
self.copy_additional_css_and_js(&html_config, &ctx.root, &destination)
.with_context(|| "Unable to copy across additional CSS and JS")?;
// Render search index
#[cfg(feature = "search")]
{
let search = html_config.search.clone().unwrap_or_default();
if search.enable {
super::search::create_files(&search, &destination, &book)?;
}
}
self.emit_redirects(&ctx.destination, handlebars, &html_config.redirect)
.context("Unable to emit redirects")?;
// Copy all remaining files, avoid a recursive copy from/to the book build dir
utils::fs::copy_files_except_ext(&extra_file_dir, &destination, true, Some(&build_dir), &["md"])?;
Ok(())
}
fn render_item( fn render_item(
&self, &self,
item: &BookItem, item: &BookItem,
@ -100,7 +229,7 @@ impl HtmlHandlebars {
); );
if let Some(ref section) = ch.number { if let Some(ref section) = ch.number {
ctx.data ctx.data
.insert("section".to_owned(), json!(section.to_string())); .insert("section".to_owned(), json!(section.to_string()));
} }
// Render the handlebars template with the data // Render the handlebars template with the data
@ -131,11 +260,11 @@ impl HtmlHandlebars {
&self, &self,
ctx: &RenderContext, ctx: &RenderContext,
html_config: &HtmlConfig, html_config: &HtmlConfig,
src_dir: &Path, src_dir: &PathBuf,
destination: &PathBuf,
handlebars: &mut Handlebars<'_>, handlebars: &mut Handlebars<'_>,
data: &mut serde_json::Map<String, serde_json::Value>, data: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<()> { ) -> Result<()> {
let destination = &ctx.destination;
let content_404 = if let Some(ref filename) = html_config.input_404 { let content_404 = if let Some(ref filename) = html_config.input_404 {
let path = src_dir.join(filename); let path = src_dir.join(filename);
std::fs::read_to_string(&path) std::fs::read_to_string(&path)
@ -149,7 +278,7 @@ impl HtmlHandlebars {
})? })?
} else { } else {
"# Document not found (404)\n\nThis URL is invalid, sorry. Please use the \ "# Document not found (404)\n\nThis URL is invalid, sorry. Please use the \
navigation bar or search to continue." navigation bar or search to continue."
.to_string() .to_string()
} }
}; };
@ -161,14 +290,15 @@ impl HtmlHandlebars {
} else { } else {
debug!( debug!(
"HTML 'site-url' parameter not set, defaulting to '/'. Please configure \ "HTML 'site-url' parameter not set, defaulting to '/'. Please configure \
this to ensure the 404 page work correctly, especially if your site is hosted in a \ this to ensure the 404 page work correctly, especially if your site is hosted in a \
subdirectory on the HTTP server." subdirectory on the HTTP server."
); );
"/" "/"
}; };
data_404.insert("base_url".to_owned(), json!(base_url)); data_404.insert("base_url".to_owned(), json!(base_url));
// Set a dummy path to ensure other paths (e.g. in the TOC) are generated correctly // Set a dummy path to ensure other paths (e.g. in the TOC) are generated correctly
data_404.insert("path".to_owned(), json!("404.md")); data_404.insert("path".to_owned(), json!("404.md"));
data_404.insert("path_to_root".to_owned(), json!(""));
data_404.insert("content".to_owned(), json!(html_content_404)); data_404.insert("content".to_owned(), json!(html_content_404));
let rendered = handlebars.render("index", &data_404)?; let rendered = handlebars.render("index", &data_404)?;
@ -331,6 +461,7 @@ impl HtmlHandlebars {
handlebars.register_helper("previous", Box::new(helpers::navigation::previous)); handlebars.register_helper("previous", Box::new(helpers::navigation::previous));
handlebars.register_helper("next", Box::new(helpers::navigation::next)); handlebars.register_helper("next", Box::new(helpers::navigation::next));
handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option)); handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option));
handlebars.register_helper("language_option", Box::new(helpers::language::language_option));
} }
/// Copy across any additional CSS and JavaScript files which the book /// Copy across any additional CSS and JavaScript files which the book
@ -437,7 +568,7 @@ impl HtmlHandlebars {
fn maybe_wrong_theme_dir(dir: &Path) -> Result<bool> { fn maybe_wrong_theme_dir(dir: &Path) -> Result<bool> {
fn entry_is_maybe_book_file(entry: fs::DirEntry) -> Result<bool> { fn entry_is_maybe_book_file(entry: fs::DirEntry) -> Result<bool> {
Ok(entry.file_type()?.is_file() Ok(entry.file_type()?.is_file()
&& entry.path().extension().map_or(false, |ext| ext == "md")) && entry.path().extension().map_or(false, |ext| ext == "md"))
} }
if dir.is_dir() { if dir.is_dir() {
@ -462,8 +593,6 @@ impl Renderer for HtmlHandlebars {
let html_config = ctx.config.html_config().unwrap_or_default(); let html_config = ctx.config.html_config().unwrap_or_default();
let src_dir = ctx.source_dir(); let src_dir = ctx.source_dir();
let destination = &ctx.destination; let destination = &ctx.destination;
let book = &ctx.book;
let build_dir = ctx.root.join(&ctx.config.build.build_dir);
if destination.exists() { if destination.exists() {
utils::fs::remove_dir_content(destination) utils::fs::remove_dir_content(destination)
@ -581,6 +710,7 @@ impl Renderer for HtmlHandlebars {
fn make_data( fn make_data(
root: &Path, root: &Path,
book: &Book, book: &Book,
loaded_book: &LoadedBook,
config: &Config, config: &Config,
html_config: &HtmlConfig, html_config: &HtmlConfig,
theme: &Theme, theme: &Theme,
@ -702,6 +832,22 @@ fn make_data(
}; };
data.insert("git_repository_icon".to_owned(), json!(git_repository_icon)); data.insert("git_repository_icon".to_owned(), json!(git_repository_icon));
match loaded_book {
LoadedBook::Localized(books) => {
data.insert("languages_enabled".to_owned(), json!(true));
let mut languages = Vec::new();
for (lang_ident, _) in books.0.iter() {
languages.push(lang_ident.clone());
}
languages.sort();
data.insert("languages".to_owned(), json!(languages));
data.insert("language_config".to_owned(), json!(config.language.clone()));
},
LoadedBook::Single(_) => {
data.insert("languages_enabled".to_owned(), json!(false));
}
}
let mut chapters = vec![]; let mut chapters = vec![];
for item in book.iter() { for item in book.iter() {
@ -866,7 +1012,7 @@ fn add_playground_pre(
"\n# #![allow(unused)]\n{}#fn main() {{\n{}#}}", "\n# #![allow(unused)]\n{}#fn main() {{\n{}#}}",
attrs, code attrs, code
) )
.into() .into()
}; };
hide_lines(&content) hide_lines(&content)
} }
@ -988,20 +1134,20 @@ mod tests {
#[test] #[test]
fn add_playground() { fn add_playground() {
let inputs = [ let inputs = [
("<code class=\"language-rust\">x()</code>", ("<code class=\"language-rust\">x()</code>",
"<pre class=\"playground\"><code class=\"language-rust\">\n<span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"), "<pre class=\"playground\"><code class=\"language-rust\">\n<span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"),
("<code class=\"language-rust\">fn main() {}</code>", ("<code class=\"language-rust\">fn main() {}</code>",
"<pre class=\"playground\"><code class=\"language-rust\">fn main() {}\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust\">fn main() {}\n</code></pre>"),
("<code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code>", ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code>",
"<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";\n</code></pre>"),
("<code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code>", ("<code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code>",
"<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";\n</code></pre>"),
("<code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code>", ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code>",
"<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span><span class=\"boring\">\n</span>\";\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span><span class=\"boring\">\n</span>\";\n</code></pre>"),
("<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>", ("<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>",
"<code class=\"language-rust ignore\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";\n</code>"), "<code class=\"language-rust ignore\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";\n</code>"),
("<code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code>", ("<code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code>",
"<pre class=\"playground\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]\n</code></pre>"),
]; ];
for (src, should_be) in &inputs { for (src, should_be) in &inputs {
let got = add_playground_pre( let got = add_playground_pre(
@ -1018,14 +1164,14 @@ mod tests {
#[test] #[test]
fn add_playground_edition2015() { fn add_playground_edition2015() {
let inputs = [ let inputs = [
("<code class=\"language-rust\">x()</code>", ("<code class=\"language-rust\">x()</code>",
"<pre class=\"playground\"><code class=\"language-rust edition2015\">\n<span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"), "<pre class=\"playground\"><code class=\"language-rust edition2015\">\n<span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"),
("<code class=\"language-rust\">fn main() {}</code>", ("<code class=\"language-rust\">fn main() {}</code>",
"<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"),
("<code class=\"language-rust edition2015\">fn main() {}</code>", ("<code class=\"language-rust edition2015\">fn main() {}</code>",
"<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"),
("<code class=\"language-rust edition2018\">fn main() {}</code>", ("<code class=\"language-rust edition2018\">fn main() {}</code>",
"<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"),
]; ];
for (src, should_be) in &inputs { for (src, should_be) in &inputs {
let got = add_playground_pre( let got = add_playground_pre(
@ -1042,14 +1188,14 @@ mod tests {
#[test] #[test]
fn add_playground_edition2018() { fn add_playground_edition2018() {
let inputs = [ let inputs = [
("<code class=\"language-rust\">x()</code>", ("<code class=\"language-rust\">x()</code>",
"<pre class=\"playground\"><code class=\"language-rust edition2018\">\n<span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"), "<pre class=\"playground\"><code class=\"language-rust edition2018\">\n<span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"),
("<code class=\"language-rust\">fn main() {}</code>", ("<code class=\"language-rust\">fn main() {}</code>",
"<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"),
("<code class=\"language-rust edition2015\">fn main() {}</code>", ("<code class=\"language-rust edition2015\">fn main() {}</code>",
"<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"),
("<code class=\"language-rust edition2018\">fn main() {}</code>", ("<code class=\"language-rust edition2018\">fn main() {}</code>",
"<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"), "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"),
]; ];
for (src, should_be) in &inputs { for (src, should_be) in &inputs {
let got = add_playground_pre( let got = add_playground_pre(

View File

@ -0,0 +1,59 @@
use handlebars::{Context, Handlebars, Helper, Output, RenderContext, RenderError};
use std::path::Path;
use crate::config::LanguageConfig;
pub fn language_option(
h: &Helper<'_, '_>,
_r: &Handlebars<'_>,
ctx: &Context,
rc: &mut RenderContext<'_, '_>,
out: &mut dyn Output,
) -> Result<(), RenderError> {
trace!("language_option (handlebars helper)");
let param = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| {
RenderError::new("Param 0 with String type is required for language_option helper.")
})?;
let languages = rc.evaluate(ctx, "@root/language_config").and_then(|c| {
serde_json::value::from_value::<LanguageConfig>(c.as_json().clone())
.map_err(|_| RenderError::new("Could not decode the JSON data"))
})?;
let current_path = rc
.evaluate(ctx, "@root/path")?
.as_json()
.as_str()
.ok_or_else(|| RenderError::new("Type error for `path`, string expected"))?
.replace("\"", "");
let rendered_path = Path::new(&current_path)
.with_extension("html")
.to_str()
.ok_or_else(|| RenderError::new("Path could not be converted to str"))?
.to_string();
let path_to_root = rc
.evaluate(ctx, "@root/path_to_root")?
.as_json()
.as_str()
.ok_or_else(|| RenderError::new("Type error for `path_to_root`, string expected"))?
.to_string();
let language = languages.0.get(param).ok_or_else(|| {
RenderError::new(format!("Unknown language identifier '{}'", param))
})?;
let mut href = String::new();
href.push_str(&path_to_root);
href.push_str("../");
href.push_str(param);
href.push_str("/");
href.push_str(&rendered_path);
out.write(&format!("<a href=\"{}\"><button role=\"menuitem\" class=\"language\" id=\"light\">", href))?;
out.write(&language.name)?;
out.write("</button></a>")?;
Ok(())
}

View File

@ -1,3 +1,4 @@
pub mod navigation; pub mod navigation;
pub mod theme; pub mod theme;
pub mod toc; pub mod toc;
pub mod language;

View File

@ -1,9 +1,10 @@
use crate::book::BookItem; use crate::book::{BookItem, LoadedBook, Book};
use crate::errors::*; use crate::errors::*;
use crate::renderer::{RenderContext, Renderer}; use crate::renderer::{RenderContext, Renderer};
use crate::utils; use crate::utils;
use std::fs; use std::fs;
use std::path::Path;
#[derive(Default)] #[derive(Default)]
/// A renderer to output the Markdown after the preprocessors have run. Mostly useful /// A renderer to output the Markdown after the preprocessors have run. Mostly useful
@ -31,22 +32,36 @@ impl Renderer for MarkdownRenderer {
.with_context(|| "Unable to remove stale Markdown output")?; .with_context(|| "Unable to remove stale Markdown output")?;
} }
trace!("markdown render"); match book {
for item in book.iter() { LoadedBook::Localized(books) => {
if let BookItem::Chapter(ref ch) = *item { for (lang_ident, book) in books.0.iter() {
if !ch.is_draft_chapter() { let localized_destination = destination.join(lang_ident);
utils::fs::write_file( render_book(&localized_destination, book)?;
&ctx.destination,
&ch.path.as_ref().expect("Checked path exists before"),
ch.content.as_bytes(),
)?;
} }
} }
LoadedBook::Single(book) => render_book(destination, &book)?,
} }
fs::create_dir_all(&destination)
.with_context(|| "Unexpected error when constructing destination path")?;
Ok(()) Ok(())
} }
} }
fn render_book(destination: &Path, book: &Book) -> Result<()> {
fs::create_dir_all(destination)
.with_context(|| "Unexpected error when constructing destination path")?;
trace!("markdown render");
for item in book.iter() {
if let BookItem::Chapter(ref ch) = *item {
if !ch.is_draft_chapter() {
utils::fs::write_file(
destination,
&ch.path.as_ref().expect("Checked path exists before"),
ch.content.as_bytes(),
)?;
}
}
}
Ok(())
}

View File

@ -24,7 +24,7 @@ use std::io::{self, ErrorKind, Read};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use crate::book::Book; use crate::book::LoadedBook;
use crate::build_opts::BuildOpts; use crate::build_opts::BuildOpts;
use crate::config::Config; use crate::config::Config;
use crate::errors::*; use crate::errors::*;
@ -57,8 +57,10 @@ pub struct RenderContext {
pub version: String, pub version: String,
/// The book's root directory. /// The book's root directory.
pub root: PathBuf, pub root: PathBuf,
/// A loaded representation of the book itself. /// A loaded representation of the book itself. This can either be a single
pub book: Book, /// book or a set of localized books, to allow for the renderer to insert
/// its own logic for handling switching between the localizations.
pub book: LoadedBook,
/// The build options passed from the frontend. /// The build options passed from the frontend.
pub build_opts: BuildOpts, pub build_opts: BuildOpts,
/// The loaded configuration file. /// The loaded configuration file.
@ -77,7 +79,7 @@ impl RenderContext {
/// Create a new `RenderContext`. /// Create a new `RenderContext`.
pub fn new<P, Q>( pub fn new<P, Q>(
root: P, root: P,
book: Book, book: LoadedBook,
build_opts: BuildOpts, build_opts: BuildOpts,
config: Config, config: Config,
destination: Q, destination: Q,

View File

@ -424,6 +424,87 @@ function playground_text(playground) {
}); });
})(); })();
(function languages() {
var languageToggleButton = document.getElementById('language-toggle');
var languagePopup = document.getElementById('language-list');
function showLanguages() {
languagePopup.style.display = 'block';
languageToggleButton.setAttribute('aria-expanded', true);
}
function hideLanguages() {
languagePopup.style.display = 'none';
languageToggleButton.setAttribute('aria-expanded', false);
languageToggleButton.focus();
}
function set_language(language) {
console.log("Set language " + language)
}
languageToggleButton.addEventListener('click', function () {
if (languagePopup.style.display === 'block') {
hideLanguages();
} else {
showLanguages();
}
});
languagePopup.addEventListener('click', function (e) {
var language = e.target.id || e.target.parentElement.id;
set_language(language);
});
languagePopup.addEventListener('focusout', function(e) {
// e.relatedTarget is null in Safari and Firefox on macOS (see workaround below)
if (!!e.relatedTarget && !languageToggleButton.contains(e.relatedTarget) && !languagePopup.contains(e.relatedTarget)) {
hideLanguages();
}
});
// Should not be needed, but it works around an issue on macOS & iOS: https://github.com/rust-lang/mdBook/issues/628
document.addEventListener('click', function(e) {
if (languagePopup.style.display === 'block' && !languageToggleButton.contains(e.target) && !languagePopup.contains(e.target)) {
hideLanguages();
}
});
document.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; }
if (!languagePopup.contains(e.target)) { return; }
switch (e.key) {
case 'Escape':
e.preventDefault();
hideLanguages();
break;
case 'ArrowUp':
e.preventDefault();
var li = document.activeElement.parentElement;
if (li && li.previousElementSibling) {
li.previousElementSibling.querySelector('button').focus();
}
break;
case 'ArrowDown':
e.preventDefault();
var li = document.activeElement.parentElement;
if (li && li.nextElementSibling) {
li.nextElementSibling.querySelector('button').focus();
}
break;
case 'Home':
e.preventDefault();
languagePopup.querySelector('li:first-child button').focus();
break;
case 'End':
e.preventDefault();
languagePopup.querySelector('li:last-child button').focus();
break;
}
});
})();
(function sidebar() { (function sidebar() {
var html = document.querySelector("html"); var html = document.querySelector("html");
var sidebar = document.getElementById("sidebar"); var sidebar = document.getElementById("sidebar");

View File

@ -493,3 +493,50 @@ ul#searchresults span.teaser em {
border-top-left-radius: inherit; border-top-left-radius: inherit;
border-top-right-radius: inherit; border-top-right-radius: inherit;
} }
/* Language Menu Popup */
.language-popup {
position: absolute;
left: 150px;
top: var(--menu-bar-height);
z-index: 1000;
border-radius: 4px;
font-size: 0.7em;
color: var(--fg);
background: var(--theme-popup-bg);
border: 1px solid var(--theme-popup-border);
margin: 0;
padding: 0;
list-style: none;
display: none;
}
.language-popup .default {
color: var(--icons);
}
.language-popup .language {
width: 100%;
border: 0;
margin: 0;
padding: 2px 10px;
line-height: 25px;
white-space: nowrap;
text-align: left;
cursor: pointer;
color: inherit;
background: inherit;
font-size: inherit;
}
.language-popup .language:hover {
background-color: var(--theme-hover);
}
.language-popup .language:hover:first-child,
.language-popup .language:hover:last-child {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.language-popup a {
color: var(--fg);
text-decoration: none;
}

View File

@ -133,6 +133,16 @@
<i class="fa fa-search"></i> <i class="fa fa-search"></i>
</button> </button>
{{/if}} {{/if}}
{{#if languages_enabled}}
<button id="language-toggle" class="icon-button" type="button" title="Select language" aria-label="Select language" aria-haspopup="true" aria-expanded="false" aria-controls="language-list">
<i class="fa fa-globe"></i>
</button>
<ul id="language-list" class="language-popup" aria-label="Languages" role="menu">
{{#each languages}}
<li role="none">{{ language_option this }}</li>
{{/each}}
</ul>
{{/if}}
</div> </div>
<h1 class="menu-title">{{ book_title }}</h1> <h1 class="menu-title">{{ book_title }}</h1>