diff --git a/crates/zensical/src/compat/mkdocs.rs b/crates/zensical/src/compat/mkdocs.rs index 2390871..f40735b 100644 --- a/crates/zensical/src/compat/mkdocs.rs +++ b/crates/zensical/src/compat/mkdocs.rs @@ -28,3 +28,4 @@ pub mod html; pub mod plugin; pub mod resource; +pub mod url; diff --git a/crates/zensical/src/compat/mkdocs/html.rs b/crates/zensical/src/compat/mkdocs/html.rs index b23140a..65980bd 100644 --- a/crates/zensical/src/compat/mkdocs/html.rs +++ b/crates/zensical/src/compat/mkdocs/html.rs @@ -27,9 +27,12 @@ use html5gum::emitters::callback::{CallbackEmitter, CallbackEvent}; use html5gum::{Span, Tokenizer}; +use std::collections::HashMap; use std::convert::Infallible; use std::ops::Range; +use super::url; + // ---------------------------------------------------------------------------- // Traits // ---------------------------------------------------------------------------- @@ -55,6 +58,30 @@ pub struct Editor<'a> { edits: Vec, } +/// Rewrites page-relative link and media targets between route bases. +struct RebaseUrls<'a> { + /// Route against which current relative URLs are resolved. + from: &'a str, + /// Route from which rewritten relative URLs are emitted. + to: &'a str, + /// Optional target used for fragment-only links. + fragment_base: Option<&'a str>, + /// Whether the tokenizer is currently reading an attribute value. + attribute: bool, + /// Whether the current attribute is an `href` rather than a `src`. + href: bool, +} + +/// Rewrites resource source paths to their emitted public paths. +struct RewriteUrls<'a> { + /// Route against which relative resource URLs are resolved. + base: &'a str, + /// Source-to-public resource path mapping. + mappings: &'a HashMap, + /// Whether the tokenizer is currently reading a rewritable attribute. + attribute: bool, +} + /// One replacement in the original HTML input. #[derive(Debug, PartialEq, Eq)] struct Edit { @@ -202,6 +229,71 @@ impl<'a> Editor<'a> { } } +impl Visitor for RebaseUrls<'_> { + fn visit( + &mut self, event: &CallbackEvent<'_>, span: Span, + editor: &mut Editor<'_>, + ) { + match event { + CallbackEvent::OpenStartTag { .. } => { + self.attribute = false; + self.href = false; + } + CallbackEvent::AttributeName { name } => { + self.attribute = matches!(*name, b"href" | b"src"); + self.href = *name == b"href"; + } + CallbackEvent::AttributeValue { value } if self.attribute => { + let value = String::from_utf8_lossy(value); + if self.href + && value.starts_with('#') + && let Some(base) = self.fragment_base + { + editor.replace( + span.start..span.end, + format!("{base}{value}"), + ); + } else if let Some(value) = + url::rebase(self.from, self.to, &value) + { + editor.replace(span.start..span.end, value); + } + } + _ => {} + } + } +} + +impl Visitor for RewriteUrls<'_> { + fn visit( + &mut self, event: &CallbackEvent<'_>, span: Span, + editor: &mut Editor<'_>, + ) { + match event { + CallbackEvent::OpenStartTag { .. } => self.attribute = false, + CallbackEvent::AttributeName { name } => { + self.attribute = matches!(*name, b"href" | b"src"); + } + CallbackEvent::AttributeValue { value } if self.attribute => { + let value = String::from_utf8_lossy(value); + let Some(target) = url::resolve(self.base, &value) else { + return; + }; + let suffix = target.find(['?', '#']).unwrap_or(target.len()); + let (path, suffix) = target.split_at(suffix); + let Some(path) = self.mappings.get(path) else { + return; + }; + editor.replace( + span.start..span.end, + url::relative(self.base, &format!("{path}{suffix}")), + ); + } + _ => {} + } + } +} + // ---------------------------------------------------------------------------- // Functions // ---------------------------------------------------------------------------- @@ -230,6 +322,50 @@ pub fn scan(input: &str, visitors: &mut [&mut dyn Visitor]) -> Option { editor.finish() } +/// Rebases local `href` and `src` attributes between two page routes. +pub fn rebase_urls(input: &str, from: &str, to: &str) -> Option { + if from == to { + return None; + } + let mut visitor = RebaseUrls { + from, + to, + fragment_base: None, + attribute: false, + href: false, + }; + scan(input, &mut [&mut visitor]) +} + +/// Rebases URLs and prefixes fragment-only links with a separate base. +pub fn rebase_urls_with_fragment_base( + input: &str, from: &str, to: &str, fragment_base: &str, +) -> Option { + let mut visitor = RebaseUrls { + from, + to, + fragment_base: Some(fragment_base), + attribute: false, + href: false, + }; + scan(input, &mut [&mut visitor]) +} + +/// Rewrites local URLs whose source resources are emitted at another path. +pub fn rewrite_urls( + input: &str, base: &str, mappings: &HashMap, +) -> Option { + if mappings.is_empty() { + return None; + } + let mut visitor = RewriteUrls { + base, + mappings, + attribute: false, + }; + scan(input, &mut [&mut visitor]) +} + /// Returns whether a byte is HTML whitespace. fn is_whitespace(byte: u8) -> bool { matches!(byte, b'\t' | b'\n' | 0x0c | b'\r' | b' ') @@ -251,7 +387,11 @@ mod tests { use html5gum::emitters::callback::CallbackEvent; use html5gum::Span; - use super::{scan, Editor, Visitor}; + use super::{ + rebase_urls, rebase_urls_with_fragment_base, rewrite_urls, scan, + Editor, Visitor, + }; + use std::collections::HashMap; #[derive(Default)] struct RemoveDataAttribute; @@ -334,4 +474,69 @@ mod tests { Some("slot") ); } + + #[test] + fn rebases_link_and_media_attributes_without_reserializing_html() { + let input = concat!( + r#"Notes"#, + r#""#, + r#"External"#, + ); + assert_eq!( + rebase_urls(input, "blog/2026/09/post/", "blog/page/2/").as_deref(), + Some(concat!( + r#"Notes"#, + r#""#, + r#"External"#, + )) + ); + } + + #[test] + fn rebases_excerpt_fragment_links_to_the_full_post() { + let input = concat!( + r##"Detail"##, + r#""#, + ); + assert_eq!( + rebase_urls_with_fragment_base( + input, + "blog/2026/09/post/", + "blog/page/2/", + "../../2026/09/post/", + ) + .as_deref(), + Some(concat!( + r##"Detail"##, + r#""#, + )) + ); + } + + #[test] + fn rewrites_relocated_resource_urls_without_reserializing_html() { + let input = concat!( + r#"File"#, + r#""#, + r#"External"#, + ); + let mappings = HashMap::from([ + ( + "blog/posts/assets/file.pdf".into(), + "blog/assets/file.pdf".into(), + ), + ( + "blog/posts/assets/image.png".into(), + "blog/assets/image.png".into(), + ), + ]); + assert_eq!( + rewrite_urls(input, "blog/2026/09/post/", &mappings).as_deref(), + Some(concat!( + r#"File"#, + r#""#, + r#"External"#, + )) + ); + } } diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index e4095eb..72fae04 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -36,6 +36,7 @@ use super::html::{self, Visitor}; pub mod autorefs; pub mod awesome_nav; +pub mod blog; pub mod literate_nav; pub mod meta; pub mod minify; @@ -70,6 +71,8 @@ pub struct Settings { pub search: Arc, /// Material tags compatibility pipeline. pub tags: tags::Tags, + /// Material blog compatibility pipeline. + pub blog: blog::Blog, } // ---------------------------------------------------------------------------- @@ -83,6 +86,7 @@ impl Settings { autorefs: Arc::new(autorefs::Autorefs::new(config)), search: Arc::new(search::Search::new(config)), tags: tags::Tags::new(config, serve), + blog: blog::Blog::new(config, serve), } } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs index 02c88e2..1476edd 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs @@ -38,6 +38,7 @@ use zrx::stream::function::Collection; use zrx::stream::{Key, Signal, Stream, Value}; use crate::compat::mkdocs::html; +use crate::compat::mkdocs::url::relative; use crate::config::Config; use crate::path::SourcePath; use crate::structure::nav::source_sort_key; @@ -48,7 +49,7 @@ mod url; pub use parser::{Parser, References}; use parser::{Reference, SLOT_PREFIX, SLOT_SUFFIX}; -use url::{closest, is_relative, relative}; +use url::{closest, is_relative}; /// Handled autoref attributes that should not be passed through to the output link. const HANDLED_ATTRS: &[&str] = &[ diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs index d2b84bf..79fc584 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs @@ -28,8 +28,6 @@ use std::path::Path; use std::string::ToString; -use zrx::path::PathExt; - // ---------------------------------------------------------------------------- // Functions // ---------------------------------------------------------------------------- @@ -67,30 +65,6 @@ pub fn closest(from: &str, urls: &[String], _qualifier: &str) -> String { } } -/// Computes a relative URL from one page URL to another. -pub fn relative(from: &str, to: &str) -> String { - let from = Path::new(from); - let (to, fragment) = to - .split_once('#') - .map_or((Path::new(to), None), |(path, fragment)| { - (Path::new(path), Some(fragment)) - }); - let mut relative = - to.relative_to(from).to_string_lossy().replace('\\', "/"); - - if let Some(fragment) = fragment { - if relative == "." { - return format!("#{fragment}"); - } - if to.as_os_str().is_empty() { - relative.push('/'); - } - relative.push('#'); - relative.push_str(fragment); - } - relative -} - /// Returns whether a URL has no HTTP(S) scheme. pub fn is_relative(url: &str) -> bool { !(url.starts_with("http://") || url.starts_with("https://")) @@ -129,7 +103,8 @@ fn parent(url: &str) -> Option { #[cfg(test)] mod tests { - use super::{closest, relative}; + use super::closest; + use crate::compat::mkdocs::url::relative; #[test] fn resolves_the_closest_url() { diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog.rs b/crates/zensical/src/compat/mkdocs/plugin/blog.rs new file mode 100644 index 0000000..c00415b --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog.rs @@ -0,0 +1,1809 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Native Material blog compatibility. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use zrx::id::Id; +use zrx::stream::function::Collection; +use zrx::stream::{Key, Signal, Stream, StreamSetExt, StreamTupleExt, Value}; + +use crate::compat::mkdocs::resource::Resource; +use crate::compat::mkdocs::{html, url}; +use crate::config::plugins::{BlogPluginConfig, CategorySort}; +use crate::config::Config; +use crate::path::SourcePath; +use crate::structure::document::DocumentHeader; +use crate::structure::dynamic::Dynamic; +use crate::structure::nav::{ + NavigationContribution, NavigationItem, NavigationResolution, +}; +use crate::structure::page::{Page, PageDescriptor, PageOrigin, PageRoute}; +use crate::structure::slug; +use crate::structure::toc::Section; +use crate::template::Template; +use crate::watcher::Source; + +mod author; +mod collection; +mod date; +mod excerpt; +mod links; +mod pagination; +mod post; +mod readtime; + +pub use author::Author; +pub use collection::{BlogId, PostId, ViewPageSpec}; +pub use date::BlogDate; +pub use post::PostDescriptor; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Inputs consumed by native blog classification. +pub struct Dependencies<'a> { + /// All resolved Markdown documents in the current revision. + pub documents: &'a Stream, + /// Physical sources used for watched auxiliary blog data. + pub sources: &'a Stream, +} + +/// Unified page descriptors and validated posts. +pub struct Output { + /// Ordinary pages, routed posts, and generated pages. + pub pages: Stream, + /// Published post descriptors for view collection. + pub posts: Stream, + /// Stable page specifications for populated logical views. + pub view_pages: Stream, + /// Revision-complete ordered logical views. + pub views: Stream, +} + +/// Page-local variables derived from revision-complete blog views. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Patch { + /// Stable identity of the page receiving this patch. + target: Key, + /// Rendered page content after blog-owned URL rewrites. + pub content: Option, + /// Page fields contributed after revision-complete resolution. + pub properties: BTreeMap, + /// Top-level variables consumed by Material blog templates. + pub variables: BTreeMap, + /// Visible navigation URL represented by a paginated view page. + pub navigation_url: Option, + /// Explicit hidden-page siblings, when the page isn't a visible item. + pub siblings: Option<(Option, Option)>, + /// View template applied after revision-complete view classification. + pub template: Option, + /// View table of contents after optional excerpt integration. + pub toc: Option>, +} + +/// Configured native blog instances. +#[derive(Clone, Debug)] +pub struct Blog { + /// Resolved project configuration used for routes and template values. + config: Config, + /// Ordered native blog instances paired with their stable identities. + instances: Arc>, + /// Whether draft-on-serve behavior is active. + serve: bool, + /// Build timestamp used to classify future-dated posts. + now: i64, +} + +/// One document classified as an ordinary page or blog post. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Classified { + /// Page descriptor emitted for every admitted document. + page: Option, + /// Post descriptor emitted when the document belongs to a blog. + post: Option, +} + +/// Source or synthesized entrypoint for one configured blog. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Entrypoint { + /// Owning blog instance. + blog: BlogId, + /// Entrypoint document used to render the main view. + document: DocumentHeader, + /// Physical source when the entrypoint was supplied by the user. + provenance: Option, +} + +/// Revision-complete rendered pages used by navigation composition. +#[derive(Clone, Debug)] +struct Pages( + /// Pages in stable stream order. + Arc>, +); + +/// Revision-complete rendered pages paired with their stream identities. +#[derive(Clone, Debug)] +struct KeyedPages( + /// Pages and the stable keys targeted by page-local patches. + Arc, Page)>>, +); + +/// Revision-complete generated view-page specifications. +#[derive(Clone, Debug)] +struct ViewPages( + /// View pages in stable stream order. + Arc>, +); + +/// Revision-complete document headers used by generated views. +#[derive(Clone, Debug)] +struct Documents( + /// Documents in stable stream order. + Arc>, +); + +/// Hidden generated page admitted into navigation relation resolution. +#[derive(Clone, Debug, PartialEq, Eq)] +struct HiddenNavigationPage { + /// Stable identity of the hidden page. + target: Key, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Patch { + fn relations( + target: Key, + siblings: (Option, Option), + ) -> Self { + Self { + target, + content: None, + properties: BTreeMap::new(), + variables: BTreeMap::new(), + navigation_url: None, + siblings: Some(siblings), + template: None, + toc: None, + } + } + + fn merge(&mut self, other: &Self) -> anyhow::Result<()> { + if self.target != other.target { + anyhow::bail!("cannot merge blog patches for different pages") + } + merge_map(&mut self.properties, &other.properties, "properties")?; + merge_map(&mut self.variables, &other.variables, "variables")?; + merge_option(&mut self.content, other.content.as_ref(), "content")?; + merge_option( + &mut self.navigation_url, + other.navigation_url.as_ref(), + "navigation URL", + )?; + merge_option(&mut self.siblings, other.siblings.as_ref(), "siblings")?; + merge_option(&mut self.template, other.template.as_ref(), "template")?; + merge_option(&mut self.toc, other.toc.as_ref(), "table of contents")?; + Ok(()) + } +} + +impl Blog { + /// Resolves enabled instances and their stable configuration-order IDs. + pub fn new(config: &Config, serve: bool) -> Self { + let instances = config + .project + .plugins + .blogs + .config + .iter() + .enumerate() + .filter(|(_, instance)| instance.config.enabled) + .map(|(index, instance)| (BlogId(index), instance.config.clone())) + .collect(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_micros()).unwrap_or(i64::MAX) + }); + Self { + config: config.clone(), + instances: Arc::new(instances), + serve, + now, + } + } + + /// Routes posts before Markdown rendering and preserves ordinary pages. + pub fn setup(&self, dependencies: Dependencies<'_>) -> Output { + let blog = self.clone(); + let classified = + dependencies + .documents + .map(move |document: &DocumentHeader| { + blog.classify(document.clone()) + }); + let pages = classified.filter_map(|item: &Classified| { + item.post.is_none().then(|| item.page.clone()).flatten() + }); + let posts = + classified.filter_map(|item: &Classified| item.post.clone()); + let catalogs = self.author_catalogs(dependencies.sources); + let selected = catalogs.select(&posts, |post| { + let blog = post.id.blog; + move |catalog: &author::Catalog| catalog.blog == blog + }); + let blog = self.clone(); + let posts = (posts, selected).join().map( + move |(post, catalogs): &AuthorJoin| { + blog.resolve_authors(post.clone(), catalogs) + }, + ); + let post_pages = posts.map(|post: &PostDescriptor| post.page.clone()); + let pages = (pages, post_pages).coalesce(); + let entrypoints = self.entrypoints(dependencies.documents); + let collection = collection::setup(&posts, self.instances.clone()); + let views = collection.views; + let main = views.select(&entrypoints, |entrypoint| { + let blog = entrypoint.blog; + move |view: &collection::OrderedView| { + view.id.blog == blog + && matches!(view.id.kind, collection::ViewKind::Blog) + } + }); + let blog = self.clone(); + let empty = (entrypoints.clone(), main).join().filter_map( + move |(entrypoint, views): &( + Entrypoint, + Vec<(Key, collection::OrderedView)>, + )| { + views.is_empty().then(|| collection::ViewPageSpec { + view: collection::ViewId { + blog: entrypoint.blog, + kind: collection::ViewKind::Blog, + }, + title: entrypoint.document.title.clone(), + path: String::new(), + page: 1, + pages: 1, + posts_total: 0, + posts_per_page: blog + .settings(entrypoint.blog) + .pagination_per_page, + posts: Arc::new(Vec::new()), + order: None, + }) + }, + ); + let view_pages = (collection.pages, empty).coalesce(); + let generated = self.generate_pages( + dependencies.documents, + &entrypoints, + &view_pages, + ); + let pages = (generated, pages).coalesce(); + Output { + pages, + posts, + view_pages, + views, + } + } + + /// Excludes hidden posts and generated blog pages from base navigation. + pub fn navigation_pages( + &self, pages: &Stream, + view_pages: &Stream, + ) -> Stream { + let blog = self.clone(); + let candidates = pages.filter_map(move |page: &Page| { + if matches!( + page.origin(), + PageOrigin::Generated { identity, .. } + if identity.starts_with("blog:") + && !identity.ends_with(":main:1") + ) || blog.is_post_source(page.source())? + { + return Ok(None); + } + Ok::<_, anyhow::Error>(Some(page.clone())) + }); + let blog = self.clone(); + let grouped = candidates.select(view_pages, move |page| { + let source = + (!matches!(page.view.kind, collection::ViewKind::Blog)) + .then(|| { + view_page_source(blog.settings(page.view.blog), page) + }) + .transpose() + .ok() + .flatten(); + move |candidate: &Page| source.as_ref() == Some(candidate.source()) + }); + let grouped = grouped + .flat_map(|pages: &Vec<(Key, Page)>| { + pages + .iter() + .map(|(key, _)| { + ( + key.clone(), + HiddenNavigationPage { target: key.clone() }, + ) + }) + .collect::>() + }) + .unique_by_key(|page: &HiddenNavigationPage| page.target.clone()); + (candidates, grouped).left_join().filter_map( + |(page, hidden): &(Page, Option)| { + hidden.is_none().then(|| page.clone()) + }, + ) + } + + /// Adds grouped views to the resolved navigation immutably. + pub fn navigation( + &self, resolution: &Signal, + pages: &Stream, + view_pages: &Stream, + ) -> Signal { + let pages = pages.reduce(|pages: &dyn Collection, Page>| { + Some(Pages(Arc::new(pages.values().cloned().collect()))) + }); + let view_pages = view_pages.reduce( + |pages: &dyn Collection, collection::ViewPageSpec>| { + Some(ViewPages(Arc::new(pages.values().cloned().collect()))) + }, + ); + let blog = self.clone(); + resolution + .product(&pages) + .product(&view_pages) + .map( + move |(resolution, pages): &(NavigationResolution, Pages), + view_pages: &ViewPages| { + Ok::<_, anyhow::Error>(resolution.contribute( + &blog.contributions(&pages.0, &view_pages.0)?, + )) + }, + ) + .reduce(|values: &dyn Collection, NavigationResolution>| { + values.values().next().cloned() + }) + } + + /// Computes a missing post read time from rendered HTML. + pub fn apply_readtime( + &self, source: &SourcePath, content: &str, + properties: &mut BTreeMap, + ) -> anyhow::Result<()> { + let Some((_, settings)) = + self.instances.iter().find(|(_, settings)| { + post::post_dir(settings).is_ok_and(|directory| { + source.parent().as_ref() == Some(&directory) + || source.is_descendant_of(&directory) + }) + }) + else { + return Ok(()); + }; + if !settings.post_readtime { + return Ok(()); + } + let Some(Dynamic::Map(config)) = properties.get_mut("config") else { + return Ok(()); + }; + if matches!( + config.get("readtime"), + Some(Dynamic::Integer(value)) if *value > 0 + ) { + return Ok(()); + } + config.insert( + "readtime".into(), + Dynamic::Integer(i64::try_from(readtime::calculate( + content, + settings.post_readtime_words_per_minute, + ))?), + ); + Ok(()) + } + + /// Derives excerpt and pagination context for every rendered blog view. + pub fn patches( + &self, pages: &Stream, posts: &Stream, + resources: &Stream, + view_pages: &Stream, + ordered_views: &Stream, + resolution: &Signal, + ) -> Stream { + let patches = ( + self.view_patches(pages, resources, view_pages), + self.post_patches(pages, posts, resources, ordered_views), + self.relation_patches(pages, view_pages, resolution), + ) + .coalesce(); + patches.reduce_by_key( + |patch: &Patch| Ok::<_, anyhow::Error>(patch.target.clone()), + |patches: &dyn Collection, Patch>| { + let mut patches = patches.values(); + let Some(first) = patches.next() else { + return Ok::<_, anyhow::Error>(None); + }; + let mut merged = first.clone(); + for patch in patches { + merged.merge(patch)?; + } + Ok(Some(merged)) + }, + ) + } + + fn relation_patches( + &self, pages: &Stream, + view_pages: &Stream, + resolution: &Signal, + ) -> Stream { + let pages = pages.reduce(|pages: &dyn Collection, Page>| { + Some(KeyedPages(Arc::new( + pages + .iter() + .map(|(key, page)| (key.clone(), page.clone())) + .collect(), + ))) + }); + let view_pages = view_pages.reduce( + |pages: &dyn Collection, collection::ViewPageSpec>| { + Some(ViewPages(Arc::new(pages.values().cloned().collect()))) + }, + ); + let blog = self.clone(); + resolution.product(&pages).product(&view_pages).flat_map( + move |(resolution, pages): &(NavigationResolution, KeyedPages), + view_pages: &ViewPages| { + blog.relation_patch_values(resolution, &pages.0, &view_pages.0) + }, + ) + } + + fn relation_patch_values( + &self, resolution: &NavigationResolution, pages: &[(Key, Page)], + specs: &[collection::ViewPageSpec], + ) -> anyhow::Result, Patch)>> { + let values = pages + .iter() + .map(|(_, page)| page.clone()) + .collect::>(); + let contributions = self.contributions(&values, specs)?; + let by_url = pages + .iter() + .map(|(key, page)| (page.url.as_str(), (key, page))) + .collect::>(); + let by_source = pages + .iter() + .map(|(key, page)| (page.source(), (key, page))) + .collect::>(); + let mut siblings = HashMap::new(); + + for contribution in contributions { + let Some((_, host)) = by_url.get(contribution.index_url.as_str()) + else { + continue; + }; + let Some(visual_tail) = contribution + .items + .last() + .and_then(|section| section.children.last()) + .and_then(|item| item.url.as_deref()) + else { + continue; + }; + let head = resolution.navigation.next_page_for_url(visual_tail); + let mut chain = Vec::from([navigation_item(host)]); + chain.extend( + contribution + .items + .iter() + .rev() + .flat_map(|section| section.children.iter().cloned()), + ); + chain.extend(head.clone()); + + for (index, item) in chain.iter().enumerate() { + let Some(url) = item.url.as_deref() else { + continue; + }; + let previous = if index == 0 { + resolution.navigation.previous_page_for_url(url) + } else { + chain.get(index - 1).cloned() + }; + let next = if let Some(next) = chain.get(index + 1) { + Some(next.clone()) + } else { + resolution.navigation.next_page_for_url(url) + }; + siblings.insert(url.to_owned(), (previous, next)); + } + } + + // Material gives paginated view pages the same external siblings as + // their first page, even though only that first page is visible in + // navigation. + for spec in specs.iter().filter(|spec| spec.page > 1) { + let source = view_page_source(self.settings(spec.view.blog), spec)?; + let mut first = spec.clone(); + first.page = 1; + let first_source = + view_page_source(self.settings(spec.view.blog), &first)?; + let Some((_, first_page)) = by_source.get(&first_source) else { + continue; + }; + let Some(value) = siblings.get(&first_page.url).cloned() else { + continue; + }; + let Some((_, page)) = by_source.get(&source) else { + continue; + }; + siblings.insert(page.url.clone(), value); + } + + Ok(siblings + .into_iter() + .filter_map(|(url, siblings)| { + by_url.get(url.as_str()).map(|(target, _)| { + ( + (*target).clone(), + Patch::relations((*target).clone(), siblings), + ) + }) + }) + .collect()) + } + + fn view_patches( + &self, pages: &Stream, resources: &Stream, + view_pages: &Stream, + ) -> Stream { + let blog = self.clone(); + let views = pages.select(view_pages, move |spec| { + let blog = blog.clone(); + let spec = spec.clone(); + move |page: &Page| blog.matches_view(page, &spec) + }); + let selected_posts = pages.select(view_pages, |spec| { + let sources = spec + .posts + .iter() + .map(|post| post.source.clone()) + .collect::>(); + move |page: &Page| sources.contains(page.source()) + }); + let relocated_resources = resources.select(view_pages, |_| { + |resource: &Resource| resource.source_path != resource.path + }); + let blog = self.clone(); + let patches = ( + view_pages.clone(), + views, + selected_posts, + relocated_resources, + ) + .join() + .filter_map(move |input: &ViewPatchInput| { + blog.view_patch_value(input) + }); + patches.unique_by_key(|patch: &Patch| patch.target.clone()) + } + + fn view_patch_value( + &self, input: &ViewPatchInput, + ) -> anyhow::Result> { + let (spec, views, posts, resources) = input; + let Some((target, view)) = views.first() else { + return Ok(None); + }; + let by_source = posts + .iter() + .map(|(_, page)| (page.source().clone(), page)) + .collect::>(); + let mappings = resource_mappings(resources); + let excerpts = spec + .posts + .iter() + .filter_map(|id| by_source.get(&id.source).copied()) + .map(|page| { + excerpt( + page, + &view.url, + self.settings(spec.view.blog), + &mappings, + ) + }) + .collect::>>()?; + let settings = self.settings(spec.view.blog); + let toc = view_toc(settings, &spec.view.kind) + .then(|| integrated_toc(view, spec, &by_source)); + let pagination = pagination_value(&self.config, settings, spec)?; + let navigation_url = if spec.page > 1 { + let mut first = spec.clone(); + first.page = 1; + Some( + PageRoute::from_source( + &self.config, + view_page_source(self.settings(spec.view.blog), &first)?, + )? + .url, + ) + } else { + None + }; + let mut variables = BTreeMap::from([ + ( + "_blog_date_format".into(), + Dynamic::String(settings.post_date_format.clone()), + ), + ("posts".into(), Dynamic::List(excerpts)), + ("pagination".into(), pagination), + ]); + if let Some(url) = &navigation_url { + variables.insert( + "_blog_original_url".into(), + Dynamic::String(url.clone()), + ); + } + Ok(Some(Patch { + target: target.clone(), + content: None, + properties: BTreeMap::new(), + navigation_url, + siblings: None, + template: Some("blog.html".into()), + toc, + variables, + })) + } + + fn post_patches( + &self, pages: &Stream, posts: &Stream, + resources: &Stream, + ordered_views: &Stream, + ) -> Stream { + let main = + ordered_views.filter_map(|view: &collection::OrderedView| { + matches!(view.id.kind, collection::ViewKind::Blog) + .then(|| view.clone()) + }); + let descriptors = posts.select(&main, |view| { + let sources = view + .posts + .iter() + .map(|post| post.source.clone()) + .collect::>(); + move |post: &PostDescriptor| sources.contains(&post.id.source) + }); + let selected = pages.select(&descriptors, |posts| { + let mut sources = HashSet::new(); + for (_, post) in posts { + sources.insert(post.id.source.clone()); + if let Some(items) = &post.links { + sources.extend(links::targets(items)); + } + } + move |page: &Page| sources.contains(page.source()) + }); + let selected_resources = resources.select(&descriptors, |posts| { + let sources = posts + .iter() + .filter_map(|(_, post)| post.links.as_deref()) + .flat_map(links::targets) + .collect::>(); + move |resource: &Resource| { + resource.source_path != resource.path + || sources.iter().any(|source| { + source.as_str() == resource.source_path.as_str() + }) + } + }); + let blog = self.clone(); + let patches = (main, descriptors, selected, selected_resources) + .join() + .flat_map(move |input: &PostPatchInput| { + blog.post_patch_values(input) + }); + patches.unique_by_key(|patch: &Patch| patch.target.clone()) + } + + fn post_patch_values( + &self, input: &PostPatchInput, + ) -> anyhow::Result, Patch)>> { + let (view, descriptors, pages, resources) = input; + let by_source = pages + .iter() + .map(|(key, page)| (page.source().clone(), (key, page))) + .collect::>(); + let descriptors = descriptors + .iter() + .map(|(_, post)| (post.id.source.clone(), post)) + .collect::>(); + let resolver = links::Resolver::new( + by_source.values().map(|(_, page)| *page), + resources.iter().map(|(_, resource)| resource), + ); + let mappings = resource_mappings(resources); + let item = |index: usize| { + view.posts.get(index).and_then(|post| { + by_source + .get(&post.source) + .map(|(_, page)| navigation_item(page)) + }) + }; + let navigation_url = PageRoute::from_source( + &self.config, + entrypoint_source(self.settings(view.id.blog))?, + )? + .url; + view.posts + .iter() + .enumerate() + .map(|(index, post)| { + let (target, page) = by_source + .get(&post.source) + .expect("ordered posts have selected pages"); + let content = + html::rewrite_urls(&page.content, &page.url, &mappings); + let properties = descriptors + .get(&post.source) + .and_then(|post| post.links.as_deref()) + .map(|items| resolver.resolve(items)) + .transpose()? + .map_or_else(BTreeMap::new, |links| { + BTreeMap::from([( + "config".into(), + Dynamic::Map(BTreeMap::from([( + "links".into(), + links, + )])), + )]) + }); + Ok(( + (*target).clone(), + Patch { + target: (*target).clone(), + content, + properties, + variables: BTreeMap::new(), + navigation_url: Some(navigation_url.clone()), + siblings: Some(( + item(index + 1), + index.checked_sub(1).and_then(item), + )), + template: None, + toc: None, + }, + )) + }) + .collect() + } + + fn classify(&self, document: DocumentHeader) -> anyhow::Result { + let mut matched = None; + for (id, settings) in self.instances.iter() { + let Some(post) = PostDescriptor::from_document( + &self.config, + *id, + settings, + document.clone(), + )? + else { + continue; + }; + if matched.is_some() { + anyhow::bail!( + "post '{}' is claimed by multiple blog instances", + document.source + ) + } + matched = Some((post, settings)); + } + if let Some((post, settings)) = matched { + if post.is_excluded(settings, self.serve, self.now) { + return Ok(Classified { page: None, post: None }); + } + return Ok(Classified { + page: Some(post.page.clone()), + post: Some(post), + }); + } + let mut document = document; + if self.entrypoint(&document.source)?.is_some() { + document + .meta + .entry("template".into()) + .or_insert_with(|| Dynamic::String("blog.html".into())); + } + Ok(Classified { + page: Some(PageDescriptor::source(&self.config, document)?), + post: None, + }) + } + + fn author_catalogs( + &self, sources: &Stream, + ) -> Stream { + let docs = self.config.project.docs_dir.clone(); + let instances = self.instances.clone(); + sources.flat_map(move |id: &Id, source: &Source| { + if id.context() != docs { + return Ok(Vec::new()); + } + let location = id.location().parse::()?; + let mut catalogs = Vec::new(); + for (blog, settings) in instances.iter() { + if !(settings.authors || settings.authors_profiles) + || author::source(settings)? != location + { + continue; + } + let data = fs::read_to_string(&**source)?; + catalogs.push(( + author_catalog_key(*blog), + author::Catalog::parse(*blog, location.clone(), &data)?, + )); + } + Ok::<_, anyhow::Error>(catalogs) + }) + } + + fn resolve_authors( + &self, mut post: PostDescriptor, + catalogs: &[(Key, author::Catalog)], + ) -> anyhow::Result { + let settings = self.settings(post.id.blog); + if !(settings.authors || settings.authors_profiles) { + return Ok(post); + } + let catalog = match catalogs { + [] => None, + [(_, catalog)] => Some(catalog), + _ => anyhow::bail!( + "blog instance {} has multiple authors catalogs", + post.id.blog.0 + ), + }; + let mut authors = Vec::new(); + for id in &post.author_ids { + let Some(author) = + catalog.and_then(|catalog| catalog.authors.get(id)) + else { + anyhow::bail!("couldn't find author '{id}'") + }; + let mut author = author.clone(); + if settings.authors_profiles && author.url.is_none() { + let source = + view_source(settings, &author.profile_path(settings))?; + author.url = + Some(PageRoute::from_source(&self.config, source)?.url); + } + authors.push(author); + } + if settings.authors { + post.page.properties.insert( + "authors".into(), + Dynamic::List( + authors + .iter() + .map(Dynamic::from_serialize) + .collect::>()?, + ), + ); + } + post.authors = authors; + Ok(post) + } + + fn is_post_source(&self, source: &SourcePath) -> anyhow::Result { + for (_, settings) in self.instances.iter() { + let directory = post::post_dir(settings)?; + if source.parent().as_ref() == Some(&directory) + || source.is_descendant_of(&directory) + { + return Ok(true); + } + } + Ok(false) + } + + fn contributions( + &self, pages: &[Page], specs: &[collection::ViewPageSpec], + ) -> anyhow::Result> { + let template = Template::new(self.config.theme_dirs.clone()); + let mut by_source = BTreeMap::new(); + for page in pages { + if by_source.insert(page.source().clone(), page).is_some() { + anyhow::bail!( + "multiple pages expose navigation source '{}'", + page.source() + ) + } + } + let mut contributions = Vec::new(); + for (id, settings) in self.instances.iter() { + let mut archives = Vec::new(); + let mut categories = Vec::new(); + let mut authors = Vec::new(); + for spec in specs + .iter() + .filter(|spec| spec.view.blog == *id && spec.page == 1) + { + let source = view_page_source(settings, spec)?; + let page = by_source.get(&source).ok_or_else(|| { + anyhow::anyhow!("blog view '{source}' has no rendered page") + })?; + match &spec.view.kind { + collection::ViewKind::Blog => {} + collection::ViewKind::Archive(_) => { + archives.push(( + *page, + spec.order + .as_ref() + .expect("archive views have members"), + )); + } + collection::ViewKind::Category(name) => { + categories.push((name, spec.posts.len(), *page)); + } + collection::ViewKind::Author(_) => { + authors.push(( + *page, + spec.order + .as_ref() + .expect("author views have members"), + )); + } + } + } + archives.sort_by(|left, right| { + collection::compare_order(left.1, right.1) + }); + sort_categories(settings, &mut categories); + authors.sort_by(|left, right| { + collection::compare_order(left.1, right.1) + }); + + let mut items = Vec::new(); + if !archives.is_empty() { + items.push(navigation_section( + &template, + &self.config, + &settings.archive_name, + archives.into_iter().map(|(page, _)| page), + )?); + } + if !categories.is_empty() { + items.push(navigation_section( + &template, + &self.config, + &settings.categories_name, + categories.into_iter().map(|(_, _, page)| page), + )?); + } + if !authors.is_empty() { + items.push(navigation_section( + &template, + &self.config, + &settings.authors_profiles_name, + authors.into_iter().map(|(page, _)| page), + )?); + } + if !items.is_empty() { + contributions.push(NavigationContribution { + index_url: PageRoute::from_source( + &self.config, + entrypoint_source(settings)?, + )? + .url, + allow_root: settings.blog_dir.trim_matches('/') == ".", + items, + }); + } + } + Ok(contributions) + } + + fn entrypoints( + &self, documents: &Stream, + ) -> Stream { + let documents = documents.reduce( + |documents: &dyn Collection, DocumentHeader>| { + Some(Documents(Arc::new(documents.values().cloned().collect()))) + }, + ); + let blog = self.clone(); + documents.flat_map(move |documents: &Documents| { + let mut entrypoints = Vec::new(); + let mut sources = HashSet::new(); + for (id, settings) in blog.instances.iter() { + let source = entrypoint_source(settings)?; + if !sources.insert(source.clone()) { + anyhow::bail!( + "page '{source}' is claimed by multiple blog instances" + ) + } + let existing = documents + .0 + .iter() + .find(|document| document.source == source); + let (mut document, provenance) = match existing { + Some(document) => { + (document.clone(), Some(document.source.clone())) + } + None => ( + DocumentHeader::new( + source, + "# Blog\n\n".into(), + BTreeMap::new(), + ), + None, + ), + }; + document + .meta + .entry("template".into()) + .or_insert_with(|| Dynamic::String("blog.html".into())); + entrypoints.push(( + instance_key(*id), + Entrypoint { + blog: *id, + document, + provenance, + }, + )); + } + Ok::<_, anyhow::Error>(entrypoints) + }) + } + + fn generate_pages( + &self, documents: &Stream, + entrypoints: &Stream, + pages: &Stream, + ) -> Stream { + let selected = entrypoints.select(pages, |page| { + let blog = page.view.blog; + move |entrypoint: &Entrypoint| entrypoint.blog == blog + }); + let blog = self.clone(); + let current = documents.select(pages, move |page| { + let source = + view_page_source(blog.settings(page.view.blog), page).ok(); + move |document: &DocumentHeader| { + source.as_ref() == Some(&document.source) + } + }); + let blog = self.clone(); + let original = documents.select(pages, move |page| { + let mut page = page.clone(); + page.page = 1; + let source = + view_page_source(blog.settings(page.view.blog), &page).ok(); + move |document: &DocumentHeader| { + source.as_ref() == Some(&document.source) + } + }); + let blog = self.clone(); + (pages.clone(), selected, current, original) + .join() + .filter_map( + move |(page, entrypoints, current, original): &GeneratedPageInput| { + if page.page == 1 + && matches!(page.view.kind, collection::ViewKind::Blog) + && entrypoints.first().is_some_and(|(_, entrypoint)| { + entrypoint.provenance.is_some() + }) + { + return Ok(None); + } + if !current.is_empty() { + return Ok(None); + } + let entrypoint = entrypoints.first().map(|(_, item)| item); + let original = + original.first().map(|(_, document)| document); + blog.generated_view_page(page, entrypoint, original) + .map(Some) + }, + ) + } + + fn generated_view_page( + &self, page: &collection::ViewPageSpec, + entrypoint: Option<&Entrypoint>, original: Option<&DocumentHeader>, + ) -> anyhow::Result { + let settings = self.settings(page.view.blog); + let source = view_page_source(settings, page)?; + let (title, provenance, content) = match &page.view.kind { + collection::ViewKind::Blog => { + let entrypoint = entrypoint.ok_or_else(|| { + anyhow::anyhow!( + "blog instance {} has no entrypoint", + page.view.blog.0 + ) + })?; + ( + entrypoint.document.title.clone(), + entrypoint.provenance.clone(), + entrypoint.document.body.clone(), + ) + } + collection::ViewKind::Archive(_) + | collection::ViewKind::Category(_) + | collection::ViewKind::Author(_) => { + if let Some(original) = original { + ( + original.title.clone(), + Some(original.source.clone()), + original.body.clone(), + ) + } else { + (page.title.clone(), None, format!("# {}", page.title)) + } + } + }; + let body = if page.page == 1 || settings.pagination_keep_content { + content + } else { + format!("# {title}") + }; + let mut meta = match (&page.view.kind, entrypoint) { + (collection::ViewKind::Blog, Some(entrypoint)) => { + entrypoint.document.meta.clone() + } + (_, _) if original.is_some() => { + original.expect("checked above").meta.clone() + } + _ => BTreeMap::new(), + }; + meta.insert("template".into(), Dynamic::String("blog.html".into())); + let document = DocumentHeader::new(source.clone(), body, meta); + let route = PageRoute::from_source(&self.config, source)?; + Ok(PageDescriptor::generated( + view_page_identity(page), + provenance, + document, + route, + )) + } + + fn entrypoint( + &self, source: &SourcePath, + ) -> anyhow::Result> { + let mut found = None; + for (id, settings) in self.instances.iter() { + if &entrypoint_source(settings)? != source { + continue; + } + if found.is_some() { + anyhow::bail!( + "page '{source}' is claimed by multiple blog instances" + ) + } + found = Some((*id, settings)); + } + Ok(found) + } + + fn settings(&self, id: BlogId) -> &BlogPluginConfig { + &self + .instances + .iter() + .find(|(candidate, _)| *candidate == id) + .expect("view refers to a configured blog instance") + .1 + } + + fn matches_view( + &self, page: &Page, spec: &collection::ViewPageSpec, + ) -> bool { + view_page_source(self.settings(spec.view.blog), spec) + .is_ok_and(|source| page.source() == &source) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Patch {} +impl Value for Classified {} +impl Value for Entrypoint {} +impl Value for Pages {} +impl Value for KeyedPages {} +impl Value for ViewPages {} +impl Value for Documents {} +impl Value for HiddenNavigationPage {} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +type ViewPatchInput = ( + collection::ViewPageSpec, + Vec<(Key, Page)>, + Vec<(Key, Page)>, + Vec<(Key, Resource)>, +); + +type GeneratedPageInput = ( + collection::ViewPageSpec, + Vec<(Key, Entrypoint)>, + Vec<(Key, DocumentHeader)>, + Vec<(Key, DocumentHeader)>, +); + +type AuthorJoin = (PostDescriptor, Vec<(Key, author::Catalog)>); + +type PostPatchInput = ( + collection::OrderedView, + Vec<(Key, PostDescriptor)>, + Vec<(Key, Page)>, + Vec<(Key, Resource)>, +); + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +fn merge_map( + target: &mut BTreeMap, source: &BTreeMap, + field: &str, +) -> anyhow::Result<()> { + for (key, value) in source { + if target.get(key).is_some_and(|current| current != value) { + anyhow::bail!("conflicting blog patch {field} key '{key}'") + } + target.insert(key.clone(), value.clone()); + } + Ok(()) +} + +fn merge_option( + target: &mut Option, source: Option<&T>, field: &str, +) -> anyhow::Result<()> { + let Some(value) = source else { + return Ok(()); + }; + if target.as_ref().is_some_and(|current| current != value) { + anyhow::bail!("conflicting blog patch {field}") + } + *target = Some(value.clone()); + Ok(()) +} + +fn entrypoint_source( + settings: &BlogPluginConfig, +) -> anyhow::Result { + let root = settings.blog_dir.trim_matches('/'); + if matches!(root, "" | ".") { + "index.md".parse().map_err(Into::into) + } else { + format!("{root}/index.md").parse().map_err(Into::into) + } +} + +fn view_page_source( + settings: &BlogPluginConfig, page: &collection::ViewPageSpec, +) -> anyhow::Result { + let source = match &page.view.kind { + collection::ViewKind::Blog => entrypoint_source(settings)?, + collection::ViewKind::Archive(_) + | collection::ViewKind::Category(_) + | collection::ViewKind::Author(_) => view_source(settings, &page.path)?, + }; + if page.page == 1 { + return Ok(source); + } + paginated_source(settings, &source, page.page) +} + +fn view_source( + settings: &BlogPluginConfig, path: &str, +) -> anyhow::Result { + let path = path.trim_matches('/'); + let root = settings.blog_dir.trim_matches('/'); + let source = if matches!(root, "" | ".") { + format!("{path}.md") + } else { + format!("{root}/{path}.md") + }; + source.parse().map_err(Into::into) +} + +pub(super) fn category_source( + settings: &BlogPluginConfig, name: &str, +) -> anyhow::Result { + let slug = slug::unicode(name, &settings.categories_slugify_separator); + view_source( + settings, + &settings.categories_url_format.replace("{slug}", &slug), + ) +} + +fn paginated_source( + settings: &BlogPluginConfig, source: &SourcePath, page: usize, +) -> anyhow::Result { + let path = settings + .pagination_url_format + .replace("{page}", &page.to_string()) + .trim_matches('/') + .to_owned(); + let source = source.as_str(); + let base = source + .strip_suffix(".md") + .expect("blog view sources use the Markdown suffix"); + let source = if base == "index" { + format!("{path}/index.md") + } else if let Some(parent) = base.strip_suffix("/index") { + format!("{parent}/{path}/index.md") + } else { + format!("{base}/{path}.md") + }; + source.parse().map_err(Into::into) +} + +fn view_page_identity(page: &collection::ViewPageSpec) -> String { + let kind = match &page.view.kind { + collection::ViewKind::Blog => "main".into(), + collection::ViewKind::Archive(key) => format!("archive:{key}"), + collection::ViewKind::Category(key) => format!("category:{key}"), + collection::ViewKind::Author(key) => format!("author:{key}"), + }; + format!("blog:{}:{kind}:{}", page.view.blog.0, page.page) +} + +fn instance_key(id: BlogId) -> Key { + Key::from( + Id::builder() + .provider("blog-entrypoint") + .context(".") + .location(id.0.to_string()) + .build() + .expect("numeric blog identity is valid"), + ) +} + +fn author_catalog_key(id: BlogId) -> Key { + Key::from( + Id::builder() + .provider("blog-authors") + .context(".") + .location(id.0.to_string()) + .build() + .expect("numeric blog identity is valid"), + ) +} + +fn navigation_section<'a>( + template: &Template<'_>, config: &Config, title: &str, + pages: impl IntoIterator, +) -> anyhow::Result { + Ok(NavigationItem { + title: Some(template.translate(title, config.project.as_ref())?), + url: None, + canonical_url: None, + meta: None, + children: pages.into_iter().map(navigation_item).collect(), + is_index: false, + active: false, + }) +} + +fn sort_categories( + settings: &BlogPluginConfig, categories: &mut [(&String, usize, &Page)], +) { + categories.sort_by(|left, right| left.0.cmp(right.0)); + match settings.categories_sort_by { + CategorySort::Name if settings.categories_sort_reverse => { + categories.reverse(); + } + CategorySort::Name => {} + CategorySort::PostCount => categories.sort_by(|left, right| { + let order = left.1.cmp(&right.1); + if settings.categories_sort_reverse { + order.reverse() + } else { + order + } + }), + } +} + +fn navigation_item(page: &Page) -> NavigationItem { + NavigationItem { + title: Some(page.title.clone()), + url: Some(page.url.clone()), + canonical_url: page.canonical_url.clone(), + meta: Some(page.meta.clone()), + children: Vec::new(), + is_index: false, + active: false, + } +} + +fn pagination_value( + config: &Config, settings: &BlogPluginConfig, + spec: &collection::ViewPageSpec, +) -> anyhow::Result { + if !collection::pagination(settings, &spec.view.kind) { + return Ok(Dynamic::Null); + } + let empty = spec.posts_total == 0; + let item = |page| pagination_item(config, settings, spec, page); + let items = + if empty || (spec.pages == 1 && !settings.pagination_if_single_page) { + Vec::new() + } else { + pagination_items(config, settings, spec)? + }; + let first_item = (spec.page - 1) + .saturating_mul(spec.posts_per_page) + .saturating_add(1) + .min(spec.posts_total); + let last_item = spec + .page + .saturating_mul(spec.posts_per_page) + .min(spec.posts_total); + let optional = |page: Option| -> anyhow::Result { + page.map(item) + .transpose() + .map(|item| item.unwrap_or(Dynamic::Null)) + }; + let number = |value: usize| -> anyhow::Result { + Ok(Dynamic::Integer(i64::try_from(value)?)) + }; + let boundary = |value: usize| -> anyhow::Result { + if empty { + Ok(Dynamic::Null) + } else { + number(value) + } + }; + Ok(Dynamic::Map(BTreeMap::from([ + ("page".into(), Dynamic::Integer(i64::try_from(spec.page)?)), + ("pages".into(), number(if empty { 0 } else { spec.pages })?), + ("first_page".into(), boundary(1)?), + ("last_page".into(), boundary(spec.pages)?), + ( + "page_count".into(), + number(if empty { 0 } else { spec.pages })?, + ), + ( + "items_per_page".into(), + Dynamic::Integer(i64::try_from(spec.posts_per_page)?), + ), + ( + "first_item".into(), + if empty { + Dynamic::Null + } else { + number(first_item)? + }, + ), + ( + "last_item".into(), + if empty { + Dynamic::Null + } else { + number(last_item)? + }, + ), + ( + "item_count".into(), + Dynamic::Integer(i64::try_from(spec.posts_total)?), + ), + ("items".into(), Dynamic::List(items)), + ("first".into(), if empty { Dynamic::Null } else { item(1)? }), + ( + "previous".into(), + optional( + (!empty) + .then_some(spec.page) + .and_then(|page| page.checked_sub(1)), + )?, + ), + ( + "next".into(), + optional( + (!empty && spec.page < spec.pages).then_some(spec.page + 1), + )?, + ), + ( + "last".into(), + if empty { + Dynamic::Null + } else { + item(spec.pages)? + }, + ), + ]))) +} + +fn pagination_items( + config: &Config, settings: &BlogPluginConfig, + spec: &collection::ViewPageSpec, +) -> anyhow::Result> { + let metrics = pagination::PaginationMetrics { + page: spec.page, + pages: spec.pages, + items_per_page: spec.posts_per_page, + item_count: spec.posts_total, + }; + pagination::items(&settings.pagination_format, metrics) + .into_iter() + .map(|item| match item { + pagination::PaginationItem::Page { page, current } => { + let mut item = pagination_item(config, settings, spec, page)?; + let Dynamic::Map(fields) = &mut item else { + unreachable!("pagination items are maps") + }; + fields.insert( + "type".into(), + Dynamic::String(if current { + "current_page".into() + } else { + "page".into() + }), + ); + Ok(item) + } + pagination::PaginationItem::Ellipsis => { + Ok(pagination_static_item("span", "..", true)) + } + pagination::PaginationItem::Link { kind, page } => { + let mut item = pagination_item(config, settings, spec, page)?; + let Dynamic::Map(fields) = &mut item else { + unreachable!("pagination items are maps") + }; + fields.insert( + "type".into(), + Dynamic::String(kind.as_str().into()), + ); + Ok(item) + } + pagination::PaginationItem::Text(value) => { + Ok(pagination_static_item("text", &value, false)) + } + }) + .collect() +} + +fn pagination_static_item( + item_type: &str, value: &str, ellipsis: bool, +) -> Dynamic { + Dynamic::Map(BTreeMap::from([ + ("type".into(), Dynamic::String(item_type.into())), + ("value".into(), Dynamic::String(value.into())), + ("page".into(), Dynamic::Null), + ("url".into(), Dynamic::Null), + ("current".into(), Dynamic::Bool(false)), + ("ellipsis".into(), Dynamic::Bool(ellipsis)), + ])) +} + +fn pagination_item( + config: &Config, settings: &BlogPluginConfig, + spec: &collection::ViewPageSpec, page: usize, +) -> anyhow::Result { + let mut target = spec.clone(); + target.page = page; + let url = + PageRoute::from_source(config, view_page_source(settings, &target)?)? + .url; + Ok(Dynamic::Map(BTreeMap::from([ + ( + "type".into(), + Dynamic::String(if page == spec.page { + "current_page".into() + } else { + "page".into() + }), + ), + ("page".into(), Dynamic::Integer(i64::try_from(page)?)), + ("number".into(), Dynamic::Integer(i64::try_from(page)?)), + ("value".into(), Dynamic::String(page.to_string())), + ("url".into(), Dynamic::String(url)), + ("current".into(), Dynamic::Bool(page == spec.page)), + ("ellipsis".into(), Dynamic::Bool(false)), + ]))) +} + +fn view_toc(settings: &BlogPluginConfig, kind: &collection::ViewKind) -> bool { + match kind { + collection::ViewKind::Archive(_) => { + settings.archive_toc.unwrap_or(settings.blog_toc) + } + collection::ViewKind::Category(_) => { + settings.categories_toc.unwrap_or(settings.blog_toc) + } + collection::ViewKind::Author(_) => { + settings.authors_profiles_toc.unwrap_or(settings.blog_toc) + } + collection::ViewKind::Blog => settings.blog_toc, + } +} + +fn integrated_toc( + view: &Page, spec: &collection::ViewPageSpec, + posts: &HashMap, +) -> Vec
{ + let mut toc = view.toc.clone(); + let Some(root) = toc.first_mut() else { + return toc; + }; + for post in spec.posts.iter() { + let Some(page) = posts.get(&post.source) else { + continue; + }; + let mut section = page + .toc + .iter() + .find(|section| section.level == 1) + .cloned() + .unwrap_or_else(|| Section { + title: page.title.clone(), + content: escape_html(&page.title), + id: slug::ascii(&page.title, "-"), + url: String::new(), + children: Vec::new(), + level: 2, + }); + section.url = url::relative(&view.url, &page.url); + section.children.clear(); + section.level = 2; + root.children.push(section); + } + toc +} + +fn resource_mappings( + resources: &[(Key, Resource)], +) -> HashMap { + resources + .iter() + .filter(|(_, resource)| resource.source_path != resource.path) + .map(|(_, resource)| { + (resource.source_path.to_string(), resource.path.to_string()) + }) + .collect() +} + +fn excerpt( + page: &Page, view_url: &str, settings: &BlogPluginConfig, + mappings: &HashMap, +) -> anyhow::Result { + let rewritten = html::rewrite_urls(&page.content, &page.url, mappings); + let content = rewritten.as_deref().unwrap_or(&page.content); + let (content, more) = + excerpt_parts(content, &settings.post_excerpt_separator); + let href = url::relative(view_url, &page.url); + let content = html::rebase_urls_with_fragment_base( + content, &page.url, view_url, &href, + ) + .unwrap_or_else(|| content.into()); + let (content, has_heading) = excerpt::headings(&content, &href); + let content = if has_heading { + content + } else { + format!( + "

{}

\n{content}", + slug::ascii(&page.title, "-"), + escape_html(&page.title) + ) + }; + let Dynamic::Map(mut value) = Dynamic::from_serialize(page)? else { + unreachable!("pages serialize to mappings") + }; + value.insert("content".into(), Dynamic::String(content)); + value.insert( + "more".into(), + more.map_or(Dynamic::Null, |more| Dynamic::String(more.into())), + ); + truncate_list(&mut value, "authors", settings.post_excerpt_max_authors); + truncate_list( + &mut value, + "categories", + settings.post_excerpt_max_categories, + ); + Ok(Dynamic::Map(value)) +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn excerpt_parts<'a>( + content: &'a str, separator: &str, +) -> (&'a str, Option<&'a str>) { + content + .split_once(separator) + .map_or((content, None), |(before, after)| (before, Some(after))) +} + +fn truncate_list( + value: &mut BTreeMap, name: &str, maximum: usize, +) { + if let Some(Dynamic::List(values)) = value.get_mut(name) { + values.truncate(maximum); + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use crate::compat::mkdocs::{html, url}; + + #[test] + fn rebases_local_excerpt_links_between_page_routes() { + assert_eq!( + url::rebase( + "blog/2026/09/post/", + "blog/page/2/", + "../../../../notes/#detail" + ) + .as_deref(), + Some("../../../notes/#detail") + ); + assert_eq!( + html::rebase_urls( + concat!( + r#"Notes"#, + r#""#, + r#"External"#, + ), + "blog/2026/09/post/", + "blog/page/2/", + ) + .as_deref(), + Some(concat!( + r#"Notes"#, + r#""#, + r#"External"#, + )) + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog/author.rs b/crates/zensical/src/compat/mkdocs/plugin/blog/author.rs new file mode 100644 index 0000000..a777b7c --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog/author.rs @@ -0,0 +1,242 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Native author-catalog parsing and post author resolution. + +use anyhow::{bail, Context}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use zrx::stream::Value; + +use crate::compat::mkdocs::plugin::meta; +use crate::config::plugins::BlogPluginConfig; +use crate::path::SourcePath; +use crate::structure::dynamic::Dynamic; + +use super::BlogId; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// One validated author definition. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct Author { + /// Stable author identifier used by post metadata. + pub id: String, + /// Display name. + pub name: String, + /// Profile description. + pub description: String, + /// Avatar URL or documentation-relative path. + pub avatar: String, + /// Optional explicit profile slug. + pub slug: Option, + /// Optional explicit author URL. + pub url: Option, +} + +/// Authors loaded for one configured blog instance. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Catalog { + /// Owning blog instance. + pub blog: BlogId, + /// Authors keyed by stable metadata identifier. + pub authors: BTreeMap, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Catalog { + /// Parses and validates Material's authors-file mapping. + pub fn parse( + blog: BlogId, path: SourcePath, source: &str, + ) -> anyhow::Result { + let mut values = meta::parse_yaml(path.clone(), source) + .with_context(|| format!("error reading authors file '{path}'"))?; + let unknown = values + .keys() + .filter(|name| name.as_str() != "authors") + .cloned() + .collect::>(); + if !unknown.is_empty() { + bail!( + "authors file '{path}' has unknown option(s): {}", + unknown.join(", ") + ) + } + let authors = match values.remove("authors") { + None | Some(Dynamic::Null) => BTreeMap::new(), + Some(Dynamic::Map(authors)) => authors, + Some(_) => bail!("'authors' in '{path}' must be a mapping"), + }; + let authors = authors + .into_iter() + .map(|(id, value)| { + parse_author(&path, id.clone(), value) + .map(|author| (id, author)) + }) + .collect::>()?; + Ok(Self { blog, authors }) + } +} + +impl Author { + /// Formats this author's configured profile path below the blog root. + pub fn profile_path(&self, settings: &BlogPluginConfig) -> String { + settings + .authors_profiles_url_format + .replace("{slug}", self.slug.as_ref().unwrap_or(&self.id)) + .replace("{name}", &self.name) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Catalog {} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Resolves the configured authors-file path for one blog instance. +pub fn source(settings: &BlogPluginConfig) -> anyhow::Result { + let path = settings + .authors_file + .replace("{blog}", settings.blog_dir.trim_matches('/')); + path.strip_prefix("./") + .unwrap_or(&path) + .parse() + .context("invalid authors_file path") +} + +fn parse_author( + path: &SourcePath, id: String, value: Dynamic, +) -> anyhow::Result { + let Dynamic::Map(mut values) = value else { + bail!("author '{id}' in '{path}' must be a mapping") + }; + let unknown = values + .keys() + .filter(|key| { + !matches!( + key.as_str(), + "name" | "description" | "avatar" | "slug" | "url" + ) + }) + .cloned() + .collect::>(); + if !unknown.is_empty() { + bail!( + "author '{id}' in '{path}' has unknown option(s): {}", + unknown.join(", ") + ) + } + let name = required_string(&mut values, &id, path, "name")?; + let description = required_string(&mut values, &id, path, "description")?; + let avatar = required_string(&mut values, &id, path, "avatar")?; + let slug = optional_string(&mut values, &id, path, "slug")?; + let url = optional_string(&mut values, &id, path, "url")?; + Ok(Author { + id, + name, + description, + avatar, + slug, + url, + }) +} + +fn required_string( + values: &mut BTreeMap, id: &str, path: &SourcePath, + name: &str, +) -> anyhow::Result { + let Some(value) = values.remove(name) else { + bail!("author '{id}' in '{path}' is missing '{name}'") + }; + match value { + Dynamic::String(value) => Ok(value), + _ => { + bail!("author '{id}' option '{name}' in '{path}' must be a string") + } + } +} + +fn optional_string( + values: &mut BTreeMap, id: &str, path: &SourcePath, + name: &str, +) -> anyhow::Result> { + match values.remove(name) { + None | Some(Dynamic::Null) => Ok(None), + Some(Dynamic::String(value)) => Ok(Some(value)), + Some(_) => { + bail!("author '{id}' option '{name}' in '{path}' must be a string") + } + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{Author, BlogId, Catalog}; + + #[test] + fn parses_material_author_mappings() { + let catalog = Catalog::parse( + BlogId(0), + "blog/.authors.yml".parse().unwrap(), + "authors:\n jane:\n name: Jane\n description: Writer\n avatar: jane.png\n", + ) + .unwrap(); + assert_eq!( + catalog.authors["jane"], + Author { + id: "jane".into(), + name: "Jane".into(), + description: "Writer".into(), + avatar: "jane.png".into(), + slug: None, + url: None, + } + ); + } + + #[test] + fn normalizes_the_default_standalone_authors_path() { + let settings = crate::config::plugins::BlogPluginConfig { + blog_dir: ".".into(), + ..crate::config::plugins::BlogPluginConfig::default() + }; + assert_eq!(super::source(&settings).unwrap().as_str(), ".authors.yml"); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog/collection.rs b/crates/zensical/src/compat/mkdocs/plugin/blog/collection.rs new file mode 100644 index 0000000..ad5292b --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog/collection.rs @@ -0,0 +1,727 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Stable blog collection identities, ordering, and pagination. + +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::stream::function::Collection; +use zrx::stream::{Key, Stream, Value}; + +use crate::config::plugins::BlogPluginConfig; +use crate::path::SourcePath; + +use super::PostDescriptor; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Logical type and key of a view. +#[derive( + Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +pub enum ViewKind { + /// Main blog entrypoint. + Blog, + /// Formatted archive key. + Archive( + /// Date-derived grouping key. + String, + ), + /// Normalized category identity. + Category( + /// Original category name. + String, + ), + /// Author identifier. + Author( + /// Stable author identifier from the catalog. + String, + ), +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Stable identity of one configured blog instance. +#[derive( + Clone, + Copy, + Debug, + Hash, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, +)] +pub struct BlogId( + /// Zero-based position of the plugin instance in configuration order. + pub usize, +); + +/// Stable identity of one post, independent of its title and route. +#[derive( + Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +pub struct PostId { + /// Owning blog instance. + pub blog: BlogId, + /// Physical source identity. + pub source: SourcePath, +} + +/// Stable identity of one logical view. +#[derive( + Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +pub struct ViewId { + /// Owning blog instance. + pub blog: BlogId, + /// View kind and logical key. + pub kind: ViewKind, +} + +/// One post's explicit membership in one logical view. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct ViewMembership { + /// Containing view. + pub view: ViewId, + /// Display title independent of stable view identity. + pub title: String, + /// Configured route path relative to the blog root. + pub path: String, + /// Contained post. + pub post: PostId, + /// Pinned posts sort before unpinned posts. + pub pin: bool, + /// Comparable UTC creation timestamp. + pub created: i64, + /// Declaration position within a post for first-appearance view ordering. + pub position: usize, +} + +/// Ordering fact retained by a logical view for navigation composition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ViewOrder { + /// Whether the first post is pinned. + pin: bool, + /// UTC creation timestamp of the first post. + created: i64, + /// Stable identity of the first post. + post: PostId, + /// Declaration position for category or author memberships. + position: usize, +} + +/// Revision-complete, deterministically ordered post set for one view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OrderedView { + /// Logical view identity. + pub id: ViewId, + /// Display title independent of stable view identity. + pub title: String, + /// Configured route path relative to the blog root. + pub path: String, + /// Post identities in Material display order. + pub posts: Arc>, + /// First appearance of this view in the globally ordered post sequence. + pub order: Option, +} + +/// Stable identity and boundaries of one paginated view page. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ViewPageSpec { + /// Logical view identity. + pub view: ViewId, + /// Display title independent of stable view identity. + pub title: String, + /// Configured route path relative to the blog root. + pub path: String, + /// One-based page number. + pub page: usize, + /// Total number of pages. + pub pages: usize, + /// Total number of posts across all pages. + pub posts_total: usize, + /// Configured maximum number of posts on one page. + pub posts_per_page: usize, + /// Posts shown on this page. + pub posts: Arc>, + /// First appearance of the logical view in post order. + pub order: Option, +} + +/// Revision-complete logical views and their paginated projections. +pub struct Output { + /// Ordered posts for each independent view. + pub views: Stream, + /// Stable page specifications for each view. + pub pages: Stream, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl OrderedView { + /// Materializes one logical view from its complete membership set. + pub fn new( + id: ViewId, memberships: impl IntoIterator, + ) -> Self { + let mut memberships = memberships.into_iter().collect::>(); + memberships.sort_by(display_order); + let (title, path) = memberships + .first() + .map(|first| (first.title.clone(), first.path.clone())) + .unwrap_or_default(); + debug_assert!(memberships + .iter() + .all(|membership| membership.path == path)); + let order = memberships.first().map(ViewOrder::from); + Self { + id, + title, + path, + posts: Arc::new( + memberships + .into_iter() + .map(|membership| membership.post) + .collect(), + ), + order, + } + } + + /// Divides this view into stable one-based page identities. + pub fn paginate(&self, per_page: usize) -> Vec { + assert!(per_page > 0, "pagination size is validated at admission"); + let pages = self.posts.len().div_ceil(per_page).max(1); + (0..pages) + .map(|index| { + let start = index * per_page; + let end = (start + per_page).min(self.posts.len()); + ViewPageSpec { + view: self.id.clone(), + title: self.title.clone(), + path: self.path.clone(), + page: index + 1, + pages, + posts_total: self.posts.len(), + posts_per_page: per_page, + posts: Arc::new(self.posts[start..end].to_vec()), + order: self.order.clone(), + } + }) + .collect() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for ViewMembership {} +impl Value for OrderedView {} +impl Value for ViewPageSpec {} + +impl From<&ViewMembership> for ViewOrder { + fn from(value: &ViewMembership) -> Self { + Self { + pin: value.pin, + created: value.created, + post: value.post.clone(), + position: value.position, + } + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Installs explicit membership, per-view ordering, and pagination relations. +pub fn setup( + posts: &Stream, + instances: Arc>, +) -> Output { + let membership_settings = instances.clone(); + let memberships = posts.flat_map(move |post: &PostDescriptor| { + let settings = settings(&membership_settings, post.id.blog); + Ok::<_, anyhow::Error>( + memberships(post, settings)? + .into_iter() + .map(|membership| { + let key = membership_key(&membership.view); + (key, membership) + }) + .collect::>(), + ) + }); + let views = memberships.reduce_by_key( + |membership: &ViewMembership| view_key(&membership.view), + |memberships: &dyn Collection, ViewMembership>| { + let mut values = memberships.values().cloned(); + let Some(first) = values.next() else { + return Ok(None); + }; + let id = first.view.clone(); + Ok::<_, anyhow::Error>(Some(OrderedView::new( + id, + std::iter::once(first).chain(values), + ))) + }, + ); + let pages = views.clone().flat_map(move |view: &OrderedView| { + let settings = settings(&instances, view.id.blog); + let paginate = pagination(settings, &view.id.kind); + let per_page = pagination_per_page(settings, &view.id.kind); + let per_page = if paginate { per_page } else { usize::MAX }; + view.paginate(per_page) + .into_iter() + .map(|page| (page_key(page.page), page)) + .collect::>() + }); + Output { views, pages } +} + +/// Returns whether one logical view kind is paginated. +pub fn pagination(settings: &BlogPluginConfig, kind: &ViewKind) -> bool { + match kind { + ViewKind::Archive(_) => { + settings.archive_pagination.unwrap_or(settings.pagination) + } + ViewKind::Category(_) => settings + .categories_pagination + .unwrap_or(settings.pagination), + ViewKind::Author(_) => settings + .authors_profiles_pagination + .unwrap_or(settings.pagination), + ViewKind::Blog => settings.pagination, + } +} + +fn pagination_per_page(settings: &BlogPluginConfig, kind: &ViewKind) -> usize { + match kind { + ViewKind::Archive(_) => settings + .archive_pagination_per_page + .unwrap_or(settings.pagination_per_page), + ViewKind::Category(_) => settings + .categories_pagination_per_page + .unwrap_or(settings.pagination_per_page), + ViewKind::Author(_) => settings + .authors_profiles_pagination_per_page + .unwrap_or(settings.pagination_per_page), + ViewKind::Blog => settings.pagination_per_page, + } +} + +fn memberships( + post: &PostDescriptor, settings: &BlogPluginConfig, +) -> anyhow::Result> { + let member = |kind, title, path, position| ViewMembership { + view: ViewId { blog: post.id.blog, kind }, + title, + path, + post: post.id.clone(), + pin: post.pin, + created: post.created().timestamp_micros(), + position, + }; + let mut memberships = + vec![member(ViewKind::Blog, String::new(), String::new(), 0)]; + if settings.archive { + let key = post + .created() + .format_url(&settings.archive_url_date_format)?; + let title = post + .created() + .format_display(&settings.archive_date_format)?; + let path = settings.archive_url_format.replace("{date}", &key); + memberships.push(member(ViewKind::Archive(key), title, path, 0)); + } + if settings.categories { + for name in &post.categories { + let category = crate::structure::slug::unicode( + name, + &settings.categories_slugify_separator, + ); + let path = + settings.categories_url_format.replace("{slug}", &category); + memberships.push(member( + ViewKind::Category(name.clone()), + name.clone(), + path, + 0, + )); + } + } + if settings.authors_profiles { + for (position, author) in post.authors.iter().enumerate() { + let path = author.profile_path(settings); + memberships.push(member( + ViewKind::Author(author.id.clone()), + author.name.clone(), + path, + position, + )); + } + } + Ok(memberships) +} + +fn settings( + instances: &[(BlogId, BlogPluginConfig)], id: BlogId, +) -> &BlogPluginConfig { + &instances + .iter() + .find(|(candidate, _)| *candidate == id) + .expect("post refers to a configured blog instance") + .1 +} + +fn membership_key(view: &ViewId) -> Key { + Key::from( + Id::builder() + .provider("blog-membership") + .resource(view.blog.0.to_string()) + .variant(view_variant(&view.kind)) + .context(".") + .location(view_location(&view.kind)) + .build() + .expect("validated blog view identity"), + ) +} + +fn view_key(view: &ViewId) -> anyhow::Result> { + Ok(Key::from( + Id::builder() + .provider("blog-view") + .resource(view.blog.0.to_string()) + .variant(view_variant(&view.kind)) + .context(".") + .location(view_location(&view.kind)) + .build()?, + )) +} + +fn page_key(page: usize) -> Key { + Key::from( + Id::builder() + .provider("blog-page") + .context(".") + .location(page.to_string()) + .build() + .expect("numeric page identity is valid"), + ) +} + +fn view_variant(kind: &ViewKind) -> &'static str { + match kind { + ViewKind::Blog => "blog", + ViewKind::Archive(_) => "archive", + ViewKind::Category(_) => "category", + ViewKind::Author(_) => "author", + } +} + +fn view_location(kind: &ViewKind) -> &str { + match kind { + ViewKind::Blog => "index", + ViewKind::Archive(key) + | ViewKind::Category(key) + | ViewKind::Author(key) => key, + } +} + +/// Orders posts by pin and creation date descending, then source ascending. +fn display_order(left: &ViewMembership, right: &ViewMembership) -> Ordering { + compare_order(&ViewOrder::from(left), &ViewOrder::from(right)) +} + +/// Orders views by their first appearance in Material's ordered post stream. +pub fn compare_order(left: &ViewOrder, right: &ViewOrder) -> Ordering { + right + .pin + .cmp(&left.pin) + .then_with(|| right.created.cmp(&left.created)) + .then_with(|| left.post.source.cmp(&right.post.source)) + .then_with(|| left.position.cmp(&right.position)) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::sync::Arc; + + use zrx::id::Id; + use zrx::stream::{Change, Key, Run, Workflow}; + + use crate::compat::mkdocs::plugin::blog::{BlogDate, PostDescriptor}; + use crate::config::plugins::BlogPluginConfig; + use crate::structure::document::DocumentHeader; + use crate::structure::page::{PageDescriptor, PageOrigin, PageRoute}; + + use super::{ + setup, BlogId, OrderedView, PostId, ViewId, ViewKind, ViewMembership, + }; + + fn membership(source: &str, created: i64, pin: bool) -> ViewMembership { + ViewMembership { + view: ViewId { + blog: BlogId(0), + kind: ViewKind::Blog, + }, + title: String::new(), + path: String::new(), + post: PostId { + blog: BlogId(0), + source: source.parse().unwrap(), + }, + pin, + created, + position: 0, + } + } + + #[test] + fn orders_by_pin_date_and_deterministic_source_tie_breaker() { + let view = OrderedView::new( + ViewId { + blog: BlogId(0), + kind: ViewKind::Blog, + }, + [ + membership("posts/b.md", 20, false), + membership("posts/c.md", 10, true), + membership("posts/a.md", 20, false), + membership("posts/d.md", 30, true), + ], + ); + let sources = view + .posts + .iter() + .map(|post| post.source.as_str()) + .collect::>(); + assert_eq!( + sources, + ["posts/d.md", "posts/c.md", "posts/a.md", "posts/b.md"] + ); + } + + #[test] + fn view_order_retains_author_declaration_position() { + let mut first = membership("zeta.md", 2, false); + first.position = 1; + let mut second = first.clone(); + second.position = 0; + assert_eq!( + super::compare_order( + &super::ViewOrder::from(&second), + &super::ViewOrder::from(&first), + ), + std::cmp::Ordering::Less + ); + } + + #[test] + fn pagination_has_stable_one_based_ids_and_retractable_suffix() { + let id = ViewId { + blog: BlogId(0), + kind: ViewKind::Category("rust".into()), + }; + let view = OrderedView::new( + id.clone(), + (0..5).map(|index| { + membership(&format!("posts/{index}.md"), index, false) + }), + ); + let pages = view.paginate(2); + assert_eq!(pages.len(), 3); + assert_eq!(pages[0].page, 1); + assert_eq!(pages[2].page, 3); + assert_eq!(pages[2].posts.len(), 1); + + let smaller = OrderedView::new( + id, + (0..3).map(|index| { + membership(&format!("posts/{index}.md"), index, false) + }), + ); + assert_eq!(smaller.paginate(2).len(), 2); + } + + #[test] + fn empty_view_still_has_its_entry_page() { + let view = OrderedView::new( + ViewId { + blog: BlogId(0), + kind: ViewKind::Blog, + }, + [], + ); + let pages = view.paginate(10); + assert_eq!(pages.len(), 1); + assert!(pages[0].posts.is_empty()); + } + + #[test] + fn retained_graph_reorders_pages_and_retracts_obsolete_suffixes() { + let settings = BlogPluginConfig { + archive: false, + categories: false, + authors_profiles: false, + pagination_per_page: 2, + ..BlogPluginConfig::default() + }; + let instances = Arc::new(vec![(BlogId(0), settings)]); + let workflow = Workflow::::build(|workflow| { + let posts = workflow.input::(); + let output = setup(&posts, instances); + workflow.output(&output.pages); + }); + let mut runner = workflow.runner().unwrap(); + let input = runner.input::().unwrap(); + + let mut revision = input.begin().unwrap(); + for index in 0..5 { + let source = format!("blog/posts/{index}.md"); + revision + .insert(source_key(&source), post(&source, index)) + .unwrap(); + } + let mut input = revision.seal().unwrap(); + let initial = changes(&mut runner.settle().unwrap()); + assert_eq!( + initial.iter().filter(|(_, posts)| posts.is_some()).count(), + 3 + ); + + let mut revision = input.begin().unwrap(); + revision.remove(source_key("blog/posts/4.md")).unwrap(); + revision.remove(source_key("blog/posts/3.md")).unwrap(); + input = revision.seal().unwrap(); + let smaller = changes(&mut runner.settle().unwrap()); + assert!(smaller + .iter() + .any(|(page, posts)| { page == "3" && posts.is_none() })); + + let mut revision = input.begin().unwrap(); + revision + .insert(source_key("blog/posts/0.md"), post("blog/posts/0.md", 10)) + .unwrap(); + input = revision.seal().unwrap(); + let reordered = changes(&mut runner.settle().unwrap()); + assert!(reordered.iter().any(|(page, posts)| { + page == "1" + && posts.as_ref().is_some_and(|posts| { + posts.first().is_some_and(|source| source.ends_with("0.md")) + }) + })); + drop(input); + } + + fn post(source: &str, day: i64) -> PostDescriptor { + let source = source.parse::().unwrap(); + let document = DocumentHeader::new( + source.clone(), + format!("# Post {day}"), + BTreeMap::default(), + ); + let route = PageRoute { + source: source.clone(), + destination: format!("blog/{day}/index.html").parse().unwrap(), + url: format!("blog/{day}/"), + }; + let created = + BlogDate::parse(&format!("2026-09-{:02}", day + 1)).unwrap(); + PostDescriptor { + id: PostId { + blog: BlogId(0), + source: source.clone(), + }, + page: PageDescriptor { + origin: PageOrigin::Source(source), + document, + route, + properties: BTreeMap::new(), + variables: BTreeMap::new(), + }, + dates: BTreeMap::from([("created".into(), created)]), + author_ids: Vec::new(), + authors: Vec::new(), + categories: Vec::new(), + pin: false, + draft: None, + slug: None, + readtime: None, + links: None, + } + } + + fn source_key(source: &str) -> Key { + Key::from( + Id::builder() + .provider("test") + .context("docs") + .location(source) + .build() + .unwrap(), + ) + } + + fn changes(run: &mut Run) -> Vec<(String, Option>)> { + let mut changes = run + .output::() + .unwrap() + .map(|change| match change { + Change::Insert(key, page) => ( + key.iter().last().unwrap().location().to_string(), + Some( + page.posts + .iter() + .map(|post| post.source.to_string()) + .collect(), + ), + ), + Change::Remove(key) => { + (key.iter().last().unwrap().location().to_string(), None) + } + }) + .collect::>(); + changes.sort(); + changes + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog/date.rs b/crates/zensical/src/compat/mkdocs/plugin/blog/date.rs new file mode 100644 index 0000000..4157c01 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog/date.rs @@ -0,0 +1,461 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Native ISO date parsing and route-date formatting. + +use anyhow::{bail, Context}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write as _; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Normalized post date with a UTC ordering key and original civil fields. +#[derive( + Clone, + Copy, + Debug, + Hash, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, +)] +pub struct BlogDate { + /// UTC ordering key with microsecond precision. + timestamp_micros: i64, + /// Original civil year. + year: i32, + /// Original civil month in the range 1 through 12. + month: u8, + /// Original civil day of the month. + day: u8, + /// Original hour in the range 0 through 23. + hour: u8, + /// Original minute in the range 0 through 59. + minute: u8, + /// Original second in the range 0 through 59. + second: u8, + /// Original fractional second normalized to microseconds. + microsecond: u32, + /// Original UTC offset in minutes. + offset_minutes: i16, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl BlogDate { + /// Parses a YAML-compatible ISO date or datetime. + pub fn parse(value: &str) -> anyhow::Result { + let value = value.trim(); + let (date, time) = value + .split_once(['T', ' ']) + .map_or((value, None), |(date, time)| (date, Some(time))); + let (year, month, day) = parse_date(date)?; + let (hour, minute, second, microsecond, offset_minutes) = + time.map(parse_time).transpose()?.unwrap_or((0, 0, 0, 0, 0)); + validate_date(year, month, day)?; + let timestamp = days_from_civil(year, month, day) * 86_400 + + i64::from(hour) * 3_600 + + i64::from(minute) * 60 + + i64::from(second) + - i64::from(offset_minutes) * 60; + let timestamp_micros = timestamp * 1_000_000 + i64::from(microsecond); + Ok(Self { + timestamp_micros, + year, + month, + day, + hour, + minute, + second, + microsecond, + offset_minutes, + }) + } + + /// Returns the UTC ordering key in microseconds. + pub const fn timestamp_micros(self) -> i64 { + self.timestamp_micros + } + + /// Returns Material's string representation of a timezone-aware datetime. + pub fn template_value(self) -> String { + let sign = if self.offset_minutes < 0 { '-' } else { '+' }; + let minutes = self.offset_minutes.unsigned_abs(); + let offset = format!("{sign}{:02}:{:02}", minutes / 60, minutes % 60); + let fraction = if self.microsecond == 0 { + String::new() + } else { + format!(".{:06}", self.microsecond) + }; + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}{fraction}{offset}", + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second + ) + } + + /// Formats Material's default English `long` display date. + pub fn long_en(self) -> String { + format!( + "{} {}, {}", + month_name(self.month, false), + self.day, + self.year + ) + } + + /// Formats a Material/Babel date pattern using native English names. + pub fn format_display(self, pattern: &str) -> anyhow::Result { + match pattern { + "full" => Ok(format!( + "{}, {} {}, {}", + weekday_name(self, false), + month_name(self.month, false), + self.day, + self.year + )), + "long" => Ok(self.long_en()), + "medium" => Ok(format!( + "{} {}, {}", + month_name(self.month, true), + self.day, + self.year + )), + "short" => Ok(format!( + "{}/{}/{:02}", + self.month, + self.day, + self.year.rem_euclid(100) + )), + pattern => self.format_pattern(pattern), + } + } + + /// Formats the subset of Unicode date patterns used in blog URL defaults. + pub fn format_url(self, pattern: &str) -> anyhow::Result { + self.format_pattern(pattern) + } + + fn format_pattern(self, pattern: &str) -> anyhow::Result { + let mut output = String::new(); + let mut chars = pattern.chars().peekable(); + let mut quoted = false; + while let Some(character) = chars.next() { + if character == '\'' { + if chars.peek() == Some(&'\'') { + chars.next(); + output.push('\''); + } else { + quoted = !quoted; + } + continue; + } + if quoted + || !matches!( + character, + 'y' | 'M' | 'd' | 'E' | 'H' | 'h' | 'm' | 's' | 'a' + ) + { + output.push(character); + continue; + } + let mut width = 1; + while chars.peek() == Some(&character) { + chars.next(); + width += 1; + } + match character { + 'y' if width == 2 => { + write!(&mut output, "{:02}", self.year.rem_euclid(100)) + .expect("writing to a string cannot fail"); + } + 'y' => write!(&mut output, "{:0width$}", self.year) + .expect("writing to a string cannot fail"), + 'M' if width <= 2 => { + write!(&mut output, "{:0width$}", self.month) + .expect("writing to a string cannot fail"); + } + 'M' if width == 3 => output.push_str(month_name(self.month, true)), + 'M' if width == 4 => output.push_str(month_name(self.month, false)), + 'd' if width <= 2 => { + write!(&mut output, "{:0width$}", self.day) + .expect("writing to a string cannot fail"); + } + 'E' if width <= 3 => output.push_str(weekday_name(self, true)), + 'E' if width == 4 => output.push_str(weekday_name(self, false)), + 'H' if width <= 2 => { + write!(&mut output, "{:0width$}", self.hour) + .expect("writing to a string cannot fail"); + } + 'h' if width <= 2 => { + let hour = match self.hour % 12 { + 0 => 12, + hour => hour, + }; + write!(&mut output, "{hour:0width$}") + .expect("writing to a string cannot fail"); + } + 'm' if width <= 2 => { + write!(&mut output, "{:0width$}", self.minute) + .expect("writing to a string cannot fail"); + } + 's' if width <= 2 => { + write!(&mut output, "{:0width$}", self.second) + .expect("writing to a string cannot fail"); + } + 'a' => output.push_str(if self.hour < 12 { "AM" } else { "PM" }), + _ => bail!( + "unsupported date field '{character}' in URL format '{pattern}'" + ), + } + } + if quoted { + bail!("unterminated quote in URL date format '{pattern}'") + } + Ok(output) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +fn month_name(month: u8, short: bool) -> &'static str { + const LONG: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + const SHORT: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", + "Nov", "Dec", + ]; + let index = usize::from(month - 1); + if short { + SHORT[index] + } else { + LONG[index] + } +} + +fn weekday_name(date: BlogDate, short: bool) -> &'static str { + const LONG: [&str; 7] = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + const SHORT: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + let days = days_from_civil(date.year, date.month, date.day); + let index = + usize::try_from((days + 4).rem_euclid(7)).expect("weekday range"); + if short { + SHORT[index] + } else { + LONG[index] + } +} + +fn parse_date(value: &str) -> anyhow::Result<(i32, u8, u8)> { + let mut parts = value.split('-'); + let year = number(parts.next(), "year")?; + let month = number(parts.next(), "month")?; + let day = number(parts.next(), "day")?; + if parts.next().is_some() { + bail!("date must use YYYY-MM-DD syntax") + } + Ok((year, month, day)) +} + +fn parse_time(value: &str) -> anyhow::Result<(u8, u8, u8, u32, i16)> { + let (time, offset) = if let Some(time) = value.strip_suffix(['Z', 'z']) { + (time, 0) + } else { + let at = value.char_indices().skip(1).find_map(|(index, character)| { + matches!(character, '+' | '-').then_some(index) + }); + match at { + Some(at) => { + let sign = if value.as_bytes()[at] == b'-' { -1 } else { 1 }; + let (time, zone) = value.split_at(at); + let zone = &zone[1..]; + let (hours, minutes) = zone + .split_once(':') + .context("timezone must use +HH:MM syntax")?; + let hours = + hours.parse::().context("invalid timezone hour")?; + let minutes = minutes + .parse::() + .context("invalid timezone minute")?; + if hours > 23 || minutes > 59 { + bail!("timezone offset is out of range") + } + (time, sign * (hours * 60 + minutes)) + } + None => (value, 0), + } + }; + let (time, microsecond) = match time.split_once('.') { + Some((time, fraction)) => (time, parse_fraction(fraction)?), + None => (time, 0), + }; + let mut parts = time.split(':'); + let hour = number(parts.next(), "hour")?; + let minute = number(parts.next(), "minute")?; + let second = parts + .next() + .map(|value| value.parse::().context("invalid second")) + .transpose()? + .unwrap_or(0); + if parts.next().is_some() || hour > 23 || minute > 59 || second > 59 { + bail!("time is out of range") + } + Ok((hour, minute, second, microsecond, offset)) +} + +fn parse_fraction(value: &str) -> anyhow::Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + bail!("invalid fractional second") + } + let digits = &value[..value.len().min(6)]; + let fraction = + digits.parse::().context("invalid fractional second")?; + Ok(fraction * 10_u32.pow(u32::try_from(6 - digits.len())?)) +} + +fn number(value: Option<&str>, name: &str) -> anyhow::Result +where + T: std::str::FromStr, + T::Err: std::error::Error + Send + Sync + 'static, +{ + value + .context(format!("missing {name}"))? + .parse() + .with_context(|| format!("invalid {name}")) +} + +fn validate_date(year: i32, month: u8, day: u8) -> anyhow::Result<()> { + if !(1..=9999).contains(&year) { + bail!("year is out of range") + } + let leap = year.rem_euclid(4) == 0 + && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0); + let days = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => bail!("month is out of range"), + }; + if day == 0 || day > days { + bail!("day is out of range") + } + Ok(()) +} + +// Howard Hinnant's proleptic Gregorian civil-date conversion. +fn days_from_civil(year: i32, month: u8, day: u8) -> i64 { + let year = year - i32::from(month <= 2); + let era = if year >= 0 { year } else { year - 399 } / 400; + let yoe = year - era * 400; + let month = i32::from(month); + let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + + i32::from(day) + - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + i64::from(era * 146_097 + doe - 719_468) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::BlogDate; + + #[test] + fn parses_dates_datetimes_and_offsets() { + let date = BlogDate::parse("2026-09-03").unwrap(); + assert_eq!(date.format_url("yyyy/MM/dd").unwrap(), "2026/09/03"); + let utc = BlogDate::parse("2026-09-03T10:30:00Z").unwrap(); + let offset = BlogDate::parse("2026-09-03 12:30:00+02:00").unwrap(); + assert_eq!(utc.timestamp_micros(), offset.timestamp_micros()); + } + + #[test] + fn preserves_material_fractional_second_precision() { + let short = BlogDate::parse("2026-09-03T10:30:00.1Z").unwrap(); + let long = BlogDate::parse("2026-09-03T10:30:00.123456789Z").unwrap(); + assert_eq!(short.template_value(), "2026-09-03 10:30:00.100000+00:00"); + assert_eq!(long.template_value(), "2026-09-03 10:30:00.123456+00:00"); + assert!(long.timestamp_micros() > short.timestamp_micros()); + } + + #[test] + fn validates_leap_days_and_url_patterns() { + assert!(BlogDate::parse("2024-02-29").is_ok()); + assert!(BlogDate::parse("2025-02-29").is_err()); + let date = BlogDate::parse("2026-09-03").unwrap(); + assert_eq!(date.format_url("yy-M-d").unwrap(), "26-9-3"); + assert_eq!(date.format_url("yyyy'year'MM").unwrap(), "2026year09"); + assert_eq!( + date.format_display("MMMM d, yyyy").unwrap(), + "September 3, 2026" + ); + assert_eq!( + date.format_display("full").unwrap(), + "Thursday, September 3, 2026" + ); + let time = BlogDate::parse("2026-09-03T14:05:09Z").unwrap(); + assert_eq!( + time.format_display("MMM d, yyyy h:mm a").unwrap(), + "Sep 3, 2026 2:05 PM" + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog/excerpt.rs b/crates/zensical/src/compat/mkdocs/plugin/blog/excerpt.rs new file mode 100644 index 0000000..0a7ae1c --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog/excerpt.rs @@ -0,0 +1,120 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTML-level excerpt transformations owned by the blog compatibility module. + +use regex::Regex; +use std::sync::LazyLock; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Shifts and links rendered Markdown headings for a Material blog excerpt. +/// +/// Material reparses each excerpt with a base heading level of two and anchor +/// links enabled. Reconstructing that small HTML-level difference lets the +/// native pipeline reuse the rendered post for every view appearance. +pub fn headings(input: &str, target: &str) -> (String, bool) { + static HEADING: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)]*)>(.*?)") + .expect("static expression") + }); + static ID: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bid=(?:\"([^\"]*)\"|'([^']*)'|([^\s>]+))"#) + .expect("static expression") + }); + static PERMALINK: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?s)]*class=(?:\"[^\"]*\bheaderlink\b[^\"]*\"|'[^']*\bheaderlink\b[^']*')[^>]*>.*?"#, + ) + .expect("static expression") + }); + + let has_h1 = HEADING + .captures_iter(input) + .any(|captures| &captures[1] == "1" && ID.is_match(&captures[2])); + let mut main_seen = !has_h1; + let content = HEADING.replace_all(input, |captures: ®ex::Captures<'_>| { + let Some(id) = ID.captures(&captures[2]).and_then(|captures| { + captures.get(1).or_else(|| captures.get(2)).or_else(|| captures.get(3)) + }) else { + return captures[0].to_owned(); + }; + let level = captures[1] + .parse::() + .expect("heading expression captures a digit") + .saturating_add(1) + .min(6); + let href = if main_seen { + format!("{target}#{}", id.as_str()) + } else { + main_seen = true; + target.to_owned() + }; + let title = PERMALINK.replace_all(&captures[3], ""); + format!( + "{title}", + &captures[2] + ) + }); + (content.into_owned(), has_h1) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::headings; + + #[test] + fn shifts_and_links_headings_without_reparsing_markdown() { + let input = concat!( + r##"

One

"##, + r##"

Two

"##, + r##"
Six
"##, + ); + assert_eq!( + headings(input, "post/").0, + concat!( + r#"

One

"#, + r##"

Two

"##, + r##"
Six
"##, + ) + ); + } + + #[test] + fn reserves_the_main_link_for_a_synthetic_heading() { + let input = + r##"

Two

"##; + assert_eq!( + headings(input, "post/").0, + r#"

Two

"# + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog/links.rs b/crates/zensical/src/compat/mkdocs/plugin/blog/links.rs new file mode 100644 index 0000000..4127895 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog/links.rs @@ -0,0 +1,306 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Structured post-link parsing and revision-complete page resolution. + +use anyhow::bail; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +use crate::compat::mkdocs::resource::Resource; +use crate::path::SourcePath; +use crate::structure::dynamic::Dynamic; +use crate::structure::nav::NavigationItem; +use crate::structure::page::Page; +use crate::structure::toc::Section; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// One navigation-shaped post-link item. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum LinkItem { + /// Page, asset, or external URL. + Reference { + /// Optional explicit display title. + title: Option, + /// Unresolved link target from post metadata. + target: String, + }, + /// Named nested group. + Section { + /// Display title of the group. + title: String, + /// Nested references and sections in declaration order. + children: Vec, + }, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Revision-complete targets shared by every post in one blog view. +pub struct Resolver<'a> { + /// Rendered pages indexed by physical source identity. + pages: HashMap, + /// Emitted resources indexed by physical source path. + resources: HashMap<&'a str, &'a Resource>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'a> Resolver<'a> { + /// Builds one lookup index for all post link resolutions in a view. + pub fn new( + pages: impl IntoIterator, + resources: impl IntoIterator, + ) -> Self { + Self { + pages: pages + .into_iter() + .map(|page| (page.source().clone(), page)) + .collect(), + resources: resources + .into_iter() + .map(|resource| (resource.source_path.as_str(), resource)) + .collect(), + } + } + + /// Resolves page and asset facts while preserving missing/external links. + pub fn resolve(&self, items: &[LinkItem]) -> anyhow::Result { + let items = items + .iter() + .map(|item| self.resolve_item(item)) + .collect::>>()?; + Ok(Dynamic::List( + items + .iter() + .map(Dynamic::from_serialize) + .collect::>()?, + )) + } + + fn resolve_item(&self, item: &LinkItem) -> anyhow::Result { + resolve_item(item, &self.pages, &self.resources) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Parses optional navigation-shaped `links` metadata. +pub fn parse(value: Option<&Dynamic>) -> anyhow::Result>> { + let Some(value) = value else { return Ok(None) }; + let Dynamic::List(items) = value else { + bail!("post links must be a list") + }; + items + .iter() + .map(parse_item) + .collect::>() + .map(Some) +} + +/// Returns local page sources referenced by a structured link tree. +pub fn targets(items: &[LinkItem]) -> HashSet { + let mut targets = HashSet::new(); + collect_targets(items, &mut targets); + targets +} + +fn parse_item(value: &Dynamic) -> anyhow::Result { + match value { + Dynamic::String(target) => Ok(LinkItem::Reference { + title: None, + target: target.clone(), + }), + Dynamic::Map(values) if values.len() == 1 => { + let (title, value) = values.iter().next().expect("checked length"); + match value { + Dynamic::String(target) => Ok(LinkItem::Reference { + title: Some(title.clone()), + target: target.clone(), + }), + Dynamic::List(children) => Ok(LinkItem::Section { + title: title.clone(), + children: children + .iter() + .map(parse_item) + .collect::>()?, + }), + _ => bail!("post link '{title}' must target a URL or list"), + } + } + Dynamic::Map(_) => bail!("post link mappings must contain one item"), + _ => bail!("post link items must be URLs or one-item mappings"), + } +} + +fn collect_targets(items: &[LinkItem], targets: &mut HashSet) { + for item in items { + match item { + LinkItem::Reference { target, .. } if is_local(target) => { + let path = target + .split_once('#') + .map_or(target.as_str(), |item| item.0); + if let Ok(path) = path.trim_start_matches('/').parse() { + targets.insert(path); + } + } + LinkItem::Section { children, .. } => { + collect_targets(children, targets); + } + LinkItem::Reference { .. } => {} + } + } +} + +fn resolve_item( + item: &LinkItem, pages: &HashMap, + resources: &HashMap<&str, &Resource>, +) -> anyhow::Result { + match item { + LinkItem::Section { title, children } => Ok(NavigationItem { + title: Some(title.clone()), + url: None, + canonical_url: None, + meta: None, + children: children + .iter() + .map(|item| resolve_item(item, pages, resources)) + .collect::>()?, + is_index: false, + active: false, + }), + LinkItem::Reference { title, target } => { + let (path, fragment) = target + .split_once('#') + .map_or((target.as_str(), None), |(path, fragment)| { + (path, Some(fragment)) + }); + let source = path.trim_start_matches('/').parse::(); + let Some(page) = source.ok().and_then(|source| pages.get(&source)) + else { + let resource = resources.get(path).copied(); + return Ok(NavigationItem { + title: title.clone(), + url: Some(resource.map_or_else( + || target.clone(), + |resource| { + fragment.map_or_else( + || resource.path.to_string(), + |fragment| { + format!("{}#{fragment}", resource.path) + }, + ) + }, + )), + canonical_url: None, + meta: None, + children: Vec::new(), + is_index: false, + active: false, + }); + }; + let mut meta = page.meta.clone(); + let url = match fragment { + None => page.url.clone(), + Some(fragment) => { + if let Some(anchor) = find_anchor(&page.toc, fragment) { + meta.insert( + "subtitle".into(), + Dynamic::String(anchor.title.clone()), + ); + format!("{}#{}", page.url, anchor.id) + } else { + target.clone() + } + } + }; + Ok(NavigationItem { + title: title.clone().or_else(|| Some(page.title.clone())), + url: Some(url), + canonical_url: page.canonical_url.clone(), + meta: Some(meta), + children: Vec::new(), + is_index: matches!( + page.source().file_name(), + "index.md" | "README.md" + ), + active: false, + }) + } + } +} + +fn find_anchor<'a>(sections: &'a [Section], id: &str) -> Option<&'a Section> { + sections.iter().find_map(|section| { + (section.id == id) + .then_some(section) + .or_else(|| find_anchor(§ion.children, id)) + }) +} + +fn is_local(target: &str) -> bool { + !target.starts_with(['#', '?']) + && !target + .split('/') + .next() + .is_some_and(|prefix| prefix.contains(':')) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{parse, LinkItem}; + use crate::structure::dynamic::Dynamic; + use std::collections::BTreeMap; + + #[test] + fn parses_navigation_shaped_links() { + let value = Dynamic::List(vec![ + Dynamic::String("guide.md".into()), + Dynamic::Map(BTreeMap::from([( + "References".into(), + Dynamic::List(vec![Dynamic::Map(BTreeMap::from([( + "API".into(), + Dynamic::String("api.md#call".into()), + )]))]), + )])), + ]); + let links = parse(Some(&value)).unwrap().unwrap(); + assert_eq!(links.len(), 2); + assert!(matches!(links[1], LinkItem::Section { .. })); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog/pagination.rs b/crates/zensical/src/compat/mkdocs/plugin/blog/pagination.rs new file mode 100644 index 0000000..6f2a121 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog/pagination.rs @@ -0,0 +1,418 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Native parsing of Material's pagination format language. + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// One directional link in a pagination format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LinkKind { + /// Link to the first page. + First, + /// Link to the last page. + Last, + /// Link to the previous page. + Previous, + /// Link to the next page. + Next, +} + +/// One ordered component of a rendered pagination control. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PaginationItem { + /// Numbered page link or current-page marker. + Page { + /// One-based target page number. + page: usize, + /// Whether this item denotes the current page. + current: bool, + }, + /// Collapsed range between numbered pages. + Ellipsis, + /// Directional link to another page. + Link { + /// Direction represented by the link. + kind: LinkKind, + /// One-based target page number. + page: usize, + }, + /// Literal text from the configured pagination format. + Text( + /// Preserved literal or scalar substitution. + String, + ), +} + +/// Parsed replacement for one pagination-format placeholder. +enum Substitution { + /// Optional structured pagination item. + Item( + /// Generated item, or `None` when a directional link is unreachable. + Option, + ), + /// Scalar value rendered as literal text. + Text( + /// Rendered scalar value. + String, + ), +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Values substituted into scalar pagination placeholders. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PaginationMetrics { + /// One-based current page number. + pub page: usize, + /// Total number of reachable pages. + pub pages: usize, + /// Configured maximum number of items on a page. + pub items_per_page: usize, + /// Total number of items across all pages. + pub item_count: usize, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl LinkKind { + /// Returns the stable template-facing item type. + pub fn as_str(self) -> &'static str { + match self { + Self::First => "first_page", + Self::Last => "last_page", + Self::Previous => "previous_page", + Self::Next => "next_page", + } + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Parses a pagination format into bounded, presentation-independent items. +/// +/// This mirrors the `paginate` package's format substitutions while leaving +/// link markup and directional symbols to the active UI template. +pub fn items(format: &str, metrics: PaginationMetrics) -> Vec { + debug_assert!(metrics.page >= 1 && metrics.page <= metrics.pages); + let radius = first_radius(format).unwrap_or(2); + let bytes = format.as_bytes(); + let mut result = Vec::new(); + let mut text = String::new(); + let mut index = 0; + while index < bytes.len() { + if let Some((end, _)) = range(bytes, index) { + push_text(&mut result, &mut text); + result.extend(page_range(metrics, radius)); + index = end; + } else if bytes[index] == b'$' { + let (end, placeholder) = placeholder(format, index); + match placeholder.and_then(|name| substitution(name, metrics)) { + Some(Substitution::Item(item)) => { + if let Some(item) = item { + push_text(&mut result, &mut text); + result.push(item); + } + } + Some(Substitution::Text(value)) => text.push_str(&value), + None if format[index..end].starts_with("$$") => { + text.push('$'); + } + None => text.push_str(&format[index..end]), + } + index = end; + } else { + let character = format[index..] + .chars() + .next() + .expect("index is within the format"); + text.push(character); + index += character.len_utf8(); + } + } + push_text(&mut result, &mut text); + result +} + +fn substitution( + name: &str, metrics: PaginationMetrics, +) -> Option { + let scalar = match name { + "first_page" => Some(1), + "last_page" | "page_count" => Some(metrics.pages), + "page" => Some(metrics.page), + "items_per_page" => Some(metrics.items_per_page), + "first_item" => Some( + (metrics.page - 1) + .saturating_mul(metrics.items_per_page) + .saturating_add(1) + .min(metrics.item_count), + ), + "last_item" => Some( + metrics + .page + .saturating_mul(metrics.items_per_page) + .min(metrics.item_count), + ), + "item_count" => Some(metrics.item_count), + _ => None, + }; + if let Some(value) = scalar { + return Some(Substitution::Text(value.to_string())); + } + + let link = match name { + "link_first" => Some((LinkKind::First, 1, metrics.page > 1)), + "link_last" => { + Some((LinkKind::Last, metrics.pages, metrics.page < metrics.pages)) + } + "link_previous" => Some(( + LinkKind::Previous, + metrics.page.saturating_sub(1), + metrics.page > 1, + )), + "link_next" => Some(( + LinkKind::Next, + metrics.page.saturating_add(1), + metrics.page < metrics.pages, + )), + _ => None, + }; + link.map(|(kind, page, visible)| { + Substitution::Item( + visible.then_some(PaginationItem::Link { kind, page }), + ) + }) +} + +fn page_range( + metrics: PaginationMetrics, radius: usize, +) -> Vec { + let left = metrics.page.saturating_sub(radius).max(1); + let right = metrics.page.saturating_add(radius).min(metrics.pages); + let mut result = Vec::new(); + if metrics.page != 1 && 1 < left { + result.push(PaginationItem::Page { page: 1, current: false }); + } + if left.saturating_sub(1) > 1 { + result.push(PaginationItem::Ellipsis); + } + result.extend((left..=right).map(|page| PaginationItem::Page { + page, + current: page == metrics.page, + })); + if metrics.pages.saturating_sub(right) > 1 { + result.push(PaginationItem::Ellipsis); + } + if metrics.page != metrics.pages && right < metrics.pages { + result.push(PaginationItem::Page { + page: metrics.pages, + current: false, + }); + } + result +} + +fn first_radius(format: &str) -> Option { + let bytes = format.as_bytes(); + (0..bytes.len()).find_map(|index| range(bytes, index).map(|(_, n)| n)) +} + +fn range(bytes: &[u8], start: usize) -> Option<(usize, usize)> { + if bytes.get(start) != Some(&b'~') { + return None; + } + let mut index = start + 1; + let first = index; + let mut value = 0usize; + while bytes.get(index).is_some_and(u8::is_ascii_digit) { + value = value + .saturating_mul(10) + .saturating_add(usize::from(bytes[index] - b'0')); + index += 1; + } + (index > first && bytes.get(index) == Some(&b'~')) + .then_some((index + 1, value)) +} + +fn placeholder(format: &str, start: usize) -> (usize, Option<&str>) { + let bytes = format.as_bytes(); + let Some(next) = bytes.get(start + 1).copied() else { + return (start + 1, None); + }; + if next == b'$' { + return (start + 2, None); + } + if next == b'{' { + let name_start = start + 2; + let mut end = name_start; + while bytes.get(end).is_some_and(|byte| *byte != b'}') { + end += 1; + } + if bytes.get(end) == Some(&b'}') { + let name = &format[name_start..end]; + return (end + 1, identifier(name).then_some(name)); + } + return (start + 1, None); + } + let name_start = start + 1; + if !identifier_start(next) { + return (start + 1, None); + } + let mut end = name_start + 1; + while bytes.get(end).is_some_and(|byte| identifier_byte(*byte)) { + end += 1; + } + (end, Some(&format[name_start..end])) +} + +fn identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes.next().is_some_and(identifier_start) && bytes.all(identifier_byte) +} + +fn identifier_start(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphabetic() +} + +fn identifier_byte(byte: u8) -> bool { + identifier_start(byte) || byte.is_ascii_digit() +} + +fn push_text(result: &mut Vec, text: &mut String) { + if !text.is_empty() { + result.push(PaginationItem::Text(std::mem::take(text))); + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn metrics(page: usize) -> PaginationMetrics { + PaginationMetrics { + page, + pages: 10, + items_per_page: 3, + item_count: 29, + } + } + + #[test] + fn expands_the_default_page_range() { + assert_eq!( + items("~2~", metrics(5)), + vec![ + PaginationItem::Page { page: 1, current: false }, + PaginationItem::Ellipsis, + PaginationItem::Page { page: 3, current: false }, + PaginationItem::Page { page: 4, current: false }, + PaginationItem::Page { page: 5, current: true }, + PaginationItem::Page { page: 6, current: false }, + PaginationItem::Page { page: 7, current: false }, + PaginationItem::Ellipsis, + PaginationItem::Page { page: 10, current: false }, + ] + ); + } + + #[test] + fn preserves_directional_and_scalar_placeholder_order() { + assert_eq!( + items( + "$link_first $link_previous $page/$page_count \ + $link_next $link_last", + metrics(5), + ), + vec![ + PaginationItem::Link { kind: LinkKind::First, page: 1 }, + PaginationItem::Text(" ".into()), + PaginationItem::Link { + kind: LinkKind::Previous, + page: 4, + }, + PaginationItem::Text(" 5/10 ".into()), + PaginationItem::Link { kind: LinkKind::Next, page: 6 }, + PaginationItem::Text(" ".into()), + PaginationItem::Link { kind: LinkKind::Last, page: 10 }, + ] + ); + } + + #[test] + fn omits_unreachable_links_but_preserves_literal_separators() { + assert_eq!( + items("$link_previous $page $link_next", metrics(1)), + vec![ + PaginationItem::Text(" 1 ".into()), + PaginationItem::Link { kind: LinkKind::Next, page: 2 }, + ] + ); + } + + #[test] + fn substitutes_item_boundaries_and_keeps_unknown_tokens() { + assert_eq!( + items( + "${first_item}-${last_item}/$item_count $$ $unknown", + metrics(10), + ), + vec![PaginationItem::Text("28-29/29 $ $unknown".into())] + ); + } + + #[test] + fn uses_the_first_radius_for_every_range_placeholder() { + let result = items("~0~ + ~3~", metrics(5)); + assert_eq!( + result, + vec![ + PaginationItem::Page { page: 1, current: false }, + PaginationItem::Ellipsis, + PaginationItem::Page { page: 5, current: true }, + PaginationItem::Ellipsis, + PaginationItem::Page { page: 10, current: false }, + PaginationItem::Text(" + ".into()), + PaginationItem::Page { page: 1, current: false }, + PaginationItem::Ellipsis, + PaginationItem::Page { page: 5, current: true }, + PaginationItem::Ellipsis, + PaginationItem::Page { page: 10, current: false }, + ] + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog/post.rs b/crates/zensical/src/compat/mkdocs/plugin/blog/post.rs new file mode 100644 index 0000000..b5cc92b --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog/post.rs @@ -0,0 +1,527 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Blog post classification, metadata, and route derivation. + +use anyhow::{bail, Context}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; + +use zrx::stream::Value; + +use crate::config::plugins::{BlogPluginConfig, ExcerptPolicy}; +use crate::config::Config; +use crate::path::SourcePath; +use crate::structure::document::DocumentHeader; +use crate::structure::dynamic::Dynamic; +use crate::structure::page::{PageDescriptor, PageOrigin, PageRoute}; +use crate::structure::slug; + +use super::links::{self, LinkItem}; +use super::{Author, BlogDate, BlogId, PostId}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Validated blog interpretation of one Markdown document. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostDescriptor { + /// Stable source-based identity. + pub id: PostId, + /// Pre-render page descriptor with the post's final route. + pub page: PageDescriptor, + /// Structured post dates. `created` is always present. + pub dates: BTreeMap, + /// Unique author identifiers in declaration order. + pub author_ids: Vec, + /// Resolved author objects in declaration order. + pub authors: Vec, + /// Unique category names in declaration order. + pub categories: Vec, + /// Whether the post is pinned in views. + pub pin: bool, + /// Whether metadata explicitly marks this post as a draft. + pub draft: Option, + /// Explicit slug, if supplied. + pub slug: Option, + /// Optional explicit read-time override. + pub readtime: Option, + /// Parsed navigation-shaped related links. + pub links: Option>, +} + +/// Validated metadata used to construct template-facing post properties. +struct PostProperties<'a> { + /// Structured post dates keyed by semantic role. + dates: &'a BTreeMap, + /// Unique category names in declaration order. + categories: &'a [String], + /// Whether the post sorts before ordinary posts. + pin: bool, + /// Explicit draft metadata, when present. + draft: Option, + /// Explicit read-time override, when present. + readtime: Option, + /// Original structured related-link metadata, when present. + links: Option<&'a Dynamic>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl PostDescriptor { + /// Classifies and validates a document below one blog's post directory. + pub fn from_document( + config: &Config, blog: BlogId, settings: &BlogPluginConfig, + mut document: DocumentHeader, + ) -> anyhow::Result> { + let post_dir = post_dir(settings)?; + if document.source.parent().as_ref() != Some(&post_dir) + && !document.source.is_descendant_of(&post_dir) + { + return Ok(None); + } + + let dates = dates(&document)?; + let created = dates + .get("created") + .copied() + .expect("created date is validated"); + let author_ids = string_list(&document, "authors")?; + let categories = string_list(&document, "categories")?; + if !settings.categories_allowed.is_empty() { + for category in &categories { + if !settings.categories_allowed.contains(category) { + bail!( + "post '{}' uses category '{}' outside categories_allowed", + document.source, + category + ) + } + } + } + let pin = optional_bool(&document, "pin")?.unwrap_or(false); + let draft = optional_bool(&document, "draft")?; + let slug = optional_string(&document, "slug")?; + let readtime = optional_usize(&document, "readtime")?; + let links = links::parse(document.meta.get("links"))?; + if settings.post_excerpt == ExcerptPolicy::Required + && !document.body.contains(&settings.post_excerpt_separator) + { + bail!( + "post '{}' requires the excerpt separator '{}'", + document.source, + settings.post_excerpt_separator + ) + } + + document + .meta + .entry("template".into()) + .or_insert_with(|| Dynamic::String("blog-post.html".into())); + hide_navigation(&mut document)?; + + let route_source = route_source( + settings, + &document, + created, + &categories, + slug.as_deref(), + )?; + let destination = PageRoute::destination( + &route_source, + config.project.use_directory_urls, + )?; + let route = PageRoute::from_destination( + config, + document.source.clone(), + destination, + ); + let id = PostId { + blog, + source: document.source.clone(), + }; + let properties = properties( + config, + settings, + PostProperties { + dates: &dates, + categories: &categories, + pin, + draft, + readtime, + links: document.meta.get("links"), + }, + )?; + let page = PageDescriptor { + origin: PageOrigin::Source(document.source.clone()), + document, + route, + properties, + variables: BTreeMap::from([( + "_blog_date_format".into(), + Dynamic::String(settings.post_date_format.clone()), + )]), + }; + Ok(Some(Self { + id, + page, + dates, + author_ids, + authors: Vec::new(), + categories, + pin, + draft, + slug, + readtime, + links, + })) + } + + /// Returns the mandatory creation date. + pub fn created(&self) -> BlogDate { + self.dates["created"] + } + + /// Returns whether this post is hidden by draft policy at `now`. + pub fn is_excluded( + &self, settings: &BlogPluginConfig, serve: bool, now: i64, + ) -> bool { + let include_drafts = + settings.draft || (serve && settings.draft_on_serve); + if include_drafts { + return false; + } + self.draft.unwrap_or_else(|| { + settings.draft_if_future_date + && self.created().timestamp_micros() > now + }) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for PostDescriptor {} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +fn properties( + config: &Config, settings: &BlogPluginConfig, input: PostProperties<'_>, +) -> anyhow::Result> { + let mut post = BTreeMap::new(); + post.insert( + "date".into(), + Dynamic::Map( + input + .dates + .iter() + .map(|(name, date)| { + (name.clone(), Dynamic::String(date.template_value())) + }) + .collect(), + ), + ); + post.insert("pin".into(), Dynamic::Bool(input.pin)); + if let Some(draft) = input.draft { + post.insert("draft".into(), Dynamic::Bool(draft)); + } + if let Some(readtime) = input.readtime { + post.insert( + "readtime".into(), + Dynamic::Integer( + i64::try_from(readtime) + .context("readtime exceeds native integer range")?, + ), + ); + } + if let Some(links) = input.links { + post.insert("links".into(), links.clone()); + } + let root = settings.blog_dir.trim_matches('/'); + let source = if matches!(root, "" | ".") { + "index.md".parse::()? + } else { + format!("{root}/index.md").parse()? + }; + let parent = PageRoute::from_source(config, source)?; + let categories = if settings.categories { + input + .categories + .iter() + .map(|name| { + let source = super::category_source(settings, name)?; + let route = PageRoute::from_source(config, source)?; + Ok(Dynamic::Map(BTreeMap::from([ + ("title".into(), Dynamic::String(name.clone())), + ("url".into(), Dynamic::String(route.url)), + ]))) + }) + .collect::>>()? + } else { + Vec::new() + }; + Ok(BTreeMap::from([ + ("config".into(), Dynamic::Map(post)), + ("authors".into(), Dynamic::List(Vec::new())), + ("categories".into(), Dynamic::List(categories)), + ( + "parent".into(), + Dynamic::Map(BTreeMap::from([( + "url".into(), + Dynamic::String(parent.url), + )])), + ), + ])) +} + +fn hide_navigation(document: &mut DocumentHeader) -> anyhow::Result<()> { + let hide = document + .meta + .entry("hide".into()) + .or_insert_with(|| Dynamic::List(Vec::new())); + let Dynamic::List(values) = hide else { + bail!("post '{}': hide must be a list", document.source) + }; + if !values + .iter() + .any(|value| value == &Dynamic::String("navigation".into())) + { + values.push(Dynamic::String("navigation".into())); + } + Ok(()) +} + +pub(super) fn post_dir( + settings: &BlogPluginConfig, +) -> anyhow::Result { + let path = settings.post_dir.replace("{blog}", blog_root(settings)); + path.trim_matches('/') + .strip_prefix("./") + .unwrap_or(path.trim_matches('/')) + .parse() + .context("invalid blog post_dir") +} + +fn route_source( + settings: &BlogPluginConfig, document: &DocumentHeader, created: BlogDate, + categories: &[String], explicit_slug: Option<&str>, +) -> anyhow::Result { + let slug = explicit_slug.map_or_else( + || slug::unicode(&document.title, &settings.post_slugify_separator), + ToOwned::to_owned, + ); + let categories = categories + .iter() + .take(settings.post_url_max_categories) + .map(|category| { + slug::unicode(category, &settings.categories_slugify_separator) + }) + .collect::>() + .join("/"); + let date = created.format_url(&settings.post_url_date_format)?; + let path = settings + .post_url_format + .replace("{categories}", &categories) + .replace("{date}", &date) + .replace("{file}", document.source.file_stem()) + .replace("{slug}", &slug); + let path = path.trim_matches('/'); + if path.is_empty() { + bail!("post '{}' produces an empty URL", document.source) + } + let root = blog_root(settings); + let source = if root.is_empty() { + format!("{path}.md") + } else { + format!("{root}/{path}.md") + }; + source.parse().with_context(|| { + format!("invalid route for post '{}'", document.source) + }) +} + +fn blog_root(settings: &BlogPluginConfig) -> &str { + match settings.blog_dir.trim_matches('/') { + "." => "", + root => root, + } +} + +fn dates( + document: &DocumentHeader, +) -> anyhow::Result> { + let value = document.meta.get("date").ok_or_else(|| { + anyhow::anyhow!("post '{}' requires date metadata", document.source) + })?; + let values = match value { + Dynamic::Map(values) => values.clone(), + value => BTreeMap::from([("created".into(), value.clone())]), + }; + let mut dates = BTreeMap::new(); + for (name, value) in values { + let Dynamic::String(value) = value else { + bail!( + "post '{}': date.{name} must be a date or datetime", + document.source + ) + }; + dates.insert( + name.clone(), + BlogDate::parse(&value).with_context(|| { + format!( + "post '{}': invalid date.{name} value '{value}'", + document.source + ) + })?, + ); + } + if !dates.contains_key("created") { + bail!("post '{}': date.created is required", document.source) + } + Ok(dates) +} + +fn string_list( + document: &DocumentHeader, name: &str, +) -> anyhow::Result> { + let Some(value) = document.meta.get(name) else { + return Ok(Vec::new()); + }; + let Dynamic::List(values) = value else { + bail!("post '{}': {name} must be a list", document.source) + }; + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for value in values { + let Dynamic::String(value) = value else { + bail!("post '{}': {name} entries must be strings", document.source) + }; + if seen.insert(value.clone()) { + result.push(value.clone()); + } + } + Ok(result) +} + +fn optional_bool( + document: &DocumentHeader, name: &str, +) -> anyhow::Result> { + match document.meta.get(name) { + None | Some(Dynamic::Null) => Ok(None), + Some(Dynamic::Bool(value)) => Ok(Some(*value)), + Some(_) => { + bail!("post '{}': {name} must be a Boolean", document.source) + } + } +} + +fn optional_string( + document: &DocumentHeader, name: &str, +) -> anyhow::Result> { + match document.meta.get(name) { + None | Some(Dynamic::Null) => Ok(None), + Some(Dynamic::String(value)) => Ok(Some(value.clone())), + Some(_) => bail!("post '{}': {name} must be a string", document.source), + } +} + +fn optional_usize( + document: &DocumentHeader, name: &str, +) -> anyhow::Result> { + match document.meta.get(name) { + None | Some(Dynamic::Null) => Ok(None), + Some(Dynamic::Integer(value)) => usize::try_from(*value) + .map(Some) + .map_err(anyhow::Error::from), + Some(_) => bail!( + "post '{}': {name} must be a non-negative integer", + document.source + ), + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::structure::dynamic::Dynamic; + + use super::{ + dates, route_source, BlogDate, BlogPluginConfig, DocumentHeader, + }; + + fn document(meta: BTreeMap) -> DocumentHeader { + DocumentHeader::new( + "blog/posts/hello.md".parse().unwrap(), + "# Héllo, World!".into(), + meta, + ) + } + + #[test] + fn parses_scalar_and_structured_dates() { + let scalar = document(BTreeMap::from([( + "date".into(), + Dynamic::String("2026-09-03".into()), + )])); + assert_eq!( + dates(&scalar).unwrap()["created"], + BlogDate::parse("2026-09-03").unwrap() + ); + + let structured = document(BTreeMap::from([( + "date".into(), + Dynamic::Map(BTreeMap::from([ + ("created".into(), Dynamic::String("2026-09-03".into())), + ("updated".into(), Dynamic::String("2026-09-04".into())), + ])), + )])); + assert_eq!(dates(&structured).unwrap().len(), 2); + } + + #[test] + fn derives_native_unicode_routes() { + let document = document(BTreeMap::default()); + let route = route_source( + &BlogPluginConfig::default(), + &document, + BlogDate::parse("2026-09-03").unwrap(), + &[], + None, + ) + .unwrap(); + assert_eq!(route.as_str(), "blog/2026/09/03/héllo-world.md"); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/blog/readtime.rs b/crates/zensical/src/compat/mkdocs/plugin/blog/readtime.rs new file mode 100644 index 0000000..81863fa --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/blog/readtime.rs @@ -0,0 +1,130 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Native Material-compatible post read-time calculation. + +use html5gum::emitters::callback::CallbackEvent; +use html5gum::Span; +use regex::Regex; +use std::sync::LazyLock; + +use crate::compat::mkdocs::html::{self, Editor, Visitor}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Text and image facts accumulated during one HTML scan. +#[derive(Default)] +struct Readtime { + /// Visible text included in the word count. + text: String, + /// Images contributing decreasing reading-time penalties. + images: usize, + /// Depth inside elements excluded from the word count. + skipped: usize, +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Visitor for Readtime { + fn visit( + &mut self, event: &CallbackEvent<'_>, _span: Span, + _editor: &mut Editor<'_>, + ) { + match event { + CallbackEvent::OpenStartTag { name } => { + if *name == b"img" { + self.images += 1; + } + if matches!(*name, b"object" | b"script" | b"style" | b"svg") { + self.skipped += 1; + } + } + CallbackEvent::EndTag { name } + if matches!( + *name, + b"object" | b"script" | b"style" | b"svg" + ) => + { + self.skipped = self.skipped.saturating_sub(1); + } + CallbackEvent::String { value } if self.skipped == 0 => { + self.text.push_str(&String::from_utf8_lossy(value)); + } + _ => {} + } + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Returns Material's rounded read time in minutes. +pub fn calculate(input: &str, words_per_minute: usize) -> usize { + static SEPARATOR: LazyLock = + LazyLock::new(|| Regex::new(r"\W+").expect("static expression")); + let mut readtime = Readtime::default(); + let _ = html::scan(input, &mut [&mut readtime]); + let words = SEPARATOR.split(&readtime.text).count(); + let mut seconds = words.saturating_mul(60).div_ceil(words_per_minute); + let mut penalty = 12; + for _ in 0..readtime.images { + seconds += penalty; + if penalty > 3 { + penalty -= 1; + } + } + seconds.div_ceil(60) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::calculate; + + #[test] + fn ignores_embedded_code_and_applies_decreasing_image_penalties() { + let words = std::iter::repeat_n("word", 265) + .collect::>() + .join(" "); + assert_eq!(calculate(&format!("

{words}

"), 265), 1); + assert_eq!( + calculate( + &format!( + "

{words}

" + ), + 265, + ), + 2 + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/meta.rs b/crates/zensical/src/compat/mkdocs/plugin/meta.rs index 4285c60..208ef96 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/meta.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/meta.rs @@ -213,6 +213,28 @@ impl Node { // ---------------------------------------------------------------------------- +impl Document { + /// Projects a parsed YAML document into plain runtime values. + pub(crate) fn values(&self) -> BTreeMap { + let Value::Map(values) = &self.root.value else { + unreachable!("metadata root is always a mapping") + }; + values + .iter() + .map(|(key, value)| (key.clone(), value.dynamic())) + .collect() + } +} + +/// Parses a standalone YAML mapping into shared dynamic values. +pub(crate) fn parse_yaml( + path: SourcePath, source: &str, +) -> Result> { + Ok(parser::parse(path, source, 0)?.values()) +} + +// ---------------------------------------------------------------------------- + impl Resolved { /// Returns plain values for the Python Markdown boundary. pub fn values(&self) -> BTreeMap { diff --git a/crates/zensical/src/compat/mkdocs/plugin/tags/tag.rs b/crates/zensical/src/compat/mkdocs/plugin/tags/tag.rs index 1e4e250..79f6c4a 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/tags/tag.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/tags/tag.rs @@ -27,14 +27,13 @@ use anyhow::{bail, Result}; use icu_casemap::CaseMapper; -use icu_locale_core::LanguageIdentifier; -use icu_normalizer::{ComposingNormalizer, DecomposingNormalizer}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use crate::config::plugins::{python_scalar, TagsPluginConfig}; use crate::structure::dynamic::Dynamic; +use crate::structure::slug; use crate::structure::tag::{Tag as TemplateTag, TagNode as TemplateTagNode}; // ---------------------------------------------------------------------------- @@ -201,23 +200,21 @@ fn slug_part(value: &str, separator: &str, strategy: &str) -> Result { match strategy { "pymdownx:lower" => Ok(slug_pymdownx(value, separator, false)), "pymdownx:fold" => Ok(slug_pymdownx(value, separator, true)), - "markdown:slugify" => Ok(slug_markdown(value, separator)), + "markdown:slugify" => Ok(slug::ascii(value, separator)), _ => bail!("unsupported tags slug strategy: {strategy}"), } } /// Matches pymdownx's NFC, HTML stripping, case, and character policy. fn slug_pymdownx(value: &str, separator: &str, fold: bool) -> String { + if !fold { + return slug::unicode(value, separator); + } let stripped = strip_html(value); - let normalized = ComposingNormalizer::new_nfc().normalize(&stripped); + let normalized = + icu_normalizer::ComposingNormalizer::new_nfc().normalize(&stripped); let normalized = normalized.trim(); - let cased = if fold { - CaseMapper::new().fold_string(normalized).into_owned() - } else { - CaseMapper::new() - .lowercase_to_string(normalized, &LanguageIdentifier::UNKNOWN) - .into_owned() - }; + let cased = CaseMapper::new().fold_string(normalized).into_owned(); let mut output = String::with_capacity(cased.len()); for character in cased.chars() { if character.is_alphanumeric() || matches!(character, '_' | '-') { @@ -229,34 +226,6 @@ fn slug_pymdownx(value: &str, separator: &str, fold: bool) -> String { output } -/// Matches Python Markdown's ASCII NFKD slug function. -fn slug_markdown(value: &str, separator: &str) -> String { - let normalized = DecomposingNormalizer::new_nfkd().normalize(value); - let filtered = normalized - .chars() - .filter(|character| { - character.is_ascii_alphanumeric() - || character.is_ascii_whitespace() - || matches!(character, '_' | '-') - }) - .flat_map(char::to_lowercase) - .collect::(); - let mut output = String::with_capacity(filtered.len()); - let mut inside_separator = false; - for character in filtered.trim().chars() { - if character.is_whitespace() || separator.contains(character) { - if !inside_separator { - output.push_str(separator); - inside_separator = true; - } - } else { - inside_separator = false; - output.push(character); - } - } - output -} - /// Removes HTML tags using pymdownx's permissive non-nesting semantics. fn strip_html(value: &str) -> String { let mut output = String::with_capacity(value.len()); diff --git a/crates/zensical/src/compat/mkdocs/resource.rs b/crates/zensical/src/compat/mkdocs/resource.rs index eab6226..85856fc 100644 --- a/crates/zensical/src/compat/mkdocs/resource.rs +++ b/crates/zensical/src/compat/mkdocs/resource.rs @@ -57,6 +57,8 @@ pub struct Dependencies<'a> { /// One resource after MkDocs source precedence has been resolved. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Resource { + /// Original documentation-relative identity before module routing. + pub source_path: SitePath, /// Logical output path relative to the site directory. pub path: SitePath, /// Physical source path. @@ -74,6 +76,8 @@ struct Classifier { extra_templates: Vec, static_templates: Vec, meta: meta::Settings, + blog_assets: Vec<(String, String)>, + private_files: Vec, } // ---------------------------------------------------------------------------- @@ -117,11 +121,33 @@ impl Resources { impl Classifier { /// Resolves classification settings once for the workflow lifetime. fn new(config: &Config, meta: &meta::Meta) -> Self { + let blogs = config + .project + .plugins + .blogs + .config + .iter() + .filter(|instance| instance.config.enabled) + .map(|instance| &instance.config); + let mut blog_assets = Vec::new(); + let mut private_files = Vec::new(); + for blog in blogs { + let root = normalize(&blog.blog_dir); + let posts = normalize(&blog.post_dir.replace("{blog}", &root)); + blog_assets.push((posts, root)); + if blog.authors || blog.authors_profiles { + private_files.push(normalize( + &blog.authors_file.replace("{blog}", &blog.blog_dir), + )); + } + } Self { docs: config.project.docs_dir.clone(), extra_templates: config.project.extra_templates.clone(), static_templates: config.project.theme.static_templates.clone(), meta: meta.settings().clone(), + blog_assets, + private_files, } } @@ -141,13 +167,18 @@ impl Classifier { .and_then(|index| index.checked_add(1)) .unwrap_or(usize::MAX) }; - let path = id.location().parse::()?; + let mut path = id.location().parse::()?; if path.is_hidden() { return Ok(None); } + let source_path = path.clone(); if is_docs { if has_extension(&path, "md") || meta::claims(path.as_str(), &self.meta) + || self + .private_files + .iter() + .any(|private| private == path.as_str()) || self .extra_templates .iter() @@ -155,6 +186,7 @@ impl Classifier { { return Ok(None); } + path = self.relocate(path)?; } else if has_extension(&path, "html") || self .static_templates @@ -164,11 +196,33 @@ impl Classifier { return Ok(None); } Ok(Some(Resource { + source_path, path, source: source.clone(), priority, })) } + + fn relocate(&self, path: SitePath) -> Result { + let matches = self + .blog_assets + .iter() + .filter_map(|(from, to)| { + path.as_str() + .strip_prefix(from) + .and_then(|suffix| suffix.strip_prefix('/')) + .map(|suffix| (to, suffix)) + }) + .collect::>(); + match matches.as_slice() { + [] => Ok(path), + [(to, suffix)] if to.is_empty() => Ok(suffix.parse()?), + [(to, suffix)] => Ok(format!("{to}/{suffix}").parse()?), + _ => anyhow::bail!( + "resource '{path}' is claimed by multiple blog post directories" + ), + } + } } // ---------------------------------------------------------------------------- @@ -204,6 +258,15 @@ fn has_extension(path: &SitePath, expected: &str) -> bool { .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) } +fn normalize(path: &str) -> String { + let path = path.trim_matches('/'); + if matches!(path, "" | ".") { + String::new() + } else { + path.strip_prefix("./").unwrap_or(path).to_owned() + } +} + // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- @@ -297,6 +360,31 @@ mod tests { .is_some()); } + #[test] + fn relocates_blog_assets_and_excludes_author_catalogs() { + let mut classifier = classifier(); + classifier.blog_assets = vec![("blog/posts".into(), "blog".into())]; + classifier.private_files = vec!["blog/.authors.yml".into()]; + + let asset = classifier + .classify( + &id("docs", "blog/posts/assets/image.png"), + &source("image.png"), + ) + .unwrap() + .unwrap(); + assert_eq!(asset.path.as_str(), "blog/assets/image.png"); + assert!( + classifier + .classify( + &id("docs", "blog/.authors.yml"), + &source(".authors.yml"), + ) + .unwrap() + .is_none() + ); + } + #[test] fn ignores_sources_outside_docs_and_themes() { assert!(classifier() @@ -308,11 +396,13 @@ mod tests { #[test] fn uses_source_path_as_a_deterministic_tie_breaker() { let left = Resource { + source_path: "asset.js".parse().unwrap(), path: "asset.js".parse().unwrap(), source: source("b/asset.js"), priority: 1, }; let right = Resource { + source_path: "asset.js".parse().unwrap(), path: "asset.js".parse().unwrap(), source: source("a/asset.js"), priority: 1, @@ -336,6 +426,8 @@ mod tests { enabled: true, meta_file: ".meta.yml".into(), }, + blog_assets: Vec::new(), + private_files: Vec::new(), } } diff --git a/crates/zensical/src/compat/mkdocs/url.rs b/crates/zensical/src/compat/mkdocs/url.rs new file mode 100644 index 0000000..3c62cdc --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/url.rs @@ -0,0 +1,133 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Shared MkDocs-compatible URL transformations. + +use std::path::Path; + +use zrx::path::PathExt; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Computes a relative URL from one page URL to another. +pub fn relative(from: &str, to: &str) -> String { + let from = Path::new(from); + let (to, fragment) = to + .split_once('#') + .map_or((Path::new(to), None), |(path, fragment)| { + (Path::new(path), Some(fragment)) + }); + let mut relative = + to.relative_to(from).to_string_lossy().replace('\\', "/"); + + if let Some(fragment) = fragment { + if relative == "." { + return format!("#{fragment}"); + } + if to.as_os_str().is_empty() { + relative.push('/'); + } + relative.push('#'); + relative.push_str(fragment); + } + relative +} + +/// Resolves a local URL against `from`, then makes it relative to `to`. +pub fn rebase(from: &str, to: &str, value: &str) -> Option { + resolve(from, value).map(|target| relative(to, &target)) +} + +/// Resolves one local URL against a page route. +pub fn resolve(from: &str, value: &str) -> Option { + if value.starts_with(['#', '?', '/']) + || value + .split('/') + .next() + .is_some_and(|prefix| prefix.contains(':')) + { + return None; + } + let suffix = value.find(['?', '#']).unwrap_or(value.len()); + let (path, suffix) = value.split_at(suffix); + let base = if from.ends_with('/') { + Path::new(from) + } else { + Path::new(from).parent().unwrap_or_else(|| Path::new("")) + }; + let target = base + .join(path) + .normalize() + .to_string_lossy() + .replace('\\', "/"); + Some(format!("{target}{suffix}")) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{rebase, relative, resolve}; + + #[test] + fn computes_relative_urls() { + assert_eq!(relative("a/b/", "a/c#d"), "../c#d"); + assert_eq!(relative("a/index.html", "a/b.html#c"), "b.html#c"); + assert_eq!(relative("a/b/", "a/b#c"), "#c"); + } + + #[test] + fn rebases_local_urls() { + assert_eq!( + rebase( + "blog/2026/09/post/", + "blog/page/2/", + "../../../../notes/#detail" + ) + .as_deref(), + Some("../../../notes/#detail") + ); + assert_eq!(rebase("a/", "b/", "https://example.com"), None); + assert_eq!(rebase("a/", "b/", "#local"), None); + } + + #[test] + fn resolves_local_urls_against_page_routes() { + assert_eq!( + resolve( + "blog/2026/09/post/", + "../../../../blog/posts/assets/image.png?raw#preview" + ) + .as_deref(), + Some("blog/posts/assets/image.png?raw#preview") + ); + assert_eq!(resolve("a/", "https://example.com"), None); + assert_eq!(resolve("a/", "/root"), None); + } +} diff --git a/crates/zensical/src/config.rs b/crates/zensical/src/config.rs index c71c421..f66344f 100644 --- a/crates/zensical/src/config.rs +++ b/crates/zensical/src/config.rs @@ -35,7 +35,7 @@ use std::sync::Arc; use zrx::path::PathExt; -use crate::config::plugins::TagsPlugin; +use crate::config::plugins::{BlogPlugin, TagsPlugin}; use crate::path::{OutputRoot, SourceRoot}; mod error; @@ -124,6 +124,10 @@ impl Config { .get_item("plugins")? .get_item("tags")? .extract::()?; + config + .get_item("plugins")? + .get_item("blogs")? + .extract::()?; let project = config.extract::()?; // Return configuration and theme directory diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index df33b8b..97b1555 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -29,8 +29,10 @@ use pyo3::FromPyObject; use serde::Serialize; use std::collections::BTreeMap; +mod blog; mod tags; +pub use blog::{BlogPlugin, BlogPluginConfig, CategorySort, ExcerptPolicy}; pub use tags::{ python_bool, python_float, python_scalar, TagsListingConfig, TagsPlugin, TagsPluginConfig, @@ -62,6 +64,8 @@ pub struct Plugins { pub minify: MinifyPlugin, /// Material tags plugin instances. pub tags: TagsPlugin, + /// Material blog plugin instances. + pub blogs: BlogPlugin, /// Literate navigation plugin. pub literate_nav: LiterateNavPlugin, /// Awesome navigation plugin. diff --git a/crates/zensical/src/config/plugins/blog.rs b/crates/zensical/src/config/plugins/blog.rs new file mode 100644 index 0000000..e45539a --- /dev/null +++ b/crates/zensical/src/config/plugins/blog.rs @@ -0,0 +1,689 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Native configuration for Material blog compatibility. + +use pyo3::exceptions::PyValueError; +use pyo3::types::{ + PyAny, PyAnyMethods, PyDict, PyDictMethods, PyList, PyListMethods, +}; +use pyo3::{Borrowed, Bound, FromPyObject, PyErr, PyResult}; +use serde::Serialize; +use std::collections::BTreeSet; + +use super::tags::{callable, lower_slug}; + +// ---------------------------------------------------------------------------- +// Constants +// ---------------------------------------------------------------------------- + +/// Complete Material blog configuration surface. +const OPTIONS: &[&str] = &[ + "enabled", + "blog_dir", + "blog_toc", + "post_dir", + "post_date_format", + "post_url_date_format", + "post_url_format", + "post_url_max_categories", + "post_slugify", + "post_slugify_separator", + "post_excerpt", + "post_excerpt_max_authors", + "post_excerpt_max_categories", + "post_excerpt_separator", + "post_readtime", + "post_readtime_words_per_minute", + "archive", + "archive_name", + "archive_date_format", + "archive_url_date_format", + "archive_url_format", + "archive_pagination", + "archive_pagination_per_page", + "archive_toc", + "categories", + "categories_name", + "categories_url_format", + "categories_slugify", + "categories_slugify_separator", + "categories_sort_by", + "categories_sort_reverse", + "categories_allowed", + "categories_pagination", + "categories_pagination_per_page", + "categories_toc", + "authors", + "authors_file", + "authors_profiles", + "authors_profiles_name", + "authors_profiles_url_format", + "authors_profiles_pagination", + "authors_profiles_pagination_per_page", + "authors_profiles_toc", + "pagination", + "pagination_per_page", + "pagination_url_format", + "pagination_format", + "pagination_if_single_page", + "pagination_keep_content", + "draft", + "draft_on_serve", + "draft_if_future_date", + "pagination_template", +]; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Native category ordering strategies. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CategorySort { + /// Sort by display name. + Name, + /// Sort by descending or ascending post count. + PostCount, +} + +/// Excerpt separator policy. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExcerptPolicy { + /// Use the whole post if the separator is absent. + Optional, + /// Reject a post whose separator is absent. + Required, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Material blog plugins. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct BlogPlugin { + /// Ordered plugin instances. + pub config: Vec, +} + +/// One Material blog plugin instance. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct BlogPluginInstance { + /// Canonical plugin name. + pub name: String, + /// Native instance configuration. + pub config: BlogPluginConfig, +} + +/// One native Material blog configuration. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Hash, Serialize)] +pub struct BlogPluginConfig { + /// Whether this plugin instance participates in the build. + pub enabled: bool, + /// Documentation directory containing the blog entrypoint. + pub blog_dir: String, + /// Whether the main blog view integrates post headings into its TOC. + pub blog_toc: bool, + /// Documentation directory containing posts. + pub post_dir: String, + /// Display format applied to post dates. + pub post_date_format: String, + /// Date format substituted into post URLs. + pub post_url_date_format: String, + /// Route format used for posts. + pub post_url_format: String, + /// Maximum number of category slugs substituted into a post URL. + pub post_url_max_categories: usize, + /// Separator used by the configured post slugifier. + pub post_slugify_separator: String, + /// Whether posts must contain an excerpt separator. + pub post_excerpt: ExcerptPolicy, + /// Maximum number of authors exposed by an excerpt. + pub post_excerpt_max_authors: usize, + /// Maximum number of categories exposed by an excerpt. + pub post_excerpt_max_categories: usize, + /// Marker separating excerpt content from the rest of a post. + pub post_excerpt_separator: String, + /// Whether read time is calculated for posts without an override. + pub post_readtime: bool, + /// Word rate used by automatic read-time calculation. + pub post_readtime_words_per_minute: usize, + /// Whether archive views are generated. + pub archive: bool, + /// Translation key or literal navigation label for archive views. + pub archive_name: String, + /// Display format applied to archive dates. + pub archive_date_format: String, + /// Date format substituted into archive URLs. + pub archive_url_date_format: String, + /// Route format used for archive views. + pub archive_url_format: String, + /// Archive-specific pagination override. + pub archive_pagination: Option, + /// Archive-specific page-size override. + pub archive_pagination_per_page: Option, + /// Archive-specific table-of-contents override. + pub archive_toc: Option, + /// Whether category views are generated. + pub categories: bool, + /// Translation key or literal navigation label for category views. + pub categories_name: String, + /// Route format used for category views. + pub categories_url_format: String, + /// Separator used by the configured category slugifier. + pub categories_slugify_separator: String, + /// Ordering strategy used for category navigation. + pub categories_sort_by: CategorySort, + /// Whether the selected category ordering is reversed. + pub categories_sort_reverse: bool, + /// Allowed category names, or an empty list to allow every category. + pub categories_allowed: Vec, + /// Category-specific pagination override. + pub categories_pagination: Option, + /// Category-specific page-size override. + pub categories_pagination_per_page: Option, + /// Category-specific table-of-contents override. + pub categories_toc: Option, + /// Whether author metadata is rendered on posts and excerpts. + pub authors: bool, + /// Documentation-relative path to the author catalog. + pub authors_file: String, + /// Whether author profile views are generated. + pub authors_profiles: bool, + /// Translation key or literal navigation label for author profiles. + pub authors_profiles_name: String, + /// Route format used for author profile views. + pub authors_profiles_url_format: String, + /// Author-profile-specific pagination override. + pub authors_profiles_pagination: Option, + /// Author-profile-specific page-size override. + pub authors_profiles_pagination_per_page: Option, + /// Author-profile-specific table-of-contents override. + pub authors_profiles_toc: Option, + /// Whether the main blog view is paginated. + pub pagination: bool, + /// Default maximum number of posts shown on one view page. + pub pagination_per_page: usize, + /// Route format used for pagination pages. + pub pagination_url_format: String, + /// Material pagination-format expression. + pub pagination_format: String, + /// Whether pagination context is exposed for a single page. + pub pagination_if_single_page: bool, + /// Whether entrypoint content is retained after the first page. + pub pagination_keep_content: bool, + /// Whether draft posts are included in ordinary builds. + pub draft: bool, + /// Whether draft posts are included while serving. + pub draft_on_serve: bool, + /// Whether future-dated posts are treated as drafts. + pub draft_if_future_date: bool, +} + +/// Typed reader for one Python plugin configuration mapping. +struct Reader<'py> { + /// Python mapping being validated. + value: &'py Bound<'py, PyDict>, + /// Configuration path used in diagnostics. + path: String, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl BlogPluginConfig { + fn from_python(value: &Bound<'_, PyAny>, path: String) -> PyResult { + let value = value.cast::().map_err(|_| { + configuration_error(&path, "expected a configuration mapping") + })?; + let reader = Reader { value, path }; + reader.reject_unknown()?; + if reader.get("pagination_template")?.is_some() { + return Err(reader.error( + "pagination_template", + "is deprecated; use 'pagination_format' instead", + )); + } + + // Callable options are deliberately only classified here. Their + // behavior is implemented by native Rust functions. + reader.slug("post_slugify")?; + reader.slug("categories_slugify")?; + + let mut config = Self::default(); + config.read_post(&reader)?; + config.read_views(&reader)?; + config.read_pagination(&reader)?; + config.validate(&reader)?; + Ok(config) + } + + fn read_post(&mut self, reader: &Reader<'_>) -> PyResult<()> { + let config = self; + config.enabled = reader.bool("enabled", config.enabled)?; + config.blog_dir = reader.string("blog_dir", &config.blog_dir)?; + config.blog_toc = reader.bool("blog_toc", config.blog_toc)?; + config.post_dir = reader.string("post_dir", &config.post_dir)?; + config.post_date_format = + reader.string("post_date_format", &config.post_date_format)?; + config.post_url_date_format = reader + .string("post_url_date_format", &config.post_url_date_format)?; + config.post_url_format = + reader.string("post_url_format", &config.post_url_format)?; + config.post_url_max_categories = reader + .usize("post_url_max_categories", config.post_url_max_categories)?; + config.post_slugify_separator = reader + .string("post_slugify_separator", &config.post_slugify_separator)?; + config.post_excerpt = reader.excerpt(config.post_excerpt)?; + config.post_excerpt_max_authors = reader.usize( + "post_excerpt_max_authors", + config.post_excerpt_max_authors, + )?; + config.post_excerpt_max_categories = reader.usize( + "post_excerpt_max_categories", + config.post_excerpt_max_categories, + )?; + config.post_excerpt_separator = reader + .string("post_excerpt_separator", &config.post_excerpt_separator)?; + config.post_readtime = + reader.bool("post_readtime", config.post_readtime)?; + config.post_readtime_words_per_minute = reader.usize( + "post_readtime_words_per_minute", + config.post_readtime_words_per_minute, + )?; + Ok(()) + } + + fn read_views(&mut self, reader: &Reader<'_>) -> PyResult<()> { + let config = self; + config.archive = reader.bool("archive", config.archive)?; + config.archive_name = + reader.string("archive_name", &config.archive_name)?; + config.archive_date_format = reader + .string("archive_date_format", &config.archive_date_format)?; + config.archive_url_date_format = reader.string( + "archive_url_date_format", + &config.archive_url_date_format, + )?; + config.archive_url_format = + reader.string("archive_url_format", &config.archive_url_format)?; + config.archive_pagination = + reader.optional_bool("archive_pagination")?; + config.archive_pagination_per_page = + reader.optional_usize("archive_pagination_per_page")?; + config.archive_toc = reader.optional_bool("archive_toc")?; + config.categories = reader.bool("categories", config.categories)?; + config.categories_name = + reader.string("categories_name", &config.categories_name)?; + config.categories_url_format = reader + .string("categories_url_format", &config.categories_url_format)?; + config.categories_slugify_separator = reader.string( + "categories_slugify_separator", + &config.categories_slugify_separator, + )?; + config.categories_sort_by = reader.category_sort()?; + config.categories_sort_reverse = reader + .bool("categories_sort_reverse", config.categories_sort_reverse)?; + config.categories_allowed = reader.string_list("categories_allowed")?; + config.categories_pagination = + reader.optional_bool("categories_pagination")?; + config.categories_pagination_per_page = + reader.optional_usize("categories_pagination_per_page")?; + config.categories_toc = reader.optional_bool("categories_toc")?; + config.authors = reader.bool("authors", config.authors)?; + config.authors_file = + reader.string("authors_file", &config.authors_file)?; + config.authors_profiles = + reader.bool("authors_profiles", config.authors_profiles)?; + config.authors_profiles_name = reader + .string("authors_profiles_name", &config.authors_profiles_name)?; + config.authors_profiles_url_format = reader.string( + "authors_profiles_url_format", + &config.authors_profiles_url_format, + )?; + config.authors_profiles_pagination = + reader.optional_bool("authors_profiles_pagination")?; + config.authors_profiles_pagination_per_page = + reader.optional_usize("authors_profiles_pagination_per_page")?; + config.authors_profiles_toc = + reader.optional_bool("authors_profiles_toc")?; + Ok(()) + } + + fn read_pagination(&mut self, reader: &Reader<'_>) -> PyResult<()> { + let config = self; + config.pagination = reader.bool("pagination", config.pagination)?; + config.pagination_per_page = + reader.usize("pagination_per_page", config.pagination_per_page)?; + config.pagination_url_format = reader + .string("pagination_url_format", &config.pagination_url_format)?; + config.pagination_format = + reader.string("pagination_format", &config.pagination_format)?; + config.pagination_if_single_page = reader.bool( + "pagination_if_single_page", + config.pagination_if_single_page, + )?; + config.pagination_keep_content = reader + .bool("pagination_keep_content", config.pagination_keep_content)?; + config.draft = reader.bool("draft", config.draft)?; + config.draft_on_serve = + reader.bool("draft_on_serve", config.draft_on_serve)?; + config.draft_if_future_date = + reader.bool("draft_if_future_date", config.draft_if_future_date)?; + Ok(()) + } + + fn validate(&self, reader: &Reader<'_>) -> PyResult<()> { + for (name, value) in [ + ("pagination_per_page", self.pagination_per_page), + ( + "post_readtime_words_per_minute", + self.post_readtime_words_per_minute, + ), + ] { + if value == 0 { + return Err(reader.error(name, "must be greater than zero")); + } + } + for (name, value) in [ + ( + "archive_pagination_per_page", + self.archive_pagination_per_page, + ), + ( + "categories_pagination_per_page", + self.categories_pagination_per_page, + ), + ( + "authors_profiles_pagination_per_page", + self.authors_profiles_pagination_per_page, + ), + ] { + if value == Some(0) { + return Err(reader.error(name, "must be greater than zero")); + } + } + Ok(()) + } +} + +impl<'py> Reader<'py> { + fn reject_unknown(&self) -> PyResult<()> { + let allowed = OPTIONS.iter().copied().collect::>(); + for (key, _) in self.value.iter() { + let key = key.extract::().map_err(|_| { + configuration_error(&self.path, "option names must be strings") + })?; + if !allowed.contains(key.as_str()) { + return Err(self.error(&key, "is not a supported option")); + } + } + Ok(()) + } + + fn get(&self, name: &str) -> PyResult>> { + Ok(self.value.get_item(name)?.filter(|value| !value.is_none())) + } + + fn bool(&self, name: &str, default: bool) -> PyResult { + self.optional_bool(name) + .map(|value| value.unwrap_or(default)) + } + + fn optional_bool(&self, name: &str) -> PyResult> { + self.get(name)? + .map(|value| { + value + .extract::() + .map_err(|_| self.error(name, "must be a Boolean")) + }) + .transpose() + } + + fn string(&self, name: &str, default: &str) -> PyResult { + self.get(name)? + .map(|value| { + value + .extract::() + .map_err(|_| self.error(name, "must be a string")) + }) + .transpose() + .map(|value| value.unwrap_or_else(|| default.into())) + } + + fn usize(&self, name: &str, default: usize) -> PyResult { + self.optional_usize(name) + .map(|value| value.unwrap_or(default)) + } + + fn optional_usize(&self, name: &str) -> PyResult> { + self.get(name)? + .map(|value| { + value.extract::().map_err(|_| { + self.error(name, "must be a non-negative integer") + }) + }) + .transpose() + } + + fn string_list(&self, name: &str) -> PyResult> { + let Some(value) = self.get(name)? else { + return Ok(Vec::new()); + }; + let values = value + .cast::() + .map_err(|_| self.error(name, "must be a list"))?; + values + .iter() + .enumerate() + .map(|(index, value)| { + value.extract::().map_err(|_| { + self.error(name, &format!("item {index} must be a string")) + }) + }) + .collect() + } + + fn slug(&self, name: &str) -> PyResult<()> { + let Some(value) = self.get(name)? else { + return Ok(()); + }; + let callable = + callable(&value).map_err(|reason| self.error(name, &reason))?; + let strategy = + lower_slug(callable).map_err(|reason| self.error(name, &reason))?; + if strategy != "pymdownx:lower" { + return Err(self.error( + name, + "only Material's Unicode lowercase slug function is supported", + )); + } + Ok(()) + } + + fn category_sort(&self) -> PyResult { + let Some(value) = self.get("categories_sort_by")? else { + return Ok(CategorySort::Name); + }; + let callable = callable(&value) + .map_err(|reason| self.error("categories_sort_by", &reason))?; + if !callable.keywords.is_empty() { + return Err(self.error( + "categories_sort_by", + "sorting callable does not accept keyword arguments", + )); + } + match callable.name.as_str() { + "view_name" | "material.plugins.blog.view_name" => { + Ok(CategorySort::Name) + } + "view_post_count" | "material.plugins.blog.view_post_count" => { + Ok(CategorySort::PostCount) + } + _ => Err(self.error( + "categories_sort_by", + &format!("unsupported callable '{}'", callable.name), + )), + } + } + + fn excerpt(&self, default: ExcerptPolicy) -> PyResult { + match self.get("post_excerpt")? { + None => Ok(default), + Some(value) => match value.extract::()?.as_str() { + "optional" => Ok(ExcerptPolicy::Optional), + "required" => Ok(ExcerptPolicy::Required), + _ => Err(self + .error("post_excerpt", "must be 'optional' or 'required'")), + }, + } + } + + fn error(&self, name: &str, reason: &str) -> PyErr { + configuration_error(&format!("{}.{}", self.path, name), reason) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Default for BlogPluginConfig { + fn default() -> Self { + Self { + enabled: true, + blog_dir: "blog".into(), + blog_toc: false, + post_dir: "{blog}/posts".into(), + post_date_format: "long".into(), + post_url_date_format: "yyyy/MM/dd".into(), + post_url_format: "{date}/{slug}".into(), + post_url_max_categories: 1, + post_slugify_separator: "-".into(), + post_excerpt: ExcerptPolicy::Optional, + post_excerpt_max_authors: 1, + post_excerpt_max_categories: 5, + post_excerpt_separator: "".into(), + post_readtime: true, + post_readtime_words_per_minute: 265, + archive: true, + archive_name: "blog.archive".into(), + archive_date_format: "yyyy".into(), + archive_url_date_format: "yyyy".into(), + archive_url_format: "archive/{date}".into(), + archive_pagination: None, + archive_pagination_per_page: None, + archive_toc: None, + categories: true, + categories_name: "blog.categories".into(), + categories_url_format: "category/{slug}".into(), + categories_slugify_separator: "-".into(), + categories_sort_by: CategorySort::Name, + categories_sort_reverse: false, + categories_allowed: Vec::new(), + categories_pagination: None, + categories_pagination_per_page: None, + categories_toc: None, + authors: true, + authors_file: "{blog}/.authors.yml".into(), + authors_profiles: false, + authors_profiles_name: "blog.authors".into(), + authors_profiles_url_format: "author/{slug}".into(), + authors_profiles_pagination: None, + authors_profiles_pagination_per_page: None, + authors_profiles_toc: None, + pagination: true, + pagination_per_page: 10, + pagination_url_format: "page/{page}".into(), + pagination_format: "~2~".into(), + pagination_if_single_page: false, + pagination_keep_content: false, + draft: false, + draft_on_serve: true, + draft_if_future_date: false, + } + } +} + +impl<'a, 'py> FromPyObject<'a, 'py> for BlogPlugin { + type Error = PyErr; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> PyResult { + let root = obj.cast::().map_err(|_| { + configuration_error("plugins.blogs", "expected a mapping") + })?; + let entries = root.get_item("config")?.ok_or_else(|| { + configuration_error("plugins.blogs", "missing configuration") + })?; + let entries = entries.cast::().map_err(|_| { + configuration_error("plugins.blogs", "expected an instance list") + })?; + let mut config = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + let entry = entry.cast::().map_err(|_| { + configuration_error( + &format!("plugins.blogs[{index}]"), + "expected an instance mapping", + ) + })?; + let name = entry + .get_item("name")? + .ok_or_else(|| { + configuration_error( + &format!("plugins.blogs[{index}]"), + "missing instance name", + ) + })? + .extract::()?; + let raw = entry.get_item("config")?.ok_or_else(|| { + configuration_error( + &format!("plugins.blogs[{index}]"), + "missing instance configuration", + ) + })?; + config.push(BlogPluginInstance { + name: name.clone(), + config: BlogPluginConfig::from_python( + &raw, + format!("plugins.{name}"), + )?, + }); + } + Ok(Self { config }) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +fn configuration_error(path: &str, reason: &str) -> PyErr { + PyValueError::new_err(format!("invalid {path}: {reason}")) +} diff --git a/crates/zensical/src/config/plugins/tags.rs b/crates/zensical/src/config/plugins/tags.rs index a5588cb..b8346e0 100644 --- a/crates/zensical/src/config/plugins/tags.rs +++ b/crates/zensical/src/config/plugins/tags.rs @@ -215,11 +215,11 @@ struct Reader<'py> { // ---------------------------------------------------------------------------- /// Callable identity lowered without invoking Python code. -struct Callable { +pub(super) struct Callable { /// Qualified or short callable name. - name: String, + pub(super) name: String, /// Declarative keyword arguments. - keywords: BTreeMap, + pub(super) keywords: BTreeMap, } // ---------------------------------------------------------------------------- @@ -664,7 +664,7 @@ fn validate(config: &TagsPluginConfig, reader: &Reader<'_>) -> PyResult<()> { } /// Extracts a string, declarative object descriptor, or Python callable name. -fn callable(value: &Bound<'_, PyAny>) -> Result { +pub(super) fn callable(value: &Bound<'_, PyAny>) -> Result { if let Ok(name) = value.extract::() { return Ok(Callable { name, @@ -725,7 +725,7 @@ fn callable(value: &Bound<'_, PyAny>) -> Result { } /// Lowers the supported native slug functions. -fn lower_slug(callable: Callable) -> Result { +pub(super) fn lower_slug(callable: Callable) -> Result { match callable.name.as_str() { "pymdownx:lower" | "pymdownx.slugs.uslugify" => { require_no_keywords(&callable)?; diff --git a/crates/zensical/src/config/theme.rs b/crates/zensical/src/config/theme.rs index d2c09cf..3bcda65 100644 --- a/crates/zensical/src/config/theme.rs +++ b/crates/zensical/src/config/theme.rs @@ -123,6 +123,8 @@ pub struct Icon { pub previous: Option, /// Next page icon. pub next: Option, + /// Blog icons. + pub blog: BlogIcon, /// Admonition icons. pub admonition: BTreeMap, /// Tag icons. @@ -131,6 +133,24 @@ pub struct Icon { // ---------------------------------------------------------------------------- +/// Blog icon settings. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct BlogIcon { + /// Back-to-index icon. + pub back: String, + /// Publication date icon. + pub date: String, + /// Updated date icon. + pub date_updated: String, + /// Categories icon. + pub categories: String, + /// Reading time icon. + pub readtime: String, +} + +// ---------------------------------------------------------------------------- + /// Color palette settings. #[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] diff --git a/crates/zensical/src/structure.rs b/crates/zensical/src/structure.rs index 4538f38..2af6fac 100644 --- a/crates/zensical/src/structure.rs +++ b/crates/zensical/src/structure.rs @@ -25,9 +25,11 @@ //! Site structure. +pub mod document; pub mod dynamic; pub mod markdown; pub mod nav; pub mod page; +pub mod slug; pub mod tag; pub mod toc; diff --git a/crates/zensical/src/structure/document.rs b/crates/zensical/src/structure/document.rs new file mode 100644 index 0000000..fc5be52 --- /dev/null +++ b/crates/zensical/src/structure/document.rs @@ -0,0 +1,155 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Pre-render document facts. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use zrx::stream::Value; + +use crate::path::SourcePath; + +use super::dynamic::Dynamic; +use super::nav::to_title; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Source document after metadata resolution and before Markdown rendering. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentHeader { + /// Documentation-relative source identity. + pub source: SourcePath, + /// Markdown body with front matter removed. + pub body: String, + /// Resolved inherited and page-local metadata. + pub meta: BTreeMap, + /// MkDocs-compatible title available before rendering. + pub title: String, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl DocumentHeader { + /// Creates pre-render facts for one Markdown source. + pub fn new( + source: SourcePath, body: String, meta: BTreeMap, + ) -> Self { + let title = title(&source, &body, &meta); + Self { source, body, meta, title } + } +} + +impl Value for DocumentHeader {} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Resolves the title observable before MkDocs renders a page. +/// +/// MkDocs' pre-render fallback deliberately recognizes only an H1 at the first +/// non-empty source line. Its full renderer can later refine ordinary page +/// titles, but Material's blog routes are computed from this earlier value. +fn title( + source: &SourcePath, body: &str, meta: &BTreeMap, +) -> String { + if let Some(value) = meta.get("title") + && !matches!(value, Dynamic::Null) + { + return value.to_string(); + } + + let normalized = body.replace("\r\n", "\n").replace('\r', "\n"); + for line in normalized.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(title) = line.strip_prefix("# ") { + return title.trim_start_matches(['#', ' ']).to_owned(); + } + break; + } + + if source.depth() == 1 + && matches!(source.file_name(), "index.md" | "README.md") + { + return "Home".into(); + } + to_title(source.file_name()) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::DocumentHeader; + use crate::structure::dynamic::Dynamic; + + fn document(path: &str, body: &str) -> DocumentHeader { + DocumentHeader::new(path.parse().unwrap(), body.into(), BTreeMap::new()) + } + + #[test] + fn metadata_title_has_precedence() { + let mut meta = BTreeMap::new(); + meta.insert("title".into(), Dynamic::String("Metadata".into())); + let document = DocumentHeader::new( + "post.md".parse().unwrap(), + "# Heading".into(), + meta, + ); + + assert_eq!(document.title, "Metadata"); + } + + #[test] + fn matches_mkdocs_pre_render_heading_rules() { + assert_eq!(document("post.md", "\n# Heading\n").title, "Heading"); + assert_eq!(document("post.md", "# ## Heading").title, "Heading"); + assert_eq!(document("post.md", "## Heading").title, "Post"); + assert_eq!(document("post.md", "#Heading").title, "Post"); + assert_eq!(document("post.md", "Intro\n\n# Heading").title, "Post"); + assert_eq!(document("post.md", "Setext\n======").title, "Post"); + } + + #[test] + fn falls_back_to_homepage_or_filename() { + assert_eq!(document("index.md", "No heading").title, "Home"); + assert_eq!(document("README.md", "No heading").title, "Home"); + assert_eq!( + document("guides/my-post.md", "No heading").title, + "My post" + ); + } +} diff --git a/crates/zensical/src/structure/dynamic.rs b/crates/zensical/src/structure/dynamic.rs index 2b131d7..8750b49 100644 --- a/crates/zensical/src/structure/dynamic.rs +++ b/crates/zensical/src/structure/dynamic.rs @@ -77,6 +77,14 @@ impl Dynamic { pub fn from_float(value: f64) -> Self { Self::Float(Float(value)) } + + /// Converts any JSON-compatible serializable value. + pub fn from_serialize(value: &T) -> Result + where + T: Serialize, + { + serde_json::from_value(serde_json::to_value(value)?) + } } // ---------------------------------------------------------------------------- diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs index 12e53c3..a7ee3ba 100644 --- a/crates/zensical/src/structure/markdown.rs +++ b/crates/zensical/src/structure/markdown.rs @@ -33,7 +33,6 @@ use std::collections::BTreeMap; use std::ops::Deref; use std::sync::Arc; -use zrx::id::Id; use zrx::stream::Value; use crate::path::SourcePath; @@ -88,14 +87,14 @@ impl Markdown { /// Renders Markdown using Python Markdown. #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] pub fn new( - id: &Id, url: String, content: String, meta: BTreeMap, + source: &SourcePath, url: String, content: String, + meta: BTreeMap, ) -> Result<(Markdown, String)> { - let id = id.clone(); let meta = serde_json::to_string(&meta)?; let res = Python::attach(|py| { let module = py.import("zensical.markdown.render")?; module - .call_method1("render", (content, id.location(), url, meta))? + .call_method1("render", (content, source.as_str(), url, meta))? .extract::() }) .map_err(python_error); @@ -106,7 +105,7 @@ impl Markdown { content: data.content, toc: data.toc, }; - let title = extract_title(&id, &data); + let title = extract_title(source, &data); (Markdown { data: Arc::new(data) }, title) }) } @@ -116,6 +115,11 @@ impl Markdown { Arc::make_mut(&mut self.data).content = content; } + /// Inserts or replaces one rendered metadata value. + pub fn insert_meta(&mut self, name: String, value: Dynamic) { + Arc::make_mut(&mut self.data).meta.insert(name, value); + } + /// Applies optional derived HTML and TOC values with one copy-on-write. pub fn replace_derived( &mut self, content: Option, toc: Option>, @@ -197,7 +201,7 @@ impl Eq for Markdown {} /// /// We'll fix this in our modular navigation proposal that will make title /// handling much more flexible in the near future. -fn extract_title(id: &Id, markdown: &MarkdownData) -> String { +fn extract_title(source: &SourcePath, markdown: &MarkdownData) -> String { if let Some(value) = markdown.meta.get("title") && !matches!(value, Dynamic::Null) { @@ -211,10 +215,6 @@ fn extract_title(id: &Id, markdown: &MarkdownData) -> String { } // As a last resort, use the provider-relative file name. - let source = id - .location() - .parse::() - .expect("Markdown source identity is canonical"); to_title(source.file_name()) } diff --git a/crates/zensical/src/structure/nav.rs b/crates/zensical/src/structure/nav.rs index b94f9c9..7457ce5 100644 --- a/crates/zensical/src/structure/nav.rs +++ b/crates/zensical/src/structure/nav.rs @@ -83,6 +83,17 @@ pub struct NavigationResolution { title_overrides: Arc>, } +/// Immutable items attached beside one resolved index page. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NavigationContribution { + /// URL of the index page that owns the contributed items. + pub index_url: String, + /// Whether a root index can receive sibling items. + pub allow_root: bool, + /// Ordered items appended after the index page. + pub items: Vec, +} + // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- @@ -213,6 +224,11 @@ impl Navigation { /// Return the next page for the given page in pre-order, if any. pub fn next_page(&self, page: &Page) -> Option { + self.next_page_for_url(&page.url) + } + + /// Returns the next page after a URL in pre-order, if any. + pub fn next_page_for_url(&self, url: &str) -> Option { let mut found = false; for item in self { if found { @@ -221,7 +237,7 @@ impl Navigation { } continue; } - if item.url.as_deref() == Some(&page.url) { + if item.url.as_deref() == Some(url) { found = true; } } @@ -230,9 +246,14 @@ impl Navigation { /// Return the previous page for the given page in pre-order, if any. pub fn previous_page(&self, page: &Page) -> Option { + self.previous_page_for_url(&page.url) + } + + /// Returns the previous page before a URL in pre-order, if any. + pub fn previous_page_for_url(&self, url: &str) -> Option { let mut prev: Option = None; for item in self { - if item.url.as_deref() == Some(&page.url) { + if item.url.as_deref() == Some(url) { return prev; } if item.url.is_some() { @@ -256,6 +277,21 @@ impl NavigationResolution { pub fn title(&self, source: &SourcePath) -> Option<&str> { self.title_overrides.get(source).map(String::as_str) } + + /// Applies ordered navigation contributions without mutating the base. + pub fn contribute(&self, contributions: &[NavigationContribution]) -> Self { + let mut navigation = self.navigation.clone(); + let mut items = navigation.items.as_ref().clone(); + for contribution in contributions { + let _ = attach_contribution(&mut items, contribution, 0); + } + navigation.hash = navigation_hash(&items); + navigation.items = Arc::new(items); + Self { + navigation, + title_overrides: Arc::clone(&self.title_overrides), + } + } } impl Value for NavigationResolution {} @@ -389,6 +425,34 @@ fn resolve_items( } } +/// Attaches a contribution next to its owning index's first occurrence. +fn attach_contribution( + items: &mut Vec, contribution: &NavigationContribution, + depth: usize, +) -> bool { + for index in 0..items.len() { + if items[index].url.as_deref() == Some(&contribution.index_url) { + if items[index].is_index { + if depth == 0 && !contribution.allow_root { + return true; + } + items.splice((index + 1)..=index, contribution.items.clone()); + } else { + items[index].children.extend(contribution.items.clone()); + } + return true; + } + if attach_contribution( + &mut items[index].children, + contribution, + depth + 1, + ) { + return true; + } + } + false +} + // ---------------------------------------------------------------------------- /// Returns the MkDocs navigation sort key for one validated source path. @@ -441,11 +505,15 @@ fn extract_shared_items( #[cfg(test)] mod tests { + use ahash::HashMap; use std::sync::Arc; use crate::path::SourcePath; - use super::{navigation_hash, source_sort_key, to_title, Navigation}; + use super::{ + navigation_hash, source_sort_key, to_title, Navigation, + NavigationContribution, NavigationItem, NavigationResolution, + }; #[test] fn test_clone_shares_immutable_data() { @@ -495,4 +563,54 @@ mod tests { assert_eq!(sources[1].as_str(), "guide/café.md"); assert_eq!(sources[2].as_str(), "guide/zebra.md"); } + + #[test] + fn contributes_items_beside_a_nested_index_immutably() { + let index = NavigationItem { + title: Some("Journal".into()), + url: Some("blog/".into()), + canonical_url: None, + meta: None, + children: Vec::new(), + is_index: true, + active: false, + }; + let base = NavigationResolution { + navigation: Navigation { + items: Arc::new(vec![NavigationItem { + title: Some("Blog".into()), + url: None, + canonical_url: None, + meta: None, + children: vec![index], + is_index: false, + active: false, + }]), + homepage: None, + hash: 0, + generation: 0, + }, + title_overrides: Arc::new(HashMap::default()), + }; + let result = base.contribute(&[NavigationContribution { + index_url: "blog/".into(), + allow_root: false, + items: vec![NavigationItem { + title: Some("Archive".into()), + url: None, + canonical_url: None, + meta: None, + children: Vec::new(), + is_index: false, + active: false, + }], + }]); + + assert_eq!(base.navigation.items[0].children.len(), 1); + assert_eq!(result.navigation.items[0].children.len(), 2); + assert_eq!( + result.navigation.items[0].children[1].title.as_deref(), + Some("Archive") + ); + } } diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs index 6d24f15..25559b5 100644 --- a/crates/zensical/src/structure/page.rs +++ b/crates/zensical/src/structure/page.rs @@ -40,6 +40,7 @@ use crate::config::{Config, Project}; use crate::path::{PathError, SitePath, SourcePath}; use crate::template::{Output, Template, GENERATOR}; +use super::document::DocumentHeader; use super::dynamic::Dynamic; use super::markdown::Markdown; use super::nav::{Navigation, NavigationItem, NavigationView}; @@ -64,6 +65,43 @@ impl Value for PageRoute {} // ---------------------------------------------------------------------------- +/// Stable ownership and edit provenance of a page. +#[derive( + Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +pub enum PageOrigin { + /// Page backed by a document in the documentation source tree. + Source(SourcePath), + /// Page emitted by a native module under a stable logical identity. + Generated { + /// Module-owned identity, independent of the page's current route. + identity: String, + /// Optional source document from which edit links can be derived. + provenance: Option, + }, +} + +// ---------------------------------------------------------------------------- + +/// Pre-render page descriptor consumed by the shared page pipeline. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct PageDescriptor { + /// Stable owner and optional edit provenance. + pub origin: PageOrigin, + /// Resolved Markdown document facts. + pub document: DocumentHeader, + /// Final route, which may differ from the document's default route. + pub route: PageRoute, + /// Module-owned fields flattened into the template-facing page object. + pub properties: BTreeMap, + /// Module-owned top-level template variables. + pub variables: BTreeMap, +} + +impl Value for PageDescriptor {} + +// ---------------------------------------------------------------------------- + /// Immutable page data shared between scheduler branches. /// /// Page values are cloned by the scheduler as they fan out into navigation, @@ -71,7 +109,10 @@ impl Value for PageRoute {} /// behind an [`Arc`] makes those clones constant-sized. #[derive(Clone, Debug, Serialize)] pub struct PageData { - /// Validated documentation-relative source used by internal consumers. + /// Stable owner and optional physical-source provenance. + #[serde(skip)] + origin: PageOrigin, + /// Validated logical source URI used by navigation and link resolution. #[serde(skip)] source: SourcePath, /// Validated site-relative output used by the writer. @@ -87,6 +128,9 @@ pub struct PageData { pub path: String, /// Effective page title, including an explicit navigation title. pub title: String, + /// Module-owned template-facing page fields. + #[serde(flatten)] + properties: BTreeMap, /// Rendered Markdown shared with the upstream value. #[serde(flatten)] markdown: Markdown, @@ -112,7 +156,14 @@ pub struct Page { pub next_page: Option, /// Dynamic page-level template variables supplied by compatibility modules. #[serde(skip)] - template_variables: Option>>, + template_variables: Option>, + /// Visible navigation item represented by this page, when different. + #[serde(skip)] + navigation_url: Option, + /// Sibling relations for a page hidden from visible navigation. + #[serde(skip)] + navigation_siblings: + Option<(Option, Option)>, } // ---------------------------------------------------------------------------- @@ -135,6 +186,14 @@ impl PageRoute { Ok(Self { source, destination, url }) } + /// Creates route facts for a logical source and explicit destination. + pub fn from_destination( + config: &Config, source: SourcePath, destination: SitePath, + ) -> Self { + let url = route_url(&destination, config.project.use_directory_urls); + Self { source, destination, url } + } + /// Computes the site-relative destination for a Markdown source. pub fn destination( source: &SourcePath, use_directory_urls: bool, @@ -145,11 +204,92 @@ impl PageRoute { // ---------------------------------------------------------------------------- +impl PageDescriptor { + /// Creates an ordinary source-backed page descriptor. + pub fn source( + config: &Config, document: DocumentHeader, + ) -> Result { + let origin = PageOrigin::Source(document.source.clone()); + let route = PageRoute::from_source(config, document.source.clone())?; + Ok(Self { + origin, + document, + route, + properties: BTreeMap::new(), + variables: BTreeMap::new(), + }) + } + + /// Creates a generated descriptor with an explicit stable identity. + pub fn generated( + identity: impl Into, provenance: Option, + document: DocumentHeader, route: PageRoute, + ) -> Self { + debug_assert_eq!(document.source, route.source); + Self { + origin: PageOrigin::Generated { + identity: identity.into(), + provenance, + }, + document, + route, + properties: BTreeMap::new(), + variables: BTreeMap::new(), + } + } +} + +// ---------------------------------------------------------------------------- + impl Page { /// Creates a page. #[allow(clippy::similar_names)] pub fn new( config: &Config, route: PageRoute, markdown: Markdown, title: String, + ) -> Page { + let origin = PageOrigin::Source(route.source.clone()); + Self::from_origin(config, origin, route, markdown, title) + } + + /// Creates a generated page without requiring a physical Markdown file. + pub fn generated( + config: &Config, identity: String, provenance: Option, + route: PageRoute, markdown: Markdown, title: String, + ) -> Page { + Self::from_origin( + config, + PageOrigin::Generated { identity, provenance }, + route, + markdown, + title, + ) + } + + /// Creates a rendered page from a unified pre-render descriptor. + pub fn from_descriptor( + config: &Config, descriptor: &PageDescriptor, markdown: Markdown, + title: String, + ) -> Page { + let mut page = Self::from_origin( + config, + descriptor.origin.clone(), + descriptor.route.clone(), + markdown, + title, + ); + Arc::make_mut(&mut page.data).properties = + descriptor.properties.clone(); + if !descriptor.variables.is_empty() { + page.template_variables = Some(descriptor.variables.clone()); + } + page + } + + /// Creates a page with explicit logical ownership. + #[allow(clippy::similar_names)] + fn from_origin( + config: &Config, origin: PageOrigin, route: PageRoute, + markdown: Markdown, title: String, ) -> Page { let path = config.output_root().join(&route.destination); let source = route.source; @@ -170,13 +310,19 @@ impl Page { // Compute edit URL - edit URIs can be relative or absolute, as both // variants are supported by MkDocs, so we mirror behavior for now - let edit_url = repo_url.clone().and_then(|repo_url| { - edit_uri.clone().map(|uri| { - if uri.starts_with("https://") { - format!("{uri}/{source}") - } else { - format!("{repo_url}/{uri}/{source}") - } + let edit_source = match &origin { + PageOrigin::Source(source) => Some(source), + PageOrigin::Generated { provenance, .. } => provenance.as_ref(), + }; + let edit_url = edit_source.and_then(|source| { + repo_url.clone().and_then(|repo_url| { + edit_uri.clone().map(|uri| { + if uri.starts_with("https://") { + format!("{uri}/{source}") + } else { + format!("{repo_url}/{uri}/{source}") + } + }) }) }); @@ -186,6 +332,7 @@ impl Page { // single struct, but to split up the page as necessary later on. Page { data: Arc::new(PageData { + origin, source, destination, url, @@ -196,12 +343,15 @@ impl Page { .expect("configured output path is valid UTF-8") .into(), title, + properties: BTreeMap::new(), markdown, }), ancestors: Vec::new(), previous_page: None, next_page: None, template_variables: None, + navigation_url: None, + navigation_siblings: None, } } @@ -220,14 +370,26 @@ impl Page { }; // Compute page relations from the immutable navigation. - self.ancestors = nav.ancestors(self); - self.previous_page = nav.previous_page(self); - self.next_page = nav.next_page(self); + let navigation_url = self + .navigation_url + .clone() + .unwrap_or_else(|| self.url.clone()); + self.ancestors = nav.ancestors_for_url(&navigation_url); + self.previous_page = nav.previous_page_for_url(&navigation_url); + self.next_page = nav.next_page_for_url(&navigation_url); + if let Some((previous, next)) = &self.navigation_siblings { + self.previous_page = previous.clone(); + self.next_page = next.clone(); + } // Add the page-local active overlay without cloning the navigation tree. - let nav = NavigationView::new(nav, Some(&self.url)); + let nav = NavigationView::new(nav, Some(&navigation_url)); let variables = self.template_variables.clone().unwrap_or_else(|| { - BTreeMap::from([(String::from("tags"), self.tags())]) + BTreeMap::from([( + String::from("tags"), + Dynamic::from_serialize(&self.tags()) + .expect("tag template data is JSON compatible"), + )]) }); let output = template.render_with_context( &name, @@ -268,7 +430,12 @@ impl Page { &self.destination } - /// Returns the validated documentation-relative page source. + /// Returns the stable logical owner and optional edit provenance. + pub fn origin(&self) -> &PageOrigin { + &self.origin + } + + /// Returns the validated logical source URI. pub fn source(&self) -> &SourcePath { &self.source } @@ -276,20 +443,76 @@ impl Page { /// Adds module-derived template context to a page-render cache key. pub fn hash_derived_template_context(&self, state: &mut H) { self.template_variables.hash(state); + self.properties.hash(state); + self.navigation_url.hash(state); + self.navigation_siblings.hash(state); } /// Replaces page-local content, table of contents, and template variables. pub fn apply_derived( &mut self, content: Option, toc: Option>, - variables: BTreeMap>, + variables: BTreeMap, ) { if content.is_some() || toc.is_some() { Arc::make_mut(&mut self.data) .markdown .replace_derived(content, toc); } - self.template_variables = Some(variables); + match &mut self.template_variables { + Some(existing) => existing.extend(variables), + None => self.template_variables = Some(variables), + } + } + + /// Applies module-owned page properties and top-level template variables. + pub(crate) fn apply_template_context( + &mut self, properties: BTreeMap, + variables: BTreeMap, + ) { + Arc::make_mut(&mut self.data).properties = properties; + if !variables.is_empty() { + self.template_variables = Some(variables); + } + } + + /// Merges module-owned top-level template variables into this page. + pub(crate) fn merge_template_variables( + &mut self, variables: BTreeMap, + ) { + self.template_variables + .get_or_insert_with(BTreeMap::new) + .extend(variables); + } + + /// Deep-merges module-owned page properties. + pub(crate) fn merge_properties( + &mut self, properties: &BTreeMap, + ) { + merge_properties( + &mut Arc::make_mut(&mut self.data).properties, + properties, + ); + } + + /// Uses another page's visible navigation position for this page. + pub(crate) fn apply_navigation_url(&mut self, url: String) { + self.navigation_url = Some(url); + } + + /// Applies sibling relations for a page hidden from visible navigation. + pub(crate) fn apply_navigation_siblings( + &mut self, previous: Option, + next: Option, + ) { + self.navigation_siblings = Some((previous, next)); + } + + /// Applies a module-selected page template after view classification. + pub(crate) fn apply_template(&mut self, template: String) { + Arc::make_mut(&mut self.data) + .markdown + .insert_meta("template".into(), Dynamic::String(template)); } /// Applies the title assigned to this page by navigation. @@ -300,6 +523,21 @@ impl Page { } } +fn merge_properties( + target: &mut BTreeMap, source: &BTreeMap, +) { + for (name, value) in source { + match (target.get_mut(name), value) { + (Some(Dynamic::Map(target)), Dynamic::Map(source)) => { + merge_properties(target, source); + } + _ => { + target.insert(name.clone(), value.clone()); + } + } + } +} + // ---------------------------------------------------------------------------- // Trait implementations // ---------------------------------------------------------------------------- @@ -311,11 +549,13 @@ impl Value for Page {} impl PartialEq for PageData { fn eq(&self, other: &Self) -> bool { self.url == other.url + && self.origin == other.origin && self.source == other.source && self.destination == other.destination && self.canonical_url == other.canonical_url && self.edit_url == other.edit_url && self.title == other.title + && self.properties == other.properties && self.meta == other.meta && self.path == other.path && self.content == other.content @@ -401,9 +641,14 @@ fn route_url(destination: &SitePath, use_directory_urls: bool) -> String { #[cfg(test)] mod tests { use serde_json::json; + use std::collections::BTreeMap; use std::sync::Arc; - use super::{destination, route_url, Page, PageData, PageRoute}; + use super::{ + destination, route_url, Page, PageData, PageDescriptor, PageOrigin, + PageRoute, + }; + use crate::structure::document::DocumentHeader; fn page() -> Page { let markdown = serde_json::from_value(json!({ @@ -416,6 +661,7 @@ mod tests { .unwrap(); Page { data: Arc::new(PageData { + origin: PageOrigin::Source("index.md".parse().unwrap()), source: "index.md".parse().unwrap(), destination: "index.html".parse().unwrap(), url: String::from("/"), @@ -423,12 +669,15 @@ mod tests { edit_url: None, path: String::from("site/index.html"), title: String::from("Home"), + properties: BTreeMap::new(), markdown, }), ancestors: Vec::new(), previous_page: None, next_page: None, template_variables: None, + navigation_url: None, + navigation_siblings: None, } } @@ -494,6 +743,35 @@ mod tests { assert_eq!(serde_json::from_value::(value).unwrap(), route); } + #[test] + fn generated_identity_is_independent_of_route_and_provenance() { + let document = DocumentHeader::new( + "blog/page/2/index.md".parse().unwrap(), + "# Blog".into(), + BTreeMap::default(), + ); + let route = PageRoute { + source: document.source.clone(), + destination: "blog/page/2/index.html".parse().unwrap(), + url: "blog/page/2/".into(), + }; + let descriptor = PageDescriptor::generated( + "blog:main:2", + Some("blog/index.md".parse().unwrap()), + document, + route, + ); + + assert!(matches!( + descriptor.origin, + PageOrigin::Generated { + ref identity, + provenance: Some(_), + } if identity == "blog:main:2" + )); + assert_eq!(descriptor.route.url, "blog/page/2/"); + } + #[test] fn serialization_keeps_flat_page_shape() { let value = serde_json::to_value(page()).unwrap(); diff --git a/crates/zensical/src/structure/slug.rs b/crates/zensical/src/structure/slug.rs new file mode 100644 index 0000000..33b97f0 --- /dev/null +++ b/crates/zensical/src/structure/slug.rs @@ -0,0 +1,141 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Native slug functions shared by generated site structures. + +use icu_casemap::CaseMapper; +use icu_locale_core::LanguageIdentifier; +use icu_normalizer::{ComposingNormalizer, DecomposingNormalizer}; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Creates the default Unicode-aware Material slug. +/// +/// This mirrors `pymdownx.slugs.slugify(case = "lower")`: input is normalized +/// to NFC, HTML tags are removed, surrounding whitespace is stripped, Unicode +/// lowercase mapping is applied, and only word characters, dashes, and spaces +/// are retained. Each literal space becomes the configured separator. +pub fn unicode(value: &str, separator: &str) -> String { + let stripped = strip_html(value); + let normalized = ComposingNormalizer::new_nfc().normalize(&stripped); + let normalized = normalized.trim(); + let cased = CaseMapper::new() + .lowercase_to_string(normalized, &LanguageIdentifier::UNKNOWN) + .into_owned(); + let mut output = String::with_capacity(cased.len()); + for character in cased.chars() { + if character.is_alphanumeric() || matches!(character, '_' | '-') { + output.push(character); + } else if character == ' ' { + output.push_str(separator); + } + } + output +} + +/// Creates Python Markdown's default ASCII NFKD slug. +pub fn ascii(value: &str, separator: &str) -> String { + let normalized = DecomposingNormalizer::new_nfkd().normalize(value); + let filtered = normalized + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() + || character.is_ascii_whitespace() + || matches!(character, '_' | '-') + }) + .flat_map(char::to_lowercase) + .collect::(); + let mut output = String::with_capacity(filtered.len()); + let mut inside_separator = false; + for character in filtered.trim().chars() { + if character.is_whitespace() || separator.contains(character) { + if !inside_separator { + output.push_str(separator); + inside_separator = true; + } + } else { + inside_separator = false; + output.push(character); + } + } + output +} + +/// Removes HTML tags using pymdownx's permissive non-nesting semantics. +fn strip_html(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find('<') { + output.push_str(&rest[..start]); + let candidate = &rest[start + 1..]; + if let Some(end) = candidate.find('>') { + rest = &candidate[end + 1..]; + } else { + output.push_str(&rest[start..]); + return output; + } + } + output.push_str(rest); + output +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{ascii, unicode}; + + #[test] + fn matches_material_default_unicode_slugification() { + let cases = [ + ("Über Café 東京", "über-café-東京"), + ("A Straße", "a--straße"), + ("ΣΣ", "σς"), + (" spaced ", "spaced"), + ("punctuation!? remains_ok", "punctuation-remains_ok"), + ("tab\tremoved", "tabremoved"), + ("a { env.add_filter("striptags", striptags); env.add_filter("url", url_filter); env.add_filter("script_tag", script_tag_filter); + env.add_filter("date", date_filter); // Reset auto-escaping, as we don't want to escape HTML in templates env.set_auto_escape_callback(|_| AutoEscape::None); @@ -107,6 +108,16 @@ impl Template<'_> { } } + /// Translates a theme language key. + pub fn translate( + &self, key: &str, project: &Project, + ) -> Result { + self.env.render_str( + TRANSLATION_TEMPLATE, + context! { config => project, key => key }, + ) + } + /// Renders the template. pub fn render( &self, name: &str, config: &Config, nav: &Navigation, @@ -142,3 +153,9 @@ impl Template<'_> { /// Generator string. pub const GENERATOR: &str = concat!(env!("CARGO_PKG_NAME"), "-", env!("CARGO_PKG_VERSION")); + +/// Adapter for invoking the theme's language macro outside of a template. +const TRANSLATION_TEMPLATE: &str = concat!( + "{% import \"partials/language.html\" as lang with context %}", + "{{ lang.t(key) }}", +); diff --git a/crates/zensical/src/template/filter.rs b/crates/zensical/src/template/filter.rs index 53d9023..999d62b 100644 --- a/crates/zensical/src/template/filter.rs +++ b/crates/zensical/src/template/filter.rs @@ -32,6 +32,8 @@ use std::path::Path; use zensical_serve::http::Uri; use zrx::path::PathExt; +use crate::compat::mkdocs::plugin::blog::BlogDate; + // ---------------------------------------------------------------------------- // Functions // ---------------------------------------------------------------------------- @@ -41,7 +43,7 @@ use zrx::path::PathExt; /// This filter replicates the filter of the same name in MkDocs, resolving URLs /// relative to the current page. If no page object is given, a static template /// is rendered, which means that URLs must be resolved relative to base URL. -pub fn url_filter(state: &State, url: String) -> String { +pub fn url_filter(state: &State, mut url: String) -> String { if url.starts_with('#') { return url; } @@ -56,9 +58,6 @@ pub fn url_filter(state: &State, url: String) -> String { return encode_local_url(&url); } - // Create target URL - let target = Path::new(&url); - // Render URLs in pages if let Some(source) = state .lookup("page") @@ -66,6 +65,14 @@ pub fn url_filter(state: &State, url: String) -> String { .filter(|value| !value.is_undefined()) .map(|value| value.to_string()) { + if source == url + && let Some(original) = state + .lookup("_blog_original_url") + .filter(|value| !value.is_undefined()) + { + url = original.to_string(); + } + let target = Path::new(&url); // Make target URL relative to page let mut relative_url = target .relative_to(&source) @@ -101,6 +108,7 @@ pub fn url_filter(state: &State, url: String) -> String { // Render URLs in static templates } else { + let target = Path::new(&url); let source = state.lookup("base_url").expect("invariant"); let url = Path::new(&source.to_string()) .join(target.normalize()) @@ -156,6 +164,17 @@ pub fn script_tag_filter(state: &State, value: Value) -> String { html } +/// Material blog's per-instance English date filter. +pub fn date_filter(state: &State, value: String) -> String { + let pattern = state + .lookup("_blog_date_format") + .filter(|value| !value.is_undefined()) + .map_or_else(|| "long".into(), |value| value.to_string()); + BlogDate::parse(&value) + .and_then(|date| date.format_display(&pattern)) + .unwrap_or(value) +} + // ---------------------------------------------------------------------------- // Local URL encoding diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 20df4a7..36e0210 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -27,6 +27,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::ops::Deref; @@ -43,22 +44,26 @@ use zrx::stream::{ use crate::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; use crate::compat::mkdocs::{ + html, plugin::{ - self, autorefs, awesome_nav, literate_nav, meta, minify, mkdocstrings, - redirects, search, tags, + self, autorefs, awesome_nav, blog, literate_nav, meta, minify, + mkdocstrings, redirects, search, tags, }, resource, }; use crate::config::Config; use crate::path::{PathError, SitePath, SourcePath}; use crate::python::{Anchors, Issues, References, SharedReferences}; +use crate::structure::document::DocumentHeader; +use crate::structure::dynamic::Dynamic; use crate::structure::markdown::Markdown; use crate::structure::nav::{Navigation, NavigationResolution}; -use crate::structure::page::{Page, PageRoute}; +use crate::structure::page::{Page, PageDescriptor, PageOrigin, PageRoute}; use crate::template::Template; use crate::watcher::Source; mod cached; +mod output; use cached::cached; @@ -154,6 +159,19 @@ impl Value for SitePage {} // ---------------------------------------------------------------------------- +/// Rendered page artifact paired with its validation facts. +#[derive(Clone, Debug)] +struct RenderedSitePage { + /// Removal-aware, source-owned output artifact. + artifact: output::Artifact, + /// Autorefs that could not be resolved in this revision. + unresolved: UnresolvedAutorefs, +} + +impl Value for RenderedSitePage {} + +// ---------------------------------------------------------------------------- + /// Page-local work paired with revision-settled shared rendering facts. #[derive(Clone, Debug)] struct PageRender { @@ -173,24 +191,17 @@ impl Value for PageRender {} // ---------------------------------------------------------------------------- -/// Markdown source paired with route facts available before rendering. -#[derive(Clone, Debug, PartialEq, Eq)] -struct RoutedMarkdown { - /// Source and revision-local facts supplied by the provider. - input: Input, - /// Route derived without parsing or rendering Markdown. - route: PageRoute, -} - -impl Value for RoutedMarkdown {} - -// ---------------------------------------------------------------------------- - /// Cached output of rendering one Markdown source. #[derive(Clone, Debug, Serialize, Deserialize)] struct RenderedMarkdown { + /// Stable logical owner and edit provenance. + origin: PageOrigin, /// Route computed once before Markdown rendering. route: PageRoute, + /// Module-owned fields flattened into the template-facing page object. + properties: BTreeMap, + /// Module-owned top-level template variables. + variables: BTreeMap, /// Rendered Markdown consumed by page construction. markdown: Markdown, /// Page title derived from metadata, Markdown, or source name. @@ -237,12 +248,19 @@ impl Main { .setup(resource::Dependencies { sources: &sources }); let assets = minify.setup(minify::Dependencies { resources: &resources }); - let markdown = route_markdown(&self.config, &files); + let documents = read_documents(&self.config, &files); + let plugins = plugin::Settings::new(&self.config, self.serve); + let blogs = plugins.blog.clone(); + let blog = setup_blog(&blogs, &documents, &sources); + let view_pages = blog.view_pages; + let ordered_views = blog.views; + let posts = blog.posts; + let markdown = blog.pages; // Redirects depend on routes, not rendered Markdown. Settle their // compact input independently so they can proceed concurrently with // the Python rendering branch. - let routes = markdown.map(|input: &RoutedMarkdown| input.route.clone()); + let routes = markdown.map(|input: &PageDescriptor| input.route.clone()); let redirect_settings = configuration.map(|configuration: &Configuration| { redirects::Settings::new( @@ -255,14 +273,14 @@ impl Main { routes: &routes, }); - let plugins = plugin::Settings::new(&self.config, self.serve); let rendered = process_markdown(&self.config, &plugins, &markdown); - // Construct pages before resolving navigation, which needs the titles - // derived from Markdown for entries without an explicit title. + // Navigation needs the final titles derived from Markdown. let provisional = generate_page(&self.config, &rendered); let provisional_page = provisional.map(|rendered: &RenderedPage| rendered.page.clone()); + let navigation_page = + blogs.navigation_pages(&provisional_page, &view_pages); // Autorefs only consumes registrations gathered during Markdown // rendering, so keep it independent of finalized navigation titles. let autorefs_input = @@ -273,23 +291,23 @@ impl Main { let autorefs = plugins .autorefs .setup(autorefs::Dependencies { pages: &autorefs_input }); - let awesome_nav = - awesome_nav::AwesomeNav::new(&self.config, self.strict).expect( - "awesome-nav configuration is validated during loading", - ); - let resolution = if awesome_nav.is_enabled() { - awesome_nav.setup(awesome_nav::Dependencies { - sources: &sources, - pages: &provisional_page, - }) - } else { - literate_nav::LiterateNav::new(&self.config).setup( - literate_nav::Dependencies { - sources: &sources, - pages: &provisional_page, - }, - ) - }; + let resolution = resolve_navigation( + &self.config, + self.strict, + &blogs, + &sources, + &navigation_page, + &provisional_page, + &view_pages, + ); + let blog_patches = blogs.patches( + &provisional_page, + &posts, + &resources, + &view_pages, + &ordered_views, + &resolution, + ); let nav = resolution .map(|value: &NavigationResolution| value.navigation.clone()); // MkDocs assigns configured navigation titles when constructing Page @@ -297,6 +315,7 @@ impl Main { // navigation is resolved later, so apply that highest-precedence title // once the complete navigation is available. let rendered_page = apply_navigation_titles(&provisional, &resolution); + let rendered_page = apply_blog(&rendered_page, &blog_patches); let rendered_page = apply_tags(&plugins.tags, &rendered_page); let page = rendered_page.map(|rendered: &RenderedPage| rendered.page.clone()); @@ -336,6 +355,66 @@ impl Main { // Functions // ---------------------------------------------------------------------------- +/// Applies revision-complete blog view variables to their containing pages. +fn apply_blog( + pages: &Stream, patches: &Stream, +) -> Stream { + (pages.clone(), patches.clone()).left_join().map( + |(rendered, patch): &(RenderedPage, Option)| { + let mut rendered = rendered.clone(); + if let Some(patch) = patch { + if let Some(url) = &patch.navigation_url { + rendered.page.apply_navigation_url(url.clone()); + } + if let Some((previous, next)) = &patch.siblings { + rendered.page.apply_navigation_siblings( + previous.clone(), + next.clone(), + ); + } + if let Some(template) = &patch.template { + rendered.page.apply_template(template.clone()); + } + if patch.content.is_some() || patch.toc.is_some() { + rendered.page.apply_derived( + patch.content.clone(), + patch.toc.clone(), + BTreeMap::new(), + ); + } + rendered.page.merge_properties(&patch.properties); + rendered + .page + .merge_template_variables(patch.variables.clone()); + } + rendered + }, + ) +} + +fn resolve_navigation( + config: &Config, strict: bool, blogs: &blog::Blog, + sources: &Stream, navigation_pages: &Stream, + all_pages: &Stream, view_pages: &Stream, +) -> Signal { + let awesome_nav = awesome_nav::AwesomeNav::new(config, strict) + .expect("awesome-nav configuration is validated during loading"); + let resolution = if awesome_nav.is_enabled() { + awesome_nav.setup(awesome_nav::Dependencies { + sources, + pages: navigation_pages, + }) + } else { + literate_nav::LiterateNav::new(config).setup( + literate_nav::Dependencies { + sources, + pages: navigation_pages, + }, + ) + }; + blogs.navigation(&resolution, all_pages, view_pages) +} + /// Applies explicit navigation titles to their pages. fn apply_navigation_titles( pages: &Stream, @@ -418,10 +497,17 @@ fn page_hash(page: &Page, autorefs: &autorefs::References) -> u64 { hasher.finish() } -/// Select Markdown sources and derive their routes before rendering. -fn route_markdown( +fn setup_blog( + blogs: &blog::Blog, documents: &Stream, + sources: &Stream, +) -> blog::Output { + blogs.setup(blog::Dependencies { documents, sources }) +} + +/// Read Markdown sources and resolve their pre-render document facts. +fn read_documents( config: &Config, files: &Stream, -) -> Stream { +) -> Stream { let matcher = Arc::new( Matcher::from_str(&format!( "zrs::::{}:**/*.md:", @@ -429,7 +515,6 @@ fn route_markdown( )) .expect("invariant"), ); - let config = config.clone(); files.filter_map(move |id: &Id, input: &Input| { if !matcher.is_match(id).expect("invariant") { return Ok(None); @@ -438,10 +523,14 @@ fn route_markdown( if source.is_hidden() { return Ok(None); } - Ok::<_, crate::path::PathError>(Some(RoutedMarkdown { - input: input.clone(), - route: PageRoute::from_source(&config, source)?, - })) + let data = fs::read_to_string(&*input.source)?; + let (body, page_meta) = meta::front_matter(&source, &data)?; + let resolved = input.metadata.resolve(&source, page_meta)?; + Ok::<_, anyhow::Error>(Some(DocumentHeader::new( + source, + body, + resolved.values(), + ))) }) } @@ -453,7 +542,7 @@ pub fn has_snippets(data: &str) -> bool { /// Create a stream to process routed Markdown files. fn process_markdown( config: &Config, plugins: &plugin::Settings, - routed: &Stream, + routed: &Stream, ) -> Stream { // Create pipeline to render Markdown files let plugins = plugins.clone(); @@ -463,36 +552,47 @@ fn process_markdown( // disposal. Otherwise, just return that if the content did not change. // Note that we need to limit concurrency here, or we'll overwhelm the // Python interpreter with all tasks competing for the GIL. - .map(concurrent(1, move |id: &Id, routed: &RoutedMarkdown| { - let data = fs::read_to_string(&*routed.input.source)?; + .map(concurrent(1, move |routed: &PageDescriptor| { + let origin = routed.origin.clone(); let route = routed.route.clone(); - - let (data, page_meta) = meta::front_matter(&route.source, &data)?; - let resolved = - routed.input.metadata.resolve(&route.source, page_meta)?; + let document = routed.document.clone(); + let properties = routed.properties.clone(); + let variables = routed.variables.clone(); // Don't cache page if it inserts (pymdownx) snippets. // This is a hack while waiting for CommonMark (AST) and components, // as well as topic-based authoring functionality. - if has_snippets(&data) { - render_markdown(id, route, data, plugins.clone(), resolved) + if has_snippets(&document.body) { + render_markdown( + &config, + origin, + route, + document, + properties, + variables, + plugins.clone(), + ) } else { cached( &config, - id.as_str(), + document.source.as_str(), ( - 1_u8, + 3_u8, config.hash, - data.clone(), + origin, + document.clone(), route.clone(), - resolved.clone(), + properties, + variables, ), - |(_, _, data, route, resolved)| { + |(_, _, origin, document, route, properties, variables)| { render_markdown( - id, + &config, + origin, route, - data, + document, + properties, + variables, plugins.clone(), - resolved, ) }, ) @@ -516,30 +616,57 @@ fn apply_tags( (pages.clone(), patches).join().map( |(rendered, patch): &(RenderedPage, tags::Patch)| { let mut rendered = rendered.clone(); + let variables = patch + .variables + .iter() + .map(|(name, value)| { + Ok((name.clone(), Dynamic::from_serialize(value)?)) + }) + .collect::, serde_json::Error>>()?; rendered.page.apply_derived( patch.content.clone(), patch.toc.clone(), - patch.variables.clone(), + variables, ); if let Some(search) = &patch.search { rendered.html.search = search.clone(); } - rendered + Ok::<_, serde_json::Error>(rendered) }, ) } /// Render Markdown and collect the page-local facts produced alongside it. fn render_markdown( - id: &Id, route: PageRoute, content: String, plugins: plugin::Settings, - meta: meta::Resolved, + config: &Config, origin: PageOrigin, route: PageRoute, + document: DocumentHeader, properties: BTreeMap, + variables: BTreeMap, plugins: plugin::Settings, ) -> anyhow::Result { - let (mut markdown, title) = - Markdown::new(id, route.url.clone(), content, meta.values())?; + let mut properties = properties; + let (mut markdown, title) = Markdown::new( + &document.source, + route.url.clone(), + document.body, + document.meta, + )?; + let source_route = PageRoute::from_source(config, document.source.clone())?; + if let Some(content) = + html::rebase_urls(&markdown.content, &source_route.url, &route.url) + { + markdown.replace_content(content); + } let html = plugin::prepare(&mut markdown, &route.source, &plugins)?; + plugins.blog.apply_readtime( + &route.source, + &markdown.content, + &mut properties, + )?; let registrations = plugins.autorefs.take_page(&route.url); Ok(RenderedMarkdown { + origin, route, + properties, + variables, markdown, title, registrations, @@ -552,15 +679,32 @@ fn generate_page( config: &Config, markdown: &Stream, ) -> Stream { let config = config.clone(); - markdown.map(move |markdown: &RenderedMarkdown| RenderedPage { - page: Page::new( - &config, - markdown.route.clone(), - markdown.markdown.clone(), - markdown.title.clone(), - ), - registrations: markdown.registrations.clone(), - html: markdown.html.clone(), + markdown.map(move |markdown: &RenderedMarkdown| { + let mut page = match &markdown.origin { + PageOrigin::Source(_) => Page::new( + &config, + markdown.route.clone(), + markdown.markdown.clone(), + markdown.title.clone(), + ), + PageOrigin::Generated { identity, provenance } => Page::generated( + &config, + identity.clone(), + provenance.clone(), + markdown.route.clone(), + markdown.markdown.clone(), + markdown.title.clone(), + ), + }; + page.apply_template_context( + markdown.properties.clone(), + markdown.variables.clone(), + ); + RenderedPage { + page, + registrations: markdown.registrations.clone(), + html: markdown.html.clone(), + } }) } @@ -648,8 +792,9 @@ fn render_pages( let template = OnceLock::new(); let theme_dirs = config.theme_dirs.clone(); let minify = minify.clone(); + let output = config.output_root().clone(); let config = config.clone(); - pages.map(move |input: &PageRender| { + let rendered = pages.map(move |input: &PageRender| { let mut page = input.input.page.clone(); let references = &input.input.autorefs; let id = page.url.clone(); @@ -681,11 +826,19 @@ fn render_pages( input.autorefs.replace_in(rendered, references, &page.url); let data = minify.html(data); - let path = config.output_root().join(page.destination()); - fs::create_dir_all(path.parent().expect("invariant"))?; - fs::write(&path, &data)?; - Ok::<_, anyhow::Error>(unresolved) - }) + Ok::<_, anyhow::Error>(RenderedSitePage { + artifact: output::Artifact::page( + page.origin().clone(), + page.destination().clone(), + data.into_bytes(), + ), + unresolved, + }) + }); + let artifacts = + rendered.map(|rendered: &RenderedSitePage| rendered.artifact.clone()); + output::setup(output, &artifacts); + rendered.map(|rendered: &RenderedSitePage| rendered.unresolved.clone()) } /// Creates a workflow for the given config. diff --git a/crates/zensical/src/workflow/output.rs b/crates/zensical/src/workflow/output.rs new file mode 100644 index 0000000..7f4a5a6 --- /dev/null +++ b/crates/zensical/src/workflow/output.rs @@ -0,0 +1,432 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Page-output ownership and reconciliation. + +use anyhow::{anyhow, bail}; +use std::fs; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::scheduler::action::{Action, Concurrency, Context}; +use zrx::stream::function::Collection; +use zrx::stream::operator::Operator; +use zrx::stream::{Change, Key, Stream, Value}; + +#[cfg(test)] +use crate::path::SourcePath; +use crate::path::{OutputRoot, SitePath}; +use crate::structure::page::PageOrigin; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// One rendered page claiming a site-relative destination. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Artifact { + /// Site-relative output path. + pub destination: SitePath, + /// Complete rendered bytes. + pub contents: Arc>, + /// Logical producer used for arbitration and diagnostics. + pub owner: PageOrigin, +} + +/// Writes effective insertions and removes retracted page outputs. +#[derive(Clone)] +struct Writer { + output: OutputRoot, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Artifact { + /// Creates an ordinary source-owned page artifact. + #[cfg(test)] + pub fn source( + source: SourcePath, destination: SitePath, contents: Vec, + ) -> Self { + Self { + destination, + contents: Arc::new(contents), + owner: PageOrigin::Source(source), + } + } + + /// Creates an artifact owned by an already constructed page. + pub fn page( + owner: PageOrigin, destination: SitePath, contents: Vec, + ) -> Self { + Self { + destination, + contents: Arc::new(contents), + owner, + } + } +} + +impl Writer { + fn path(&self, key: &Key) -> anyhow::Result { + let id = key.try_as_id()?; + if id.context() != "." { + return Err(anyhow!("page output escaped the site directory")); + } + let path = id.location().parse::()?; + Ok(self.output.join(&path)) + } + + fn insert(&self, key: &Key, artifact: &Artifact) -> anyhow::Result<()> { + let path = self.path(key)?; + fs::create_dir_all(path.parent().expect("site page has parent"))?; + fs::write(path, artifact.contents.as_slice())?; + Ok(()) + } + + fn remove(&self, key: &Key) -> anyhow::Result<()> { + let path = self.path(key)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Artifact {} + +impl Action> for Writer { + type Inputs = (Artifact,); + type Output = (); + + fn concurrency(&self) -> Concurrency { + Concurrency::adaptive() + } + + fn execute(&mut self, context: Context<'_, Key, Self>) { + let Context { inputs: input, output, .. } = context; + input.for_each(output, |change, emit| { + match change { + Change::Insert(key, artifact) => { + self.insert(&key, artifact.as_ref())?; + emit.insert(key, ()); + } + Change::Remove(key) => { + self.remove(&key)?; + emit.remove(key); + } + } + Ok(()) + }); + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Resolves page ownership by destination and installs the retained writer. +pub fn setup(output: OutputRoot, artifacts: &Stream) { + let outputs = artifacts.reduce_by_key( + |artifact: &Artifact| output_key(&artifact.destination), + |claims: &dyn Collection, Artifact>| preferred(claims.values()), + ); + let _ = outputs.subscribe(Writer { output }); +} + +/// Creates the destination identity used by the output relation. +fn output_key(path: &SitePath) -> anyhow::Result> { + let id = Id::builder() + .provider("page") + .context(".") + .location(path.as_str()) + .build()?; + Ok(Key::from(id)) +} + +/// Selects one effective claim or rejects ambiguous ownership. +fn preferred<'a>( + claims: impl Iterator, +) -> anyhow::Result> { + let mut claims = claims.collect::>(); + claims.sort_by(|left, right| left.owner.cmp(&right.owner)); + match claims.as_slice() { + [] => Ok(None), + [artifact] => Ok(Some((*artifact).clone())), + [left, right] if index_readme_pair(&left.owner, &right.owner) => { + Ok(claims + .into_iter() + .find(|artifact| { + source_name(&artifact.owner) == Some("index.md") + }) + .cloned()) + } + _ => { + let destination = &claims[0].destination; + let owners = claims + .iter() + .map(|artifact| owner_name(&artifact.owner)) + .collect::>() + .join(", "); + bail!("page destination '{destination}' has multiple owners: {owners}") + } + } +} + +/// Returns whether two claims are MkDocs' index-over-README source pair. +fn index_readme_pair(left: &PageOrigin, right: &PageOrigin) -> bool { + matches!( + (source_name(left), source_name(right)), + (Some("README.md"), Some("index.md")) + | (Some("index.md"), Some("README.md")) + ) +} + +fn source_name(owner: &PageOrigin) -> Option<&str> { + match owner { + PageOrigin::Source(source) => Some(source.file_name()), + PageOrigin::Generated { .. } => None, + } +} + +fn owner_name(owner: &PageOrigin) -> String { + match owner { + PageOrigin::Source(source) => format!("source '{source}'"), + PageOrigin::Generated { identity, .. } => { + format!("generated '{identity}'") + } + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use zrx::id::Id; + use zrx::stream::{Key, Workflow}; + + use super::{output_key, preferred, Artifact, Writer}; + use crate::path::OutputRoot; + use crate::structure::page::PageOrigin; + + fn source(source: &str, contents: &str) -> Artifact { + Artifact::source( + source.parse().unwrap(), + "index.html".parse().unwrap(), + contents.as_bytes().to_vec(), + ) + } + + #[test] + fn one_owner_is_selected() { + let artifact = source("index.md", "index"); + assert_eq!(preferred([&artifact].into_iter()).unwrap(), Some(artifact)); + } + + #[test] + fn index_takes_precedence_over_readme() { + let index = source("index.md", "index"); + let readme = source("README.md", "readme"); + assert_eq!( + preferred([&readme, &index].into_iter()).unwrap(), + Some(index) + ); + } + + #[test] + fn ambiguous_sources_are_rejected_deterministically() { + let left = source("one.md", "one"); + let right = source("two.md", "two"); + let error = preferred([&right, &left].into_iter()).unwrap_err(); + assert_eq!( + error.to_string(), + "page destination 'index.html' has multiple owners: source 'one.md', source 'two.md'" + ); + } + + #[test] + fn generated_and_source_ownership_is_ambiguous() { + let source = source("index.md", "source"); + let generated = Artifact { + destination: "index.html".parse().unwrap(), + contents: std::sync::Arc::default(), + owner: PageOrigin::Generated { + identity: "blog:index".into(), + provenance: None, + }, + }; + assert!(preferred([&source, &generated].into_iter()).is_err()); + } + + #[test] + fn writer_retracts_removed_outputs() { + let directory = tempfile::tempdir().unwrap(); + let writer = Writer { + output: OutputRoot::prepare(directory.path()).unwrap(), + }; + let artifact = Artifact::source( + "guide.md".parse().unwrap(), + "guide/index.html".parse().unwrap(), + b"guide".to_vec(), + ); + let key = output_key(&artifact.destination).unwrap(); + + writer.insert(&key, &artifact).unwrap(); + let path = directory.path().join("guide/index.html"); + assert_eq!(std::fs::read(&path).unwrap(), b"guide"); + + writer.remove(&key).unwrap(); + assert!(!path.exists()); + writer.remove(&key).unwrap(); + } + + #[test] + fn writer_reconciles_route_changes() { + let directory = tempfile::tempdir().unwrap(); + let writer = Writer { + output: OutputRoot::prepare(directory.path()).unwrap(), + }; + let old = Artifact::source( + "post.md".parse().unwrap(), + "old/index.html".parse().unwrap(), + b"old".to_vec(), + ); + let new = Artifact::source( + "post.md".parse().unwrap(), + "new/index.html".parse().unwrap(), + b"new".to_vec(), + ); + let old_key = output_key(&old.destination).unwrap(); + let new_key = output_key(&new.destination).unwrap(); + + writer.insert(&old_key, &old).unwrap(); + writer.remove(&old_key).unwrap(); + writer.insert(&new_key, &new).unwrap(); + + assert!(!directory.path().join("old/index.html").exists()); + assert_eq!( + std::fs::read(directory.path().join("new/index.html")).unwrap(), + b"new" + ); + } + + #[test] + fn retained_ownership_reconciles_moves_handoffs_and_removal() { + let directory = tempfile::tempdir().unwrap(); + let root = OutputRoot::prepare(directory.path()).unwrap(); + let workflow = Workflow::::build(|workflow| { + let artifacts = workflow.input::(); + super::setup(root, &artifacts); + }); + let mut runner = workflow.runner().unwrap(); + let input = runner.input::().unwrap(); + let source_key = Key::from( + Id::builder() + .provider("test") + .context(".") + .location("page") + .build() + .unwrap(), + ); + + let mut revision = input.begin().unwrap(); + revision + .insert( + source_key.clone(), + Artifact::page( + PageOrigin::Generated { + identity: "blog:main:2".into(), + provenance: None, + }, + "old/index.html".parse().unwrap(), + b"old".to_vec(), + ), + ) + .unwrap(); + let mut input = revision.seal().unwrap(); + let _run = runner.settle().unwrap(); + assert_eq!( + std::fs::read(directory.path().join("old/index.html")).unwrap(), + b"old" + ); + + let mut revision = input.begin().unwrap(); + revision + .insert( + source_key.clone(), + Artifact::page( + PageOrigin::Generated { + identity: "blog:main:2".into(), + provenance: None, + }, + "new/index.html".parse().unwrap(), + b"generated".to_vec(), + ), + ) + .unwrap(); + input = revision.seal().unwrap(); + let _run = runner.settle().unwrap(); + assert!(!directory.path().join("old/index.html").exists()); + assert_eq!( + std::fs::read(directory.path().join("new/index.html")).unwrap(), + b"generated" + ); + + let mut revision = input.begin().unwrap(); + revision + .insert( + source_key.clone(), + Artifact::page( + PageOrigin::Source("new.md".parse().unwrap()), + "new/index.html".parse().unwrap(), + b"source".to_vec(), + ), + ) + .unwrap(); + input = revision.seal().unwrap(); + let _run = runner.settle().unwrap(); + assert_eq!( + std::fs::read(directory.path().join("new/index.html")).unwrap(), + b"source" + ); + + let mut revision = input.begin().unwrap(); + revision.remove(source_key).unwrap(); + input = revision.seal().unwrap(); + let _run = runner.settle().unwrap(); + assert!(!directory.path().join("new/index.html").exists()); + drop(input); + } +} diff --git a/python/tests/fixtures/blog/README.md b/python/tests/fixtures/blog/README.md new file mode 100644 index 0000000..f337165 --- /dev/null +++ b/python/tests/fixtures/blog/README.md @@ -0,0 +1,34 @@ +# Material blog compatibility fixtures + +These fixtures define the user-visible compatibility target for the native +Zensical blog implementation. They are built with the pinned Material checkout +and reduced to semantic JSON manifests by `scripts/blog_compatibility.py`. + +Run the compatibility check from the repository root: + +```console +uv run python scripts/blog_compatibility.py \ + --mkdocs ../mkdocs-material/community/venv/bin/mkdocs \ + --zensical .venv/bin/zensical +``` + +Pass `--update` only after reviewing an intentional Material baseline change. +The harness compares routes, titles, navigation, page relations, ordered view +memberships, pagination, links, and normalized excerpt fragments. It does not +compare complete generated documents or theme assets. The Zensical build uses +the built-in UI distribution, so this also validates the native blog templates. + +The fixtures are divided by compatibility concern: + +- `vertical-slice` defines the executable contract for phases 1 through 5. +- `mutations` records ordered clean-build snapshots for route, content, + ordering, draft, deletion, and pagination changes. +- `grouped-views` covers archive, category, and author membership. +- `standalone` records a blog omitted from configured navigation. +- `multiple-instances` proves instance isolation. +- `collisions` records Material's unsafe last-writer behavior as an upstream + quirk. Zensical is expected to diagnose the collision instead of reproducing + it. + +Additional focused fixtures are added before implementing deferred +compatibility areas such as advanced date formatting and custom templates. diff --git a/python/tests/fixtures/blog/collisions/docs/blog/index.md b/python/tests/fixtures/blog/collisions/docs/blog/index.md new file mode 100644 index 0000000..05761ac --- /dev/null +++ b/python/tests/fixtures/blog/collisions/docs/blog/index.md @@ -0,0 +1 @@ +# Blog diff --git a/python/tests/fixtures/blog/collisions/docs/blog/posts/first.md b/python/tests/fixtures/blog/collisions/docs/blog/posts/first.md new file mode 100644 index 0000000..453c38f --- /dev/null +++ b/python/tests/fixtures/blog/collisions/docs/blog/posts/first.md @@ -0,0 +1,8 @@ +--- +date: 2024-01-02 +slug: collision +--- + +# First claimant + +First claimant content. diff --git a/python/tests/fixtures/blog/collisions/docs/blog/posts/second.md b/python/tests/fixtures/blog/collisions/docs/blog/posts/second.md new file mode 100644 index 0000000..0786ba9 --- /dev/null +++ b/python/tests/fixtures/blog/collisions/docs/blog/posts/second.md @@ -0,0 +1,8 @@ +--- +date: 2024-01-02 +slug: collision +--- + +# Second claimant + +Second claimant content. diff --git a/python/tests/fixtures/blog/collisions/material.json b/python/tests/fixtures/blog/collisions/material.json new file mode 100644 index 0000000..5daca75 --- /dev/null +++ b/python/tests/fixtures/blog/collisions/material.json @@ -0,0 +1,72 @@ +{ + "outputs": [ + "404.html", + "blog/2024/01/02/collision/index.html", + "blog/index.html" + ], + "navigation": [ + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2024/01/02/collision/index.html": { + "document_title": "Second claimant - Blog collision oracle", + "canonical": "/docs/blog/2024/01/02/collision/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/01/02/collision/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "second-claimant", + "title": "Second claimant" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Blog - Blog collision oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "First claimant", + "url": "/docs/blog/2024/01/02/collision/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

First claimant

First claimant content.

" + }, + { + "title": "Second claimant", + "url": "/docs/blog/2024/01/02/collision/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Second claimant

Second claimant content.

" + } + ], + "pagination": null + } + } +} diff --git a/python/tests/fixtures/blog/collisions/mkdocs.yml b/python/tests/fixtures/blog/collisions/mkdocs.yml new file mode 100644 index 0000000..a9653db --- /dev/null +++ b/python/tests/fixtures/blog/collisions/mkdocs.yml @@ -0,0 +1,16 @@ +site_name: Blog collision oracle +site_url: https://example.test/docs/ +use_directory_urls: true + +theme: + name: material + +plugins: + - material/blog: + archive: false + categories: false + authors: false + pagination: false + +nav: + - Blog: blog/index.md diff --git a/python/tests/fixtures/blog/grouped-views/docs/blog/.authors.yml b/python/tests/fixtures/blog/grouped-views/docs/blog/.authors.yml new file mode 100644 index 0000000..cb2bbe9 --- /dev/null +++ b/python/tests/fixtures/blog/grouped-views/docs/blog/.authors.yml @@ -0,0 +1,10 @@ +authors: + alice: + name: Alice Example + description: Writes examples + avatar: https://example.test/alice.png + bob: + name: Bob Example + description: Reviews examples + avatar: https://example.test/bob.png + slug: reviewer diff --git a/python/tests/fixtures/blog/grouped-views/docs/blog/index.md b/python/tests/fixtures/blog/grouped-views/docs/blog/index.md new file mode 100644 index 0000000..05761ac --- /dev/null +++ b/python/tests/fixtures/blog/grouped-views/docs/blog/index.md @@ -0,0 +1 @@ +# Blog diff --git a/python/tests/fixtures/blog/grouped-views/docs/blog/posts/alpha.md b/python/tests/fixtures/blog/grouped-views/docs/blog/posts/alpha.md new file mode 100644 index 0000000..5647f23 --- /dev/null +++ b/python/tests/fixtures/blog/grouped-views/docs/blog/posts/alpha.md @@ -0,0 +1,22 @@ +--- +date: + created: 2024-02-03T10:30:00.123456+01:00 + updated: 2024-02-04 +authors: + - alice + - bob +categories: + - Release Notes + - Engineering +links: + - Notes: notes.md#detail +pin: true +--- + +# Alpha heading + +Alpha excerpt. + + + +Alpha remainder. diff --git a/python/tests/fixtures/blog/grouped-views/docs/blog/posts/beta.md b/python/tests/fixtures/blog/grouped-views/docs/blog/posts/beta.md new file mode 100644 index 0000000..81f91e2 --- /dev/null +++ b/python/tests/fixtures/blog/grouped-views/docs/blog/posts/beta.md @@ -0,0 +1,12 @@ +--- +title: Beta metadata +date: 2024-01-02 +authors: + - alice +categories: + - Engineering +--- + +# Beta heading + +Beta excerpt. diff --git a/python/tests/fixtures/blog/grouped-views/docs/index.md b/python/tests/fixtures/blog/grouped-views/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/blog/grouped-views/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/blog/grouped-views/docs/notes.md b/python/tests/fixtures/blog/grouped-views/docs/notes.md new file mode 100644 index 0000000..e120a50 --- /dev/null +++ b/python/tests/fixtures/blog/grouped-views/docs/notes.md @@ -0,0 +1,3 @@ +# Notes + +## Detail diff --git a/python/tests/fixtures/blog/grouped-views/material.json b/python/tests/fixtures/blog/grouped-views/material.json new file mode 100644 index 0000000..112011a --- /dev/null +++ b/python/tests/fixtures/blog/grouped-views/material.json @@ -0,0 +1,625 @@ +{ + "outputs": [ + "404.html", + "blog/2024/01/02/beta-metadata/index.html", + "blog/2024/02/03/alpha-heading/index.html", + "blog/archive/2024/01/index.html", + "blog/archive/2024/02/index.html", + "blog/author/alice/index.html", + "blog/author/alice/page/2/index.html", + "blog/author/reviewer/index.html", + "blog/category/engineering/index.html", + "blog/category/engineering/page/2/index.html", + "blog/category/release-notes/index.html", + "blog/index.html", + "blog/page/2/index.html", + "index.html", + "notes/index.html" + ], + "navigation": [ + { + "title": "Home", + "url": "/docs/" + }, + { + "title": "Notes", + "url": "/docs/notes/" + }, + { + "title": "Blog", + "url": "/docs/blog/", + "children": [ + { + "title": "Archive", + "children": [ + { + "title": "February 2024", + "url": "/docs/blog/archive/2024/02/" + }, + { + "title": "January 2024", + "url": "/docs/blog/archive/2024/01/" + } + ] + }, + { + "title": "Categories", + "children": [ + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + }, + { + "title": "Release Notes", + "url": "/docs/blog/category/release-notes/" + } + ] + }, + { + "title": "Authors", + "children": [ + { + "title": "Alice Example", + "url": "/docs/blog/author/alice/" + }, + { + "title": "Bob Example", + "url": "/docs/blog/author/reviewer/" + } + ] + } + ] + } + ], + "pages": { + "blog/2024/01/02/beta-metadata/index.html": { + "document_title": "Beta metadata - Grouped blog oracle", + "canonical": "/docs/blog/2024/01/02/beta-metadata/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/02/03/alpha-heading/" + }, + "active_ancestors": [], + "headings": [ + { + "level": 1, + "id": "beta-heading", + "title": "Beta heading" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/02/03/alpha-heading/index.html": { + "document_title": "Alpha heading - Grouped blog oracle", + "canonical": "/docs/blog/2024/02/03/alpha-heading/", + "relations": { + "prev": "/docs/blog/2024/01/02/beta-metadata/", + "next": null + }, + "active_ancestors": [], + "headings": [ + { + "level": 1, + "id": "alpha-heading", + "title": "Alpha heading" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/archive/2024/01/index.html": { + "document_title": "January 2024 - Grouped blog oracle", + "canonical": "/docs/blog/archive/2024/01/", + "relations": { + "prev": "/docs/blog/archive/2024/02/", + "next": null + }, + "active_ancestors": [ + "Archive", + "January 2024" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Beta heading", + "url": "/docs/blog/2024/01/02/beta-metadata/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": false, + "continue": null, + "content": "

Beta heading

Beta excerpt.

" + } + ], + "pagination": { + "current": 1, + "links": [] + } + }, + "blog/archive/2024/02/index.html": { + "document_title": "February 2024 - Grouped blog oracle", + "canonical": "/docs/blog/archive/2024/02/", + "relations": { + "prev": "/docs/blog/category/release-notes/", + "next": "/docs/blog/archive/2024/01/" + }, + "active_ancestors": [ + "Archive", + "February 2024" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha heading", + "url": "/docs/blog/2024/02/03/alpha-heading/", + "date": "2024-02-03 10:30:00.123456+01:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Release Notes", + "url": "/docs/blog/category/release-notes/" + }, + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": true, + "continue": "/docs/blog/2024/02/03/alpha-heading/", + "content": "

Alpha heading

Alpha excerpt.


" + } + ], + "pagination": { + "current": 1, + "links": [] + } + }, + "blog/author/alice/index.html": { + "document_title": "Alice Example - Grouped blog oracle", + "canonical": "/docs/blog/author/alice/", + "relations": { + "prev": "/docs/blog/", + "next": "/docs/blog/author/reviewer/" + }, + "active_ancestors": [ + "Authors", + "Alice Example" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha heading", + "url": "/docs/blog/2024/02/03/alpha-heading/", + "date": "2024-02-03 10:30:00.123456+01:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Release Notes", + "url": "/docs/blog/category/release-notes/" + }, + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": true, + "continue": "/docs/blog/2024/02/03/alpha-heading/", + "content": "

Alpha heading

Alpha excerpt.


" + } + ], + "pagination": { + "current": 1, + "links": [ + { + "title": "2", + "url": "/docs/blog/author/alice/page/2/" + }, + { + "title": "", + "url": "/docs/blog/author/alice/page/2/" + }, + { + "title": "", + "url": "/docs/blog/author/alice/page/2/" + } + ] + } + }, + "blog/author/alice/page/2/index.html": { + "document_title": "Alice Example - Grouped blog oracle", + "canonical": "/docs/blog/author/alice/page/2/", + "relations": { + "prev": "/docs/blog/", + "next": "/docs/blog/author/reviewer/" + }, + "active_ancestors": [ + "Authors" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Beta heading", + "url": "/docs/blog/2024/01/02/beta-metadata/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": false, + "continue": null, + "content": "

Beta heading

Beta excerpt.

" + } + ], + "pagination": { + "current": 2, + "links": [ + { + "title": "", + "url": "/docs/blog/author/alice/" + }, + { + "title": "", + "url": "/docs/blog/author/alice/" + }, + { + "title": "1", + "url": "/docs/blog/author/alice/" + } + ] + } + }, + "blog/author/reviewer/index.html": { + "document_title": "Bob Example - Grouped blog oracle", + "canonical": "/docs/blog/author/reviewer/", + "relations": { + "prev": "/docs/blog/author/alice/", + "next": "/docs/blog/category/engineering/" + }, + "active_ancestors": [ + "Authors", + "Bob Example" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha heading", + "url": "/docs/blog/2024/02/03/alpha-heading/", + "date": "2024-02-03 10:30:00.123456+01:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Release Notes", + "url": "/docs/blog/category/release-notes/" + }, + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": true, + "continue": "/docs/blog/2024/02/03/alpha-heading/", + "content": "

Alpha heading

Alpha excerpt.


" + } + ], + "pagination": { + "current": 1, + "links": [] + } + }, + "blog/category/engineering/index.html": { + "document_title": "Engineering - Grouped blog oracle", + "canonical": "/docs/blog/category/engineering/", + "relations": { + "prev": "/docs/blog/author/reviewer/", + "next": "/docs/blog/category/release-notes/" + }, + "active_ancestors": [ + "Categories", + "Engineering" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha heading", + "url": "/docs/blog/2024/02/03/alpha-heading/", + "date": "2024-02-03 10:30:00.123456+01:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Release Notes", + "url": "/docs/blog/category/release-notes/" + }, + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": true, + "continue": "/docs/blog/2024/02/03/alpha-heading/", + "content": "

Alpha heading

Alpha excerpt.


" + } + ], + "pagination": { + "current": 1, + "links": [ + { + "title": "2", + "url": "/docs/blog/category/engineering/page/2/" + }, + { + "title": "", + "url": "/docs/blog/category/engineering/page/2/" + }, + { + "title": "", + "url": "/docs/blog/category/engineering/page/2/" + } + ] + } + }, + "blog/category/engineering/page/2/index.html": { + "document_title": "Engineering - Grouped blog oracle", + "canonical": "/docs/blog/category/engineering/page/2/", + "relations": { + "prev": "/docs/blog/author/reviewer/", + "next": "/docs/blog/category/release-notes/" + }, + "active_ancestors": [ + "Categories" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Beta heading", + "url": "/docs/blog/2024/01/02/beta-metadata/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": false, + "continue": null, + "content": "

Beta heading

Beta excerpt.

" + } + ], + "pagination": { + "current": 2, + "links": [ + { + "title": "", + "url": "/docs/blog/category/engineering/" + }, + { + "title": "", + "url": "/docs/blog/category/engineering/" + }, + { + "title": "1", + "url": "/docs/blog/category/engineering/" + } + ] + } + }, + "blog/category/release-notes/index.html": { + "document_title": "Release Notes - Grouped blog oracle", + "canonical": "/docs/blog/category/release-notes/", + "relations": { + "prev": "/docs/blog/category/engineering/", + "next": "/docs/blog/archive/2024/02/" + }, + "active_ancestors": [ + "Categories", + "Release Notes" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha heading", + "url": "/docs/blog/2024/02/03/alpha-heading/", + "date": "2024-02-03 10:30:00.123456+01:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Release Notes", + "url": "/docs/blog/category/release-notes/" + }, + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": true, + "continue": "/docs/blog/2024/02/03/alpha-heading/", + "content": "

Alpha heading

Alpha excerpt.


" + } + ], + "pagination": { + "current": 1, + "links": [] + } + }, + "blog/index.html": { + "document_title": "Blog - Grouped blog oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": "/docs/notes/", + "next": "/docs/blog/author/alice/" + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha heading", + "url": "/docs/blog/2024/02/03/alpha-heading/", + "date": "2024-02-03 10:30:00.123456+01:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Release Notes", + "url": "/docs/blog/category/release-notes/" + }, + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": true, + "continue": "/docs/blog/2024/02/03/alpha-heading/", + "content": "

Alpha heading

Alpha excerpt.


" + } + ], + "pagination": { + "current": 1, + "links": [ + { + "title": "2", + "url": "/docs/blog/page/2/" + }, + { + "title": "", + "url": "/docs/blog/page/2/" + }, + { + "title": "", + "url": "/docs/blog/page/2/" + } + ] + } + }, + "blog/page/2/index.html": { + "document_title": "Blog - Grouped blog oracle", + "canonical": "/docs/blog/page/2/", + "relations": { + "prev": "/docs/notes/", + "next": "/docs/blog/author/alice/" + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Beta heading", + "url": "/docs/blog/2024/01/02/beta-metadata/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [ + "Alice Example" + ], + "categories": [ + { + "title": "Engineering", + "url": "/docs/blog/category/engineering/" + } + ], + "pinned": false, + "continue": null, + "content": "

Beta heading

Beta excerpt.

" + } + ], + "pagination": { + "current": 2, + "links": [ + { + "title": "", + "url": "/docs/blog/" + }, + { + "title": "", + "url": "/docs/blog/" + }, + { + "title": "1", + "url": "/docs/blog/" + } + ] + } + }, + "index.html": { + "document_title": "Grouped blog oracle", + "canonical": "/docs/", + "relations": { + "prev": null, + "next": "/docs/notes/" + }, + "active_ancestors": [ + "Home" + ], + "headings": [ + { + "level": 1, + "id": "home", + "title": "Home" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "notes/index.html": { + "document_title": "Notes - Grouped blog oracle", + "canonical": "/docs/notes/", + "relations": { + "prev": "/docs/", + "next": "/docs/blog/" + }, + "active_ancestors": [ + "Notes" + ], + "headings": [ + { + "level": 1, + "id": "notes", + "title": "Notes" + }, + { + "level": 2, + "id": "detail", + "title": "Detail" + } + ], + "links": [], + "posts": [], + "pagination": null + } + } +} diff --git a/python/tests/fixtures/blog/grouped-views/mkdocs.yml b/python/tests/fixtures/blog/grouped-views/mkdocs.yml new file mode 100644 index 0000000..8b63ddc --- /dev/null +++ b/python/tests/fixtures/blog/grouped-views/mkdocs.yml @@ -0,0 +1,22 @@ +site_name: Grouped blog oracle +site_url: https://example.test/docs/ +use_directory_urls: true + +theme: + name: material + features: + - navigation.indexes + +plugins: + - material/blog: + archive_date_format: MMMM yyyy + archive_url_date_format: yyyy/MM + authors_profiles: true + pagination_per_page: 1 + pagination_format: "$link_first $link_previous ~1~ $link_next $link_last" + +nav: + - Home: index.md + - Notes: notes.md + - Blog: + - blog/index.md diff --git a/python/tests/fixtures/blog/multiple-instances/docs/index.md b/python/tests/fixtures/blog/multiple-instances/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/blog/multiple-instances/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/blog/multiple-instances/docs/journal/index.md b/python/tests/fixtures/blog/multiple-instances/docs/journal/index.md new file mode 100644 index 0000000..3d84c38 --- /dev/null +++ b/python/tests/fixtures/blog/multiple-instances/docs/journal/index.md @@ -0,0 +1 @@ +# Journal diff --git a/python/tests/fixtures/blog/multiple-instances/docs/journal/posts/journal.md b/python/tests/fixtures/blog/multiple-instances/docs/journal/posts/journal.md new file mode 100644 index 0000000..f7b1339 --- /dev/null +++ b/python/tests/fixtures/blog/multiple-instances/docs/journal/posts/journal.md @@ -0,0 +1,7 @@ +--- +date: 2024-01-02 +--- + +# Journal post + +Journal content. diff --git a/python/tests/fixtures/blog/multiple-instances/docs/news/index.md b/python/tests/fixtures/blog/multiple-instances/docs/news/index.md new file mode 100644 index 0000000..e614590 --- /dev/null +++ b/python/tests/fixtures/blog/multiple-instances/docs/news/index.md @@ -0,0 +1 @@ +# News diff --git a/python/tests/fixtures/blog/multiple-instances/docs/news/posts/news.md b/python/tests/fixtures/blog/multiple-instances/docs/news/posts/news.md new file mode 100644 index 0000000..7314ae9 --- /dev/null +++ b/python/tests/fixtures/blog/multiple-instances/docs/news/posts/news.md @@ -0,0 +1,7 @@ +--- +date: 2024-02-03 +--- + +# News post + +News content. diff --git a/python/tests/fixtures/blog/multiple-instances/material.json b/python/tests/fixtures/blog/multiple-instances/material.json new file mode 100644 index 0000000..f0cbe28 --- /dev/null +++ b/python/tests/fixtures/blog/multiple-instances/material.json @@ -0,0 +1,141 @@ +{ + "outputs": [ + "404.html", + "index.html", + "journal/2024/01/02/journal-post/index.html", + "journal/index.html", + "news/2024/02/03/news-post/index.html", + "news/index.html" + ], + "navigation": [ + { + "title": "Home", + "url": "/docs/" + }, + { + "title": "Journal", + "url": "/docs/journal/" + }, + { + "title": "News", + "url": "/docs/news/" + } + ], + "pages": { + "index.html": { + "document_title": "Multiple blogs oracle", + "canonical": "/docs/", + "relations": { + "prev": null, + "next": "/docs/journal/" + }, + "active_ancestors": [ + "Home" + ], + "headings": [ + { + "level": 1, + "id": "home", + "title": "Home" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "journal/2024/01/02/journal-post/index.html": { + "document_title": "Journal post - Multiple blogs oracle", + "canonical": "/docs/journal/2024/01/02/journal-post/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Journal" + ], + "headings": [ + { + "level": 1, + "id": "journal-post", + "title": "Journal post" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "journal/index.html": { + "document_title": "Journal - Multiple blogs oracle", + "canonical": "/docs/journal/", + "relations": { + "prev": "/docs/", + "next": "/docs/news/" + }, + "active_ancestors": [ + "Journal" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Journal post", + "url": "/docs/journal/2024/01/02/journal-post/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Journal post

Journal content.

" + } + ], + "pagination": null + }, + "news/2024/02/03/news-post/index.html": { + "document_title": "News post - Multiple blogs oracle", + "canonical": "/docs/news/2024/02/03/news-post/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "News" + ], + "headings": [ + { + "level": 1, + "id": "news-post", + "title": "News post" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "news/index.html": { + "document_title": "News - Multiple blogs oracle", + "canonical": "/docs/news/", + "relations": { + "prev": "/docs/journal/", + "next": null + }, + "active_ancestors": [ + "News" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "News post", + "url": "/docs/news/2024/02/03/news-post/", + "date": "2024-02-03 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

News post

News content.

" + } + ], + "pagination": null + } + } +} diff --git a/python/tests/fixtures/blog/multiple-instances/mkdocs.yml b/python/tests/fixtures/blog/multiple-instances/mkdocs.yml new file mode 100644 index 0000000..93f3c12 --- /dev/null +++ b/python/tests/fixtures/blog/multiple-instances/mkdocs.yml @@ -0,0 +1,27 @@ +site_name: Multiple blogs oracle +site_url: https://example.test/docs/ +use_directory_urls: true + +theme: + name: material + +plugins: + - material/blog: + blog_dir: journal + post_dir: journal/posts + archive: false + categories: false + authors: false + pagination: false + - material/blog: + blog_dir: news + post_dir: news/posts + archive: false + categories: false + authors: false + pagination: false + +nav: + - Home: index.md + - Journal: journal/index.md + - News: news/index.md diff --git a/python/tests/fixtures/blog/mutations/changes/01-insert.md b/python/tests/fixtures/blog/mutations/changes/01-insert.md new file mode 100644 index 0000000..a98c0ec --- /dev/null +++ b/python/tests/fixtures/blog/mutations/changes/01-insert.md @@ -0,0 +1,7 @@ +--- +date: 2024-03-01 +--- + +# Gamma + +Initial gamma excerpt. diff --git a/python/tests/fixtures/blog/mutations/changes/02-body.md b/python/tests/fixtures/blog/mutations/changes/02-body.md new file mode 100644 index 0000000..6c9abe6 --- /dev/null +++ b/python/tests/fixtures/blog/mutations/changes/02-body.md @@ -0,0 +1,7 @@ +--- +date: 2024-03-01 +--- + +# Gamma + +Edited gamma excerpt. diff --git a/python/tests/fixtures/blog/mutations/changes/03-route.md b/python/tests/fixtures/blog/mutations/changes/03-route.md new file mode 100644 index 0000000..2b08101 --- /dev/null +++ b/python/tests/fixtures/blog/mutations/changes/03-route.md @@ -0,0 +1,7 @@ +--- +title: Renamed gamma +slug: moved-gamma +date: 2024-03-01 +--- + +Renamed gamma excerpt. diff --git a/python/tests/fixtures/blog/mutations/changes/04-reorder.md b/python/tests/fixtures/blog/mutations/changes/04-reorder.md new file mode 100644 index 0000000..81cabab --- /dev/null +++ b/python/tests/fixtures/blog/mutations/changes/04-reorder.md @@ -0,0 +1,8 @@ +--- +title: Renamed gamma +slug: moved-gamma +date: 2023-12-01 +pin: true +--- + +Renamed gamma excerpt. diff --git a/python/tests/fixtures/blog/mutations/changes/05-draft.md b/python/tests/fixtures/blog/mutations/changes/05-draft.md new file mode 100644 index 0000000..02a3e37 --- /dev/null +++ b/python/tests/fixtures/blog/mutations/changes/05-draft.md @@ -0,0 +1,9 @@ +--- +title: Renamed gamma +slug: moved-gamma +date: 2023-12-01 +pin: true +draft: true +--- + +Renamed gamma excerpt. diff --git a/python/tests/fixtures/blog/mutations/docs/blog/index.md b/python/tests/fixtures/blog/mutations/docs/blog/index.md new file mode 100644 index 0000000..05761ac --- /dev/null +++ b/python/tests/fixtures/blog/mutations/docs/blog/index.md @@ -0,0 +1 @@ +# Blog diff --git a/python/tests/fixtures/blog/mutations/docs/blog/posts/alpha.md b/python/tests/fixtures/blog/mutations/docs/blog/posts/alpha.md new file mode 100644 index 0000000..172b761 --- /dev/null +++ b/python/tests/fixtures/blog/mutations/docs/blog/posts/alpha.md @@ -0,0 +1,7 @@ +--- +date: 2024-01-01 +--- + +# Alpha + +Alpha excerpt. diff --git a/python/tests/fixtures/blog/mutations/docs/blog/posts/beta.md b/python/tests/fixtures/blog/mutations/docs/blog/posts/beta.md new file mode 100644 index 0000000..15fc63f --- /dev/null +++ b/python/tests/fixtures/blog/mutations/docs/blog/posts/beta.md @@ -0,0 +1,7 @@ +--- +date: 2024-02-01 +--- + +# Beta + +Beta excerpt. diff --git a/python/tests/fixtures/blog/mutations/material.json b/python/tests/fixtures/blog/mutations/material.json new file mode 100644 index 0000000..2d9af97 --- /dev/null +++ b/python/tests/fixtures/blog/mutations/material.json @@ -0,0 +1,912 @@ +{ + "steps": [ + { + "name": "baseline", + "manifest": { + "outputs": [ + "404.html", + "blog/2024/01/01/alpha/index.html", + "blog/2024/02/01/beta/index.html", + "blog/index.html" + ], + "navigation": [ + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2024/01/01/alpha/index.html": { + "document_title": "Alpha - Blog mutation oracle", + "canonical": "/docs/blog/2024/01/01/alpha/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/02/01/beta/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "alpha", + "title": "Alpha" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/02/01/beta/index.html": { + "document_title": "Beta - Blog mutation oracle", + "canonical": "/docs/blog/2024/02/01/beta/", + "relations": { + "prev": "/docs/blog/2024/01/01/alpha/", + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "beta", + "title": "Beta" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Beta", + "url": "/docs/blog/2024/02/01/beta/", + "date": "2024-02-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Beta

Beta excerpt.

" + }, + { + "title": "Alpha", + "url": "/docs/blog/2024/01/01/alpha/", + "date": "2024-01-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Alpha

Alpha excerpt.

" + } + ], + "pagination": { + "current": 1, + "links": [] + } + } + } + } + }, + { + "name": "insert", + "manifest": { + "outputs": [ + "404.html", + "blog/2024/01/01/alpha/index.html", + "blog/2024/02/01/beta/index.html", + "blog/2024/03/01/gamma/index.html", + "blog/index.html", + "blog/page/2/index.html" + ], + "navigation": [ + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2024/01/01/alpha/index.html": { + "document_title": "Alpha - Blog mutation oracle", + "canonical": "/docs/blog/2024/01/01/alpha/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/02/01/beta/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "alpha", + "title": "Alpha" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/02/01/beta/index.html": { + "document_title": "Beta - Blog mutation oracle", + "canonical": "/docs/blog/2024/02/01/beta/", + "relations": { + "prev": "/docs/blog/2024/01/01/alpha/", + "next": "/docs/blog/2024/03/01/gamma/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "beta", + "title": "Beta" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/03/01/gamma/index.html": { + "document_title": "Gamma - Blog mutation oracle", + "canonical": "/docs/blog/2024/03/01/gamma/", + "relations": { + "prev": "/docs/blog/2024/02/01/beta/", + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "gamma", + "title": "Gamma" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Gamma", + "url": "/docs/blog/2024/03/01/gamma/", + "date": "2024-03-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Gamma

Initial gamma excerpt.

" + }, + { + "title": "Beta", + "url": "/docs/blog/2024/02/01/beta/", + "date": "2024-02-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Beta

Beta excerpt.

" + } + ], + "pagination": { + "current": 1, + "links": [ + { + "title": "2", + "url": "/docs/blog/page/2/" + } + ] + } + }, + "blog/page/2/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/page/2/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha", + "url": "/docs/blog/2024/01/01/alpha/", + "date": "2024-01-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Alpha

Alpha excerpt.

" + } + ], + "pagination": { + "current": 2, + "links": [ + { + "title": "1", + "url": "/docs/blog/" + } + ] + } + } + } + } + }, + { + "name": "edit-body", + "manifest": { + "outputs": [ + "404.html", + "blog/2024/01/01/alpha/index.html", + "blog/2024/02/01/beta/index.html", + "blog/2024/03/01/gamma/index.html", + "blog/index.html", + "blog/page/2/index.html" + ], + "navigation": [ + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2024/01/01/alpha/index.html": { + "document_title": "Alpha - Blog mutation oracle", + "canonical": "/docs/blog/2024/01/01/alpha/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/02/01/beta/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "alpha", + "title": "Alpha" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/02/01/beta/index.html": { + "document_title": "Beta - Blog mutation oracle", + "canonical": "/docs/blog/2024/02/01/beta/", + "relations": { + "prev": "/docs/blog/2024/01/01/alpha/", + "next": "/docs/blog/2024/03/01/gamma/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "beta", + "title": "Beta" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/03/01/gamma/index.html": { + "document_title": "Gamma - Blog mutation oracle", + "canonical": "/docs/blog/2024/03/01/gamma/", + "relations": { + "prev": "/docs/blog/2024/02/01/beta/", + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "gamma", + "title": "Gamma" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Gamma", + "url": "/docs/blog/2024/03/01/gamma/", + "date": "2024-03-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Gamma

Edited gamma excerpt.

" + }, + { + "title": "Beta", + "url": "/docs/blog/2024/02/01/beta/", + "date": "2024-02-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Beta

Beta excerpt.

" + } + ], + "pagination": { + "current": 1, + "links": [ + { + "title": "2", + "url": "/docs/blog/page/2/" + } + ] + } + }, + "blog/page/2/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/page/2/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha", + "url": "/docs/blog/2024/01/01/alpha/", + "date": "2024-01-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Alpha

Alpha excerpt.

" + } + ], + "pagination": { + "current": 2, + "links": [ + { + "title": "1", + "url": "/docs/blog/" + } + ] + } + } + } + } + }, + { + "name": "change-title-and-slug", + "manifest": { + "outputs": [ + "404.html", + "blog/2024/01/01/alpha/index.html", + "blog/2024/02/01/beta/index.html", + "blog/2024/03/01/moved-gamma/index.html", + "blog/index.html", + "blog/page/2/index.html" + ], + "navigation": [ + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2024/01/01/alpha/index.html": { + "document_title": "Alpha - Blog mutation oracle", + "canonical": "/docs/blog/2024/01/01/alpha/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/02/01/beta/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "alpha", + "title": "Alpha" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/02/01/beta/index.html": { + "document_title": "Beta - Blog mutation oracle", + "canonical": "/docs/blog/2024/02/01/beta/", + "relations": { + "prev": "/docs/blog/2024/01/01/alpha/", + "next": "/docs/blog/2024/03/01/moved-gamma/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "beta", + "title": "Beta" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/03/01/moved-gamma/index.html": { + "document_title": "Renamed gamma - Blog mutation oracle", + "canonical": "/docs/blog/2024/03/01/moved-gamma/", + "relations": { + "prev": "/docs/blog/2024/02/01/beta/", + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": null, + "title": "Renamed gamma" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Renamed gamma", + "url": "/docs/blog/2024/03/01/moved-gamma/", + "date": "2024-03-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Renamed gamma

Renamed gamma excerpt.

" + }, + { + "title": "Beta", + "url": "/docs/blog/2024/02/01/beta/", + "date": "2024-02-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Beta

Beta excerpt.

" + } + ], + "pagination": { + "current": 1, + "links": [ + { + "title": "2", + "url": "/docs/blog/page/2/" + } + ] + } + }, + "blog/page/2/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/page/2/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha", + "url": "/docs/blog/2024/01/01/alpha/", + "date": "2024-01-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Alpha

Alpha excerpt.

" + } + ], + "pagination": { + "current": 2, + "links": [ + { + "title": "1", + "url": "/docs/blog/" + } + ] + } + } + } + } + }, + { + "name": "change-date-and-pin", + "manifest": { + "outputs": [ + "404.html", + "blog/2023/12/01/moved-gamma/index.html", + "blog/2024/01/01/alpha/index.html", + "blog/2024/02/01/beta/index.html", + "blog/index.html", + "blog/page/2/index.html" + ], + "navigation": [ + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2023/12/01/moved-gamma/index.html": { + "document_title": "Renamed gamma - Blog mutation oracle", + "canonical": "/docs/blog/2023/12/01/moved-gamma/", + "relations": { + "prev": "/docs/blog/2024/02/01/beta/", + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": null, + "title": "Renamed gamma" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/01/01/alpha/index.html": { + "document_title": "Alpha - Blog mutation oracle", + "canonical": "/docs/blog/2024/01/01/alpha/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/02/01/beta/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "alpha", + "title": "Alpha" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/02/01/beta/index.html": { + "document_title": "Beta - Blog mutation oracle", + "canonical": "/docs/blog/2024/02/01/beta/", + "relations": { + "prev": "/docs/blog/2024/01/01/alpha/", + "next": "/docs/blog/2023/12/01/moved-gamma/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "beta", + "title": "Beta" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Renamed gamma", + "url": "/docs/blog/2023/12/01/moved-gamma/", + "date": "2023-12-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": true, + "continue": null, + "content": "

Renamed gamma

Renamed gamma excerpt.


" + }, + { + "title": "Beta", + "url": "/docs/blog/2024/02/01/beta/", + "date": "2024-02-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Beta

Beta excerpt.

" + } + ], + "pagination": { + "current": 1, + "links": [ + { + "title": "2", + "url": "/docs/blog/page/2/" + } + ] + } + }, + "blog/page/2/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/page/2/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Alpha", + "url": "/docs/blog/2024/01/01/alpha/", + "date": "2024-01-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Alpha

Alpha excerpt.

" + } + ], + "pagination": { + "current": 2, + "links": [ + { + "title": "1", + "url": "/docs/blog/" + } + ] + } + } + } + } + }, + { + "name": "toggle-draft", + "manifest": { + "outputs": [ + "404.html", + "blog/2024/01/01/alpha/index.html", + "blog/2024/02/01/beta/index.html", + "blog/index.html" + ], + "navigation": [ + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2024/01/01/alpha/index.html": { + "document_title": "Alpha - Blog mutation oracle", + "canonical": "/docs/blog/2024/01/01/alpha/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/02/01/beta/" + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "alpha", + "title": "Alpha" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/02/01/beta/index.html": { + "document_title": "Beta - Blog mutation oracle", + "canonical": "/docs/blog/2024/02/01/beta/", + "relations": { + "prev": "/docs/blog/2024/01/01/alpha/", + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "beta", + "title": "Beta" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Beta", + "url": "/docs/blog/2024/02/01/beta/", + "date": "2024-02-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Beta

Beta excerpt.

" + }, + { + "title": "Alpha", + "url": "/docs/blog/2024/01/01/alpha/", + "date": "2024-01-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Alpha

Alpha excerpt.

" + } + ], + "pagination": { + "current": 1, + "links": [] + } + } + } + } + }, + { + "name": "delete-post", + "manifest": { + "outputs": [ + "404.html", + "blog/2024/02/01/beta/index.html", + "blog/index.html" + ], + "navigation": [ + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2024/02/01/beta/index.html": { + "document_title": "Beta - Blog mutation oracle", + "canonical": "/docs/blog/2024/02/01/beta/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [ + { + "level": 1, + "id": "beta", + "title": "Beta" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Blog - Blog mutation oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Blog" + ], + "headings": [], + "links": [], + "posts": [ + { + "title": "Beta", + "url": "/docs/blog/2024/02/01/beta/", + "date": "2024-02-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Beta

Beta excerpt.

" + } + ], + "pagination": { + "current": 1, + "links": [] + } + } + } + } + } + ] +} diff --git a/python/tests/fixtures/blog/mutations/mkdocs.yml b/python/tests/fixtures/blog/mutations/mkdocs.yml new file mode 100644 index 0000000..e44810d --- /dev/null +++ b/python/tests/fixtures/blog/mutations/mkdocs.yml @@ -0,0 +1,16 @@ +site_name: Blog mutation oracle +site_url: https://example.test/docs/ +use_directory_urls: true + +theme: + name: material + +plugins: + - material/blog: + archive: false + categories: false + authors: false + pagination_per_page: 2 + +nav: + - Blog: blog/index.md diff --git a/python/tests/fixtures/blog/mutations/scenario.json b/python/tests/fixtures/blog/mutations/scenario.json new file mode 100644 index 0000000..1030152 --- /dev/null +++ b/python/tests/fixtures/blog/mutations/scenario.json @@ -0,0 +1,41 @@ +[ + { + "name": "baseline" + }, + { + "name": "insert", + "copy": { + "changes/01-insert.md": "docs/blog/posts/gamma.md" + } + }, + { + "name": "edit-body", + "copy": { + "changes/02-body.md": "docs/blog/posts/gamma.md" + } + }, + { + "name": "change-title-and-slug", + "copy": { + "changes/03-route.md": "docs/blog/posts/gamma.md" + } + }, + { + "name": "change-date-and-pin", + "copy": { + "changes/04-reorder.md": "docs/blog/posts/gamma.md" + } + }, + { + "name": "toggle-draft", + "copy": { + "changes/05-draft.md": "docs/blog/posts/gamma.md" + } + }, + { + "name": "delete-post", + "remove": [ + "docs/blog/posts/alpha.md" + ] + } +] diff --git a/python/tests/fixtures/blog/standalone/docs/blog/index.md b/python/tests/fixtures/blog/standalone/docs/blog/index.md new file mode 100644 index 0000000..c3ea2ef --- /dev/null +++ b/python/tests/fixtures/blog/standalone/docs/blog/index.md @@ -0,0 +1 @@ +# Unlisted blog diff --git a/python/tests/fixtures/blog/standalone/docs/blog/posts/post.md b/python/tests/fixtures/blog/standalone/docs/blog/posts/post.md new file mode 100644 index 0000000..f30b33b --- /dev/null +++ b/python/tests/fixtures/blog/standalone/docs/blog/posts/post.md @@ -0,0 +1,7 @@ +--- +date: 2024-01-02 +--- + +# Unlisted post + +The post and blog are built even though the blog is absent from navigation. diff --git a/python/tests/fixtures/blog/standalone/docs/index.md b/python/tests/fixtures/blog/standalone/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/blog/standalone/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/blog/standalone/material.json b/python/tests/fixtures/blog/standalone/material.json new file mode 100644 index 0000000..1c24e44 --- /dev/null +++ b/python/tests/fixtures/blog/standalone/material.json @@ -0,0 +1,80 @@ +{ + "outputs": [ + "404.html", + "blog/2024/01/02/unlisted-post/index.html", + "blog/index.html", + "index.html" + ], + "navigation": [ + { + "title": "Home", + "url": "/docs/" + } + ], + "pages": { + "blog/2024/01/02/unlisted-post/index.html": { + "document_title": "Unlisted post - Standalone blog oracle", + "canonical": "/docs/blog/2024/01/02/unlisted-post/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [], + "headings": [ + { + "level": 1, + "id": "unlisted-post", + "title": "Unlisted post" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Unlisted blog - Standalone blog oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Unlisted post", + "url": "/docs/blog/2024/01/02/unlisted-post/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Unlisted post

The post and blog are built even though the blog is absent from navigation.

" + } + ], + "pagination": null + }, + "index.html": { + "document_title": "Standalone blog oracle", + "canonical": "/docs/", + "relations": { + "prev": null, + "next": null + }, + "active_ancestors": [ + "Home" + ], + "headings": [ + { + "level": 1, + "id": "home", + "title": "Home" + } + ], + "links": [], + "posts": [], + "pagination": null + } + } +} diff --git a/python/tests/fixtures/blog/standalone/mkdocs.yml b/python/tests/fixtures/blog/standalone/mkdocs.yml new file mode 100644 index 0000000..ce0f1ac --- /dev/null +++ b/python/tests/fixtures/blog/standalone/mkdocs.yml @@ -0,0 +1,16 @@ +site_name: Standalone blog oracle +site_url: https://example.test/docs/ +use_directory_urls: true + +theme: + name: material + +plugins: + - material/blog: + archive: false + categories: false + authors: false + pagination: false + +nav: + - Home: index.md diff --git a/python/tests/fixtures/blog/vertical-slice/docs/blog/index.md b/python/tests/fixtures/blog/vertical-slice/docs/blog/index.md new file mode 100644 index 0000000..e707ca5 --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/blog/index.md @@ -0,0 +1,7 @@ +--- +title: Journal +--- + +# Rendered journal + +Introductory content. diff --git a/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/draft.md b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/draft.md new file mode 100644 index 0000000..2e6cb4b --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/draft.md @@ -0,0 +1,6 @@ +--- +date: 2024-05-06 +draft: true +--- + +# Draft post diff --git a/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/filename-title.md b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/filename-title.md new file mode 100644 index 0000000..030c5ed --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/filename-title.md @@ -0,0 +1,5 @@ +--- +date: 2023-12-01 +--- + +Filename-derived excerpt. diff --git a/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/future.md b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/future.md new file mode 100644 index 0000000..1caf883 --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/future.md @@ -0,0 +1,5 @@ +--- +date: 2099-01-01 +--- + +# Future post diff --git a/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/metadata.md b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/metadata.md new file mode 100644 index 0000000..0bf79e7 --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/metadata.md @@ -0,0 +1,9 @@ +--- +title: Metadata wins +date: 2024-03-04 +slug: chosen-slug +--- + +# Content heading + +Metadata excerpt without a separator. diff --git a/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/pinned.md b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/pinned.md new file mode 100644 index 0000000..a8d2fc8 --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/pinned.md @@ -0,0 +1,12 @@ +--- +date: 2024-01-02 +pin: true +--- + +# Pinned heading + +Pinned excerpt with a [relative link](../../notes.md#detail). + + + +Pinned remainder. diff --git a/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/unicode.md b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/unicode.md new file mode 100644 index 0000000..0f5df24 --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/blog/posts/unicode.md @@ -0,0 +1,11 @@ +--- +date: 2024-02-03 +--- + +# Über Café 東京 + +Unicode excerpt. + + + +Unicode remainder. diff --git a/python/tests/fixtures/blog/vertical-slice/docs/index.md b/python/tests/fixtures/blog/vertical-slice/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/blog/vertical-slice/docs/notes.md b/python/tests/fixtures/blog/vertical-slice/docs/notes.md new file mode 100644 index 0000000..e120a50 --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/docs/notes.md @@ -0,0 +1,3 @@ +# Notes + +## Detail diff --git a/python/tests/fixtures/blog/vertical-slice/material.json b/python/tests/fixtures/blog/vertical-slice/material.json new file mode 100644 index 0000000..46cb083 --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/material.json @@ -0,0 +1,241 @@ +{ + "outputs": [ + "404.html", + "blog/2023/12/01/filename-title/index.html", + "blog/2024/01/02/pinned-heading/index.html", + "blog/2024/02/03/über-café-東京/index.html", + "blog/2024/03/04/chosen-slug/index.html", + "blog/index.html", + "blog/page/2/index.html", + "index.html", + "notes/index.html" + ], + "navigation": [ + { + "title": "Home", + "url": "/docs/" + }, + { + "title": "Notes", + "url": "/docs/notes/" + }, + { + "title": "Blog", + "url": "/docs/blog/" + } + ], + "pages": { + "blog/2023/12/01/filename-title/index.html": { + "document_title": "Filename title - Blog oracle", + "canonical": "/docs/blog/2023/12/01/filename-title/", + "relations": { + "prev": null, + "next": "/docs/blog/2024/02/03/%C3%BCber-caf%C3%A9-%E6%9D%B1%E4%BA%AC/" + }, + "active_ancestors": [], + "headings": [ + { + "level": 1, + "id": null, + "title": "Filename title" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/01/02/pinned-heading/index.html": { + "document_title": "Pinned heading - Blog oracle", + "canonical": "/docs/blog/2024/01/02/pinned-heading/", + "relations": { + "prev": "/docs/blog/2024/03/04/chosen-slug/", + "next": null + }, + "active_ancestors": [], + "headings": [ + { + "level": 1, + "id": "pinned-heading", + "title": "Pinned heading" + } + ], + "links": [ + { + "title": "relative link", + "url": "/docs/notes/#detail" + } + ], + "posts": [], + "pagination": null + }, + "blog/2024/02/03/über-café-東京/index.html": { + "document_title": "Über Café 東京 - Blog oracle", + "canonical": "/docs/blog/2024/02/03/%C3%BCber-caf%C3%A9-%E6%9D%B1%E4%BA%AC/", + "relations": { + "prev": "/docs/blog/2023/12/01/filename-title/", + "next": "/docs/blog/2024/03/04/chosen-slug/" + }, + "active_ancestors": [], + "headings": [ + { + "level": 1, + "id": "uber-cafe", + "title": "Über Café 東京" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/2024/03/04/chosen-slug/index.html": { + "document_title": "Metadata wins - Blog oracle", + "canonical": "/docs/blog/2024/03/04/chosen-slug/", + "relations": { + "prev": "/docs/blog/2024/02/03/%C3%BCber-caf%C3%A9-%E6%9D%B1%E4%BA%AC/", + "next": "/docs/blog/2024/01/02/pinned-heading/" + }, + "active_ancestors": [], + "headings": [ + { + "level": 1, + "id": "content-heading", + "title": "Content heading" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "blog/index.html": { + "document_title": "Journal - Blog oracle", + "canonical": "/docs/blog/", + "relations": { + "prev": "/docs/notes/", + "next": null + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Pinned heading", + "url": "/docs/blog/2024/01/02/pinned-heading/", + "date": "2024-01-02 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": true, + "continue": "/docs/blog/2024/01/02/pinned-heading/", + "content": "

Pinned heading

Pinned excerpt with a relative link.


" + }, + { + "title": "Content heading", + "url": "/docs/blog/2024/03/04/chosen-slug/", + "date": "2024-03-04 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Content heading

Metadata excerpt without a separator.

" + } + ], + "pagination": { + "current": 1, + "links": [ + { + "title": "2", + "url": "/docs/blog/page/2/" + } + ] + } + }, + "blog/page/2/index.html": { + "document_title": "Journal - Blog oracle", + "canonical": "/docs/blog/page/2/", + "relations": { + "prev": "/docs/notes/", + "next": null + }, + "active_ancestors": [], + "headings": [], + "links": [], + "posts": [ + { + "title": "Über Café 東京", + "url": "/docs/blog/2024/02/03/%C3%BCber-caf%C3%A9-%E6%9D%B1%E4%BA%AC/", + "date": "2024-02-03 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": "/docs/blog/2024/02/03/%C3%BCber-caf%C3%A9-%E6%9D%B1%E4%BA%AC/", + "content": "

Über Café 東京

Unicode excerpt.

" + }, + { + "title": "Filename title", + "url": "/docs/blog/2023/12/01/filename-title/", + "date": "2023-12-01 00:00:00+00:00", + "authors": [], + "categories": [], + "pinned": false, + "continue": null, + "content": "

Filename title

Filename-derived excerpt.

" + } + ], + "pagination": { + "current": 2, + "links": [ + { + "title": "1", + "url": "/docs/blog/" + } + ] + } + }, + "index.html": { + "document_title": "Blog oracle", + "canonical": "/docs/", + "relations": { + "prev": null, + "next": "/docs/notes/" + }, + "active_ancestors": [ + "Home" + ], + "headings": [ + { + "level": 1, + "id": "home", + "title": "Home" + } + ], + "links": [], + "posts": [], + "pagination": null + }, + "notes/index.html": { + "document_title": "Notes - Blog oracle", + "canonical": "/docs/notes/", + "relations": { + "prev": "/docs/", + "next": "/docs/blog/" + }, + "active_ancestors": [ + "Notes" + ], + "headings": [ + { + "level": 1, + "id": "notes", + "title": "Notes" + }, + { + "level": 2, + "id": "detail", + "title": "Detail" + } + ], + "links": [], + "posts": [], + "pagination": null + } + } +} diff --git a/python/tests/fixtures/blog/vertical-slice/mkdocs.yml b/python/tests/fixtures/blog/vertical-slice/mkdocs.yml new file mode 100644 index 0000000..1c85efd --- /dev/null +++ b/python/tests/fixtures/blog/vertical-slice/mkdocs.yml @@ -0,0 +1,22 @@ +site_name: Blog oracle +site_url: https://example.test/docs/ +use_directory_urls: true + +theme: + name: material + features: + - navigation.indexes + +plugins: + - material/blog: + archive: false + categories: false + authors: false + pagination_per_page: 2 + draft_if_future_date: true + +nav: + - Home: index.md + - Notes: notes.md + - Blog: + - blog/index.md diff --git a/python/tests/integration/test_blog.py b/python/tests/integration/test_blog.py new file mode 100644 index 0000000..00f9046 --- /dev/null +++ b/python/tests/integration/test_blog.py @@ -0,0 +1,1354 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +"""Integration tests for native Material blog compatibility.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import time +from typing import TYPE_CHECKING, Any + +import pytest + +import zensical + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False} + + +def _project( + root: Path, + *, + per_page: int = 10, + archive: bool = False, + categories: bool = False, + authors: bool = False, + author_profiles: bool = False, + entrypoint: bool = True, +) -> Path: + docs = root / "docs" + posts = docs / "blog" / "posts" + overrides = root / "overrides" + posts.mkdir(parents=True) + overrides.mkdir() + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + if entrypoint: + (docs / "blog" / "index.md").write_text( + "# Journal\n", encoding="utf-8" + ) + (overrides / "main.html").write_text( + "{{ page.title }}|{{ page.url }}|{{ page.content }}", + encoding="utf-8", + ) + (overrides / "blog.html").write_text( + "BLOG|{{ page.title }}|{{ page.url }}|{{ page.content }}|" + "{% for post in posts %}{{ post.title }}:{{ post.content }}" + "{% for category in post.categories %}#{{ category.title }}@" + "{{ category.url }}{% endfor %}" + "{% for author in post.authors %}@{{ author.name }}:" + "{{ author.avatar }}:{{ author.url }}{% endfor %};{% endfor %}|" + "{% if pagination %}{{ pagination.page }}/{{ pagination.pages }}:" + "NEXT={{ pagination.next.url if pagination.next else '' }}" + "{% endif %}|SELF={{ page.url | url }}|NAV|" + "{% for item in nav.items %}{{ item.title }}(" + "{% for child in item.children %}{{ child.title }}[" + "{% for leaf in child.children %}{{ leaf.title }}=" + "{{ leaf.active }},{% endfor %}];" + "{% endfor %});{% endfor %}|TOC|" + "{% for section in page.toc %}{{ section.title }}[" + "{% for child in section.children %}{{ child.title }}=" + "{{ child.url }}(" + "{% for leaf in child.children %}{{ leaf.title }}={{ leaf.url }}," + "{% endfor %});{% endfor %}];{% endfor %}", + encoding="utf-8", + ) + (overrides / "blog-post.html").write_text( + "POST|{{ page.title }}|{{ page.url }}|" + "{{ page.config.date.created | date }}|{{ page.parent.url }}|" + "{{ page.content }}|PREV={{ page.previous_page.url }}|" + "NEXT={{ page.next_page.url }}|READ={{ page.config.readtime }}|" + "{% for author in page.authors %}AUTHOR={{ author.name }}:" + "{{ author.description }}:{{ author.avatar }}:{{ author.url }}" + "{% endfor %}|LINKS=" + "{% for link in page.config.links %}{{ link.title }}={{ link.url }}=" + "{{ link.meta.subtitle if link.meta else '' }}[" + "{% for child in link.children %}{{ child.title }}={{ child.url }};" + "{% endfor %}];{% endfor %}", + encoding="utf-8", + ) + config = root / "mkdocs.yml" + config.write_text( + f"""\ +site_name: Test +theme: + name: material + custom_dir: overrides +plugins: + - material/blog: + archive: {str(archive).lower()} + categories: {str(categories).lower()} + authors: {str(authors).lower()} + authors_profiles: {str(author_profiles).lower()} + pagination_per_page: {per_page} +""", + encoding="utf-8", + ) + return config + + +def _post( + root: Path, + name: str, + title: str, + date: str, + *, + body: str = "Body.", + **meta: object, +) -> None: + lines = ["---", f"date: {date}", f"title: {title}"] + lines.extend( + f"{key}: {json.dumps(value)}" for key, value in meta.items() + ) + lines.extend(["---", f"# {title}", "", body]) + (root / "docs" / "blog" / "posts" / name).write_text( + "\n".join(lines) + "\n", + encoding="utf-8", + ) + + +def test_posts_are_routed_from_dates_and_native_unicode_slugs( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + _post(tmp_path, "hello.md", "Héllo, World!", "2026-09-03") + _post(tmp_path, "draft.md", "Draft", "2026-09-04", draft=True) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = tmp_path / "site" / "blog" / "2026" / "09" / "03" + assert output.joinpath("héllo-world", "index.html").is_file() + assert "|READ=1" in output.joinpath( + "héllo-world", "index.html" + ).read_text("utf-8") + assert not output.parent.joinpath("04", "draft", "index.html").exists() + assert not (tmp_path / "site" / "blog" / "posts" / "hello").exists() + + +def test_explicit_post_metadata_controls_route_order_and_readtime( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + _post(tmp_path, "newer.md", "Newer", "2026-09-03") + _post( + tmp_path, + "pinned.md", + "Pinned", + "2026-09-01", + slug="explicit-route", + pin=True, + readtime=17, + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + post = ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "01" + / "explicit-route" + / "index.html" + ).read_text("utf-8") + assert "|READ=17|" in post + view = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert view.index("Pinned:") < view.index("Newer:") + + +def test_paginated_blog_pages_are_generated_without_source_files( + tmp_path: Path, +) -> None: + config = _project(tmp_path, per_page=1) + _post(tmp_path, "one.md", "One", "2026-09-01") + _post(tmp_path, "two.md", "Two", "2026-09-02") + + zensical.build(str(config), _BUILD_OPTIONS) + + page = tmp_path / "site" / "blog" / "page" / "2" / "index.html" + assert page.is_file() + second = page.read_text(encoding="utf-8") + assert "BLOG|Journal|blog/page/2/" in second + assert "One:" in second + assert "|2/2" in second + assert "|SELF=../../|" in second + first = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "|1/2:NEXT=blog/page/2/|" in first + assert not (tmp_path / "docs" / "blog" / "page").exists() + one = ( + tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one" + ).joinpath("index.html").read_text("utf-8") + two = ( + tmp_path / "site" / "blog" / "2026" / "09" / "02" / "two" + ).joinpath("index.html").read_text("utf-8") + assert "|PREV=|NEXT=blog/2026/09/02/two/" in one + assert "|PREV=blog/2026/09/01/one/|NEXT=" in two + + +def test_single_page_keeps_empty_pagination_context(tmp_path: Path) -> None: + config = _project(tmp_path) + _post(tmp_path, "one.md", "One", "2026-09-01") + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "|1/1:NEXT=|" in page + + +def test_serve_reconciles_routes_views_and_pagination(tmp_path: Path) -> None: + """Retained blog revisions retract every superseded output.""" + config = _project(tmp_path, per_page=1, archive=True, categories=True) + with config.open("a", encoding="utf-8") as stream: + stream.write("dev_addr: 127.0.0.1:0\n") + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + categories=["Alpha"], + ) + _post( + tmp_path, + "two.md", + "Two", + "2026-09-02", + categories=["Alpha"], + ) + log = (tmp_path / "serve.log").open("w+", encoding="utf-8") + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "zensical", + "serve", + "--config-file", + str(config), + ], + cwd=tmp_path, + stdout=log, + stderr=subprocess.STDOUT, + ) + site = tmp_path / "site" / "blog" + index = site / "index.html" + second = site / "page" / "2" / "index.html" + one = site / "2026" / "09" / "01" / "one" / "index.html" + two = site / "2026" / "09" / "02" / "two" / "index.html" + moved = site / "2026" / "09" / "03" / "moved" / "index.html" + alpha = site / "category" / "alpha" / "index.html" + beta = site / "category" / "beta" / "index.html" + + def contains(path: Path, value: str) -> bool: + try: + return value in path.read_text(encoding="utf-8") + except OSError: + return False + + def wait_for(condition: Callable[[], bool], timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if condition(): + return + if process.poll() is not None: + log.flush() + log.seek(0) + raise AssertionError( + f"serve exited with status {process.returncode}: " + f"{log.read()}" + ) + time.sleep(0.02) + log.flush() + log.seek(0) + raise AssertionError( + f"serve did not reconcile blog state: {log.read()}" + ) + + try: + wait_for( + lambda: ( + two.is_file() + and one.is_file() + and contains(index, "Two:") + and contains(second, "One:") + ) + ) + _post( + tmp_path, + "one.md", + "Moved", + "2026-09-03", + categories=["Beta"], + ) + wait_for( + lambda: ( + moved.is_file() + and not one.exists() + and beta.is_file() + and contains(index, "Moved:") + and contains(second, "Two:") + ) + ) + (tmp_path / "docs" / "blog" / "posts" / "two.md").unlink() + wait_for( + lambda: ( + not two.exists() + and not second.exists() + and not alpha.exists() + and contains(index, "Moved:") + ) + ) + assert process.poll() is None + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + log.close() + + +def test_pagination_format_exposes_ordered_native_items(tmp_path: Path) -> None: + config = _project(tmp_path, per_page=2) + with config.open("a", encoding="utf-8") as stream: + stream.write( + " pagination_format: >-\n" + " $link_first|$link_previous|${page}/$page_count|" + "$first_item-$last_item/$item_count|$link_next|$link_last|" + "$$|$unknown\n" + ) + (tmp_path / "overrides" / "blog.html").write_text( + "{% for item in pagination.items %}" + "[{{ item.type }}:{{ item.value }}:" + "{{ item.page if item.page else '' }}:" + "{{ item.url if item.url else '' }}]" + "{% endfor %}", + encoding="utf-8", + ) + _post(tmp_path, "one.md", "One", "2026-09-01") + _post(tmp_path, "two.md", "Two", "2026-09-02") + _post(tmp_path, "three.md", "Three", "2026-09-03") + + zensical.build(str(config), _BUILD_OPTIONS) + + first = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "[text:||1/2|1-2/3|::]" in first + assert "[next_page:2:2:blog/page/2/]" in first + assert "[last_page:2:2:blog/page/2/]" in first + assert "[text:|$|$unknown::]" in first + + second = ( + tmp_path / "site" / "blog" / "page" / "2" / "index.html" + ).read_text("utf-8") + assert "[first_page:1:1:blog/]" in second + assert "[previous_page:1:1:blog/]" in second + assert "[text:|2/2|3-3/3|||$|$unknown::]" in second + + +def test_empty_blog_has_no_reachable_pagination_pages(tmp_path: Path) -> None: + config = _project(tmp_path) + with config.open("a", encoding="utf-8") as stream: + stream.write(" pagination_if_single_page: true\n") + (tmp_path / "overrides" / "blog.html").write_text( + "{{ pagination.page }}/{{ pagination.pages }}|" + "{{ pagination.items | length }}|" + "{{ pagination.first_page if pagination.first_page else 'none' }}|" + "{{ pagination.first_item if pagination.first_item else 'none' }}", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert page == "1/0|0|none|none" + + +def test_empty_blog_omits_unreachable_archive_navigation( + tmp_path: Path, +) -> None: + config = _project(tmp_path, archive=True) + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "Archive[" not in page + assert not (tmp_path / "site" / "blog" / "archive").exists() + + +def test_missing_blog_entrypoint_is_generated_without_mutating_docs( + tmp_path: Path, +) -> None: + config = _project(tmp_path, entrypoint=False) + + zensical.build(str(config), _BUILD_OPTIONS) + + page = tmp_path / "site" / "blog" / "index.html" + assert page.is_file() + assert "BLOG|Blog|blog/" in page.read_text("utf-8") + assert "Blog(" in page.read_text("utf-8") + assert not (tmp_path / "docs" / "blog" / "index.md").exists() + + +def test_archive_and_category_views_share_native_pagination_pipeline( + tmp_path: Path, +) -> None: + config = _project( + tmp_path, + per_page=1, + archive=True, + categories=True, + ) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + categories=["Rust"], + ) + _post( + tmp_path, + "two.md", + "Two", + "2026-09-02", + categories=["Rust"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + archive = tmp_path / "site" / "blog" / "archive" / "2026" + category = tmp_path / "site" / "blog" / "category" / "rust" + assert "Two:" in archive.joinpath("index.html").read_text("utf-8") + assert "One:" in archive.joinpath("page", "2", "index.html").read_text( + "utf-8" + ) + assert "Two:" in category.joinpath("index.html").read_text("utf-8") + assert "One:" in category.joinpath("page", "2", "index.html").read_text( + "utf-8" + ) + navigation = archive.joinpath("index.html").read_text("utf-8") + assert "Archive[2026=" in navigation + assert "Categories[Rust=" in navigation + assert "#Rust@blog/category/rust/" in navigation + assert "Posts" not in navigation + paginated = category.joinpath("page", "2", "index.html").read_text( + "utf-8" + ) + assert "Categories[Rust=true,]" in paginated + assert not (tmp_path / "docs" / "blog" / "archive").exists() + assert not (tmp_path / "docs" / "blog" / "category").exists() + + +def test_navigation_labels_use_theme_language_partial(tmp_path: Path) -> None: + config = _project( + tmp_path, + archive=True, + categories=True, + authors=True, + author_profiles=True, + ) + text = config.read_text(encoding="utf-8") + config.write_text( + text.replace( + " custom_dir: overrides\n", + " custom_dir: overrides\n language: de\n", + ), + encoding="utf-8", + ) + (tmp_path / "docs" / "blog" / ".authors.yml").write_text( + "authors:\n" + " jane:\n" + " name: Jane Doe\n" + " description: Technical writer\n" + " avatar: assets/jane.png\n", + encoding="utf-8", + ) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + categories=["Rust"], + authors=["jane"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "Archiv[2026=" in page + assert "Kategorien[Rust=" in page + assert "Autoren[Jane Doe=" in page + + +def test_navigation_labels_keep_literal_configuration(tmp_path: Path) -> None: + config = _project(tmp_path, archive=True) + with config.open("a", encoding="utf-8") as stream: + stream.write(" archive_name: History\n") + _post(tmp_path, "one.md", "One", "2026-09-01") + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "History[2026=" in page + + +def test_archive_navigation_follows_post_order_for_nonnumeric_urls( + tmp_path: Path, +) -> None: + config = _project(tmp_path, archive=True) + with config.open("a", encoding="utf-8") as stream: + stream.write( + ' archive_date_format: "MMMM yyyy"\n' + " archive_url_date_format: MMMM\n" + ) + _post(tmp_path, "november.md", "November", "2026-11-01") + _post(tmp_path, "december.md", "December", "2026-12-01") + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert page.index("December 2026=") < page.index("November 2026=") + + +def test_generated_view_sections_follow_material_sibling_order( + tmp_path: Path, +) -> None: + config = _project( + tmp_path, + archive=True, + categories=True, + authors=True, + author_profiles=True, + ) + (tmp_path / "docs" / "guide.md").write_text( + "# Guide\n", encoding="utf-8" + ) + (tmp_path / "docs" / "blog" / ".authors.yml").write_text( + "authors:\n" + " jane:\n" + " name: Jane Doe\n" + " description: Technical writer\n" + " avatar: assets/jane.png\n", + encoding="utf-8", + ) + with config.open("a", encoding="utf-8") as stream: + stream.write( + "nav:\n" + " - Home: index.md\n" + " - Blog:\n" + " - blog/index.md\n" + " - Guide: guide.md\n" + ) + (tmp_path / "overrides" / "main.html").write_text( + "{{ page.url }}|" + "PREV={{ page.previous_page.url if page.previous_page else '' }}|" + "NEXT={{ page.next_page.url if page.next_page else '' }}", + encoding="utf-8", + ) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + categories=["Rust"], + authors=["jane"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + guide = (tmp_path / "site" / "guide" / "index.html").read_text( + "utf-8" + ) + assert guide == "guide/|PREV=blog/archive/2026/|NEXT=" + + +def test_archive_url_keys_can_group_multiple_display_dates( + tmp_path: Path, +) -> None: + config = _project(tmp_path, archive=True) + with config.open("a", encoding="utf-8") as stream: + stream.write( + " pagination: false\n" + ' archive_url_date_format: "\'all\'"\n' + ) + _post(tmp_path, "older.md", "Older", "2025-01-01") + _post(tmp_path, "newer.md", "Newer", "2026-01-01") + + zensical.build(str(config), _BUILD_OPTIONS) + + archive = tmp_path / "site" / "blog" / "archive" / "all" / "index.html" + page = archive.read_text("utf-8") + assert "BLOG|2026|" in page + assert "Newer:" in page + assert "Older:" in page + assert not (tmp_path / "site" / "blog" / "archive" / "2025").exists() + + +def test_authored_category_page_becomes_the_view_source(tmp_path: Path) -> None: + config = _project(tmp_path, per_page=1, categories=True) + category = tmp_path / "docs" / "blog" / "category" + category.mkdir() + category.joinpath("rust.md").write_text( + "---\ntitle: Custom Rust\n---\n# Authored category\n", + encoding="utf-8", + ) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + categories=["Rust"], + ) + _post( + tmp_path, + "two.md", + "Two", + "2026-09-02", + categories=["Rust"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + first = tmp_path / "site" / "blog" / "category" / "rust" + first_html = first.joinpath("index.html").read_text("utf-8") + second_html = first.joinpath("page", "2", "index.html").read_text("utf-8") + assert "BLOG|Custom Rust|" in first_html + assert "Authored category" in first_html + assert "BLOG|Custom Rust|" in second_html + assert first_html.count("Categories[") == 1 + + +def test_excerpt_links_are_rebased_for_each_containing_view( + tmp_path: Path, +) -> None: + config = _project(tmp_path, per_page=1) + (tmp_path / "docs" / "notes.md").write_text("# Notes\n", encoding="utf-8") + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + body="[Notes](../../notes.md)\n\n\n\nRemainder.", + ) + _post(tmp_path, "two.md", "Two", "2026-09-02") + + zensical.build(str(config), _BUILD_OPTIONS) + + page = ( + tmp_path / "site" / "blog" / "page" / "2" / "index.html" + ).read_text("utf-8") + assert 'href="../../../notes/"' in page + post = ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "01" + / "one" + / "index.html" + ).read_text("utf-8") + assert 'href="../../../../../notes/"' in post + + +def test_required_excerpt_separator_is_validated(tmp_path: Path) -> None: + config = _project(tmp_path) + with config.open("a", encoding="utf-8") as stream: + stream.write(" post_excerpt: required\n") + _post(tmp_path, "one.md", "One", "2026-09-01") + + with pytest.raises(RuntimeError) as error: + zensical.build(str(config), _BUILD_OPTIONS) + assert "requires the excerpt separator ''" in str(error.value) + + +def test_url_formats_do_not_require_a_slug_placeholder(tmp_path: Path) -> None: + config = _project(tmp_path) + with config.open("a", encoding="utf-8") as stream: + stream.write(' post_url_format: "{date}/{file}"\n') + _post(tmp_path, "source-name.md", "Different title", "2026-09-01") + + zensical.build(str(config), _BUILD_OPTIONS) + + assert ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "01" + / "source-name" + / "index.html" + ).is_file() + + +def test_disabling_pagination_keeps_all_posts_on_one_view( + tmp_path: Path, +) -> None: + config = _project(tmp_path, per_page=1) + with config.open("a", encoding="utf-8") as stream: + stream.write(" pagination: false\n") + _post(tmp_path, "one.md", "One", "2026-09-01") + _post(tmp_path, "two.md", "Two", "2026-09-02") + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "One:" in page + assert "Two:" in page + assert not (tmp_path / "site" / "blog" / "page").exists() + + +def test_multiple_blog_instances_keep_routes_and_views_isolated( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + for directory, title in [("journal", "Journal"), ("news", "News")]: + root = tmp_path / "docs" / directory + root.joinpath("posts").mkdir(parents=True) + root.joinpath("index.md").write_text(f"# {title}\n", encoding="utf-8") + root.joinpath("posts", "entry.md").write_text( + f"---\ndate: 2026-09-01\n---\n# {title} entry\n", + encoding="utf-8", + ) + config.write_text( + """\ +site_name: Test +theme: + name: material + custom_dir: overrides +plugins: + - material/blog: + blog_dir: journal + post_dir: journal/posts + archive: false + categories: false + authors: false + - material/blog: + blog_dir: news + post_dir: news/posts + archive: false + categories: false + authors: false +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert ( + tmp_path + / "site" + / "journal" + / "2026" + / "09" + / "01" + / "journal-entry" + / "index.html" + ).is_file() + assert ( + tmp_path + / "site" + / "news" + / "2026" + / "09" + / "01" + / "news-entry" + / "index.html" + ).is_file() + + +def test_categories_can_sort_by_post_count(tmp_path: Path) -> None: + config = _project(tmp_path, categories=True) + with config.open("a", encoding="utf-8") as stream: + stream.write( + " categories_sort_by:\n" + " object: material.plugins.blog.view_post_count\n" + " categories_sort_reverse: true\n" + ) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + categories=["Alpha", "Beta"], + ) + _post( + tmp_path, + "two.md", + "Two", + "2026-09-02", + categories=["Beta"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert page.index("Categories[Beta=") < page.index("Alpha=") + + +def test_authors_are_resolved_and_profiles_use_native_routes( + tmp_path: Path, +) -> None: + config = _project(tmp_path, authors=True, author_profiles=True) + (tmp_path / "docs" / "blog" / ".authors.yml").write_text( + """\ +authors: + jane: + name: Jane Doe + description: Technical writer + avatar: assets/jane.png + slug: jane-doe +""", + encoding="utf-8", + ) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + authors=["jane"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + post = ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "01" + / "one" + / "index.html" + ).read_text("utf-8") + assert ( + "AUTHOR=Jane Doe:Technical writer:assets/jane.png:" + "blog/author/jane-doe/" + ) in post + profile = ( + tmp_path / "site" / "blog" / "author" / "jane-doe" / "index.html" + ) + assert profile.is_file() + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "@Jane Doe:assets/jane.png:blog/author/jane-doe/" in page + assert "Authors[Jane Doe=" in page + assert not (tmp_path / "site" / "blog" / ".authors.yml").exists() + + +def test_author_profiles_follow_first_appearance_in_post_order( + tmp_path: Path, +) -> None: + config = _project(tmp_path, authors=True, author_profiles=True) + (tmp_path / "docs" / "blog" / ".authors.yml").write_text( + """\ +authors: + alpha: + name: Alpha Author + description: Alpha + avatar: alpha.png + zeta: + name: Zeta Author + description: Zeta + avatar: zeta.png +""", + encoding="utf-8", + ) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + authors=["zeta", "alpha"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert page.index("Zeta Author=") < page.index("Alpha Author=") + + +def test_unknown_post_author_is_rejected(tmp_path: Path) -> None: + config = _project(tmp_path, authors=True) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + authors=["missing"], + ) + + with pytest.raises(RuntimeError, match="couldn't find author 'missing'"): + zensical.build(str(config), _BUILD_OPTIONS) + + +def test_custom_author_catalog_is_consumed_without_being_published( + tmp_path: Path, +) -> None: + config = _project(tmp_path, authors=True, author_profiles=True) + with config.open("a", encoding="utf-8") as stream: + stream.write(" authors_file: '{blog}/people.yml'\n") + (tmp_path / "docs" / "blog" / "people.yml").write_text( + """\ +authors: + jane: + name: Jane Doe + description: Technical writer + avatar: jane.png + slug: jane-doe +""", + encoding="utf-8", + ) + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + authors=["jane"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + profile = ( + tmp_path / "site" / "blog" / "author" / "jane-doe" / "index.html" + ) + assert profile.is_file() + assert not (tmp_path / "site" / "blog" / "people.yml").exists() + + +def test_post_assets_are_relocated_to_the_public_blog_tree( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + assets = tmp_path / "docs" / "blog" / "posts" / "assets" + assets.mkdir() + assets.joinpath("image.png").write_bytes(b"image") + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + body="![Image](assets/image.png)", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert (tmp_path / "site" / "blog" / "assets" / "image.png").is_file() + assert not ( + tmp_path / "site" / "blog" / "posts" / "assets" / "image.png" + ).exists() + post = ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "01" + / "one" + / "index.html" + ).read_text("utf-8") + assert 'src="../../../../assets/image.png"' in post + view = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert 'src="assets/image.png"' in view + assert "posts/assets/image.png" not in post + assert "posts/assets/image.png" not in view + + +def test_nested_post_asset_links_preserve_suffixes_and_url_boundaries( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + posts = tmp_path / "docs" / "blog" / "posts" + media = posts / "nested" / "media" + media.mkdir(parents=True) + media.joinpath("image.svg").write_text("", encoding="utf-8") + media.joinpath("reference.txt").write_text("reference", encoding="utf-8") + _post( + tmp_path, + "nested/one.md", + "One", + "2026-09-01", + body=( + "[Markdown](media/reference.txt?download=1#part)\n\n" + "![Image](media/image.svg?version=2#icon)\n\n" + 'Raw\n' + 'Raw image\n\n' + "[Root](/shared.txt) [External](https://example.org/file)" + ), + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = tmp_path / "site" / "blog" / "nested" / "media" + assert output.joinpath("image.svg").is_file() + post = ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "01" + / "one" + / "index.html" + ).read_text("utf-8") + assert "../../../../nested/media/reference.txt?download=1#part" in post + assert "../../../../nested/media/image.svg?version=2#icon" in post + assert "../../../../nested/media/reference.txt?raw=1#part" in post + assert "../../../../nested/media/image.svg?raw=1#icon" in post + assert 'href="/shared.txt"' in post + assert 'href="https://example.org/file"' in post + view = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "nested/media/reference.txt?download=1#part" in view + assert "nested/media/reference.txt?raw=1#part" in view + + +@pytest.mark.parametrize( + ("date", "categories", "message"), + [ + ("not-a-date", None, "invalid date"), + ("2026-09-01", ["Forbidden"], "outside categories_allowed"), + ], +) +def test_invalid_post_metadata_is_rejected( + tmp_path: Path, + date: str, + categories: list[str] | None, + message: str, +) -> None: + config = _project(tmp_path, categories=categories is not None) + if categories is not None: + with config.open("a", encoding="utf-8") as stream: + stream.write(" categories_allowed: [Allowed]\n") + if categories is None: + _post(tmp_path, "one.md", "One", date) + else: + _post( + tmp_path, + "one.md", + "One", + date, + categories=categories, + ) + + with pytest.raises(RuntimeError, match=message): + zensical.build(str(config), _BUILD_OPTIONS) + + +def test_date_display_formats_are_independent_from_archive_routes( + tmp_path: Path, +) -> None: + config = _project(tmp_path, archive=True) + with config.open("a", encoding="utf-8") as stream: + stream.write( + " post_date_format: medium\n" + ' archive_date_format: "MMMM yyyy"\n' + ) + _post(tmp_path, "one.md", "One", "2026-09-03") + + zensical.build(str(config), _BUILD_OPTIONS) + + post = ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "03" + / "one" + / "index.html" + ).read_text("utf-8") + assert "|Sep 3, 2026|" in post + archive = tmp_path / "site" / "blog" / "archive" / "2026" + assert archive.joinpath("index.html").is_file() + assert "Archive[September 2026=" in archive.joinpath( + "index.html" + ).read_text("utf-8") + + +def test_fractional_post_dates_preserve_values_and_order( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + _post( + tmp_path, + "a-older.md", + "Older", + "2026-09-03T10:30:00.100000Z", + ) + _post( + tmp_path, + "z-newer.md", + "Newer", + "2026-09-03T10:30:00.200000Z", + ) + (tmp_path / "overrides" / "blog-post.html").write_text( + "{{ page.config.date.created }}", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + view = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert view.index("Newer:") < view.index("Older:") + post = ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "03" + / "newer" + / "index.html" + ).read_text("utf-8") + assert post == "2026-09-03 10:30:00.200000+00:00" + + +def test_structured_links_resolve_pages_anchors_and_nested_sections( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + (tmp_path / "docs" / "guide.md").write_text( + "# Guide\n\n## Details\n", + encoding="utf-8", + ) + assets = tmp_path / "docs" / "blog" / "posts" / "assets" + assets.mkdir() + assets.joinpath("reference.pdf").write_bytes(b"reference") + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + links=[ + {"Guide section": "guide.md#details"}, + {"Download": "blog/posts/assets/reference.pdf"}, + {"Resources": [{"External": "https://example.com"}]}, + ], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + post = ( + tmp_path + / "site" + / "blog" + / "2026" + / "09" + / "01" + / "one" + / "index.html" + ).read_text("utf-8") + assert "Guide section=guide/#details=Details[]" in post + assert "Download=blog/assets/reference.pdf=" in post + assert "Resources=none=[External=https://example.com;]" in post + + +def test_excerpt_toc_contains_only_the_post_root( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + with config.open("a", encoding="utf-8") as stream: + stream.write(" blog_toc: true\n") + _post( + tmp_path, + "one.md", + "One", + "2026-09-01", + body=( + "## Included\n\n[Jump](#included)\n\n" + "\n\n" + "## Excluded\n\nMore." + ), + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert "TOC|Journal[One=2026/09/01/one/();" in page + assert "Included=" not in page + assert "Excluded=" not in page + assert ( + '

Included

' + in page + ) + assert 'Jump' in page + + +def test_excerpt_inserts_a_linked_title_when_the_post_has_no_h1( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + (tmp_path / "docs" / "blog" / "posts" / "one.md").write_text( + "---\ndate: 2026-09-01\ntitle: One & Two\n---\nBody only.\n", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") + assert ( + '

One & Two

' + in page + ) + + +def test_standalone_blog_uses_root_entrypoint_and_post_routes( + tmp_path: Path, +) -> None: + config = _project(tmp_path) + posts = tmp_path / "docs" / "posts" + posts.mkdir() + posts.joinpath("one.md").write_text( + "---\ndate: 2026-09-01\n---\n# One\n", + encoding="utf-8", + ) + config.write_text( + """\ +site_name: Test +theme: + name: material + custom_dir: overrides +plugins: + - material/blog: + blog_dir: . + archive: false + categories: false + authors: false +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert (tmp_path / "site" / "index.html").is_file() + assert ( + tmp_path + / "site" + / "2026" + / "09" + / "01" + / "one" + / "index.html" + ).is_file() + assert not (tmp_path / "site" / "posts" / "one" / "index.html").exists() + + +def test_blog_routes_respect_disabled_directory_urls(tmp_path: Path) -> None: + config = _project(tmp_path, per_page=1) + with config.open("a", encoding="utf-8") as stream: + stream.write("use_directory_urls: false\n") + _post(tmp_path, "one.md", "One", "2026-09-01") + _post(tmp_path, "two.md", "Two", "2026-09-02") + + zensical.build(str(config), _BUILD_OPTIONS) + + assert ( + tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one.html" + ).is_file() + assert ( + tmp_path / "site" / "blog" / "page" / "2" / "index.html" + ).is_file() + + +def test_posts_receive_inherited_meta_before_blog_classification( + tmp_path: Path, +) -> None: + config = _project(tmp_path, categories=True) + config.write_text( + config.read_text(encoding="utf-8").replace( + "plugins:\n", + "plugins:\n - material/meta\n", + ), + encoding="utf-8", + ) + posts = tmp_path / "docs" / "blog" / "posts" + (posts / ".meta.yml").write_text( + "date: 2026-08-31\ncategories: [Inherited]\n", + encoding="utf-8", + ) + (posts / "inherited.md").write_text( + "---\ntitle: Inherited post\n---\n# Content heading\n", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert ( + tmp_path + / "site" + / "blog" + / "2026" + / "08" + / "31" + / "inherited-post" + / "index.html" + ).is_file() + category = ( + tmp_path / "site" / "blog" / "category" / "inherited" / "index.html" + ).read_text("utf-8") + assert "Inherited post:" in category + + +def test_search_uses_final_blog_post_routes(tmp_path: Path) -> None: + config = _project(tmp_path) + config.write_text( + config.read_text(encoding="utf-8").replace( + "plugins:\n", + "plugins:\n - search\n", + ), + encoding="utf-8", + ) + _post(tmp_path, "one.md", "Searchable", "2026-09-01") + + zensical.build(str(config), _BUILD_OPTIONS) + + index = json.loads((tmp_path / "site" / "search.json").read_text("utf-8")) + locations = [item["location"] for item in index["items"]] + assert "blog/2026/09/01/searchable/" in locations + + +def test_tag_listings_link_to_final_blog_post_routes(tmp_path: Path) -> None: + config = _project(tmp_path) + config.write_text( + config.read_text(encoding="utf-8").replace( + "plugins:\n", + "plugins:\n - material/tags\n", + ), + encoding="utf-8", + ) + (tmp_path / "docs" / "tags.md").write_text( + "# Tags\n\n\n", + encoding="utf-8", + ) + _post( + tmp_path, + "one.md", + "Tagged post", + "2026-09-01", + tags=["Feature"], + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "tags" / "index.html").read_text("utf-8") + assert 'href="../blog/2026/09/01/tagged-post/"' in listing diff --git a/python/tests/integration/test_config.py b/python/tests/integration/test_config.py index 011a18f..93790bf 100644 --- a/python/tests/integration/test_config.py +++ b/python/tests/integration/test_config.py @@ -239,6 +239,27 @@ class TestThemeLoadingToml: config_path = _make_toml_project(tmp_path) _build(config_path) # must not raise + def test_blog_icons_can_be_overridden(self, tmp_path: Path) -> None: + """Blog icon settings pass through configuration to templates.""" + custom = _make_custom_dir(tmp_path) + custom.joinpath("main.html").write_text( + "{{ config.theme.icon.blog.back }}", + encoding="utf-8", + ) + config_path = _make_toml_project( + tmp_path, + toml_extra=( + '[project.theme]\ncustom_dir = "overrides"\n' + '[project.theme.icon.blog]\nback = "octicons/arrow-left-16"\n' + ), + ) + + _build(config_path) + + assert ( + tmp_path / "site" / "index.html" + ).read_text() == "octicons/arrow-left-16" + def test_unknown_theme_name_raises(self, tmp_path: Path) -> None: """theme.name set to an uninstalled theme -> config error raised.""" config_path = _make_toml_project( diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index 1dc2317..ca9cf5f 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -341,6 +341,23 @@ class TestPluginShimming: } assert config["plugins_hash"] == cfg_module._hash(config["plugins"]) + def test_material_blog_instances_preserve_order_and_aliases( + self, tmp_path: Path + ) -> None: + config = self._parse_yaml( + tmp_path, + plugins=[ + {"material/blog": {"blog_dir": "journal"}}, + {"blog": {"blog_dir": "news"}}, + ], + ) + assert config["plugins"]["blogs"] == { + "config": [ + {"name": "blog", "config": {"blog_dir": "journal"}}, + {"name": "blog", "config": {"blog_dir": "news"}}, + ] + } + def test_material_offline_alias_is_normalized(self, tmp_path: Path) -> None: config = self._parse_yaml(tmp_path, plugins=["material/offline"]) assert "material/offline" not in config["plugins"] diff --git a/python/tests/unit/test_plugin_config.py b/python/tests/unit/test_plugin_config.py index 3fe6c84..35c0ec2 100644 --- a/python/tests/unit/test_plugin_config.py +++ b/python/tests/unit/test_plugin_config.py @@ -181,7 +181,7 @@ def test_silently_discards_unsupported_mike_options( assert capsys.readouterr().err == "" -@pytest.mark.parametrize("name", [*PYTHON_PLUGINS, "tags"]) +@pytest.mark.parametrize("name", [*PYTHON_PLUGINS, "tags", "blog"]) def test_plugin_configuration_must_be_a_mapping(name: str) -> None: with pytest.raises( ConfigurationError, @@ -194,7 +194,6 @@ def test_plugin_configuration_must_be_a_mapping(name: str) -> None: "name", [ "external", # does not exist - "material/blog", # exists but isn't supported yet "literate_nav", # misspelling (`_` instead of `-`) ], ) @@ -210,11 +209,35 @@ def test_ignores_unsupported_plugins( assert capsys.readouterr().err == "" -@pytest.mark.parametrize("name", ["external", "material/blog", "literate_nav"]) +@pytest.mark.parametrize("name", ["external", "literate_nav"]) def test_ignores_unsupported_plugin_names(name: str) -> None: assert _convert_plugins([name]) == _convert_plugins([]) +@pytest.mark.parametrize("name", ["blog", "material/blog"]) +@pytest.mark.parametrize("data", [None, {"blog_dir": "journal"}]) +@pytest.mark.parametrize("as_list", [False, True]) +def test_preserves_blog_plugins( + name: str, data: dict[str, Any] | None, as_list: bool +) -> None: + value = {name: data} + plugins = _convert_plugins([value] if as_list else value) + + assert plugins["blogs"]["config"] == [ + {"name": "blog", "config": data or {}} + ] + + +@pytest.mark.parametrize("name", ["blog", "material/blog"]) +@pytest.mark.parametrize("data", [True, 42, "config", []]) +def test_rejects_invalid_blog_configuration(name: str, data: Any) -> None: + with pytest.raises( + ConfigurationError, + match="blog configuration must be a mapping", + ): + _convert_plugins({name: data}) + + @pytest.mark.parametrize("prefix", ["", "material/"]) @pytest.mark.parametrize("value", [True, False, "auto", 42, [], {}, None]) @pytest.mark.parametrize( diff --git a/python/zensical/config.py b/python/zensical/config.py index 8ab2bb1..30209b6 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -120,6 +120,7 @@ _PLUGIN_UNSUPPORTED_OPTIONS = { "strip_title_tags", ), "awesome-nav": (), + "blog": (), "callouts": ( "aliases", "breakless_lists", @@ -633,6 +634,7 @@ def _apply_defaults(config: dict, path: str) -> dict: set_default(icon, "repo", None, str) set_default(icon, "annotation", None, str) set_default(icon, "tag", {}, dict) + blog = set_default(icon, "blog", {}, dict) if theme.get("variant") == "modern": set_default(icon, "logo", "lucide/book-open", str) set_default(icon, "edit", "lucide/file-pen", str) @@ -645,6 +647,11 @@ def _apply_defaults(config: dict, path: str) -> dict: set_default(icon, "close", "lucide/x", str) set_default(icon, "previous", "lucide/arrow-left", str) set_default(icon, "next", "lucide/arrow-right", str) + set_default(blog, "back", "lucide/arrow-left", str) + set_default(blog, "date", "lucide/calendar-days", str) + set_default(blog, "date_updated", "lucide/calendar-clock", str) + set_default(blog, "categories", "lucide/library", str) + set_default(blog, "readtime", "lucide/clock", str) else: set_default(icon, "logo", None, str) set_default(icon, "edit", None, str) @@ -657,6 +664,11 @@ def _apply_defaults(config: dict, path: str) -> dict: set_default(icon, "close", None, str) set_default(icon, "previous", None, str) set_default(icon, "next", None, str) + set_default(blog, "back", "material/arrow-left", str) + set_default(blog, "date", "material/calendar", str) + set_default(blog, "date_updated", "material/calendar-clock", str) + set_default(blog, "categories", "material/bookshelf", str) + set_default(blog, "readtime", "material/clock-outline", str) # Set defaults for theme admonition icons admonition = set_default(icon, "admonition", {}, dict) @@ -1521,6 +1533,7 @@ def _convert_plugins(value: Any, config: dict) -> dict: """Convert plugins configuration to something we can work with.""" plugins: dict[str, Any] = {} tags: list[dict[str, Any]] = [] + blogs: list[dict[str, Any]] = [] def add(name: Any, data: Any) -> None: """Canonicalize Material aliases while preserving tag instances.""" @@ -1540,6 +1553,8 @@ def _convert_plugins(value: Any, config: dict) -> dict: if name == "tags": _reject_unknown_options("tags", data, _TAGS_SUPPORTED_OPTIONS) tags.append({"name": name, "config": data}) + elif name == "blog": + blogs.append({"name": name, "config": data}) else: plugins[name] = data @@ -1567,6 +1582,7 @@ def _convert_plugins(value: Any, config: dict) -> dict: # Rust owns tags defaults, value validation, scalar coercion and callable # lowering. Python validates option names and preserves ordered instances. plugins["tags"] = tags + plugins["blogs"] = blogs # Search is enabled by default, even when it isn't explicitly configured. search = plugins.pop("search", {}) diff --git a/scripts/blog_compatibility.py b/scripts/blog_compatibility.py new file mode 100755 index 0000000..8a93782 --- /dev/null +++ b/scripts/blog_compatibility.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python + +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +"""Build and compare semantic manifests for Material blog fixtures.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import tempfile +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin, urlparse + +from bs4 import BeautifulSoup, Tag + +if TYPE_CHECKING: + from collections.abc import Iterable + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "python" / "tests" / "fixtures" / "blog" + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--mkdocs", + type=Path, + required=True, + help="path to the pinned Material environment's mkdocs executable", + ) + parser.add_argument( + "--zensical", + type=Path, + help="optional path to a Zensical executable to compare", + ) + parser.add_argument( + "--fixture", + action="append", + dest="fixtures", + help="fixture name to run; may be repeated (default: all)", + ) + parser.add_argument( + "--update", + action="store_true", + help="replace checked-in Material manifests", + ) + return parser.parse_args() + + +class Engine(Enum): + """Supported fixture builders.""" + + MKDOCS = "MkDocs" + ZENSICAL = "Zensical" + + +def _url(base: str, value: str | None) -> str | None: + """Normalize an internal link while retaining external URLs.""" + if value is None: + return None + absolute = urljoin(base, value) + parsed = urlparse(absolute) + if parsed.netloc == "example.test": + result = parsed.path + if parsed.query: + result += f"?{parsed.query}" + if parsed.fragment: + result += f"#{parsed.fragment}" + return result + return absolute + + +def _text(element: Tag | None) -> str | None: + """Normalize the visible text of an element.""" + if element is None: + return None + return " ".join(element.stripped_strings) + + +def _attribute(element: Tag, name: str) -> str | None: + """Return a scalar HTML attribute.""" + value = element.get(name) + return value if isinstance(value, str) else None + + +def _has_class(element: Tag, name: str) -> bool: + """Check whether an element has a class name.""" + value = element.get("class") + if isinstance(value, str): + return name in value.split() + return value is not None and name in value + + +def _fragment( + element: Tag | None, base: str, *, normalize_urls: bool +) -> str | None: + """Normalize a selected HTML fragment for semantic comparisons.""" + if element is None: + return None + clone = BeautifulSoup(str(element), "lxml").find(element.name) + if clone is None: + raise ValueError("selected HTML fragment could not be cloned") + for value in clone.find_all(string=True): + value.replace_with(re.sub(r"\s+", " ", str(value))) + if normalize_urls: + for node in clone.select("a[href], img[src]"): + attribute = "href" if node.name == "a" else "src" + value = node.get(attribute) + if isinstance(value, str): + node[attribute] = _url(base, value) or value + return re.sub(r">\s+<", "><", str(clone)).strip() + + +def _link(element: Tag, base: str) -> dict[str, Any]: + """Describe one rendered link.""" + return { + "title": _text(element), + "url": _url(base, _attribute(element, "href")), + } + + +def _nav_items(container: Tag, base: str) -> list[dict[str, Any]]: + """Extract one navigation level without depending on page objects.""" + root = container.find("ul", recursive=False) + if root is None: + return [] + items: list[dict[str, Any]] = [] + for entry in root.find_all("li", recursive=False): + link = entry.find("a", recursive=False) + wrapper = entry.find("div", recursive=False) + if link is None and wrapper is not None: + link = wrapper.find("a", recursive=False) + label = entry.find("label", recursive=False) + title = _text(link or label) + if not title: + continue + item: dict[str, Any] = {"title": title} + if link is not None: + item["url"] = _url(base, _attribute(link, "href")) + child = entry.find("nav", recursive=False) + if child is not None: + children = _nav_items(child, base) + if children: + item["children"] = children + items.append(item) + return items + + +def _active_ancestors(nav: Tag | None) -> list[str]: + """Extract visible active navigation ancestors in tree order.""" + if nav is None: + return [] + ancestors: list[str] = [] + for entry in nav.select("li.md-nav__item--active"): + label = entry.find(["a", "label"], recursive=False) + title = _text(label) + if title and title not in ancestors: + ancestors.append(title) + return ancestors + + +def _posts( + soup: BeautifulSoup, base: str, *, engine: Engine +) -> list[dict[str, Any]]: + """Extract ordered blog-view memberships and excerpt behavior.""" + posts: list[dict[str, Any]] = [] + for article in soup.select("article.md-post--excerpt"): + content = article.select_one(".md-post__content") + heading = content.find(["h1", "h2"]) if content else None + heading_link = heading.find("a") if heading else None + time = article.find("time") + categories = [ + _link(link, base) + for link in article.select(".md-post__meta a.md-meta__link") + ] + authors = [ + image.get("alt") + for image in article.select(".md-post__authors img[alt]") + ] + action = article.select_one(".md-post__action a[href]") + posts.append( + { + "title": _text(heading), + "url": _url( + base, + _attribute(heading_link, "href") if heading_link else None, + ), + "date": time.get("datetime") if time else None, + "authors": authors, + "categories": categories, + "pinned": article.select_one(".md-pin") is not None, + "continue": ( + _url(base, _attribute(action, "href")) if action else None + ), + "content": _fragment( + content, + base, + normalize_urls=engine is Engine.ZENSICAL, + ), + } + ) + return posts + + +def _pagination(soup: BeautifulSoup, base: str) -> dict[str, Any] | None: + """Extract page number and pager links.""" + pagination = soup.select_one(".md-pagination") + if pagination is None: + return None + current = pagination.select_one(".md-pagination__current") + return { + "current": int(_text(current) or "1"), + "links": [ + _link(link, base) + for link in pagination.select("a.md-pagination__link") + ], + } + + +def _page(path: Path, *, engine: Engine) -> dict[str, Any]: + """Extract the stable, user-visible facts from one generated page.""" + soup = BeautifulSoup(path.read_text(encoding="utf-8"), "lxml") + canonical = soup.select_one('link[rel="canonical"]') + canonical_url = _attribute(canonical, "href") if canonical else None + base = str(canonical_url or "https://example.test/") + primary_nav = soup.select_one("nav.md-nav--primary") + main = soup.select_one("article.md-content__inner") + relations = {} + for name in ("prev", "next"): + relation = soup.select_one(f'head link[rel="{name}"]') + relations[name] = ( + _url(base, _attribute(relation, "href")) if relation else None + ) + headings = ( + [ + { + "level": int(heading.name[1]), + "id": heading.get("id"), + "title": ( + _heading_text(heading) + if engine is Engine.ZENSICAL + else _text(heading) + ), + } + for heading in main.select("h1, h2, h3, h4, h5, h6") + ] + if main + else [] + ) + links = ( + [ + _link(link, base) + for link in main.select("a[href]") + if not _has_class(link, "headerlink") + ] + if main + else [] + ) + return { + "document_title": _text(soup.title), + "canonical": _url(base, canonical_url) if canonical_url else None, + "relations": relations, + "active_ancestors": _active_ancestors(primary_nav), + "navigation": _nav_items(primary_nav, base) if primary_nav else [], + "headings": headings, + "links": links, + "posts": _posts(soup, base, engine=engine), + "pagination": _pagination(soup, base), + } + + +def _heading_text(heading: Tag) -> str | None: + """Return visible heading text without permalink controls.""" + clone = BeautifulSoup(str(heading), "lxml").find(heading.name) + if clone is None: + return None + for permalink in clone.select(".headerlink"): + permalink.decompose() + return _text(clone) + + +def extract(site: Path, *, engine: Engine) -> dict[str, Any]: + """Create a deterministic manifest for a built fixture.""" + pages = { + path.relative_to(site).as_posix(): _page(path, engine=engine) + for path in sorted(site.rglob("*.html")) + if path.name != "404.html" + } + root = pages.get("index.html") or next(iter(pages.values()), {}) + navigation = root.get("navigation", []) + for page in pages.values(): + page.pop("navigation") + outputs = [ + path.relative_to(site).as_posix() + for path in sorted(site.rglob("*")) + if path.is_file() + and ( + path.suffix == ".html" + or not path.relative_to(site).as_posix().startswith("assets/") + ) + and path.name not in {"sitemap.xml", "sitemap.xml.gz"} + and not ( + engine is Engine.ZENSICAL + and ( + path.name + in { + "__init__.py", + "mkdocs_theme.yml", + "objects.inv", + "search.json", + } + or "__pycache__" in path.parts + ) + ) + ] + return {"outputs": outputs, "navigation": navigation, "pages": pages} + + +def _fixture_names(selected: Iterable[str] | None) -> list[str]: + """Resolve and validate the requested fixtures.""" + available = sorted( + path.name + for path in FIXTURES.iterdir() + if path.is_dir() and (path / "mkdocs.yml").is_file() + ) + names = list(selected or available) + unknown = sorted(set(names) - set(available)) + if unknown: + raise ValueError(f"unknown blog fixtures: {', '.join(unknown)}") + return names + + +def build( + executable: Path, + fixture: Path, + destination: Path, + *, + engine: Engine, +) -> None: + """Build one fixture with the supplied reference environment.""" + config = fixture / "mkdocs.yml" + if engine is Engine.ZENSICAL: + source = config.read_text(encoding="utf-8") + source = re.sub(r"(?m)^site_dir:.*\n", "", source) + source += f"\nsite_dir: {destination.relative_to(fixture)}\n" + config.write_text(source, encoding="utf-8") + command = [ + str(executable), + "build", + "--clean", + "--strict", + "--config-file", + str(config), + ] + else: + command = [ + str(executable), + "build", + "--clean", + "--strict", + "--config-file", + str(config), + "--site-dir", + str(destination), + ] + subprocess.run( + command, + check=True, + ) + + +def apply_mutation(fixture: Path, step: dict[str, Any]) -> None: + """Apply one declarative mutation to a copied fixture.""" + for source, target in step.get("copy", {}).items(): + destination = fixture / target + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(fixture / source, destination) + for target in step.get("remove", []): + path = fixture / target + if path.is_file(): + path.unlink() + + +def run_fixture( + executable: Path, + fixture: Path, + root: Path, + *, + engine: Engine, +) -> dict[str, Any]: + """Build one fixture or its ordered clean-build mutation sequence.""" + scenario = fixture / "scenario.json" + if not scenario.is_file(): + destination = ( + fixture / "site" + if engine is Engine.ZENSICAL + else root / f"{fixture.name}-site" + ) + build( + executable, + fixture, + destination, + engine=engine, + ) + return extract(destination, engine=engine) + + steps = json.loads(scenario.read_text(encoding="utf-8")) + snapshots = [] + for number, step in enumerate(steps): + apply_mutation(fixture, step) + destination = ( + fixture / "site" + if engine is Engine.ZENSICAL + else root / f"{fixture.name}-{number:02d}-site" + ) + build( + executable, + fixture, + destination, + engine=engine, + ) + snapshots.append( + { + "name": step["name"], + "manifest": extract(destination, engine=engine), + } + ) + return {"steps": snapshots} + + +def compare( + name: str, + actual: dict[str, Any], + *, + update: bool, + engine: Engine, +) -> bool: + """Update or compare one checked-in semantic manifest.""" + expected_path = FIXTURES / name / "material.json" + rendered = json.dumps(actual, indent=2, ensure_ascii=False) + "\n" + if update: + expected_path.write_text(rendered, encoding="utf-8") + print(f"updated {expected_path.relative_to(ROOT)}") + return True + if not expected_path.is_file(): + print(f"missing {expected_path.relative_to(ROOT)}; run with --update") + return False + expected = json.loads(expected_path.read_text(encoding="utf-8")) + if engine is Engine.ZENSICAL: + expected = _normalize_generator_differences( + expected, + engine=Engine.MKDOCS, + ) + actual = _normalize_generator_differences( + actual, + engine=Engine.ZENSICAL, + ) + if expected == actual: + print(f"matched {name} with {engine.value}") + return True + engine_name = engine.value.lower() + temporary = ( + Path(tempfile.gettempdir()) / f"zensical-blog-{name}-{engine_name}.json" + ) + temporary.write_text(rendered, encoding="utf-8") + print( + f"mismatch for {name} with {engine.value}; actual manifest: {temporary}" + ) + return False + + +def _normalize_generator_differences( + manifest: dict[str, Any], + *, + engine: Engine, +) -> dict[str, Any]: + """Remove known non-blog differences between the two generators.""" + manifest = json.loads(json.dumps(manifest)) + + # Mutation fixtures contain complete manifests at each step. Normalize + # each snapshot through the same path as an ordinary fixture. + for step in manifest.get("steps", []): + step["manifest"] = _normalize_generator_differences( + step["manifest"], + engine=engine, + ) + + root = manifest.get("pages", {}).get("index.html") + if root is not None: + root.pop("document_title", None) + for path, page in manifest.get("pages", {}).items(): + base = page.get("canonical") or "https://example.test/" + if engine is Engine.ZENSICAL: + for heading in page.get("headings", []): + if heading.get("id") == "__skip": + heading["id"] = None + if page.get("pagination") == {"current": 1, "links": []}: + page["pagination"] = None + if page.get("posts"): + page.pop("relations", None) + if engine is Engine.ZENSICAL and "/page/" in path: + # Zensical reuses the logical view's navigation position for + # pagination pages. Material leaves that final item inactive, + # while retaining any containing section as active. + page["active_ancestors"] = page.get("active_ancestors", [])[:-1] + for post in page.get("posts", []): + content = post.get("content") + if not content: + continue + soup = BeautifulSoup(content, "lxml") + element = ( + soup.body.find(recursive=False) if soup.body else soup.find() + ) + post["content"] = _fragment( + element, + base, + normalize_urls=True, + ) + return manifest + + +def main() -> int: + """Build selected fixtures and compare their manifests.""" + args = parse_args() + mkdocs = args.mkdocs.resolve() + if not mkdocs.is_file(): + raise FileNotFoundError(mkdocs) + zensical = args.zensical.resolve() if args.zensical else None + if zensical and not zensical.is_file(): + raise FileNotFoundError(zensical) + succeeded = True + with tempfile.TemporaryDirectory( + prefix="zensical-blog-compatibility-" + ) as raw: + root = Path(raw) + for name in _fixture_names(args.fixtures): + fixture = root / f"{name}-mkdocs" + shutil.copytree(FIXTURES / name, fixture) + manifest = run_fixture( + mkdocs, + fixture, + root, + engine=Engine.MKDOCS, + ) + succeeded &= compare( + name, + manifest, + update=args.update, + engine=Engine.MKDOCS, + ) + if zensical is None: + continue + if name == "collisions": + print( + "skipped collisions with Zensical: diagnostics are expected" + ) + continue + fixture = root / f"{name}-zensical" + shutil.copytree(FIXTURES / name, fixture) + manifest = run_fixture( + zensical, + fixture, + root, + engine=Engine.ZENSICAL, + ) + succeeded &= compare( + name, + manifest, + update=False, + engine=Engine.ZENSICAL, + ) + return 0 if succeeded else 1 + + +if __name__ == "__main__": + raise SystemExit(main())