From c72ce182d55f39c1e1a0db96b8abe3cf4ee51fe8 Mon Sep 17 00:00:00 2001 From: Ruin0x11 Date: Fri, 28 Aug 2020 14:50:04 -0700 Subject: [PATCH] Rewrite links in Markdown to point to fallback if missing in translation It will follow relative links to other pages and embedded images. --- src/cmd/serve.rs | 9 +- src/cmd/watch.rs | 4 +- src/renderer/html_handlebars/hbs_renderer.rs | 48 ++- src/utils/mod.rs | 294 +++++++++++++----- tests/localized_book/src/en/SUMMARY.md | 4 +- tests/localized_book/src/en/chapter/3.md | 1 + .../src/en/inline-link-fallbacks.md | 7 + .../src/en/missing-summary-chapter.md | 3 + tests/localized_book/src/en/rust_logo.png | Bin 0 -> 8496 bytes .../src/en/untranslated-page.md | 3 + tests/localized_book/src/en/untranslated.md | 3 - tests/localized_book/src/ja/SUMMARY.md | 5 +- .../src/ja/inline-link-fallbacks.md | 15 + .../3.md => translation-local-page.md} | 2 +- 14 files changed, 296 insertions(+), 102 deletions(-) create mode 100644 tests/localized_book/src/en/chapter/3.md create mode 100644 tests/localized_book/src/en/inline-link-fallbacks.md create mode 100644 tests/localized_book/src/en/missing-summary-chapter.md create mode 100644 tests/localized_book/src/en/rust_logo.png create mode 100644 tests/localized_book/src/en/untranslated-page.md delete mode 100644 tests/localized_book/src/en/untranslated.md create mode 100644 tests/localized_book/src/ja/inline-link-fallbacks.md rename tests/localized_book/src/ja/{chapter/3.md => translation-local-page.md} (84%) diff --git a/src/cmd/serve.rs b/src/cmd/serve.rs index 58f18acca9..73937e40b1 100644 --- a/src/cmd/serve.rs +++ b/src/cmd/serve.rs @@ -129,10 +129,11 @@ pub fn execute(args: &ArgMatches) -> Result<()> { info!("Building book..."); // FIXME: This area is really ugly because we need to re-set livereload :( - let result = MDBook::load(&book_dir).and_then(|mut b| { - update_config(&mut b); - b.build() - }); + let result = + MDBook::load_with_build_opts(&book_dir, build_opts.clone()).and_then(|mut b| { + update_config(&mut b); + b.build() + }); if let Err(e) = result { error!("Unable to load the book"); diff --git a/src/cmd/watch.rs b/src/cmd/watch.rs index aa2f0c65d5..24d7b75bbb 100644 --- a/src/cmd/watch.rs +++ b/src/cmd/watch.rs @@ -34,7 +34,7 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { pub fn execute(args: &ArgMatches) -> Result<()> { let book_dir = get_book_dir(args); let build_opts = get_build_opts(args); - let mut book = MDBook::load_with_build_opts(&book_dir, build_opts)?; + let mut book = MDBook::load_with_build_opts(&book_dir, build_opts.clone())?; let update_config = |book: &mut MDBook| { if let Some(dest_dir) = args.value_of("dest-dir") { @@ -50,7 +50,7 @@ pub fn execute(args: &ArgMatches) -> Result<()> { trigger_on_change(&book, |paths, book_dir| { info!("Files changed: {:?}\nBuilding book...\n", paths); - let result = MDBook::load(&book_dir).and_then(|mut b| { + let result = MDBook::load_with_build_opts(&book_dir, build_opts.clone()).and_then(|mut b| { update_config(&mut b); b.build() }); diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index eac3c469e8..d3d18b9696 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -119,7 +119,7 @@ impl HtmlHandlebars { let mut is_index = true; for item in book.iter() { - let ctx = RenderItemContext { + let item_ctx = RenderItemContext { handlebars: &handlebars, destination: destination.to_path_buf(), data: data.clone(), @@ -127,7 +127,7 @@ impl HtmlHandlebars { html_config: html_config.clone(), edition: ctx.config.rust.edition, }; - self.render_item(item, ctx, &mut print_content)?; + self.render_item(item, item_ctx, src_dir, &ctx.config, &mut print_content)?; is_index = false; } @@ -138,6 +138,7 @@ impl HtmlHandlebars { &html_config, src_dir, destination, + language_ident, handlebars, &mut data, )?; @@ -193,6 +194,8 @@ impl HtmlHandlebars { &self, item: &BookItem, mut ctx: RenderItemContext<'_>, + src_dir: &PathBuf, + cfg: &Config, print_content: &mut String, ) -> Result<()> { // FIXME: This should be made DRY-er and rely less on mutable state @@ -216,11 +219,29 @@ impl HtmlHandlebars { .insert("git_repository_edit_url".to_owned(), json!(edit_url)); } + let fallback_path = cfg.default_language().map(|lang_ident| { + let mut fallback = PathBuf::from(utils::fs::path_to_root(&path)); + fallback.push("../"); + fallback.push(lang_ident.clone()); + fallback + }); + let content = ch.content.clone(); - let content = utils::render_markdown(&content, ctx.html_config.curly_quotes); + let content = utils::render_markdown_with_path( + &content, + ctx.html_config.curly_quotes, + Some(&path), + Some(&src_dir), + &fallback_path, + ); - let fixed_content = - utils::render_markdown_with_path(&ch.content, ctx.html_config.curly_quotes, Some(path)); + let fixed_content = utils::render_markdown_with_path( + &ch.content, + ctx.html_config.curly_quotes, + Some(&path), + Some(&src_dir), + &fallback_path, + ); if !ctx.is_index { // Add page break between chapters // See https://developer.mozilla.org/en-US/docs/Web/CSS/break-before and https://developer.mozilla.org/en-US/docs/Web/CSS/page-break-before @@ -298,6 +319,7 @@ impl HtmlHandlebars { html_config: &HtmlConfig, src_dir: &PathBuf, destination: &PathBuf, + language_ident: &Option, handlebars: &mut Handlebars<'_>, data: &mut serde_json::Map, ) -> Result<()> { @@ -321,16 +343,26 @@ impl HtmlHandlebars { let html_content_404 = utils::render_markdown(&content_404, html_config.curly_quotes); let mut data_404 = data.clone(); - let base_url = if let Some(site_url) = &html_config.site_url { - site_url + let mut base_url = if let Some(site_url) = &html_config.site_url { + site_url.clone() } 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." ); - "/" + String::from("/") }; + + // Set the subdirectory to the currently localized version if using a + // multilingual output format. + if let LoadedBook::Localized(_) = ctx.book { + if let Some(lang_ident) = language_ident { + base_url.push_str(lang_ident); + base_url.push_str("/"); + } + } + 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")); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 2352517a13..a413306b07 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -10,13 +10,18 @@ use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, Options, Parser, Tag}; use std::borrow::Cow; use std::fmt::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; pub use self::string::{ take_anchored_lines, take_lines, take_rustdoc_include_anchored_lines, take_rustdoc_include_lines, }; +lazy_static! { + static ref SCHEME_LINK: Regex = Regex::new(r"^[a-z][a-z0-9+.-]*:").unwrap(); + static ref MD_LINK: Regex = Regex::new(r"(?P.*)\.md(?P#.*)?").unwrap(); +} + /// Replaces multiple consecutive whitespace characters with a single space character. pub fn collapse_whitespace(text: &str) -> Cow<'_, str> { lazy_static! { @@ -71,101 +76,153 @@ pub fn id_from_content(content: &str) -> String { normalize_id(trimmed) } -/// Fix links to the correct location. -/// -/// This adjusts links, such as turning `.md` extensions to `.html`. -/// -/// `path` is the path to the page being rendered relative to the root of the -/// book. This is used for the `print.html` page so that links on the print -/// page go to the original location. Normal page rendering sets `path` to -/// None. Ideally, print page links would link to anchors on the print page, -/// but that is very difficult. -fn adjust_links<'a>(event: Event<'a>, path: Option<&Path>) -> Event<'a> { - lazy_static! { - static ref SCHEME_LINK: Regex = Regex::new(r"^[a-z][a-z0-9+.-]*:").unwrap(); - static ref MD_LINK: Regex = Regex::new(r"(?P.*)\.md(?P#.*)?").unwrap(); - } +fn md_to_html_link<'a>(dest: &CowStr<'a>, fixed_link: &mut String) { + if let Some(caps) = MD_LINK.captures(&dest) { + fixed_link.push_str(&caps["link"]); + fixed_link.push_str(".html"); + if let Some(anchor) = caps.name("anchor") { + fixed_link.push_str(anchor.as_str()); + } + } else { + fixed_link.push_str(&dest); + }; +} - fn fix<'a>(dest: CowStr<'a>, path: Option<&Path>) -> CowStr<'a> { - if dest.starts_with('#') { - // Fragment-only link. - if let Some(path) = path { - let mut base = path.display().to_string(); - if base.ends_with(".md") { - base.replace_range(base.len() - 3.., ".html"); - } - return format!("{}{}", base, dest).into(); - } else { - return dest; +fn fix<'a, P: AsRef>( + dest: CowStr<'a>, + path: Option<&Path>, + src_dir: Option<&Path>, + fallback_path: &Option

, +) -> CowStr<'a> { + if dest.starts_with('#') { + // Fragment-only link. + if let Some(path) = path { + let mut base = path.display().to_string(); + if base.ends_with(".md") { + base.replace_range(base.len() - 3.., ".html"); } + return format!("{}{}", base, dest).into(); + } else { + return dest; } - // Don't modify links with schemes like `https`. - if !SCHEME_LINK.is_match(&dest) { - // This is a relative link, adjust it as necessary. - let mut fixed_link = String::new(); - if let Some(path) = path { - let base = path - .parent() - .expect("path can't be empty") - .to_str() - .expect("utf-8 paths only"); - if !base.is_empty() { - write!(fixed_link, "{}/", base).unwrap(); + } + // Don't modify links with schemes like `https`. + if !SCHEME_LINK.is_match(&dest) { + // This is a relative link, adjust it as necessary. + let mut fixed_link = String::new(); + + // If this link is missing on the filesystem in the current directory, + // but not in the fallback directory, use the fallback's page. + let mut redirected_path = false; + if let Some(src_dir) = src_dir { + let mut dest_path = src_dir.to_str().unwrap().to_string(); + write!(dest_path, "/{}", dest).unwrap(); + trace!("Check existing: {:?}", dest_path); + if !PathBuf::from(dest_path).exists() { + if let Some(fallback_path) = fallback_path { + let mut fallback_file = src_dir.to_str().unwrap().to_string(); + // Check if there is a Markdown or other file in the fallback. + write!( + fallback_file, + "/{}/{}", + fallback_path.as_ref().display(), + dest + ) + .unwrap(); + trace!("Check fallback: {:?}", fallback_file); + if PathBuf::from(fallback_file).exists() { + write!(fixed_link, "{}/", fallback_path.as_ref().display()).unwrap(); + debug!( + "Redirect link to default translation: {:?} -> {:?}", + dest, fixed_link + ); + redirected_path = true; + } } } - - if let Some(caps) = MD_LINK.captures(&dest) { - fixed_link.push_str(&caps["link"]); - fixed_link.push_str(".html"); - if let Some(anchor) = caps.name("anchor") { - fixed_link.push_str(anchor.as_str()); - } - } else { - fixed_link.push_str(&dest); - }; - return CowStr::from(fixed_link); } - dest - } - fn fix_html<'a>(html: CowStr<'a>, path: Option<&Path>) -> CowStr<'a> { - // This is a terrible hack, but should be reasonably reliable. Nobody - // should ever parse a tag with a regex. However, there isn't anything - // in Rust that I know of that is suitable for handling partial html - // fragments like those generated by pulldown_cmark. - // - // There are dozens of HTML tags/attributes that contain paths, so - // feel free to add more tags if desired; these are the only ones I - // care about right now. - lazy_static! { - static ref HTML_LINK: Regex = - Regex::new(r#"(<(?:a|img) [^>]*?(?:src|href)=")([^"]+?)""#).unwrap(); + if let Some(path) = path { + let base = path + .parent() + .expect("path can't be empty") + .to_str() + .expect("utf-8 paths only"); + trace!("Base: {:?}", base); + + if !redirected_path && !base.is_empty() { + write!(fixed_link, "{}/", base).unwrap(); + } } - HTML_LINK - .replace_all(&html, |caps: ®ex::Captures<'_>| { - let fixed = fix(caps[2].into(), path); - format!("{}{}\"", &caps[1], fixed) - }) - .into_owned() - .into() + md_to_html_link(&dest, &mut fixed_link); + return CowStr::from(fixed_link); + } + dest +} + +fn fix_html<'a, P: AsRef>( + html: CowStr<'a>, + path: Option<&Path>, + src_dir: Option<&Path>, + fallback_path: &Option

