diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bbefb177..0ac011573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.20.0 (unreleased) + +- Fix some of YAML date parsing +- Fix feed generation for languages not working in some cases (it was taking the value from the root of the config for +feed_filenames) +- Ignore `.bck` files in `zola serve` + ## 0.19.1 (2024-06-24) - Fix `config.generate_feeds` being still serialized as `config.generate_feed`. Both are available for now diff --git a/components/site/src/feeds.rs b/components/site/src/feeds.rs index 653b0f9ae..435dfea5d 100644 --- a/components/site/src/feeds.rs +++ b/components/site/src/feeds.rs @@ -74,7 +74,7 @@ pub fn render_feeds( context.insert("lang", lang); let mut feeds = Vec::new(); - for feed_filename in &site.config.feed_filenames { + for feed_filename in &site.config.languages[lang].feed_filenames { let mut context = context.clone(); let feed_url = if let Some(base) = base_path { @@ -85,9 +85,7 @@ pub fn render_feeds( }; context.insert("feed_url", &feed_url); - context = additional_context_fn(context); - feeds.push(render_template(feed_filename, &site.tera, context, &site.config.theme)?); } diff --git a/components/site/src/lib.rs b/components/site/src/lib.rs index 96c9c1500..57e47b008 100644 --- a/components/site/src/lib.rs +++ b/components/site/src/lib.rs @@ -744,27 +744,7 @@ impl Site { start = log_time(start, "Rendered orphan pages"); self.render_sitemap()?; start = log_time(start, "Rendered sitemap"); - - let library = self.library.read().unwrap(); - if self.config.generate_feeds { - let is_multilingual = self.config.is_multilingual(); - let pages: Vec<_> = if is_multilingual { - library.pages.values().filter(|p| p.lang == self.config.default_language).collect() - } else { - library.pages.values().collect() - }; - self.render_feeds(pages, None, &self.config.default_language, |c| c)?; - start = log_time(start, "Generated feed in default language"); - } - - for (code, language) in &self.config.other_languages() { - if !language.generate_feeds { - continue; - } - let pages: Vec<_> = library.pages.values().filter(|p| &p.lang == code).collect(); - self.render_feeds(pages, Some(&PathBuf::from(code)), code, |c| c)?; - start = log_time(start, "Generated feed in other language"); - } + self.render_all_feeds()?; self.render_themes_css()?; start = log_time(start, "Rendered themes css"); self.render_404()?; @@ -784,6 +764,41 @@ impl Site { Ok(()) } + pub fn render_all_feeds(&self) -> Result<()> { + let mut start = Instant::now(); + let library = self.library.read().unwrap(); + + for (code, language) in &self.config.languages { + let is_default_language = code == &self.config.default_language; + + let skip_default_language_feed_generation = + is_default_language && !self.config.generate_feeds && !language.generate_feeds; + let skip_other_language_feed_generation = + !is_default_language && !language.generate_feeds; + if skip_default_language_feed_generation || skip_other_language_feed_generation { + continue; + } + + let pages: Vec<_> = if is_default_language && !self.config.is_multilingual() { + library.pages.values().collect() + } else { + library.pages.values().filter(|p| &p.lang == code).collect() + }; + + let code_path = PathBuf::from(code); + let base_path = if is_default_language { None } else { Some(&code_path) }; + + self.render_feeds(pages, base_path, code, |c| c)?; + let debug_message = if is_default_language { + "Generated feed in default language" + } else { + "Generated feed in other language" + }; + start = log_time(start, debug_message); + } + Ok(()) + } + pub fn render_themes_css(&self) -> Result<()> { let themes = &self.config.markdown.highlight_themes_css; @@ -1046,7 +1061,9 @@ impl Site { None => return Ok(()), }; - for (feed, feed_filename) in feeds.into_iter().zip(self.config.feed_filenames.iter()) { + for (feed, feed_filename) in + feeds.into_iter().zip(self.config.languages[lang].feed_filenames.iter()) + { if let Some(base) = base_path { let mut components = Vec::new(); for component in base.components() { @@ -1209,3 +1226,200 @@ fn log_time(start: Instant, message: &str) -> Instant { } now } + +#[cfg(test)] +mod tests { + use super::*; + use config::Config; + use content::{FileInfo, Library, Page}; + use tempfile::{tempdir, TempDir}; + + fn create_page(title: &str, file_path: &str, lang: &str, config: &Config) -> Page { + let mut page = Page { lang: lang.to_owned(), ..Page::default() }; + page.file = FileInfo::new_page( + Path::new(format!("/test/base/path/{}", file_path).as_str()), + &PathBuf::new(), + ); + page.meta.title = Some(title.to_string()); + page.meta.date = Some("2016-03-01".to_string()); + page.meta.weight = Some(1); + page.lang = lang.to_string(); + page.file.find_language(&config.default_language, &config.other_languages_codes()).unwrap(); + page.permalink = config.make_permalink(file_path); + page + } + + fn create_site_from_config_and_pages( + config_raw: &str, + pages: &[(&str, &str, &str)], + ) -> (TempDir, Site) { + let config = Config::parse(config_raw).unwrap(); + let mut library = Library::default(); + for (t, f, l) in pages { + library.insert_page(create_page(t, f, l, &config)); + } + + let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path(); + let public_dir = path.join("public"); + let site = Site { + config: config.clone(), + library: Arc::new(RwLock::new(library)), + base_path: path.into(), + tera: load_tera(path, &config).unwrap(), + imageproc: Arc::new(Mutex::new(imageproc::Processor::new(path.to_path_buf(), &config))), + live_reload: None, + output_path: public_dir.clone(), + content_path: path.into(), + sass_path: path.into(), + static_path: path.into(), + templates_path: path.into(), + taxonomies: vec![], + permalinks: HashMap::new(), + include_drafts: false, + build_mode: BuildMode::Disk, + shortcode_definitions: HashMap::new(), + }; + site.render_all_feeds().unwrap(); + (tmp_dir, site) + } + + #[test] + fn can_render_feed_for_single_language_with_global_feed_flag() { + let config_raw = r#" +title = "My site" +base_url = "https://replace-this-with-your-url.com" +generate_feeds = true + + "#; + let pages = vec![("My En Article", "content/my-article.md", "en")]; + + let (tmp_dir, site) = create_site_from_config_and_pages(config_raw, &pages); + let public_dir = site.output_path; + + assert!(tmp_dir.path().exists()); + assert!(public_dir.exists()); + assert!(public_dir.join("atom.xml").exists()); + assert!(std::fs::read_to_string(public_dir.join("atom.xml")) + .unwrap() + .contains("My En Article")); + } + + #[test] + fn can_render_feed_for_multi_language_with_language_level_feed_flag() { + let config_raw = r#" +base_url = "https://replace-this-with-your-url.com" +default_language = "en" + +[languages.en] +title = "My English site" +generate_feeds = true + +[languages.fr] +title = "My French site" +generate_feeds = false + + "#; + let pages = vec![ + ("My En Article", "content/my-article.md", "en"), + ("My Fr Article", "content/my-article.fr.md", "fr"), + ]; + + let (tmp_dir, site) = create_site_from_config_and_pages(config_raw, &pages); + let public_dir = site.output_path; + + assert!(tmp_dir.path().exists()); + assert!(public_dir.exists()); + assert!(public_dir.join("atom.xml").exists()); + assert!(std::fs::read_to_string(public_dir.join("atom.xml")) + .unwrap() + .contains("My En Article")); + assert!(!std::fs::read_to_string(public_dir.join("atom.xml")) + .unwrap() + .contains("My Fr Article")); + } + + #[test] + fn can_render_feed_for_multi_language_with_language_level_feed_flag_and_feed_files() { + let config_raw = r#" +base_url = "https://replace-this-with-your-url.com" +default_language = "en" + +[languages.en] +title = "My English site" +generate_feeds = true + +[languages.fr] +title = "My French site" +generate_feeds = true +feed_filenames = ["rss.xml"] + + "#; + let pages = vec![ + ("My En Article", "content/my-article.md", "en"), + ("My Fr Article", "content/my-article.fr.md", "fr"), + ]; + + let (tmp_dir, site) = create_site_from_config_and_pages(config_raw, &pages); + let public_dir = site.output_path; + + assert!(tmp_dir.path().exists()); + assert!(public_dir.exists()); + assert!(public_dir.join("atom.xml").exists()); + assert!(public_dir.join("fr").join("rss.xml").exists()); + assert!(std::fs::read_to_string(public_dir.join("atom.xml")) + .unwrap() + .contains("My En Article")); + assert!(!std::fs::read_to_string(public_dir.join("atom.xml")) + .unwrap() + .contains("My Fr Article")); + assert!(!std::fs::read_to_string(public_dir.join("fr").join("rss.xml")) + .unwrap() + .contains("My En Article")); + assert!(std::fs::read_to_string(public_dir.join("fr").join("rss.xml")) + .unwrap() + .contains("My Fr Article")); + } + + #[test] + fn can_render_feed_for_multi_language_with_language_level_feed_flag_preferred_for_default() { + let config_raw = r#" +base_url = "https://replace-this-with-your-url.com" +default_language = "en" +generate_feeds = false + +[languages.en] +title = "My English site" +generate_feeds = true + +[languages.fr] +title = "My French site" +generate_feeds = true + + "#; + let pages = vec![ + ("My En Article", "content/my-article.md", "en"), + ("My Fr Article", "content/my-article.fr.md", "fr"), + ]; + + let (tmp_dir, site) = create_site_from_config_and_pages(config_raw, &pages); + let public_dir = site.output_path; + + assert!(tmp_dir.path().exists()); + assert!(public_dir.exists()); + assert!(public_dir.join("atom.xml").exists()); + assert!(public_dir.join("fr").join("atom.xml").exists()); + assert!(std::fs::read_to_string(public_dir.join("atom.xml")) + .unwrap() + .contains("My En Article")); + assert!(!std::fs::read_to_string(public_dir.join("atom.xml")) + .unwrap() + .contains("My Fr Article")); + assert!(!std::fs::read_to_string(public_dir.join("fr").join("atom.xml")) + .unwrap() + .contains("My En Article")); + assert!(std::fs::read_to_string(public_dir.join("fr").join("atom.xml")) + .unwrap() + .contains("My Fr Article")); + } +} diff --git a/components/utils/src/de.rs b/components/utils/src/de.rs index c257fd0e0..0b441d7eb 100644 --- a/components/utils/src/de.rs +++ b/components/utils/src/de.rs @@ -9,46 +9,26 @@ use serde::{Deserialize, Deserializer}; pub fn parse_yaml_datetime(date_string: &str) -> Result { // See https://github.com/getzola/zola/issues/2071#issuecomment-1530610650 - let re = Regex::new(r#"^"?([0-9]{4})-([0-9][0-9]?)-([0-9][0-9]?)([Tt]|[ \t]+)([0-9][0-9]?):([0-9]{2}):([0-9]{2})\.([0-9]*)?Z?([ \t]([-+][0-9][0-9]?)(:([0-9][0-9]?))?Z?|([-+][0-9]{2})?:([0-9]{2})?)?|([0-9]{4})-([0-9]{2})-([0-9]{2})"?$"#).unwrap(); + let re = Regex::new(r#"^"?(?P[0-9]{4})-(?P[0-9][0-9]?)-(?P[0-9][0-9]?)(?:(?:[Tt]|[ \t]+)(?P[0-9][0-9]?):(?P[0-9]{2}):(?P[0-9]{2})(?P\.[0-9]{0,9})?[ \t]*(?:(?PZ)|(?P(?P[-+][0-9][0-9]?)(?::(?P[0-9][0-9]))?))?)?"?$"#).unwrap(); let captures = if let Some(captures_) = re.captures(date_string) { Ok(captures_) } else { Err(anyhow!("Error parsing YAML datetime")) }?; - let year = - if let Some(cap) = captures.get(1) { cap } else { captures.get(15).unwrap() }.as_str(); - let month = - if let Some(cap) = captures.get(2) { cap } else { captures.get(16).unwrap() }.as_str(); - let day = - if let Some(cap) = captures.get(3) { cap } else { captures.get(17).unwrap() }.as_str(); - let hours = if let Some(hours_) = captures.get(5) { hours_.as_str() } else { "0" }; - let minutes = if let Some(minutes_) = captures.get(6) { minutes_.as_str() } else { "0" }; - let seconds = if let Some(seconds_) = captures.get(7) { seconds_.as_str() } else { "0" }; - let fractional_seconds_raw = - if let Some(fractionals) = captures.get(8) { fractionals.as_str() } else { "" }; - let fractional_seconds_intermediate = fractional_seconds_raw.trim_end_matches("0"); + let year = captures.name("year").unwrap().as_str(); + let month = captures.name("month").unwrap().as_str(); + let day = captures.name("day").unwrap().as_str(); + let hour = if let Some(hour_) = captures.name("hour") { hour_.as_str() } else { "0" }; + let minute = if let Some(minute_) = captures.name("minute") { minute_.as_str() } else { "0" }; + let second = if let Some(second_) = captures.name("second") { second_.as_str() } else { "0" }; + let fraction_raw = + if let Some(fraction_) = captures.name("fraction") { fraction_.as_str() } else { "" }; + let fraction_intermediate = fraction_raw.trim_end_matches("0"); // // Prepare for eventual conversion into nanoseconds - let fractional_seconds = if fractional_seconds_intermediate.len() > 0 - && fractional_seconds_intermediate.len() <= 9 - { - fractional_seconds_intermediate - } else { - "0" - }; - let maybe_timezone_hour_1 = captures.get(10); - let maybe_timezone_minute_1 = captures.get(12); - let maybe_timezone_hour_2 = captures.get(13); - let maybe_timezone_minute_2 = captures.get(14); - let maybe_timezone_hour; - let maybe_timezone_minute; - if maybe_timezone_hour_2.is_some() { - maybe_timezone_hour = maybe_timezone_hour_2; - maybe_timezone_minute = maybe_timezone_minute_2; - } else { - maybe_timezone_hour = maybe_timezone_hour_1; - maybe_timezone_minute = maybe_timezone_minute_1; - } + let fraction = if fraction_intermediate.len() > 0 { fraction_intermediate } else { "0" }; + let maybe_timezone_hour = captures.name("offset_hour"); + let maybe_timezone_minute = captures.name("offset_minute"); let mut offset_datetime = time::OffsetDateTime::UNIX_EPOCH; @@ -67,10 +47,10 @@ pub fn parse_yaml_datetime(date_string: &str) -> Result { .replace_year(year.parse().unwrap())? .replace_month(time::Month::try_from(month.parse::().unwrap())?)? .replace_day(day.parse().unwrap())? - .replace_hour(hours.parse().unwrap())? - .replace_minute(minutes.parse().unwrap())? - .replace_second(seconds.parse().unwrap())? - .replace_nanosecond(fractional_seconds.parse::().unwrap() * 100_000_000)?) + .replace_hour(hour.parse().unwrap())? + .replace_minute(minute.parse().unwrap())? + .replace_second(second.parse().unwrap())? + .replace_nanosecond((fraction.parse::().unwrap_or(0.0) * 1_000_000_000.0) as u32)?) } /// Used as an attribute when we want to convert from TOML to a string date @@ -167,23 +147,31 @@ mod tests { use time::macros::datetime; #[test] - fn yaml_spec_examples_pass() { + fn yaml_draft_timestamp_pass() { + // tests only the values from the YAML 1.1 Timestamp Draft + // See https://yaml.org/type/timestamp.html let canonical = "2001-12-15T02:59:43.1Z"; let valid_iso8601 = "2001-12-14t21:59:43.10-05:00"; let space_separated = "2001-12-14 21:59:43.10 -5"; let no_time_zone = "2001-12-15 2:59:43.10"; let date = "2002-12-14"; - assert_eq!(parse_yaml_datetime(canonical).unwrap(), datetime!(2001-12-15 2:59:43.1 +0)); + assert_eq!( + parse_yaml_datetime(canonical).unwrap(), + datetime!(2001-12-15 02:59:43.100 +00:00) + ); assert_eq!( parse_yaml_datetime(valid_iso8601).unwrap(), - datetime!(2001-12-14 21:59:43.1 -5) + datetime!(2001-12-14 21:59:43.100 -05:00) ); assert_eq!( parse_yaml_datetime(space_separated).unwrap(), - datetime!(2001-12-14 21:59:43.1 -5) + datetime!(2001-12-14 21:59:43.100 -05:00) + ); + assert_eq!( + parse_yaml_datetime(no_time_zone).unwrap(), + datetime!(2001-12-15 02:59:43.100 +00:00) ); - assert_eq!(parse_yaml_datetime(no_time_zone).unwrap(), datetime!(2001-12-15 2:59:43.1 +0)); - assert_eq!(parse_yaml_datetime(date).unwrap(), datetime!(2002-12-14 0:00:00 +0)); + assert_eq!(parse_yaml_datetime(date).unwrap(), datetime!(2002-12-14 00:00:00.000 +00:00)); } #[test] @@ -218,4 +206,125 @@ mod tests { let unparseable_time = "2001-12-15:59:4x.1Z"; assert!(parse_yaml_datetime(unparseable_time).is_err()); } + + #[test] + fn toml_test_pass() { + // tests subset from toml-test + // Taken from https://github.com/toml-lang/toml-test/tree/a80ce8268cbcf5ea95f02b2e6d6cc38406ce28c9/tests/valid/datetime + let space = "1987-07-05 17:45:00Z"; + // Z is not allowed to be lowercase + let lower = "1987-07-05t17:45:00Z"; + + let first_offset = "0001-01-01 00:00:00Z"; + let first_local = "0001-01-01 00:00:00"; + let first_date = "0001-01-01"; + let last_offset = "9999-12-31 23:59:59Z"; + let last_local = "9999-12-31 23:59:59"; + let last_date = "9999-12-31"; + + // valid leap years + let datetime_2000 = "2000-02-29 15:15:15Z"; + let datetime_2024 = "2024-02-29 15:15:15Z"; + + // milliseconds + let ms1 = "1987-07-05T17:45:56.123Z"; + let ms2 = "1987-07-05T17:45:56.6Z"; + + // timezones + let utc = "1987-07-05T17:45:56Z"; + let pdt = "1987-07-05T17:45:56-05:00"; + let nzst = "1987-07-05T17:45:56+12:00"; + let nzdt = "1987-07-05T17:45:56+13:00"; // DST + + assert_eq!(parse_yaml_datetime(space).unwrap(), datetime!(1987-07-05 17:45:00.000 +00:00)); + assert_eq!(parse_yaml_datetime(lower).unwrap(), datetime!(1987-07-05 17:45:00.000 +00:00)); + + assert_eq!( + parse_yaml_datetime(first_offset).unwrap(), + datetime!(0001-01-01 00:00:00.000 +00:00) + ); + assert_eq!( + parse_yaml_datetime(first_local).unwrap(), + datetime!(0001-01-01 00:00:00.000 +00:00) + ); + assert_eq!( + parse_yaml_datetime(first_date).unwrap(), + datetime!(0001-01-01 00:00:00.000 +00:00) + ); + assert_eq!( + parse_yaml_datetime(last_offset).unwrap(), + datetime!(9999-12-31 23:59:59.000 +00:00) + ); + assert_eq!( + parse_yaml_datetime(last_local).unwrap(), + datetime!(9999-12-31 23:59:59.000 +00:00) + ); + assert_eq!( + parse_yaml_datetime(last_date).unwrap(), + datetime!(9999-12-31 00:00:00.000 +00:00) + ); + + assert_eq!( + parse_yaml_datetime(datetime_2000).unwrap(), + datetime!(2000-02-29 15:15:15.000 +00:00) + ); + assert_eq!( + parse_yaml_datetime(datetime_2024).unwrap(), + datetime!(2024-02-29 15:15:15.000 +00:00) + ); + + assert_eq!(parse_yaml_datetime(ms1).unwrap(), datetime!(1987-07-05 17:45:56.123 +00:00)); + assert_eq!(parse_yaml_datetime(ms2).unwrap(), datetime!(1987-07-05 17:45:56.600 +00:00)); + + assert_eq!(parse_yaml_datetime(utc).unwrap(), datetime!(1987-07-05 17:45:56.000 +00:00)); + assert_eq!(parse_yaml_datetime(pdt).unwrap(), datetime!(1987-07-05 22:45:56.000 +00:00)); + assert_eq!(parse_yaml_datetime(nzst).unwrap(), datetime!(1987-07-05 05:45:56.000 +00:00)); + assert_eq!(parse_yaml_datetime(nzdt).unwrap(), datetime!(1987-07-05 04:45:56.000 +00:00)); + } + + #[test] + fn toml_test_fail() { + let not_a_leap_year = "2100-02-29T15:15:15Z"; + assert!(parse_yaml_datetime(not_a_leap_year).is_err()); + + let feb_30 = "1988-02-30T15:15:15Z"; + assert!(parse_yaml_datetime(feb_30).is_err()); + + let hour_over = "2006-01-01T24:00:00-00:00"; + assert!(parse_yaml_datetime(hour_over).is_err()); + + let mday_over = "2006-01-32T00:00:00-00:00"; + assert!(parse_yaml_datetime(mday_over).is_err()); + + let mday_under = "2006-01-00T00:00:00-00:00"; + assert!(parse_yaml_datetime(mday_under).is_err()); + + let minute_over = "2006-01-01T00:60:00-00:00"; + assert!(parse_yaml_datetime(minute_over).is_err()); + + let month_over = "2006-13-01T00:00:00-00:00"; + assert!(parse_yaml_datetime(month_over).is_err()); + + let month_under = "2007-00-01T00:00:00-00:00"; + assert!(parse_yaml_datetime(month_under).is_err()); + + let no_secs = "1987-07-05T17:45Z"; + assert!(parse_yaml_datetime(no_secs).is_err()); + + let no_sep = "1987-07-0517:45:00Z"; + assert!(parse_yaml_datetime(no_sep).is_err()); + + // 'time' supports up until ±25:59:59 + let offset_overflow = "1985-06-18 17:04:07+26:00"; + assert!(parse_yaml_datetime(offset_overflow).is_err()); + + let offset_overflow = "1985-06-18 17:04:07+12:61"; + assert!(parse_yaml_datetime(offset_overflow).is_err()); + + let second_overflow = "2006-01-01T00:00:61-00:00"; + assert!(parse_yaml_datetime(second_overflow).is_err()); + + let y10k = "10000-01-01 00:00:00z"; + assert!(parse_yaml_datetime(y10k).is_err()); + } } diff --git a/components/utils/src/fs.rs b/components/utils/src/fs.rs index 4aa13994b..85982478a 100644 --- a/components/utils/src/fs.rs +++ b/components/utils/src/fs.rs @@ -200,6 +200,8 @@ pub fn is_temp_file(path: &Path) -> bool { x if x.ends_with("jb_bak___") => true, // vim & jetbrains x if x.ends_with('~') => true, + // helix + x if x.ends_with("bck") => true, _ => { if let Some(filename) = path.file_stem() { // emacs diff --git a/src/fs_utils.rs b/src/fs_utils.rs index 693b4e535..64f8f8641 100644 --- a/src/fs_utils.rs +++ b/src/fs_utils.rs @@ -226,6 +226,7 @@ mod tests { Path::new("hello.html~"), Path::new("#hello.html"), Path::new(".index.md.kate-swp"), + Path::new("smtp.md0HlVyu.bck"), ]; for t in test_cases {