Added support for multiple feeds (i.e. generating both Atom and RSS) (#2477)

* Added support for multiple feeds

* Implemented backwards-compatibility for feed config

* Added a test for feed config backwards-compat, fixed bugs

- Fixed language config merge bug found by a test
- Adjusted two existing tests to fully check stuff related to multiple feeds
- Added a new test for backwards-compatibility of the changes
- Fixed bugs found by the newly added test

* Renamed MightBeSingle to SingleOrVec

* Made the multiple feeds config changes "loudly" backwards-incompatible

* added #[serde(deny_unknown_fields)] to front-matter, fixed problems this found in tests
This commit is contained in:
LunarEclipse363 2024-06-19 14:57:18 +02:00 committed by Vincent Prouillet
parent 7f59792d50
commit c054ed1b10
23 changed files with 163 additions and 128 deletions

View file

@ -11,6 +11,10 @@
- Add `render = false` capability to pages
- Handle string dates in YAML front-matter
- Add support for fuse.js search format
- Added support for generating multiple kinds of feeds at once
- Changed config options named `generate_feed` to `generate_feeds` (both in config.toml and in section front-matter)
- Changed config option `feed_filename: String` to `feed_filenames: Vec<String>`
- The config file no longer allows arbitrary fields outside the `[extra]` section
## 0.18.0 (2023-12-18)

View file

@ -8,17 +8,17 @@ use crate::config::search;
use crate::config::taxonomies;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct LanguageOptions {
/// Title of the site. Defaults to None
pub title: Option<String>,
/// Description of the site. Defaults to None
pub description: Option<String>,
/// Whether to generate a feed for that language, defaults to `false`
pub generate_feed: bool,
/// The filename to use for feeds. Used to find the template, too.
/// Defaults to "atom.xml", with "rss.xml" also having a template provided out of the box.
pub feed_filename: String,
/// Whether to generate feeds for that language, defaults to `false`
pub generate_feeds: bool,
/// The filenames to use for feeds. Used to find the templates, too.
/// Defaults to ["atom.xml"], with "rss.xml" also having a template provided out of the box.
pub feed_filenames: Vec<String>,
pub taxonomies: Vec<taxonomies::TaxonomyConfig>,
/// Whether to generate search index for that language, defaults to `false`
pub build_search_index: bool,
@ -66,9 +66,10 @@ impl LanguageOptions {
merge_field!(self.title, other.title, "title");
merge_field!(self.description, other.description, "description");
merge_field!(
self.feed_filename == "atom.xml",
self.feed_filename,
other.feed_filename,
self.feed_filenames.is_empty()
|| self.feed_filenames == LanguageOptions::default().feed_filenames,
self.feed_filenames,
other.feed_filenames,
"feed_filename"
);
merge_field!(self.taxonomies.is_empty(), self.taxonomies, other.taxonomies, "taxonomies");
@ -79,7 +80,7 @@ impl LanguageOptions {
"translations"
);
self.generate_feed = self.generate_feed || other.generate_feed;
self.generate_feeds = self.generate_feeds || other.generate_feeds;
self.build_search_index = self.build_search_index || other.build_search_index;
if self.search == search::Search::default() {
@ -101,8 +102,8 @@ impl Default for LanguageOptions {
LanguageOptions {
title: None,
description: None,
generate_feed: false,
feed_filename: "atom.xml".to_string(),
generate_feeds: false,
feed_filenames: vec!["atom.xml".to_string()],
taxonomies: vec![],
build_search_index: false,
search: search::Search::default(),
@ -129,8 +130,8 @@ mod tests {
let mut base_default_language_options = LanguageOptions {
title: Some("Site's title".to_string()),
description: None,
generate_feed: true,
feed_filename: "atom.xml".to_string(),
generate_feeds: true,
feed_filenames: vec!["atom.xml".to_string()],
taxonomies: vec![],
build_search_index: true,
search: search::Search::default(),
@ -140,8 +141,8 @@ mod tests {
let section_default_language_options = LanguageOptions {
title: None,
description: Some("Site's description".to_string()),
generate_feed: false,
feed_filename: "rss.xml".to_string(),
generate_feeds: false,
feed_filenames: vec!["rss.xml".to_string()],
taxonomies: vec![],
build_search_index: true,
search: search::Search::default(),
@ -156,8 +157,8 @@ mod tests {
let mut base_default_language_options = LanguageOptions {
title: Some("Site's title".to_string()),
description: Some("Duplicate site description".to_string()),
generate_feed: true,
feed_filename: "".to_string(),
generate_feeds: true,
feed_filenames: vec![],
taxonomies: vec![],
build_search_index: true,
search: search::Search::default(),
@ -167,8 +168,8 @@ mod tests {
let section_default_language_options = LanguageOptions {
title: None,
description: Some("Site's description".to_string()),
generate_feed: false,
feed_filename: "Some feed_filename".to_string(),
generate_feeds: false,
feed_filenames: vec!["Some feed_filename".to_string()],
taxonomies: vec![],
build_search_index: true,
search: search::Search::default(),

View file

@ -30,7 +30,7 @@ pub enum Mode {
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
/// Base URL of the site, the only required config argument
pub base_url: String,
@ -49,13 +49,13 @@ pub struct Config {
/// The translations strings for the default language
translations: HashMap<String, String>,
/// Whether to generate a feed. Defaults to false.
pub generate_feed: bool,
/// Whether to generate feeds. Defaults to false.
pub generate_feeds: bool,
/// The number of articles to include in the feed. Defaults to including all items.
pub feed_limit: Option<usize>,
/// The filename to use for feeds. Used to find the template, too.
/// Defaults to "atom.xml", with "rss.xml" also having a template provided out of the box.
pub feed_filename: String,
/// The filenames to use for feeds. Used to find the templates, too.
/// Defaults to ["atom.xml"], with "rss.xml" also having a template provided out of the box.
pub feed_filenames: Vec<String>,
/// If set, files from static/ will be hardlinked instead of copied to the output dir.
pub hard_link_static: bool,
pub taxonomies: Vec<taxonomies::TaxonomyConfig>,
@ -109,7 +109,7 @@ pub struct SerializedConfig<'a> {
languages: HashMap<&'a String, &'a languages::LanguageOptions>,
default_language: &'a str,
generate_feed: bool,
feed_filename: &'a str,
feed_filenames: &'a [String],
taxonomies: &'a [taxonomies::TaxonomyConfig],
author: &'a Option<String>,
build_search_index: bool,
@ -183,8 +183,10 @@ impl Config {
/// Makes a url, taking into account that the base url might have a trailing slash
pub fn make_permalink(&self, path: &str) -> String {
let trailing_bit =
if path.ends_with('/') || path.ends_with(&self.feed_filename) || path.is_empty() {
let trailing_bit = if path.ends_with('/')
|| self.feed_filenames.iter().any(|feed_filename| path.ends_with(feed_filename))
|| path.is_empty()
{
""
} else {
"/"
@ -212,8 +214,8 @@ impl Config {
let mut base_language_options = languages::LanguageOptions {
title: self.title.clone(),
description: self.description.clone(),
generate_feed: self.generate_feed,
feed_filename: self.feed_filename.clone(),
generate_feeds: self.generate_feeds,
feed_filenames: self.feed_filenames.clone(),
build_search_index: self.build_search_index,
taxonomies: self.taxonomies.clone(),
search: self.search.clone(),
@ -320,8 +322,8 @@ impl Config {
description: &options.description,
languages: self.languages.iter().filter(|(k, _)| k.as_str() != lang).collect(),
default_language: &self.default_language,
generate_feed: options.generate_feed,
feed_filename: &options.feed_filename,
generate_feed: options.generate_feeds,
feed_filenames: &options.feed_filenames,
taxonomies: &options.taxonomies,
author: &self.author,
build_search_index: options.build_search_index,
@ -369,9 +371,9 @@ impl Default for Config {
theme: None,
default_language: "en".to_string(),
languages: HashMap::new(),
generate_feed: false,
generate_feeds: false,
feed_limit: None,
feed_filename: "atom.xml".to_string(),
feed_filenames: vec!["atom.xml".to_string()],
hard_link_static: false,
taxonomies: Vec::new(),
author: None,
@ -428,8 +430,8 @@ mod tests {
languages::LanguageOptions {
title: None,
description: description_lang_section.clone(),
generate_feed: true,
feed_filename: config.feed_filename.clone(),
generate_feeds: true,
feed_filenames: config.feed_filenames.clone(),
taxonomies: config.taxonomies.clone(),
build_search_index: false,
search: search::Search::default(),
@ -456,8 +458,8 @@ mod tests {
languages::LanguageOptions {
title: title_lang_section.clone(),
description: None,
generate_feed: true,
feed_filename: config.feed_filename.clone(),
generate_feeds: true,
feed_filenames: config.feed_filenames.clone(),
taxonomies: config.taxonomies.clone(),
build_search_index: false,
search: search::Search::default(),
@ -976,4 +978,16 @@ author = "person@example.com (Some Person)"
let config = Config::parse(config).unwrap();
assert_eq!(config.author, Some("person@example.com (Some Person)".to_owned()))
}
#[test]
#[should_panic]
fn test_backwards_incompatibility_for_feeds() {
let config = r#"
base_url = "example.com"
generate_feed = true
feed_filename = "test.xml"
"#;
Config::parse(config).unwrap();
}
}

View file

@ -12,7 +12,7 @@ static DEFAULT_PAGINATE_PATH: &str = "page";
/// The front matter of every section
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct SectionFrontMatter {
/// <title> of the page
pub title: Option<String>,
@ -69,7 +69,7 @@ pub struct SectionFrontMatter {
pub aliases: Vec<String>,
/// Whether to generate a feed for the current section
#[serde(skip_serializing)]
pub generate_feed: bool,
pub generate_feeds: bool,
/// Any extra parameter present in the front matter
pub extra: Map<String, Value>,
}
@ -113,7 +113,7 @@ impl Default for SectionFrontMatter {
transparent: false,
page_template: None,
aliases: Vec::new(),
generate_feed: false,
generate_feeds: false,
extra: Map::new(),
draft: false,
}

View file

@ -283,7 +283,7 @@ mod tests {
create_dir_all(path.join(&article_path).join("foo/baz/quux"))
.expect("create nested temp dir");
let mut f = File::create(article_path.join("_index.md")).unwrap();
f.write_all(b"+++\nslug=\"hey\"\n+++\n").unwrap();
f.write_all(b"+++\n+++\n").unwrap();
File::create(article_path.join("example.js")).unwrap();
File::create(article_path.join("graph.jpg")).unwrap();
File::create(article_path.join("fail.png")).unwrap();

View file

@ -160,7 +160,7 @@ pub struct SerializingSection<'a> {
subsections: Vec<&'a str>,
translations: Vec<TranslatedContent<'a>>,
backlinks: Vec<BackLink<'a>>,
generate_feed: bool,
generate_feeds: bool,
transparent: bool,
}
@ -220,7 +220,7 @@ impl<'a> SerializingSection<'a> {
reading_time: section.reading_time,
assets: &section.serialized_assets,
lang: &section.lang,
generate_feed: section.meta.generate_feed,
generate_feeds: section.meta.generate_feeds,
transparent: section.meta.transparent,
pages,
subsections,

View file

@ -42,7 +42,7 @@ fn bench_render_feed(b: &mut test::Bencher) {
let public = &tmp_dir.path().join("public");
site.set_output_path(&public);
b.iter(|| {
site.render_feed(
site.render_feeds(
site.library.read().unwrap().pages.values().collect(),
None,
&site.config.default_language,

View file

@ -27,13 +27,13 @@ impl<'a> SerializedFeedTaxonomyItem<'a> {
}
}
pub fn render_feed(
pub fn render_feeds(
site: &Site,
all_pages: Vec<&Page>,
lang: &str,
base_path: Option<&PathBuf>,
additional_context_fn: impl Fn(Context) -> Context,
) -> Result<Option<String>> {
) -> Result<Option<Vec<String>>> {
let mut pages = all_pages.into_iter().filter(|p| p.meta.date.is_some()).collect::<Vec<_>>();
// Don't generate a feed if none of the pages has a date
@ -73,9 +73,13 @@ pub fn render_feed(
context.insert("config", &site.config.serialize(lang));
context.insert("lang", lang);
let feed_filename = &site.config.feed_filename;
let mut feeds = Vec::new();
for feed_filename in &site.config.feed_filenames {
let mut context = context.clone();
let feed_url = if let Some(base) = base_path {
site.config.make_permalink(&base.join(feed_filename).to_string_lossy().replace('\\', "/"))
site.config
.make_permalink(&base.join(feed_filename).to_string_lossy().replace('\\', "/"))
} else {
site.config.make_permalink(feed_filename)
};
@ -84,7 +88,8 @@ pub fn render_feed(
context = additional_context_fn(context);
let feed = render_template(feed_filename, &site.tera, context, &site.config.theme)?;
Ok(Some(feed))
feeds.push(render_template(feed_filename, &site.tera, context, &site.config.theme)?);
}
Ok(Some(feeds))
}

View file

@ -1,4 +1,4 @@
pub mod feed;
pub mod feeds;
pub mod link_checking;
mod minify;
pub mod sass;
@ -746,23 +746,23 @@ impl Site {
start = log_time(start, "Rendered sitemap");
let library = self.library.read().unwrap();
if self.config.generate_feed {
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_feed(pages, None, &self.config.default_language, |c| c)?;
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_feed {
if !language.generate_feeds {
continue;
}
let pages: Vec<_> = library.pages.values().filter(|p| &p.lang == code).collect();
self.render_feed(pages, Some(&PathBuf::from(code)), code, |c| c)?;
self.render_feeds(pages, Some(&PathBuf::from(code)), code, |c| c)?;
start = log_time(start, "Generated feed in other language");
}
self.render_themes_css()?;
@ -965,14 +965,16 @@ impl Site {
} else {
PathBuf::from(format!("{}/{}/{}", taxonomy.lang, taxonomy.slug, item.slug))
};
self.render_feed(
self.render_feeds(
item.pages.iter().map(|p| library.pages.get(p).unwrap()).collect(),
Some(&tax_path),
&taxonomy.lang,
|mut context: Context| {
context.insert("taxonomy", &taxonomy.kind);
context
.insert("term", &feed::SerializedFeedTaxonomyItem::from_item(item));
context.insert(
"term",
&feeds::SerializedFeedTaxonomyItem::from_item(item),
);
context
},
)
@ -1028,23 +1030,23 @@ impl Site {
Ok(())
}
/// Renders a feed for the given path and at the given path
/// If both arguments are `None`, it will render only the feed for the whole
/// Renders feeds for the given path and at the given path
/// If both arguments are `None`, it will render only the feeds for the whole
/// site at the root folder.
pub fn render_feed(
pub fn render_feeds(
&self,
all_pages: Vec<&Page>,
base_path: Option<&PathBuf>,
lang: &str,
additional_context_fn: impl Fn(Context) -> Context,
) -> Result<()> {
let feed = match feed::render_feed(self, all_pages, lang, base_path, additional_context_fn)?
{
let feeds =
match feeds::render_feeds(self, all_pages, lang, base_path, additional_context_fn)? {
Some(v) => v,
None => return Ok(()),
};
let feed_filename = &self.config.feed_filename;
for (feed, feed_filename) in feeds.into_iter().zip(self.config.feed_filenames.iter()) {
if let Some(base) = base_path {
let mut components = Vec::new();
for component in base.components() {
@ -1058,6 +1060,8 @@ impl Site {
} else {
self.write_content(&[], feed_filename, feed)?;
}
}
Ok(())
}
@ -1076,10 +1080,10 @@ impl Site {
output_path.push(component);
}
if section.meta.generate_feed {
if section.meta.generate_feeds {
let library = &self.library.read().unwrap();
let pages = section.pages.iter().map(|k| library.pages.get(k).unwrap()).collect();
self.render_feed(
self.render_feeds(
pages,
Some(&PathBuf::from(&section.path[1..])),
&section.lang,

View file

@ -461,27 +461,34 @@ title = "A title"
}
#[test]
fn can_get_feed_url_with_default_language() {
fn can_get_feed_urls_with_default_language() {
let config = Config::parse(CONFIG_DATA).unwrap();
let dir = create_temp_dir();
let static_fn =
GetUrl::new(dir.path().to_path_buf(), config.clone(), HashMap::new(), PathBuf::new());
for feed_filename in &config.feed_filenames {
let mut args = HashMap::new();
args.insert("path".to_string(), to_value(config.feed_filename).unwrap());
args.insert("path".to_string(), to_value(feed_filename).unwrap());
args.insert("lang".to_string(), to_value("fr").unwrap());
assert_eq!(static_fn.call(&args).unwrap(), "https://remplace-par-ton-url.fr/atom.xml");
}
}
#[test]
fn can_get_feed_url_with_other_language() {
fn can_get_feed_urls_with_other_language() {
let config = Config::parse(CONFIG_DATA).unwrap();
let dir = create_temp_dir();
let static_fn =
GetUrl::new(dir.path().to_path_buf(), config.clone(), HashMap::new(), PathBuf::new());
for feed_filename in &config.feed_filenames {
let mut args = HashMap::new();
args.insert("path".to_string(), to_value(config.feed_filename).unwrap());
args.insert("path".to_string(), to_value(feed_filename).unwrap());
args.insert("lang".to_string(), to_value("en").unwrap());
assert_eq!(static_fn.call(&args).unwrap(), "https://remplace-par-ton-url.fr/en/atom.xml");
assert_eq!(
static_fn.call(&args).unwrap(),
"https://remplace-par-ton-url.fr/en/atom.xml"
);
}
}
#[test]

View file

@ -11,7 +11,7 @@ to your `config.toml`. For example:
```toml
[languages.fr]
generate_feed = true # there will be a feed for French content
generate_feeds = true # there will be a feed for French content
build_search_index = true
taxonomies = [
{name = "auteurs"},

View file

@ -106,11 +106,11 @@ transparent = false
# current one. This takes an array of paths, not URLs.
aliases = []
# If set to "true", a feed file will be generated for this section at the
# If set to "true", feed files will be generated for this section at the
# section's root path. This is independent of the site-wide variable of the same
# name. The section feed will only include posts from that respective feed, and
# not from any other sections, including sub-sections under that section.
generate_feed = false
generate_feeds = false
# Your own data.
[extra]

View file

@ -65,12 +65,12 @@ ignored_content = []
ignored_static = []
# When set to "true", a feed is automatically generated.
generate_feed = false
generate_feeds = false
# The filename to use for the feed. Used as the template filename, too.
# Defaults to "atom.xml", which has a built-in template that renders an Atom 1.0 feed.
# The filenames to use for the feeds. Used as the template filenames, too.
# Defaults to ["atom.xml"], which has a built-in template that renders an Atom 1.0 feed.
# There is also a built-in template "rss.xml" that renders an RSS 2.0 feed.
feed_filename = "atom.xml"
feed_filenames = ["atom.xml"]
# The number of articles to include in the feed. All items are included if
# this limit is not set (the default).
@ -199,13 +199,13 @@ index_format = "elasticlunr_javascript"
# Additional languages definition
# You can define language specific config values and translations:
# title, description, generate_feed, feed_filename, taxonomies, build_search_index
# title, description, generate_feeds, feed_filenames, taxonomies, build_search_index
# as well as its own search configuration and translations (see above for details on those)
[languages]
# For example
# [languages.fr]
# title = "Mon blog"
# generate_feed = true
# generate_feeds = true
# taxonomies = [
# {name = "auteurs"},
# {name = "tags"},

View file

@ -4,13 +4,13 @@ weight = 50
aliases = ["/documentation/templates/rss/"]
+++
If the site `config.toml` file sets `generate_feed = true`, then Zola will
generate a feed file for the site, named according to the `feed_filename`
If the site `config.toml` file sets `generate_feeds = true`, then Zola will
generate feed files for the site, named according to the `feed_filenames`
setting in `config.toml`, which defaults to `atom.xml`. Given the feed filename
`atom.xml`, the generated file will live at `base_url/atom.xml`, based upon the
`atom.xml` file in the `templates` directory, or the built-in Atom template.
`feed_filename` can be set to any value, but built-in templates are provided
`feed_filenames` can be set to any value, but built-in templates are provided
for `atom.xml` (in the preferred Atom 1.0 format), and `rss.xml` (in the RSS
2.0 format). If you choose a different filename (e.g. `feed.xml`), you will
need to provide a template yourself.
@ -54,7 +54,7 @@ Feeds for taxonomy terms get two more variables, using types from the
- `term`: of type `TaxonomyTerm`, but without `term.pages` (use `pages` instead)
You can also enable separate feeds for each section by setting the
`generate_feed` variable to true in the respective section's front matter.
`generate_feeds` variable to true in the respective section's front matter.
Section feeds will use the same template as indicated in the `config.toml` file.
Section feeds, in addition to the five feed template variables, get the
`section` variable from the [section

View file

@ -108,8 +108,8 @@ lang: String;
translations: Array<TranslatedContent>;
// All the pages/sections linking this page: their permalink and a title if there is one
backlinks: Array<{permalink: String, title: String?}>;
// Whether this section generates a feed or not. Taken from the front-matter if set
generate_feed: bool;
// Whether this section generates feeds or not. Taken from the front-matter if set
generate_feeds: bool;
// Whether this section is transparent. Taken from the front-matter if set
transparent: bool;
```

View file

@ -120,7 +120,7 @@ Here's a breakdown of the configuration settings tailored for this theme:
- **build_search_index**: If set to `true`, a search index will be built from the pages and section content for the `default_language`.
In this configuration and for this theme, it's disabled (`false`).
- **generate_feed**: Determines if an Atom feed (file `atom.xml`) is automatically generated.
- **generate_feeds**: Determines if an Atom feed (file `atom.xml`) is automatically generated.
It's set to `true`, meaning a feed will be generated.
- **taxonomies**: An array of taxonomies (classification systems) used for the site.

View file

@ -1,7 +1,7 @@
title = "My Integration Testing site"
base_url = "https://replace-this-with-your-url.com"
compile_sass = true
generate_feed = true
generate_feeds = true
theme = "sample"
taxonomies = [

View file

@ -5,5 +5,5 @@ template = "section_paginated.html"
insert_anchor_links = "left"
sort_by = "date"
aliases = ["another-old-url/index.html"]
generate_feed = true
generate_feeds = true
+++

View file

@ -2,7 +2,7 @@
title = "Programming"
sort_by = "weight"
weight = 1
generate_feed = true
generate_feeds = true
[extra]
we_have_extra = "variables"

View file

@ -9,7 +9,7 @@ build_search_index = true
default_language = "en"
generate_feed = true
generate_feeds = true
taxonomies = [
{name = "authors", feed = true},
@ -17,7 +17,7 @@ taxonomies = [
]
[languages.fr]
generate_feed = true
generate_feeds = true
taxonomies = [
{name = "auteurs", feed = true},
{name = "tags"},

View file

@ -1,7 +1,7 @@
+++
title = "Mon blog"
sort_by = "date"
insert_anchors = "right"
insert_anchor_links = "right"
+++
[Dernières nouvelles](#news)

View file

@ -1,5 +1,5 @@
+++
title = "Il mio blog"
sort_by = "date"
insert_anchors = "right"
insert_anchor_links = "right"
+++

View file

@ -1,5 +1,5 @@
+++
title = "My blog"
sort_by = "date"
insert_anchors = "left"
insert_anchor_links = "left"
+++