, +) -> CowStr<'a> { + // This is a terrible hack, but should be reasonably reliable. Nobody + // should ever parse a tag with a regex. However, there isn't anything + // in Rust that I know of that is suitable for handling partial html + // fragments like those generated by pulldown_cmark. + // + // There are dozens of HTML tags/attributes that contain paths, so + // feel free to add more tags if desired; these are the only ones I + // care about right now. + lazy_static! { + static ref HTML_LINK: Regex = + Regex::new(r#"(<(?:a|img) [^>]*?(?:src|href)=")([^"]+?)""#).unwrap(); } + HTML_LINK + .replace_all(&html, move |caps: ®ex::Captures<'_>| { + let fixed = fix(caps[2].into(), path, src_dir, fallback_path); + format!("{}{}\"", &caps[1], fixed) + }) + .into_owned() + .into() +} + +/// Fix links to the correct location. +/// +/// This adjusts links, such as turning `.md` extensions to `.html`. +/// +/// `path` is the path to the page being rendered relative to the root of the +/// book. This is used for the `print.html` page so that links on the print +/// page go to the original location. Normal page rendering sets `path` to +/// None. Ideally, print page links would link to anchors on the print page, +/// but that is very difficult. +fn adjust_links<'a, P: AsRef>( + event: Event<'a>, + path: Option<&Path>, + src_dir: Option<&Path>, + fallback_path: &Option

