mirror of
https://github.com/zensical/zensical.git
synced 2026-09-24 23:35:35 +00:00
refactor: separate navigation title resolution
Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
@@ -37,7 +37,7 @@ use zrx::stream::{Key, Signal, Stream, Value};
|
||||
use crate::config::plugins::AwesomeNavLogs;
|
||||
use crate::config::Config;
|
||||
use crate::path::SourcePath;
|
||||
use crate::structure::nav::Navigation;
|
||||
use crate::structure::nav::NavigationResolution;
|
||||
use crate::structure::page::Page;
|
||||
use crate::watcher::Source;
|
||||
|
||||
@@ -148,7 +148,7 @@ impl AwesomeNav {
|
||||
/// Installs control-file discovery, settlement and navigation compilation.
|
||||
pub fn setup(
|
||||
&self, dependencies: Dependencies<'_>,
|
||||
) -> Signal<Id, Navigation> {
|
||||
) -> Signal<Id, NavigationResolution> {
|
||||
let settings = self.settings.clone();
|
||||
let documents = dependencies.sources.filter_map({
|
||||
let settings = settings.clone();
|
||||
@@ -198,9 +198,11 @@ impl AwesomeNav {
|
||||
Ok::<_, anyhow::Error>(navigation)
|
||||
},
|
||||
);
|
||||
navigation.reduce(|navigation: &dyn Collection<Key<Id>, Navigation>| {
|
||||
navigation.values().next().cloned()
|
||||
})
|
||||
navigation.reduce(
|
||||
|navigation: &dyn Collection<Key<Id>, NavigationResolution>| {
|
||||
navigation.values().next().cloned()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use crate::structure::dynamic::Dynamic;
|
||||
use crate::structure::nav::{
|
||||
source_sort_key, to_title, Navigation, Plan, PlanItem,
|
||||
source_sort_key, to_title, NavigationResolution, Plan, PlanItem,
|
||||
};
|
||||
use crate::structure::page::Page;
|
||||
|
||||
@@ -233,7 +233,7 @@ impl<'a> Resolver<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(mut self) -> Result<(Navigation, Vec<Diagnostic>)> {
|
||||
fn resolve(mut self) -> Result<(NavigationResolution, Vec<Diagnostic>)> {
|
||||
let config = self.directory_config(".")?.clone();
|
||||
if !self.settings.configured.is_empty() {
|
||||
self.diagnostic(
|
||||
@@ -813,7 +813,7 @@ fn components(path: &str) -> usize {
|
||||
/// Resolves native awesome-nav configuration and returns diagnostics.
|
||||
pub fn resolve(
|
||||
settings: &Settings, documents: &BTreeMap<String, String>, pages: &[Page],
|
||||
) -> Result<(Navigation, Vec<Diagnostic>)> {
|
||||
) -> Result<(NavigationResolution, Vec<Diagnostic>)> {
|
||||
Resolver::new(settings, documents, pages)
|
||||
.resolve()
|
||||
.context("failed to resolve awesome navigation")
|
||||
|
||||
@@ -36,7 +36,7 @@ use zrx::stream::{Key, Signal, Stream, Value};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::path::SourcePath;
|
||||
use crate::structure::nav::{Navigation, NavigationItem};
|
||||
use crate::structure::nav::{Navigation, NavigationItem, NavigationResolution};
|
||||
use crate::structure::page::Page;
|
||||
use crate::watcher::Source;
|
||||
|
||||
@@ -127,7 +127,7 @@ impl LiterateNav {
|
||||
/// Installs navigation discovery, settlement, and compilation.
|
||||
pub fn setup(
|
||||
&self, dependencies: Dependencies<'_>,
|
||||
) -> Signal<Id, Navigation> {
|
||||
) -> Signal<Id, NavigationResolution> {
|
||||
let settings = self.settings.clone();
|
||||
let documents = dependencies.sources.filter_map({
|
||||
let settings = settings.clone();
|
||||
@@ -177,16 +177,18 @@ impl LiterateNav {
|
||||
if settings.enabled {
|
||||
resolver::resolve(&settings, &docs.0, pages.0.as_ref())
|
||||
} else {
|
||||
Ok(Navigation::new(
|
||||
Ok(Navigation::resolve(
|
||||
settings.configured.clone(),
|
||||
pages.0.as_ref().clone(),
|
||||
))
|
||||
}
|
||||
},
|
||||
);
|
||||
navigation.reduce(|navigation: &dyn Collection<Key<Id>, Navigation>| {
|
||||
navigation.values().next().cloned()
|
||||
})
|
||||
navigation.reduce(
|
||||
|navigation: &dyn Collection<Key<Id>, NavigationResolution>| {
|
||||
navigation.values().next().cloned()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use crate::path::SourcePath;
|
||||
use crate::structure::markdown::render_literate_nav;
|
||||
use crate::structure::nav::{
|
||||
source_sort_key, to_title, Navigation, NavigationItem, Plan, PlanItem,
|
||||
source_sort_key, to_title, NavigationItem, NavigationResolution, Plan,
|
||||
PlanItem,
|
||||
};
|
||||
use crate::structure::page::Page;
|
||||
|
||||
@@ -465,7 +466,7 @@ impl<'a> Resolver<'a> {
|
||||
/// Resolves native literate navigation and attaches rendered page facts.
|
||||
pub fn resolve(
|
||||
settings: &Settings, documents: &BTreeMap<String, String>, pages: &[Page],
|
||||
) -> Result<Navigation> {
|
||||
) -> Result<NavigationResolution> {
|
||||
let plan = Resolver::new(settings, documents, pages).resolve()?;
|
||||
Ok(plan.compile(pages.to_vec()))
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@ pub struct MarkdownData {
|
||||
pub meta: BTreeMap<String, Dynamic>,
|
||||
/// Markdown content.
|
||||
pub content: String,
|
||||
/// Page title extracted from Markdown.
|
||||
pub title: String,
|
||||
/// Table of contents.
|
||||
pub toc: Vec<Section>,
|
||||
}
|
||||
@@ -78,8 +76,6 @@ struct RenderedMarkdown {
|
||||
meta: BTreeMap<String, Dynamic>,
|
||||
/// Markdown content.
|
||||
content: String,
|
||||
/// Page title extracted from Markdown.
|
||||
title: String,
|
||||
/// Table of contents.
|
||||
toc: Vec<Section>,
|
||||
}
|
||||
@@ -93,7 +89,7 @@ impl Markdown {
|
||||
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
|
||||
pub fn new(
|
||||
id: &Id, url: String, content: String, meta: BTreeMap<String, Dynamic>,
|
||||
) -> Result<Markdown> {
|
||||
) -> Result<(Markdown, String)> {
|
||||
let id = id.clone();
|
||||
let meta = serde_json::to_string(&meta)?;
|
||||
let res = Python::attach(|py| {
|
||||
@@ -105,14 +101,13 @@ impl Markdown {
|
||||
.map_err(python_error);
|
||||
|
||||
res.map(|data| {
|
||||
let mut data = MarkdownData {
|
||||
let data = MarkdownData {
|
||||
meta: data.meta,
|
||||
content: data.content,
|
||||
title: data.title,
|
||||
toc: data.toc,
|
||||
};
|
||||
data.title = extract_title(&id, &data);
|
||||
Markdown { data: Arc::new(data) }
|
||||
let title = extract_title(&id, &data);
|
||||
(Markdown { data: Arc::new(data) }, title)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -239,7 +234,6 @@ mod tests {
|
||||
data: Arc::new(MarkdownData {
|
||||
meta: BTreeMap::new(),
|
||||
content: String::from("<h1>Home</h1>"),
|
||||
title: String::from("Home"),
|
||||
toc: Vec::new(),
|
||||
}),
|
||||
}
|
||||
@@ -258,12 +252,11 @@ mod tests {
|
||||
let value = serde_json::to_value(markdown()).unwrap();
|
||||
|
||||
assert_eq!(value["content"], "<h1>Home</h1>");
|
||||
assert_eq!(value["title"], "Home");
|
||||
assert!(value.get("title").is_none());
|
||||
assert!(value.get("data").is_none());
|
||||
assert!(value.get("search").is_none());
|
||||
|
||||
let markdown: Markdown = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(markdown.content, "<h1>Home</h1>");
|
||||
assert_eq!(markdown.title, "Home");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
//! Navigation.
|
||||
|
||||
use ahash::HashMap;
|
||||
use ahash::{HashMap, HashSet};
|
||||
use pyo3::types::{PyAny, PyAnyMethods};
|
||||
use pyo3::{Bound, FromPyObject, PyResult};
|
||||
use serde::Serialize;
|
||||
@@ -72,6 +72,17 @@ pub struct Navigation {
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Navigation together with page facts established while resolving it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NavigationResolution {
|
||||
/// Template-facing navigation.
|
||||
pub navigation: Navigation,
|
||||
/// Explicit titles from the first navigation occurrence of each page.
|
||||
title_overrides: Arc<HashMap<SourcePath, String>>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Implementations
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -79,14 +90,26 @@ pub struct Navigation {
|
||||
impl Navigation {
|
||||
/// Creates a navigation from the given items.
|
||||
pub fn new(items: Vec<NavigationItem>, pages: Vec<Page>) -> Self {
|
||||
Self::resolve(items, pages).navigation
|
||||
}
|
||||
|
||||
/// Resolves navigation and retains page facts needed by the workflow.
|
||||
pub fn resolve(
|
||||
items: Vec<NavigationItem>, pages: Vec<Page>,
|
||||
) -> NavigationResolution {
|
||||
if items.is_empty() {
|
||||
return Self::from(pages);
|
||||
return NavigationResolution {
|
||||
navigation: Self::from(pages),
|
||||
title_overrides: Arc::default(),
|
||||
};
|
||||
}
|
||||
Self::from_plan(items, pages)
|
||||
}
|
||||
|
||||
/// Creates navigation from an explicit plan, including an empty one.
|
||||
fn from_plan(mut items: Vec<NavigationItem>, pages: Vec<Page>) -> Self {
|
||||
fn from_plan(
|
||||
mut items: Vec<NavigationItem>, pages: Vec<Page>,
|
||||
) -> NavigationResolution {
|
||||
// Create a map of pages for easy lookup, so we can resolve titles and
|
||||
// icons from the file location of the respective page.
|
||||
let pages = pages
|
||||
@@ -94,76 +117,50 @@ impl Navigation {
|
||||
.map(|page| (page.source().to_string(), page))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
// Since a navigation structure is given, we just need to add titles and
|
||||
// icons where necessary and defined in page metadata
|
||||
let mut stack = vec![&mut items];
|
||||
while let Some(children) = stack.pop() {
|
||||
for item in children.iter_mut() {
|
||||
// Here, we differ from MkDocs, in that navigation items can or
|
||||
// cannot have URLs, since we model sections and pages with the
|
||||
// same data type. This is definitely not the final design that
|
||||
// we want, and we'll switch to a much more flexible approach
|
||||
// once we work on modular navigation. The component system
|
||||
// will also make things much easier here.
|
||||
if let Some(url) = &item.url {
|
||||
// Try to obtain a page for the given url. Users might also
|
||||
// refer to non-existing pages, which we just ignore for now
|
||||
if let Some(page) = pages.get(url) {
|
||||
// Set URLs from page - we currently resolve the final
|
||||
// URL during rendering, so we just need to set it here.
|
||||
// Once we start working on the component and module
|
||||
// system, all of this is going to change anyway
|
||||
item.url = Some(page.url.clone());
|
||||
item.canonical_url = page.canonical_url.clone();
|
||||
// Since a navigation structure is given, attach page facts and retain
|
||||
// the explicit title from the first occurrence of each resolved page.
|
||||
let mut seen = HashSet::default();
|
||||
let mut title_overrides = HashMap::default();
|
||||
resolve_items(&mut items, &pages, &mut seen, &mut title_overrides);
|
||||
|
||||
// Set item title from page if not set
|
||||
if item.title.is_none() {
|
||||
item.title = Some(page.title.clone());
|
||||
}
|
||||
|
||||
// Extract page metadata for selected keys
|
||||
item.meta = Some(page.meta.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Push children onto the stack for further processing
|
||||
if !item.children.is_empty() {
|
||||
stack.push(&mut item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine homepage - here, we mirror MkDocs behavior, which only
|
||||
// considers index pages at the root level as potential homepages
|
||||
let mut homepage = items.iter().find(|item| item.is_index).cloned();
|
||||
if homepage.is_none() {
|
||||
// However, if we couldn't find anything, but there's still an index
|
||||
// page, we check if it's out of navigation, and if so, use it
|
||||
if let Some(page) = pages.get("index.md")
|
||||
&& !Iter::new(&items)
|
||||
.any(|item| item.url.as_deref() == Some(&page.url))
|
||||
{
|
||||
homepage = Some(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: true,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
// A homepage must be a root index source, not merely an index page at
|
||||
// the top navigation level. This mirrors MkDocs' URL constraint and
|
||||
// prevents nested index pages and external links from becoming home.
|
||||
let home = pages.get("index.md").or_else(|| pages.get("README.md"));
|
||||
let mut homepage = home.and_then(|page| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.url.as_deref() == Some(&page.url))
|
||||
.cloned()
|
||||
});
|
||||
if homepage.is_none()
|
||||
&& let Some(page) = home
|
||||
&& !Iter::new(&items)
|
||||
.any(|item| item.url.as_deref() == Some(&page.url))
|
||||
{
|
||||
homepage = Some(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: true,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Precompute hash
|
||||
let hash = navigation_hash(&items);
|
||||
|
||||
// Return navigation
|
||||
Self {
|
||||
items: Arc::new(items),
|
||||
homepage,
|
||||
hash,
|
||||
generation: 0,
|
||||
NavigationResolution {
|
||||
navigation: Self {
|
||||
items: Arc::new(items),
|
||||
homepage,
|
||||
hash,
|
||||
generation: 0,
|
||||
},
|
||||
title_overrides: Arc::new(title_overrides),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +251,17 @@ impl Value for Navigation {}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
impl NavigationResolution {
|
||||
/// Returns the explicit title assigned to the page's first occurrence.
|
||||
pub fn title(&self, source: &SourcePath) -> Option<&str> {
|
||||
self.title_overrides.get(source).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
impl Value for NavigationResolution {}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
impl From<Vec<Page>> for Navigation {
|
||||
/// Creates a navigation from pages.
|
||||
///
|
||||
@@ -347,6 +355,42 @@ impl<'a> IntoIterator for &'a Navigation {
|
||||
// Functions
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Attaches page facts while preserving first-occurrence title provenance.
|
||||
fn resolve_items(
|
||||
items: &mut [NavigationItem], pages: &HashMap<String, Page>,
|
||||
seen: &mut HashSet<SourcePath>,
|
||||
title_overrides: &mut HashMap<SourcePath, String>,
|
||||
) {
|
||||
for item in items {
|
||||
// Here, we differ from MkDocs, in that navigation items can or cannot
|
||||
// have URLs, since we model sections and pages with the same data type.
|
||||
if let Some(url) = &item.url
|
||||
&& let Some(page) = pages.get(url)
|
||||
{
|
||||
let source = page.source().clone();
|
||||
if seen.insert(source.clone())
|
||||
&& let Some(title) = &item.title
|
||||
{
|
||||
title_overrides.insert(source, title.clone());
|
||||
}
|
||||
|
||||
item.url = Some(page.url.clone());
|
||||
item.canonical_url = page.canonical_url.clone();
|
||||
item.is_index = is_index(page.source().file_name());
|
||||
if item.title.is_none() {
|
||||
item.title = Some(page.title.clone());
|
||||
}
|
||||
item.meta = Some(page.meta.clone());
|
||||
}
|
||||
|
||||
// Resolve children in pre-order, matching MkDocs' first-page
|
||||
// occurrence semantics for duplicate references.
|
||||
resolve_items(&mut item.children, pages, seen, title_overrides);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Returns the MkDocs navigation sort key for one validated source path.
|
||||
pub fn source_sort_key(source: &SourcePath) -> (Vec<String>, bool, String) {
|
||||
let file = source.file_name().to_owned();
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
//! Navigation plans before rendered page facts are attached.
|
||||
|
||||
use super::{is_index, Navigation, NavigationItem};
|
||||
use super::{Navigation, NavigationItem, NavigationResolution};
|
||||
use crate::structure::page::Page;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -73,7 +73,7 @@ impl Plan {
|
||||
}
|
||||
|
||||
/// Attaches rendered page facts and creates the final navigation.
|
||||
pub fn compile(self, pages: Vec<Page>) -> Navigation {
|
||||
pub fn compile(self, pages: Vec<Page>) -> NavigationResolution {
|
||||
Navigation::from_plan(
|
||||
self.items.into_iter().map(PlanItem::into_item).collect(),
|
||||
pages,
|
||||
@@ -97,7 +97,7 @@ impl PlanItem {
|
||||
match self {
|
||||
Self::Reference { title, target } => NavigationItem {
|
||||
title,
|
||||
is_index: is_index(&target),
|
||||
is_index: false,
|
||||
url: Some(target),
|
||||
canonical_url: None,
|
||||
meta: None,
|
||||
@@ -130,7 +130,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn lowers_references_sections_and_links() {
|
||||
let navigation = Plan::new(vec![
|
||||
let resolution = Plan::new(vec![
|
||||
PlanItem::reference(None, "index.md"),
|
||||
PlanItem::section(
|
||||
"Guide",
|
||||
@@ -139,21 +139,26 @@ mod tests {
|
||||
PlanItem::reference(Some("Start".into()), "guide.md"),
|
||||
],
|
||||
),
|
||||
PlanItem::reference(Some("Website".into()), "https://example.com"),
|
||||
PlanItem::reference(
|
||||
Some("Website".into()),
|
||||
"https://example.com/index.md",
|
||||
),
|
||||
])
|
||||
.compile(Vec::new());
|
||||
let navigation = resolution.navigation;
|
||||
|
||||
assert_eq!(navigation.items[0].url.as_deref(), Some("index.md"));
|
||||
assert!(navigation.items[0].is_index);
|
||||
assert!(!navigation.items[0].is_index);
|
||||
assert_eq!(navigation.items[1].title.as_deref(), Some("Guide"));
|
||||
assert!(navigation.items[1].children[0].is_index);
|
||||
assert!(!navigation.items[1].children[0].is_index);
|
||||
assert_eq!(
|
||||
navigation.items[1].children[1].title.as_deref(),
|
||||
Some("Start")
|
||||
);
|
||||
assert_eq!(
|
||||
navigation.items[2].url.as_deref(),
|
||||
Some("https://example.com")
|
||||
Some("https://example.com/index.md")
|
||||
);
|
||||
assert!(!navigation.items[2].is_index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
//! Page.
|
||||
|
||||
use minijinja::{context, Error, Value as TemplateValue};
|
||||
use serde::ser::{SerializeStruct, Serializer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -70,11 +69,13 @@ impl Value for PageRoute {}
|
||||
/// Page values are cloned by the scheduler as they fan out into navigation,
|
||||
/// search, validation, and rendering branches. Keeping the immutable payload
|
||||
/// behind an [`Arc`] makes those clones constant-sized.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PageData {
|
||||
/// Validated documentation-relative source used by internal consumers.
|
||||
#[serde(skip)]
|
||||
source: SourcePath,
|
||||
/// Validated site-relative output used by the writer.
|
||||
#[serde(skip)]
|
||||
destination: SitePath,
|
||||
/// Page target URL.
|
||||
pub url: String,
|
||||
@@ -87,6 +88,7 @@ pub struct PageData {
|
||||
/// Effective page title, including an explicit navigation title.
|
||||
pub title: String,
|
||||
/// Rendered Markdown shared with the upstream value.
|
||||
#[serde(flatten)]
|
||||
markdown: Markdown,
|
||||
}
|
||||
|
||||
@@ -146,7 +148,9 @@ impl PageRoute {
|
||||
impl Page {
|
||||
/// Creates a page.
|
||||
#[allow(clippy::similar_names)]
|
||||
pub fn new(config: &Config, route: PageRoute, markdown: Markdown) -> Page {
|
||||
pub fn new(
|
||||
config: &Config, route: PageRoute, markdown: Markdown, title: String,
|
||||
) -> Page {
|
||||
let path = config.output_root().join(&route.destination);
|
||||
let source = route.source;
|
||||
let destination = route.destination;
|
||||
@@ -191,7 +195,7 @@ impl Page {
|
||||
.to_str()
|
||||
.expect("configured output path is valid UTF-8")
|
||||
.into(),
|
||||
title: markdown.title.clone(),
|
||||
title,
|
||||
markdown,
|
||||
}),
|
||||
ancestors: Vec::new(),
|
||||
@@ -304,26 +308,6 @@ impl Value for Page {}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
impl Serialize for PageData {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("PageData", 8)?;
|
||||
state.serialize_field("url", &self.url)?;
|
||||
state.serialize_field("canonical_url", &self.canonical_url)?;
|
||||
state.serialize_field("edit_url", &self.edit_url)?;
|
||||
state.serialize_field("path", &self.path)?;
|
||||
state.serialize_field("meta", &self.meta)?;
|
||||
state.serialize_field("content", &self.content)?;
|
||||
state.serialize_field("title", &self.title)?;
|
||||
state.serialize_field("toc", &self.toc)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
impl PartialEq for PageData {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.url == other.url
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::ops::Deref;
|
||||
@@ -54,7 +53,7 @@ use crate::config::Config;
|
||||
use crate::path::{PathError, SitePath, SourcePath};
|
||||
use crate::python::{Anchors, Issues, References, SharedReferences};
|
||||
use crate::structure::markdown::Markdown;
|
||||
use crate::structure::nav::Navigation;
|
||||
use crate::structure::nav::{Navigation, NavigationResolution};
|
||||
use crate::structure::page::{Page, PageRoute};
|
||||
use crate::template::Template;
|
||||
use crate::watcher::Source;
|
||||
@@ -174,34 +173,6 @@ impl Value for PageRender {}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Effective navigation title indexed by page URL.
|
||||
#[derive(Clone, Debug)]
|
||||
struct PageTitles(Arc<HashMap<String, String>>);
|
||||
|
||||
impl Value for PageTitles {}
|
||||
|
||||
impl PageTitles {
|
||||
/// Indexes the first navigation occurrence of each page, like MkDocs.
|
||||
fn new(nav: &Navigation) -> Self {
|
||||
let mut titles = HashMap::new();
|
||||
for item in nav {
|
||||
if item.meta.is_some()
|
||||
&& let (Some(url), Some(title)) = (&item.url, &item.title)
|
||||
{
|
||||
titles.entry(url.clone()).or_insert_with(|| title.clone());
|
||||
}
|
||||
}
|
||||
Self(Arc::new(titles))
|
||||
}
|
||||
|
||||
/// Returns the effective title assigned to a page in navigation.
|
||||
fn get(&self, url: &str) -> Option<&str> {
|
||||
self.0.get(url).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Markdown source paired with route facts available before rendering.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct RoutedMarkdown {
|
||||
@@ -222,6 +193,8 @@ struct RenderedMarkdown {
|
||||
route: PageRoute,
|
||||
/// Rendered Markdown consumed by page construction.
|
||||
markdown: Markdown,
|
||||
/// Page title derived from metadata, Markdown, or source name.
|
||||
title: String,
|
||||
/// Page-local registrations consumed during site settlement.
|
||||
registrations: Arc<autorefs::Facts>,
|
||||
/// Facts extracted by the shared MkDocs-compatible HTML pass.
|
||||
@@ -287,32 +260,46 @@ impl Main {
|
||||
|
||||
// Construct pages before resolving navigation, which needs the titles
|
||||
// derived from Markdown for entries without an explicit title.
|
||||
let rendered_page = generate_page(&self.config, &rendered);
|
||||
let page =
|
||||
rendered_page.map(|rendered: &RenderedPage| rendered.page.clone());
|
||||
let provisional = generate_page(&self.config, &rendered);
|
||||
let provisional_page =
|
||||
provisional.map(|rendered: &RenderedPage| rendered.page.clone());
|
||||
// Autorefs only consumes registrations gathered during Markdown
|
||||
// rendering, so keep it independent of finalized navigation titles.
|
||||
let autorefs_input =
|
||||
provisional.map(|rendered: &RenderedPage| autorefs::PageInput {
|
||||
source: rendered.page.source().clone(),
|
||||
facts: rendered.registrations.clone(),
|
||||
});
|
||||
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 nav = if awesome_nav.is_enabled() {
|
||||
let resolution = if awesome_nav.is_enabled() {
|
||||
awesome_nav.setup(awesome_nav::Dependencies {
|
||||
sources: &sources,
|
||||
pages: &page,
|
||||
pages: &provisional_page,
|
||||
})
|
||||
} else {
|
||||
literate_nav::LiterateNav::new(&self.config).setup(
|
||||
literate_nav::Dependencies {
|
||||
sources: &sources,
|
||||
pages: &page,
|
||||
pages: &provisional_page,
|
||||
},
|
||||
)
|
||||
};
|
||||
let nav = resolution
|
||||
.map(|value: &NavigationResolution| value.navigation.clone());
|
||||
// MkDocs assigns configured navigation titles when constructing Page
|
||||
// objects, before metadata and Markdown fallbacks are evaluated. Our
|
||||
// navigation is resolved later, so apply that highest-precedence title
|
||||
// once the complete navigation is available.
|
||||
let rendered_page = apply_navigation_titles(&rendered_page, &nav);
|
||||
let rendered_page = apply_navigation_titles(&provisional, &resolution);
|
||||
let rendered_page = apply_tags(&plugins.tags, &rendered_page);
|
||||
let page =
|
||||
rendered_page.map(|rendered: &RenderedPage| rendered.page.clone());
|
||||
let site_page = rendered_page.map(|rendered: &RenderedPage| SitePage {
|
||||
page: rendered.page.clone(),
|
||||
autorefs: rendered.html.autorefs.clone(),
|
||||
@@ -326,14 +313,6 @@ impl Main {
|
||||
)
|
||||
})
|
||||
});
|
||||
let autorefs_input =
|
||||
rendered_page.map(|rendered: &RenderedPage| autorefs::PageInput {
|
||||
source: rendered.page.source().clone(),
|
||||
facts: rendered.registrations.clone(),
|
||||
});
|
||||
let autorefs = plugins
|
||||
.autorefs
|
||||
.setup(autorefs::Dependencies { pages: &autorefs_input });
|
||||
plugins.search.setup(search::Dependencies {
|
||||
documents: &search_document,
|
||||
navigation: &nav,
|
||||
@@ -359,13 +338,13 @@ impl Main {
|
||||
|
||||
/// Applies explicit navigation titles to their pages.
|
||||
fn apply_navigation_titles(
|
||||
pages: &Stream<Id, RenderedPage>, nav: &Signal<Id, Navigation>,
|
||||
pages: &Stream<Id, RenderedPage>,
|
||||
resolution: &Signal<Id, NavigationResolution>,
|
||||
) -> Stream<Id, RenderedPage> {
|
||||
let titles = nav.map(PageTitles::new);
|
||||
pages.product(&titles).map(
|
||||
|rendered: &RenderedPage, titles: &PageTitles| {
|
||||
pages.product(resolution).map(
|
||||
|rendered: &RenderedPage, resolution: &NavigationResolution| {
|
||||
let mut rendered = rendered.clone();
|
||||
if let Some(title) = titles.get(&rendered.page.url) {
|
||||
if let Some(title) = resolution.title(rendered.page.source()) {
|
||||
rendered.page.apply_navigation_title(title);
|
||||
}
|
||||
rendered
|
||||
@@ -545,13 +524,14 @@ fn render_markdown(
|
||||
id: &Id, route: PageRoute, content: String, plugins: plugin::Settings,
|
||||
meta: meta::Resolved,
|
||||
) -> anyhow::Result<RenderedMarkdown> {
|
||||
let mut markdown =
|
||||
let (mut markdown, title) =
|
||||
Markdown::new(id, route.url.clone(), content, meta.values())?;
|
||||
let html = plugin::prepare(&mut markdown, &route.source, &plugins)?;
|
||||
let registrations = plugins.autorefs.take_page(&route.url);
|
||||
Ok(RenderedMarkdown {
|
||||
route,
|
||||
markdown,
|
||||
title,
|
||||
registrations,
|
||||
html,
|
||||
})
|
||||
@@ -567,6 +547,7 @@ fn generate_page(
|
||||
&config,
|
||||
markdown.route.clone(),
|
||||
markdown.markdown.clone(),
|
||||
markdown.title.clone(),
|
||||
),
|
||||
registrations: markdown.registrations.clone(),
|
||||
html: markdown.html.clone(),
|
||||
@@ -718,45 +699,9 @@ pub fn create_workflow(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use zrx::id::Id;
|
||||
|
||||
use crate::structure::nav::{Navigation, NavigationItem};
|
||||
|
||||
use super::{template_output, PageTitles};
|
||||
|
||||
fn item(title: &str, url: &str, page: bool) -> NavigationItem {
|
||||
NavigationItem {
|
||||
title: Some(title.into()),
|
||||
url: Some(url.into()),
|
||||
canonical_url: None,
|
||||
meta: page.then(BTreeMap::new),
|
||||
children: Vec::new(),
|
||||
is_index: false,
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_titles_index_first_page_occurrence_and_ignores_links() {
|
||||
let navigation = Navigation {
|
||||
items: Arc::new(vec![
|
||||
item("First", "page/", true),
|
||||
item("Second", "page/", true),
|
||||
item("Link", "link/", false),
|
||||
]),
|
||||
homepage: None,
|
||||
hash: 0,
|
||||
generation: 0,
|
||||
};
|
||||
|
||||
let titles = PageTitles::new(&navigation);
|
||||
|
||||
assert_eq!(titles.get("page/"), Some("First"));
|
||||
assert_eq!(titles.get("link/"), None);
|
||||
}
|
||||
use super::template_output;
|
||||
|
||||
#[test]
|
||||
fn template_outputs_use_logical_provider_identity() {
|
||||
|
||||
+51
-3
@@ -134,9 +134,7 @@ def test_navigation_title_precedes_metadata_and_heading(tmp_path: Path) -> None:
|
||||
config = _make_yml_project(
|
||||
tmp_path,
|
||||
yml_extra=(
|
||||
" custom_dir: overrides\n"
|
||||
"nav:\n"
|
||||
" - Configured title: index.md"
|
||||
" custom_dir: overrides\nnav:\n - Configured title: index.md"
|
||||
),
|
||||
)
|
||||
(tmp_path / "docs" / "index.md").write_text(
|
||||
@@ -151,6 +149,56 @@ def test_navigation_title_precedes_metadata_and_heading(tmp_path: Path) -> None:
|
||||
assert (tmp_path / "site" / "index.html").read_text() == "Configured title"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("navigation", "expected"),
|
||||
[
|
||||
(" - index.md\n - Later: index.md", "Hello"),
|
||||
(" - First: index.md\n - Second: index.md", "First"),
|
||||
],
|
||||
)
|
||||
def test_first_navigation_occurrence_owns_page_title(
|
||||
tmp_path: Path, navigation: str, expected: str
|
||||
) -> None:
|
||||
"""Duplicate pages retain the title assigned by their first occurrence."""
|
||||
config = _make_yml_project(
|
||||
tmp_path,
|
||||
yml_extra=(f" custom_dir: overrides\nnav:\n{navigation}"),
|
||||
)
|
||||
custom = _make_custom_dir(tmp_path)
|
||||
(custom / "main.html").write_text("{{ page.title }}", encoding="utf-8")
|
||||
|
||||
_build(config)
|
||||
|
||||
assert (tmp_path / "site" / "index.html").read_text() == expected
|
||||
|
||||
|
||||
def test_only_root_index_page_becomes_navigation_homepage(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Nested index paths and similarly named links are never the homepage."""
|
||||
config = _make_yml_project(
|
||||
tmp_path,
|
||||
yml_extra=(
|
||||
" custom_dir: overrides\n"
|
||||
"nav:\n"
|
||||
" - Guide: guide/index.md\n"
|
||||
" - Website: https://example.com/index.md"
|
||||
),
|
||||
)
|
||||
guide = tmp_path / "docs" / "guide"
|
||||
guide.mkdir()
|
||||
(guide / "index.md").write_text("# Guide\n", encoding="utf-8")
|
||||
custom = _make_custom_dir(tmp_path)
|
||||
(custom / "main.html").write_text(
|
||||
"{{ nav.homepage.title if nav.homepage else 'none' }}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
_build(config)
|
||||
|
||||
assert (tmp_path / "site" / "index.html").read_text() == "Hello"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme loading: both zensical.toml and mkdocs.yml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+34
@@ -140,6 +140,40 @@ plugins:
|
||||
]
|
||||
|
||||
|
||||
def test_explicit_page_title_precedes_metadata_and_heading(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Literate navigation supplies the same page-title precedence as MkDocs."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
overrides = tmp_path / "overrides"
|
||||
overrides.mkdir()
|
||||
(overrides / "main.html").write_text("{{ page.title }}", encoding="utf-8")
|
||||
(docs / "index.md").write_text(
|
||||
"---\ntitle: Metadata title\n---\n\n# Heading title\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(docs / "SUMMARY.md").write_text(
|
||||
"* [Configured title](index.md)\n", encoding="utf-8"
|
||||
)
|
||||
config = tmp_path / "mkdocs.yml"
|
||||
config.write_text(
|
||||
"""\
|
||||
site_name: Literate navigation
|
||||
theme:
|
||||
name: material
|
||||
custom_dir: overrides
|
||||
plugins:
|
||||
- literate-nav
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
assert (tmp_path / "site" / "index.html").read_text() == "Configured title"
|
||||
|
||||
|
||||
def test_resolves_configured_directory_through_nested_literate_nav(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
+24
@@ -145,6 +145,30 @@ def test_builds_listings_references_toc_and_search_without_export(
|
||||
assert not (tmp_path / "site" / "tags.json").exists()
|
||||
|
||||
|
||||
def test_navigation_titles_flow_through_listings_and_search(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Title-sensitive plugin outputs consume finalized page titles."""
|
||||
config = _write_project(tmp_path)
|
||||
with config.open("a", encoding="utf-8") as file:
|
||||
file.write(
|
||||
"nav:\n"
|
||||
" - Catalog: index.md\n"
|
||||
" - Configured Rust: guide/rust.md\n"
|
||||
" - Python: guide/python.md\n"
|
||||
)
|
||||
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
listing = (tmp_path / "site" / "index.html").read_text()
|
||||
search = json.loads((tmp_path / "site" / "search.json").read_text())
|
||||
assert "Configured Rust" in listing
|
||||
assert "Rust page" not in listing
|
||||
assert any(
|
||||
item["path"][-1] == "Configured Rust" for item in search["items"]
|
||||
)
|
||||
|
||||
|
||||
def test_inherits_tags_from_meta_file(tmp_path: Path) -> None:
|
||||
"""Tags supplied by Material meta participate in page tag mappings."""
|
||||
_write_project(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user