Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## 0.23.4 (unreleased)

- Add `inherit_metadata` setting (config-wide and per-section) letting translated pages inherit front matter they don't specify from the default language

## 0.23.3 (2026-08-11)

- Fix Windows issues
Expand Down
20 changes: 20 additions & 0 deletions components/config/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ pub struct Config {
pub default_language: String,
/// The list of supported languages outside of the default one
pub languages: HashMap<String, languages::LanguageOptions>,
/// Whether translated pages inherit front matter they don't specify from the default
/// language. Can be overridden per section. Defaults to false.
pub inherit_metadata: bool,
/// The translations strings for the default language
translations: HashMap<String, String>,

Expand Down Expand Up @@ -460,6 +463,7 @@ impl Default for Config {
theme: None,
default_language: "en".to_string(),
languages: HashMap::new(),
inherit_metadata: false,
generate_feeds: false,
feed_limit: None,
feed_filenames: vec!["atom.xml".to_string()],
Expand Down Expand Up @@ -1142,5 +1146,21 @@ base_url = "example.com"
assert!(config.generate_robots_txt);
}

#[test]
fn can_parse_inherit_metadata() {
let config = r#"
base_url = "example.com"
inherit_metadata = true
"#;
let config = Config::parse(config).unwrap();
assert!(config.inherit_metadata);
}

#[test]
fn inherit_metadata_defaults_to_false() {
let config = Config::default();
assert!(!config.inherit_metadata);
}

// TODO: add a test for excluding paginated pages
}
2 changes: 1 addition & 1 deletion components/content/src/front_matter/extra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use serde::Deserialize;
use tera::{Map, Value};