, +) -> Event<'a> { match event { - Event::Start(Tag::Link(link_type, dest, title)) => { - Event::Start(Tag::Link(link_type, fix(dest, path), title)) - } - Event::Start(Tag::Image(link_type, dest, title)) => { - Event::Start(Tag::Image(link_type, fix(dest, path), title)) - } - Event::Html(html) => Event::Html(fix_html(html, path)), + Event::Start(Tag::Link(link_type, dest, title)) => Event::Start(Tag::Link( + link_type, + fix(dest, path, src_dir, fallback_path), + title, + )), + Event::Start(Tag::Image(link_type, dest, title)) => Event::Start(Tag::Image( + link_type, + fix(dest, path, src_dir, fallback_path), + title, + )), + Event::Html(html) => Event::Html(fix_html(html, path, src_dir, fallback_path)), _ => event, } } /// Wrapper around the pulldown-cmark parser for rendering markdown to HTML. pub fn render_markdown(text: &str, curly_quotes: bool) -> String { - render_markdown_with_path(text, curly_quotes, None) + render_markdown_with_path(text, curly_quotes, None, None, &None::) } pub fn new_cmark_parser(text: &str) -> Parser<'_> { @@ -177,13 +234,19 @@ pub fn new_cmark_parser(text: &str) -> Parser<'_> { Parser::new_ext(text, opts) } -pub fn render_markdown_with_path(text: &str, curly_quotes: bool, path: Option<&Path>) -> String { +pub fn render_markdown_with_path>( + text: &str, + curly_quotes: bool, + path: Option<&Path>, + src_dir: Option<&Path>, + fallback_path: &Option

, +) -> String { let mut s = String::with_capacity(text.len() * 3 / 2); let p = new_cmark_parser(text); let mut converter = EventQuoteConverter::new(curly_quotes); let events = p .map(clean_codeblock_headers) - .map(|event| adjust_links(event, path)) + .map(|event| adjust_links(event, path, src_dir, fallback_path)) .map(|event| converter.convert(event)); html::push_html(&mut s, events); @@ -287,7 +350,7 @@ pub fn log_backtrace(e: &Error) { #[cfg(test)] mod tests { mod render_markdown { - use super::super::render_markdown; + use super::super::{render_markdown, render_markdown_with_path}; #[test] fn preserves_external_links() { @@ -404,6 +467,75 @@ more text with spaces assert_eq!(render_markdown(input, false), expected); assert_eq!(render_markdown(input, true), expected); } + + use std::fs::{self, File}; + use std::io::Write; + use std::path::PathBuf; + use tempfile::{Builder as TempFileBuilder, TempDir}; + + const DUMMY_SRC: &str = " +# Dummy Chapter + +this is some dummy text. + +And here is some \ +more text. +"; + + /// Create a dummy `Link` in a temporary directory. + fn dummy_link() -> (PathBuf, TempDir) { + let temp = TempFileBuilder::new().prefix("book").tempdir().unwrap(); + + let chapter_path = temp.path().join("chapter_1.md"); + File::create(&chapter_path) + .unwrap() + .write_all(DUMMY_SRC.as_bytes()) + .unwrap(); + + let path = chapter_path.to_path_buf(); + + (path, temp) + } + + #[test] + fn links_are_rewritten_to_fallback_for_nonexistent_files() { + let input = r#" +[Link](chapter_1.md) +"#; + + let (localized_file, localized_dir) = dummy_link(); + fs::remove_file(&localized_file).unwrap(); + + let (_, fallback_dir) = dummy_link(); + let mut relative_fallback_dir = + PathBuf::from(super::super::fs::path_to_root(localized_dir.path())); + relative_fallback_dir.push(fallback_dir.path().file_name().unwrap()); + + let expected_fallback = format!( + "

Link

\n", + relative_fallback_dir.display() + ); + assert_eq!( + render_markdown_with_path( + input, + false, + None, + Some(localized_dir.path()), + &Some(&relative_fallback_dir) + ), + expected_fallback + ); + assert_eq!( + render_markdown_with_path( + input, + true, + None, + Some(localized_dir.path()), + &Some(&relative_fallback_dir) + ), + expected_fallback + ); + } } mod html_munging { diff --git a/tests/localized_book/src/en/SUMMARY.md b/tests/localized_book/src/en/SUMMARY.md index 904cfbf978..e94726f6ce 100644 --- a/tests/localized_book/src/en/SUMMARY.md +++ b/tests/localized_book/src/en/SUMMARY.md @@ -4,4 +4,6 @@ - [Chapter 1](chapter/README.md) - [Section 1](chapter/1.md) - [Section 2](chapter/2.md) -- [Untranslated Chapter](untranslated.md) +- [Untranslated Page](untranslated-page.md) +- [Inline Link Fallbacks](inline-link-fallbacks.md) +- [Missing Summary Chapter](missing-summary-chapter.md) diff --git a/tests/localized_book/src/en/chapter/3.md b/tests/localized_book/src/en/chapter/3.md new file mode 100644 index 0000000000..6e68b92df8 --- /dev/null +++ b/tests/localized_book/src/en/chapter/3.md @@ -0,0 +1 @@ +# 第三節 diff --git a/tests/localized_book/src/en/inline-link-fallbacks.md b/tests/localized_book/src/en/inline-link-fallbacks.md new file mode 100644 index 0000000000..b789edaadf --- /dev/null +++ b/tests/localized_book/src/en/inline-link-fallbacks.md @@ -0,0 +1,7 @@ +# Inline Link Fallbacks + +This page tests localization fallbacks of inline links. + +Select another language from the dropdown to see a demonstation. + +![Rust logo](rust_logo.png) diff --git a/tests/localized_book/src/en/missing-summary-chapter.md b/tests/localized_book/src/en/missing-summary-chapter.md new file mode 100644 index 0000000000..86a3329be5 --- /dev/null +++ b/tests/localized_book/src/en/missing-summary-chapter.md @@ -0,0 +1,3 @@ +# Missing Summary Chapter + +This page is to test that inline links to a page missing in a translation's SUMMARY.md redirect to the page in the fallback translation. diff --git a/tests/localized_book/src/en/rust_logo.png b/tests/localized_book/src/en/rust_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..11c6de63c36ddb2f7ffb571aac78207e5a210386 GIT binary patch literal 8496 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 eQ4~dK