Skip to content

Commit

Permalink
Build multiple books from localizations at once
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Ruin0x11 committed Sep 15, 2021
1 parent 96d9271 commit 8869c2c
Show file tree
Hide file tree
Showing 17 changed files with 573 additions and 98 deletions.
115 changes: 110 additions & 5 deletions src/book/book.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -14,10 +14,30 @@ pub fn load_book<P: AsRef<Path>>(
root_dir: P,
cfg: &Config,
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(
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());

Expand Down Expand Up @@ -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.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BookItem {
Expand Down Expand Up @@ -512,7 +617,7 @@ more text.
Vec::new(),
&cfg,
)
.unwrap();
.unwrap();
assert_eq!(got, should_be);
}

Expand Down
49 changes: 24 additions & 25 deletions src/book/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ 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};

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::*;
Expand All @@ -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,

Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -208,27 +211,33 @@ 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(),
self.config.clone(),
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,
Expand Down Expand Up @@ -257,25 +266,15 @@ 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(),
self.build_opts.clone(),
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.

Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/cmd/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.'",
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/cmd/clean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.'",
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/cmd/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.'",
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/cmd/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/cmd/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.'",
)
}

Expand Down
16 changes: 13 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Language>);
pub struct LanguageConfig(pub HashMap<String, Language>);

/// 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
Expand Down
4 changes: 2 additions & 2 deletions src/preprocess/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading

0 comments on commit 8869c2c

Please sign in to comment.