/// Convert a toml::Value to a tera Value
fn toml_value_to_tera(val: toml::Value) -> Value {
pub(crate) fn toml_value_to_tera(val: toml::Value) -> Value {
match val {
toml::Value::String(s) => Value::from(s),
toml::Value::Integer(i) => Value::from(i),
Expand Down
165 changes: 165 additions & 0 deletions components/content/src/front_matter/merge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
use std::path::Path;

use errors::{Context, Result, bail};
use tera::{Map, Value};

use crate::front_matter::PageFrontMatter;
use crate::front_matter::extra::toml_value_to_tera;
use crate::front_matter::split::RawFrontMatter;

/// Front-matter keys never inherited from the default-language page.
const NEVER_INHERITED: [&str; 3] = ["slug", "path", "aliases"];

/// Parses a raw front matter into a tera map; TOML datetimes become strings.
fn raw_to_map(raw: &RawFrontMatter) -> Result<Map> {
match raw.deserialize()? {
toml::Value::Table(table) => {
let mut map = Map::new();
for (key, value) in table {
map.insert(key.into(), toml_value_to_tera(value));
}
Ok(map)
}
_ => bail!("Front matter is not a table"),
}
}

/// Recursive table merge: `overlay` wins; arrays and scalars replace wholly.
fn merge_maps(base: &Map, overlay: &Map) -> Map {
let mut merged = base.clone();
for (key, value) in overlay {
let value = match (merged.get(key), value) {
(Some(base_value), overlay_value)
if base_value.as_map().is_some() && overlay_value.as_map().is_some() =>
{
Value::from(merge_maps(
base_value.as_map().unwrap(),
overlay_value.as_map().unwrap(),
))
}
_ => value.clone(),
};
merged.insert(key.clone(), value);
}
merged
}

/// Merge the translation's raw front matter over the default-language one and parse the result.
pub fn merge_inherited_raw(
default: &RawFrontMatter,
translated: &RawFrontMatter,
translated_path: &Path,
) -> Result<PageFrontMatter> {
let context = || {
format!(
"Error when parsing inherited front matter of page `{}`",
translated_path.to_string_lossy()
)
};
let mut base = raw_to_map(default).with_context(context)?;
base.retain(|key, _| !NEVER_INHERITED.contains(&key.as_str().unwrap_or_default()));
let merged = merge_maps(&base, &raw_to_map(translated).with_context(context)?);
PageFrontMatter::parse_value(Value::from(merged)).with_context(context)
}

#[cfg(test)]
mod tests {
use super::merge_inherited_raw;
use crate::front_matter::split::RawFrontMatter;
use std::path::Path;
use time::macros::datetime;

const P: &str = "content/blog/post.fr.md";

#[test]
fn missing_keys_inherit_translated_win() {
let default = RawFrontMatter::Toml("title = \"Default\"\nweight = 10\n");
let translated = RawFrontMatter::Toml("title = \"Traduit\"\n");
let meta = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap();
assert_eq!(meta.title.as_deref(), Some("Traduit"));
assert_eq!(meta.weight, Some(10));
}

#[test]
fn tables_merge_recursively_arrays_replace() {
let default = RawFrontMatter::Toml(
"[extra]\ncover = \"cover.png\"\nflavor = \"default\"\ntypes = [\"a\", \"b\"]\n\
[extra.abilities]\n\"Fire walk\" = \"yes\"\n\"Swim\" = \"no\"\n",
);
let translated = RawFrontMatter::Toml(
"[extra]\nflavor = \"traduit\"\ntypes = []\n[extra.abilities]\n\"Fire walk\" = \"oui\"\n",
);
let meta = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap();
let extra = meta.extra.as_map().unwrap();
assert_eq!(extra.get(&"cover".into()).unwrap(), &tera::Value::from("cover.png"));
assert_eq!(extra.get(&"flavor".into()).unwrap(), &tera::Value::from("traduit"));
assert!(extra.get(&"types".into()).unwrap().as_array().unwrap().is_empty());
let abilities = extra.get(&"abilities".into()).unwrap().as_map().unwrap();
assert_eq!(abilities.get(&"Fire walk".into()).unwrap(), &tera::Value::from("oui"));
assert_eq!(abilities.get(&"Swim".into()).unwrap(), &tera::Value::from("no"));
}

#[test]
fn slug_path_aliases_are_never_inherited() {
let default = RawFrontMatter::Toml(
"slug = \"default-slug\"\npath = \"/somewhere\"\naliases = [\"/old\"]\n",
);
let translated = RawFrontMatter::Toml("title = \"x\"\n");
let meta = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap();
assert_eq!(meta.slug, None);
assert_eq!(meta.path, None);
assert_eq!(meta.aliases, Vec::<String>::new());
}

#[test]
fn bools_and_dates_inherit_and_derive() {
let default = RawFrontMatter::Toml("draft = true\ndate = 2002-10-12\n");
let translated = RawFrontMatter::Toml("title = \"x\"\n");
let meta = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap();
assert!(meta.draft);
assert_eq!(meta.datetime.unwrap(), datetime!(2002 - 10 - 12 0:00 UTC));
}

#[test]
fn explicit_bool_in_translation_wins() {
let default = RawFrontMatter::Toml("draft = true\n");
let translated = RawFrontMatter::Toml("draft = false\n");
let meta = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap();
assert!(!meta.draft);
}

#[test]
fn cross_format_merge() {
let default = RawFrontMatter::Yaml("weight: 42\ndate: 2002-10-12\n");
let translated = RawFrontMatter::Toml("title = \"x\"\n");
let meta = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap();
assert_eq!(meta.weight, Some(42));
assert_eq!(meta.datetime.unwrap(), datetime!(2002 - 10 - 12 0:00 UTC));
}

#[test]
fn taxonomies_merge_per_key() {
let default = RawFrontMatter::Toml("[taxonomies]\ntags = [\"hello\"]\nrelated = [\"a\"]\n");
let translated = RawFrontMatter::Toml("[taxonomies]\ntags = [\"bonjour\"]\n");
let meta = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap();
assert_eq!(meta.taxonomies["tags"], vec!["bonjour".to_string()]);
assert_eq!(meta.taxonomies["related"], vec!["a".to_string()]);
}

#[test]
fn invalid_inherited_value_errors_with_translated_path() {
let default = RawFrontMatter::Toml("date = \"not-a-date\"\n");
let translated = RawFrontMatter::Toml("title = \"x\"\n");
let err = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap_err();
assert!(format!("{err:?}").contains(P));
}

#[test]
fn null_values_error_with_translated_path() {
// toml::Value has no null arm, so explicit YAML nulls fail during normalization
let default = RawFrontMatter::Yaml("title: Hello\ndescription:\n");
let translated = RawFrontMatter::Toml("title = \"x\"\n");
let err = merge_inherited_raw(&default, &translated, Path::new(P)).unwrap_err();
assert!(format!("{err:?}").contains(P));
}
}
6 changes: 5 additions & 1 deletion components/content/src/front_matter/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
mod datetime;
mod extra;
mod merge;
mod page;
mod section;
mod split;

pub use merge::merge_inherited_raw;
pub use page::PageFrontMatter;
pub use section::SectionFrontMatter;
pub use split::{split_page_content, split_section_content};
pub use split::{
OwnedRawFrontMatter, RawFrontMatter, split_page_content_with_raw, split_section_content,
};
27 changes: 19 additions & 8 deletions components/content/src/front_matter/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,37 +99,48 @@ fn parse_datetime(d: &str) -> Option<OffsetDateTime> {

impl PageFrontMatter {
pub fn parse(raw: &RawFrontMatter) -> Result<PageFrontMatter> {
let mut f: PageFrontMatter = raw.deserialize()?;
let f: PageFrontMatter = raw.deserialize()?;
f.validate()
}

/// Parses merged front matter (from merging a translation over its default-language
/// counterpart) with the same validation as `parse`.
pub(crate) fn parse_value(value: Value) -> Result<PageFrontMatter> {
let f: PageFrontMatter = PageFrontMatter::deserialize(value)
.map_err(|e| errors::anyhow!("Error deserializing merged front matter: {e}"))?;
f.validate()
}

if let Some(ref slug) = f.slug
fn validate(mut self) -> Result<PageFrontMatter> {
if let Some(ref slug) = self.slug
&& slug.is_empty()
{
bail!("`slug` can't be empty if present")
}

if let Some(ref path) = f.path
if let Some(ref path) = self.path
&& path.is_empty()
{
bail!("`path` can't be empty if present")
}

f.date_to_datetime();
self.date_to_datetime();

for terms in f.taxonomies.values() {
for terms in self.taxonomies.values() {
for term in terms {
if term.trim().is_empty() {
bail!("A taxonomy term cannot be an empty string");
}
}
}

if let Some(ref date) = f.date
&& f.datetime.is_none()
if let Some(ref date) = self.date
&& self.datetime.is_none()
{
bail!("`date` could not be parsed: {}.", date);
}

Ok(f)
Ok(self)
}

/// Converts the TOML datetime to a time::OffsetDateTime
Expand Down
22 changes: 22 additions & 0 deletions components/content/src/front_matter/section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ pub struct SectionFrontMatter {
/// Pages and subsections can override it by setting their own `hidden` field
#[serde(skip_serializing)]
pub hidden: Option<bool>,
/// Whether pages and subsections inherit front matter from the default language.
/// Overrides the config value for this subtree.
#[serde(skip_serializing)]
pub inherit_metadata: Option<bool>,
/// Any extra parameter present in the front matter
#[serde(default = "default_extra", deserialize_with = "deserialize_extra")]
pub extra: Value,
Expand Down Expand Up @@ -110,8 +114,26 @@ impl Default for SectionFrontMatter {
aliases: Vec::new(),
generate_feeds: false,
hidden: None,
inherit_metadata: None,
extra: Value::from(Map::new()),
draft: false,
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::front_matter::split::RawFrontMatter;

#[test]
fn section_inherit_metadata_is_optional() {
let raw = RawFrontMatter::Toml("title = \"Section\"\ninherit_metadata = false\n");
let meta = SectionFrontMatter::parse(&raw).unwrap();
assert_eq!(meta.inherit_metadata, Some(false));

let raw = RawFrontMatter::Toml("title = \"Section\"\n");
let meta = SectionFrontMatter::parse(&raw).unwrap();
assert_eq!(meta.inherit_metadata, None);
}
}
Loading