From 8869c2cf065c013500b397b92b06a6bf9b095463 Mon Sep 17 00:00:00 2001 From: Ruin0x11 Date: Fri, 28 Aug 2020 00:24:33 -0700 Subject: [PATCH] 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. --- src/book/book.rs | 115 ++++++++- src/book/mod.rs | 49 ++-- src/cmd/build.rs | 2 +- src/cmd/clean.rs | 2 +- src/cmd/serve.rs | 2 +- src/cmd/test.rs | 2 +- src/cmd/watch.rs | 2 +- src/config.rs | 16 +- src/preprocess/cmd.rs | 4 +- src/renderer/html_handlebars/hbs_renderer.rs | 228 ++++++++++++++---- .../html_handlebars/helpers/language.rs | 59 +++++ src/renderer/html_handlebars/helpers/mod.rs | 1 + src/renderer/markdown_renderer.rs | 41 +++- src/renderer/mod.rs | 10 +- src/theme/book.js | 81 +++++++ src/theme/css/chrome.css | 47 ++++ src/theme/index.hbs | 10 + 17 files changed, 573 insertions(+), 98 deletions(-) create mode 100644 src/renderer/html_handlebars/helpers/language.rs diff --git a/src/book/book.rs b/src/book/book.rs index 325383a0..0491db51 100644 --- a/src/book/book.rs +++ b/src/book/book.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::fmt::{self, Display, Formatter}; use std::fs::{self, File}; use std::io::{Read, Write}; @@ -14,10 +14,30 @@ pub fn load_book>( root_dir: P, cfg: &Config, build_opts: &BuildOpts, -) -> Result { +) -> Result { + 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>(root_dir: P, cfg: &Config, language_ident: &Option) -> Result { let localized_src_dir = root_dir.as_ref().join( - cfg.get_localized_src_path(build_opts.language_ident.as_ref()) - .unwrap(), + cfg.get_localized_src_path(language_ident.as_ref()) + .unwrap(), ); 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); + +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(&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(&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. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum BookItem { @@ -512,7 +617,7 @@ more text. Vec::new(), &cfg, ) - .unwrap(); + .unwrap(); assert_eq!(got, should_be); } diff --git a/src/book/mod.rs b/src/book/mod.rs index 308aa267..69da50e7 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -10,7 +10,7 @@ mod book; mod init; 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::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem}; @@ -18,7 +18,9 @@ use std::io::Write; use std::path::PathBuf; use std::process::Command; use std::string::ToString; +use std::collections::HashMap; use tempfile::Builder as TempFileBuilder; +use tempfile::TempDir; use toml::Value; use crate::errors::*; @@ -37,8 +39,9 @@ pub struct MDBook { pub root: PathBuf, /// The configuration used to tweak now a book is built. pub config: Config, - /// A representation of the book's contents in memory. - pub book: Book, + /// A representation of the book's contents in memory. Can be a single book, + /// or multiple books in different languages. + pub book: LoadedBook, /// Build options passed from frontend. pub build_opts: BuildOpts, @@ -131,7 +134,7 @@ impl MDBook { ); let fallback_src_dir = root.join(config.get_fallback_src_path()); 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 preprocessors = determine_preprocessors(&config)?; @@ -208,7 +211,6 @@ impl MDBook { /// Run the entire build process for a particular [`Renderer`]. pub fn execute_build_process(&self, renderer: &dyn Renderer) -> Result<()> { - let mut preprocessed_book = self.book.clone(); let preprocess_ctx = PreprocessorContext::new( self.root.clone(), self.build_opts.clone(), @@ -216,19 +218,26 @@ impl MDBook { renderer.name().to_string(), ); - for preprocessor in &self.preprocessors { - if preprocessor_should_run(&**preprocessor, renderer, &self.config) { - debug!("Running the {} preprocessor.", preprocessor.name()); - preprocessed_book = preprocessor.run(&preprocess_ctx, preprocessed_book)?; - } - } + let preprocessed_books = match &self.book { + LoadedBook::Localized(ref books) => { + let mut new_books = HashMap::new(); + + 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 build_dir = self.build_dir_for(name); let mut render_context = RenderContext::new( self.root.clone(), - preprocessed_book.clone(), + preprocessed_books.clone(), self.build_opts.clone(), self.config.clone(), build_dir, @@ -257,16 +266,7 @@ impl MDBook { self } - /// Run `rustdoc` tests on the book, linking against the provided libraries. - 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()?; - + fn test_book(&self, book: &Book, temp_dir: &TempDir, library_args: &Vec<&str>) -> Result<()> { // FIXME: Is "test" the proper renderer name to use here? let preprocess_context = PreprocessorContext::new( self.root.clone(), @@ -274,8 +274,7 @@ impl MDBook { self.config.clone(), "test".to_string(), ); - - let book = LinkPreprocessor::new().run(&preprocess_context, self.book.clone())?; + let book = LinkPreprocessor::new().run(&preprocess_context, book.clone())?; // Index Preprocessor is disabled so that chapter paths continue to point to the // actual markdown files. @@ -296,7 +295,7 @@ impl MDBook { tmpf.write_all(ch.content.as_bytes())?; 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 { match edition { diff --git a/src/cmd/build.rs b/src/cmd/build.rs index 90e8a88a..29ddacc9 100644 --- a/src/cmd/build.rs +++ b/src/cmd/build.rs @@ -20,7 +20,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { .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}\ - 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.'", ) } diff --git a/src/cmd/clean.rs b/src/cmd/clean.rs index ae736c79..98fdb505 100644 --- a/src/cmd/clean.rs +++ b/src/cmd/clean.rs @@ -21,7 +21,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { .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}\ - 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.'", ) } diff --git a/src/cmd/serve.rs b/src/cmd/serve.rs index 1ea3214d..ff2fc480 100644 --- a/src/cmd/serve.rs +++ b/src/cmd/serve.rs @@ -52,7 +52,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { .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}\ - 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.'", ) } diff --git a/src/cmd/test.rs b/src/cmd/test.rs index 0a7c7f57..0295547f 100644 --- a/src/cmd/test.rs +++ b/src/cmd/test.rs @@ -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")) .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}\ - 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 diff --git a/src/cmd/watch.rs b/src/cmd/watch.rs index 5a724b57..d7841311 100644 --- a/src/cmd/watch.rs +++ b/src/cmd/watch.rs @@ -26,7 +26,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { .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}\ - 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.'", ) } diff --git a/src/config.rs b/src/config.rs index c13fab3b..68875c1d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -776,17 +776,27 @@ impl Default for Search { /// Configuration for localizations of this book #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[serde(transparent)] -pub struct LanguageConfig(HashMap); +pub struct LanguageConfig(pub HashMap); /// Configuration for a single localization #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case")] pub struct Language { - name: String, - default: bool, + /// Human-readable name of the language. + 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 { + /// 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. pub fn default_language(&self) -> Option<&String> { self.0 diff --git a/src/preprocess/cmd.rs b/src/preprocess/cmd.rs index b84af3f6..0710bc61 100644 --- a/src/preprocess/cmd.rs +++ b/src/preprocess/cmd.rs @@ -199,11 +199,11 @@ mod tests { ); 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(); - assert_eq!(got_book, md.book); + assert_eq!(got_book, *md.book.first()); assert_eq!(got_ctx, ctx); } } diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 5263b25c..501d3a88 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -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::errors::*; use crate::renderer::html_handlebars::helpers; @@ -24,6 +24,135 @@ impl 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( &self, item: &BookItem, @@ -100,7 +229,7 @@ impl HtmlHandlebars { ); if let Some(ref section) = ch.number { 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 @@ -131,11 +260,11 @@ impl HtmlHandlebars { &self, ctx: &RenderContext, html_config: &HtmlConfig, - src_dir: &Path, + src_dir: &PathBuf, + destination: &PathBuf, handlebars: &mut Handlebars<'_>, data: &mut serde_json::Map, ) -> Result<()> { - let destination = &ctx.destination; let content_404 = if let Some(ref filename) = html_config.input_404 { let path = src_dir.join(filename); std::fs::read_to_string(&path) @@ -149,7 +278,7 @@ impl HtmlHandlebars { })? } else { "# 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() } }; @@ -161,14 +290,15 @@ impl HtmlHandlebars { } else { debug!( "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 \ - subdirectory on the HTTP server." + this to ensure the 404 page work correctly, especially if your site is hosted in a \ + subdirectory on the HTTP server." ); "/" }; 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 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)); 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("next", Box::new(helpers::navigation::next)); 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 @@ -437,7 +568,7 @@ impl HtmlHandlebars { fn maybe_wrong_theme_dir(dir: &Path) -> Result { fn entry_is_maybe_book_file(entry: fs::DirEntry) -> Result { 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() { @@ -462,8 +593,6 @@ impl Renderer for HtmlHandlebars { let html_config = ctx.config.html_config().unwrap_or_default(); let src_dir = ctx.source_dir(); let destination = &ctx.destination; - let book = &ctx.book; - let build_dir = ctx.root.join(&ctx.config.build.build_dir); if destination.exists() { utils::fs::remove_dir_content(destination) @@ -581,6 +710,7 @@ impl Renderer for HtmlHandlebars { fn make_data( root: &Path, book: &Book, + loaded_book: &LoadedBook, config: &Config, html_config: &HtmlConfig, theme: &Theme, @@ -702,6 +832,22 @@ fn make_data( }; 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![]; for item in book.iter() { @@ -866,7 +1012,7 @@ fn add_playground_pre( "\n# #![allow(unused)]\n{}#fn main() {{\n{}#}}", attrs, code ) - .into() + .into() }; hide_lines(&content) } @@ -988,20 +1134,20 @@ mod tests { #[test] fn add_playground() { let inputs = [ - ("x()", - "
\n#![allow(unused)]\nfn main() {\nx()\n}\n
"), - ("fn main() {}", - "
fn main() {}\n
"), - ("let s = \"foo\n # bar\n\";", - "
let s = \"foo\n bar\n\";\n
"), - ("let s = \"foo\n ## bar\n\";", - "
let s = \"foo\n # bar\n\";\n
"), - ("let s = \"foo\n # bar\n#\n\";", - "
let s = \"foo\n bar\n\n\";\n
"), - ("let s = \"foo\n # bar\n\";", - "let s = \"foo\n bar\n\";\n"), - ("#![no_std]\nlet s = \"foo\";\n #[some_attr]", - "
#![no_std]\nlet s = \"foo\";\n #[some_attr]\n
"), + ("x()", + "
\n#![allow(unused)]\nfn main() {\nx()\n}\n
"), + ("fn main() {}", + "
fn main() {}\n
"), + ("let s = \"foo\n # bar\n\";", + "
let s = \"foo\n bar\n\";\n
"), + ("let s = \"foo\n ## bar\n\";", + "
let s = \"foo\n # bar\n\";\n
"), + ("let s = \"foo\n # bar\n#\n\";", + "
let s = \"foo\n bar\n\n\";\n
"), + ("let s = \"foo\n # bar\n\";", + "let s = \"foo\n bar\n\";\n"), + ("#![no_std]\nlet s = \"foo\";\n #[some_attr]", + "
#![no_std]\nlet s = \"foo\";\n #[some_attr]\n
"), ]; for (src, should_be) in &inputs { let got = add_playground_pre( @@ -1018,14 +1164,14 @@ mod tests { #[test] fn add_playground_edition2015() { let inputs = [ - ("x()", - "
\n#![allow(unused)]\nfn main() {\nx()\n}\n
"), - ("fn main() {}", - "
fn main() {}\n
"), - ("fn main() {}", - "
fn main() {}\n
"), - ("fn main() {}", - "
fn main() {}\n
"), + ("x()", + "
\n#![allow(unused)]\nfn main() {\nx()\n}\n
"), + ("fn main() {}", + "
fn main() {}\n
"), + ("fn main() {}", + "
fn main() {}\n
"), + ("fn main() {}", + "
fn main() {}\n
"), ]; for (src, should_be) in &inputs { let got = add_playground_pre( @@ -1042,14 +1188,14 @@ mod tests { #[test] fn add_playground_edition2018() { let inputs = [ - ("x()", - "
\n#![allow(unused)]\nfn main() {\nx()\n}\n
"), - ("fn main() {}", - "
fn main() {}\n
"), - ("fn main() {}", - "
fn main() {}\n
"), - ("fn main() {}", - "
fn main() {}\n
"), + ("x()", + "
\n#![allow(unused)]\nfn main() {\nx()\n}\n
"), + ("fn main() {}", + "
fn main() {}\n
"), + ("fn main() {}", + "
fn main() {}\n
"), + ("fn main() {}", + "
fn main() {}\n
"), ]; for (src, should_be) in &inputs { let got = add_playground_pre( diff --git a/src/renderer/html_handlebars/helpers/language.rs b/src/renderer/html_handlebars/helpers/language.rs new file mode 100644 index 00000000..27654de4 --- /dev/null +++ b/src/renderer/html_handlebars/helpers/language.rs @@ -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::(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(¤t_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!("")?; + + Ok(()) +} diff --git a/src/renderer/html_handlebars/helpers/mod.rs b/src/renderer/html_handlebars/helpers/mod.rs index 52be6d20..8a3421ea 100644 --- a/src/renderer/html_handlebars/helpers/mod.rs +++ b/src/renderer/html_handlebars/helpers/mod.rs @@ -1,3 +1,4 @@ pub mod navigation; pub mod theme; pub mod toc; +pub mod language; diff --git a/src/renderer/markdown_renderer.rs b/src/renderer/markdown_renderer.rs index bd5def1f..3e49a9cf 100644 --- a/src/renderer/markdown_renderer.rs +++ b/src/renderer/markdown_renderer.rs @@ -1,9 +1,10 @@ -use crate::book::BookItem; +use crate::book::{BookItem, LoadedBook, Book}; use crate::errors::*; use crate::renderer::{RenderContext, Renderer}; use crate::utils; use std::fs; +use std::path::Path; #[derive(Default)] /// 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")?; } - trace!("markdown render"); - for item in book.iter() { - if let BookItem::Chapter(ref ch) = *item { - if !ch.is_draft_chapter() { - utils::fs::write_file( - &ctx.destination, - &ch.path.as_ref().expect("Checked path exists before"), - ch.content.as_bytes(), - )?; + match book { + LoadedBook::Localized(books) => { + for (lang_ident, book) in books.0.iter() { + let localized_destination = destination.join(lang_ident); + render_book(&localized_destination, book)?; } } + LoadedBook::Single(book) => render_book(destination, &book)?, } - fs::create_dir_all(&destination) - .with_context(|| "Unexpected error when constructing destination path")?; - 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(()) +} diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 78cca280..8922c2f8 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -24,7 +24,7 @@ use std::io::{self, ErrorKind, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use crate::book::Book; +use crate::book::LoadedBook; use crate::build_opts::BuildOpts; use crate::config::Config; use crate::errors::*; @@ -57,8 +57,10 @@ pub struct RenderContext { pub version: String, /// The book's root directory. pub root: PathBuf, - /// A loaded representation of the book itself. - pub book: Book, + /// A loaded representation of the book itself. This can either be a single + /// 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. pub build_opts: BuildOpts, /// The loaded configuration file. @@ -77,7 +79,7 @@ impl RenderContext { /// Create a new `RenderContext`. pub fn new( root: P, - book: Book, + book: LoadedBook, build_opts: BuildOpts, config: Config, destination: Q, diff --git a/src/theme/book.js b/src/theme/book.js index 79d40354..12785740 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -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() { var html = document.querySelector("html"); var sidebar = document.getElementById("sidebar"); diff --git a/src/theme/css/chrome.css b/src/theme/css/chrome.css index 21c08b93..0ed18a02 100644 --- a/src/theme/css/chrome.css +++ b/src/theme/css/chrome.css @@ -493,3 +493,50 @@ ul#searchresults span.teaser em { border-top-left-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; +} diff --git a/src/theme/index.hbs b/src/theme/index.hbs index 966eedbc..ff278927 100644 --- a/src/theme/index.hbs +++ b/src/theme/index.hbs @@ -133,6 +133,16 @@ {{/if}} + {{#if languages_enabled}} + + + {{/if}}

{{ book_title }}