341ac3bfbd
* Translations are also generated for the index page (fix #1332) * More tests for translations * Even better error message * Update page count for test * Patch to fix Windows tests By @mtolk Co-authored-by: southerntofu <southerntofu@thunix.net>
322 lines
13 KiB
Rust
322 lines
13 KiB
Rust
#![allow(dead_code)]
|
|
use std::env;
|
|
use std::path::{PathBuf, Path};
|
|
use std::collections::HashMap;
|
|
|
|
use site::Site;
|
|
use tempfile::{tempdir, TempDir};
|
|
use path_slash::PathExt;
|
|
|
|
// 2 helper macros to make all the build testing more bearable
|
|
#[macro_export]
|
|
macro_rules! file_exists {
|
|
($root: expr, $path: expr) => {{
|
|
let mut path = $root.clone();
|
|
for component in $path.split("/") {
|
|
path = path.join(component);
|
|
}
|
|
std::path::Path::new(&path).exists()
|
|
}};
|
|
}
|
|
|
|
#[macro_export]
|
|
macro_rules! file_contains {
|
|
($root: expr, $path: expr, $text: expr) => {{
|
|
use std::io::prelude::*;
|
|
let mut path = $root.clone();
|
|
for component in $path.split("/") {
|
|
path = path.join(component);
|
|
}
|
|
let mut file = std::fs::File::open(&path).expect(&format!("Failed to open {:?}", $path));
|
|
let mut s = String::new();
|
|
file.read_to_string(&mut s).unwrap();
|
|
println!("{}", s);
|
|
s.contains($text)
|
|
}};
|
|
}
|
|
|
|
/// We return the tmpdir otherwise it would get out of scope and be deleted
|
|
/// The tests can ignore it if they dont need it by prefixing it with a `_`
|
|
pub fn build_site(name: &str) -> (Site, TempDir, PathBuf) {
|
|
let mut path = env::current_dir().unwrap().parent().unwrap().parent().unwrap().to_path_buf();
|
|
path.push(name);
|
|
let config_file = path.join("config.toml");
|
|
let mut site = Site::new(&path, &config_file).unwrap();
|
|
site.load().unwrap();
|
|
let tmp_dir = tempdir().expect("create temp dir");
|
|
let public = &tmp_dir.path().join("public");
|
|
site.set_output_path(&public);
|
|
site.build().expect("Couldn't build the site");
|
|
(site, tmp_dir, public.clone())
|
|
}
|
|
|
|
/// Same as `build_site` but has a hook to setup some config options
|
|
pub fn build_site_with_setup<F>(name: &str, mut setup_cb: F) -> (Site, TempDir, PathBuf)
|
|
where
|
|
F: FnMut(Site) -> (Site, bool),
|
|
{
|
|
let mut path = env::current_dir().unwrap().parent().unwrap().parent().unwrap().to_path_buf();
|
|
path.push(name);
|
|
let config_file = path.join("config.toml");
|
|
let site = Site::new(&path, &config_file).unwrap();
|
|
let (mut site, needs_loading) = setup_cb(site);
|
|
if needs_loading {
|
|
site.load().unwrap();
|
|
}
|
|
let tmp_dir = tempdir().expect("create temp dir");
|
|
let public = &tmp_dir.path().join("public");
|
|
site.set_output_path(&public);
|
|
site.build().expect("Couldn't build the site");
|
|
(site, tmp_dir, public.clone())
|
|
}
|
|
|
|
/// Finds the unified path (eg. _index.fr.md -> _index.md) and
|
|
/// potential language (if not default) associated with a path
|
|
/// When the path is not a markdown file (.md), None is returned
|
|
/// Strips base_dir from the start of path
|
|
fn find_lang_for(entry: &Path, base_dir: &Path) -> Option<(String, Option<String>)> {
|
|
let ext = entry.extension();
|
|
if ext.is_none() {
|
|
// Not a markdown file (no extension), skip
|
|
return None;
|
|
}
|
|
let ext = ext.unwrap();
|
|
if ext != "md" {
|
|
// Not a markdown file, skip
|
|
return None;
|
|
}
|
|
let mut no_ext = entry.to_path_buf();
|
|
let stem = entry.file_stem().unwrap();
|
|
// Remove .md
|
|
no_ext.pop();
|
|
no_ext.push(stem);
|
|
if let Some(lang) = no_ext.extension() {
|
|
let stem = no_ext.file_stem();
|
|
// Remove lang
|
|
let mut unified_path = no_ext.clone();
|
|
unified_path.pop();
|
|
// Readd stem with .md added
|
|
unified_path.push(&format!("{}.md", stem.unwrap().to_str().unwrap()));
|
|
let unified_path_str = match unified_path.strip_prefix(base_dir) {
|
|
Ok(path_without_prefix) => {path_without_prefix.to_slash_lossy()}
|
|
_ => {unified_path.to_slash_lossy()}
|
|
};
|
|
return Some((unified_path_str, Some(lang.to_str().unwrap().into())));
|
|
} else {
|
|
// No lang, return no_ext directly
|
|
let mut no_ext_string = match no_ext.strip_prefix(base_dir) {
|
|
Ok(path_without_prefix) => {path_without_prefix.to_slash_lossy()}
|
|
_ => {no_ext.to_slash_lossy()}
|
|
};
|
|
no_ext_string.push_str(".md");
|
|
return Some((no_ext_string, None));
|
|
}
|
|
}
|
|
|
|
/// Recursively process a folder to find translations, returning a list of every language
|
|
/// translated for every page found. Translations for the default language are stored as "DEFAULT"
|
|
/// TODO: This implementation does not support files with a dot inside (foo.bar.md where bar is
|
|
/// not a language), because it requires to know what languages are enabled from config, and it's
|
|
/// unclear how to distinguish (and what to do) between disabled language or "legit" dots
|
|
pub fn add_translations_from(dir: &Path, strip: &Path, default: &str) -> HashMap<String, Vec<String>> {
|
|
let mut expected: HashMap<String, Vec<String>> = HashMap::new();
|
|
for entry in dir.read_dir().expect("Failed to read dir") {
|
|
let entry = entry.expect("Failed to read entry").path();
|
|
if entry.is_dir() {
|
|
// Recurse
|
|
expected.extend(add_translations_from(&entry, strip, default));
|
|
}
|
|
if let Some((unified_path, lang)) = find_lang_for(&entry, strip) {
|
|
if let Some(index) = expected.get_mut(&unified_path) {
|
|
// Insert found lang for rel_path, or DEFAULT otherwise
|
|
index.push(lang.unwrap_or(default.to_string()));
|
|
} else {
|
|
// rel_path is not registered yet, insert it in expected
|
|
expected.insert(unified_path, vec!(lang.unwrap_or(default.to_string())));
|
|
}
|
|
} else {
|
|
// Not a markdown file, skip
|
|
continue;
|
|
}
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
/// Calculate output path for Markdown files
|
|
/// respecting page/section `path` fields, but not aliases (yet)
|
|
/// Returns a mapping of unified Markdown paths -> translations
|
|
pub fn find_expected_translations(name: &str, default_language: &str) -> HashMap<String, Vec<String>> {
|
|
let mut path = env::current_dir().unwrap().parent().unwrap().parent().unwrap().to_path_buf();
|
|
path.push(name);
|
|
path.push("content");
|
|
|
|
// Find expected translations from content folder
|
|
// We remove BASEDIR/content/ from the keys so they match paths in library
|
|
let mut strip_prefix = path.to_str().unwrap().to_string();
|
|
strip_prefix.push('/');
|
|
add_translations_from(&path, &path, default_language)
|
|
}
|
|
|
|
/// Checks whether a given permalink has a corresponding HTML page in output folder
|
|
pub fn ensure_output_exists(outputdir: &Path, baseurl: &str, link: &str) -> bool {
|
|
// Remove the baseurl as well as the remaining /, otherwise path will be interpreted
|
|
// as absolute.
|
|
let trimmed_url = link.trim_start_matches(baseurl).trim_start_matches('/');
|
|
let path = outputdir.join(trimmed_url);
|
|
path.exists()
|
|
}
|
|
|
|
pub struct Translation {
|
|
path: String,
|
|
lang: String,
|
|
permalink: String,
|
|
}
|
|
|
|
pub struct Translations {
|
|
trans: Vec<Translation>,
|
|
}
|
|
|
|
impl Translations {
|
|
pub fn for_path(site: &Site, path: &str) -> Translations {
|
|
let library = site.library.clone();
|
|
let library = library.read().unwrap();
|
|
// WORKAROUND because site.content_path is private
|
|
let unified_path = if let Some(page) = library.get_page(site.base_path.join("content").join(path)) {
|
|
page.file.canonical.clone()
|
|
} else if let Some(section) = library.get_section(site.base_path.join("content").join(path)) {
|
|
section.file.canonical.clone()
|
|
} else {
|
|
panic!("No such page or section: {}", path);
|
|
};
|
|
|
|
let translations = library.translations.get(&unified_path);
|
|
if translations.is_none() {
|
|
println!("Page canonical path {} is not in library translations", unified_path.display());
|
|
panic!("Library error");
|
|
}
|
|
|
|
let translations = translations
|
|
.unwrap()
|
|
.iter().map(|key| {
|
|
// Are we looking for a section? (no file extension here)
|
|
if unified_path.ends_with("_index") {
|
|
//library.get_section_by_key(*key).file.relative.to_string()
|
|
let section = library.get_section_by_key(*key);
|
|
Translation {
|
|
lang: section.lang.clone(),
|
|
permalink: section.permalink.clone(),
|
|
path: section.file.path.to_str().unwrap().to_string(),
|
|
}
|
|
} else {
|
|
let page = library.get_page_by_key(*key);
|
|
Translation {
|
|
lang: page.lang.clone(),
|
|
permalink: page.permalink.clone(),
|
|
path: page.file.path.to_str().unwrap().to_string(),
|
|
}
|
|
//library.get_page_by_key(*key).file.relative.to_string()
|
|
}
|
|
}).collect();
|
|
|
|
Translations {
|
|
trans: translations,
|
|
}
|
|
}
|
|
|
|
pub fn languages(&self) -> Vec<String> {
|
|
let mut lang: Vec<String> = self.trans.iter().map(|x| x.lang.clone()).collect();
|
|
lang.sort_unstable();
|
|
lang
|
|
}
|
|
|
|
pub fn permalinks(&self) -> Vec<String> {
|
|
let mut links: Vec<String> = self.trans.iter().map(|x| x.permalink.clone()).collect();
|
|
links.sort_unstable();
|
|
links
|
|
}
|
|
|
|
pub fn paths(&self) -> Vec<String> {
|
|
let mut paths: Vec<String> = self.trans.iter().map(|x| x.path.clone()).collect();
|
|
paths.sort_unstable();
|
|
paths
|
|
}
|
|
}
|
|
|
|
/// Find translations in library for a single path
|
|
fn library_translations_lang_for(site: &Site, path: &str) -> Vec<String> {
|
|
let library_translations = Translations::for_path(site, path);
|
|
library_translations.languages()
|
|
}
|
|
|
|
/// This function takes a list of translations generated by find_expected_translations(),
|
|
/// a site instance, and a path of a page to check that translations are the same on both sides
|
|
pub fn ensure_translations_match(translations: &HashMap<String, Vec<String>>, site: &Site, path: &str) -> bool {
|
|
let library_page_translations = library_translations_lang_for(site, path);
|
|
|
|
if let Some((unified_path, _lang)) = find_lang_for(&PathBuf::from(path), Path::new("")) {
|
|
if let Some(page_translations) = translations.get(&unified_path) {
|
|
// We order both claimed translations so we can compare them
|
|
// library_page_translations is already ordered
|
|
let mut page_translations = page_translations.clone();
|
|
page_translations.sort_unstable();
|
|
|
|
if page_translations != library_page_translations {
|
|
// Some translations don't match, print some context
|
|
// There is a special case where the index page may be autogenerated for a lang
|
|
// by zola so if we are looking at the index page, library may contain more (not
|
|
// less) languages than our tests.
|
|
if unified_path == "_index.md" {
|
|
for lang in &page_translations {
|
|
if !library_page_translations.contains(lang) {
|
|
println!("Library is missing language: {} for page {}", lang, unified_path);
|
|
return false;
|
|
}
|
|
}
|
|
// All languages from Markdown were found. We don't care if the library
|
|
// auto-generated more.
|
|
return true;
|
|
}
|
|
println!("Translations don't match for {}:", path);
|
|
println!(" - library: {:?}", library_page_translations);
|
|
println!(" - tests: {:?}", page_translations);
|
|
return false;
|
|
}
|
|
// Everything went well
|
|
return true;
|
|
} else {
|
|
// Should never happen because even the default language counts as a translation
|
|
// Reaching here means either there is a logic error in the tests themselves,
|
|
// or the permalinks contained a page which does not exist for some reason
|
|
unreachable!("Translations not found for {}", unified_path);
|
|
}
|
|
} else {
|
|
// None means the page does not end with .md. Only markdown pages should be passed to this function.
|
|
// Maybe a non-markdown path was found in site's permalinks?
|
|
unreachable!("{} is not a markdown page (extension not .md)", path);
|
|
}
|
|
}
|
|
|
|
/// For a given URL (from the permalinks), find the corresponding output page
|
|
/// and ensure all translation permalinks are linked inside
|
|
pub fn ensure_translations_in_output(site: &Site, path: &str, permalink: &str) -> bool {
|
|
let library_page_translations = Translations::for_path(site, path);
|
|
let translations_permalinks = library_page_translations.permalinks();
|
|
|
|
let output_path = permalink.trim_start_matches(&site.config.base_url);
|
|
// Strip leading / so it's not interpreted as an absolute path
|
|
let output_path = output_path.trim_start_matches('/');
|
|
// Don't forget to remove / because
|
|
let output_path = site.output_path.join(output_path);
|
|
|
|
let output = std::fs::read_to_string(&output_path).expect(&format!("Output not found in {}", output_path.display()));
|
|
|
|
for permalink in &translations_permalinks {
|
|
if !output.contains(permalink) {
|
|
println!("Page {} has translation {}, but it was not found in output", path, permalink);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|