From acee89a5f8f58dbd8e146401e41fdd84d47f4624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Sat, 12 Sep 2026 15:36:22 +0200 Subject: [PATCH] feature: support autorefs settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timothée Mazzucotelli --- .../src/compat/mkdocs/plugin/autorefs.rs | 351 +++++++++++++++++- .../src/compat/mkdocs/plugin/autorefs/url.rs | 30 +- crates/zensical/src/config/plugins.rs | 42 +++ python/tests/integration/test_autorefs.py | 234 ++++++++++++ python/tests/unit/test_config.py | 23 +- python/tests/unit/test_plugin_config.py | 81 +++- python/zensical/config.py | 35 +- 7 files changed, 753 insertions(+), 43 deletions(-) create mode 100644 python/tests/integration/test_autorefs.py diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs index f0dcf75..c530379 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs @@ -26,10 +26,13 @@ //! MkDocs-compatible autorefs plugin. use ahash::{HashMap, HashSet}; +use html5gum::emitters::callback::{CallbackEmitter, CallbackEvent}; +use html5gum::{Span, Tokenizer}; use pyo3::types::PyAnyMethods; use pyo3::{FromPyObject, Python}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::convert::Infallible; use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::PathBuf; use std::string::ToString; @@ -40,6 +43,7 @@ use zrx::stream::function::Collection; use zrx::stream::{Key, Signal, Stream, Value}; use crate::compat::mkdocs::url::relative; +use crate::config::plugins::{AutorefsPluginConfig, AutorefsTitleSetting}; use crate::config::Config; use crate::path::SourcePath; use crate::structure::nav::{source_sort_key, Navigation, NavigationItem}; @@ -91,6 +95,61 @@ pub struct Autorefs { cache: PathBuf, /// Hash of configuration that can affect backlink collection. config_hash: u64, + /// Resolved URL selection and title rendering behavior. + settings: Settings, +} + +// ---------------------------------------------------------------------------- + +/// Effective behavior after resolving automatic title settings. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Settings { + resolve_closest: bool, + link_titles: LinkTitles, + strip_title_tags: bool, +} + +/// Links on which a generated title may be shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LinkTitles { + All, + External, + None, +} + +impl Settings { + fn new(config: &AutorefsPluginConfig, features: &[String]) -> Self { + let has_feature = |name| features.iter().any(|feature| feature == name); + Self { + resolve_closest: config.resolve_closest, + link_titles: match &config.link_titles { + AutorefsTitleSetting::Enabled(true) => LinkTitles::All, + AutorefsTitleSetting::Enabled(false) => LinkTitles::None, + AutorefsTitleSetting::Mode(mode) if mode == "external" => { + LinkTitles::External + } + AutorefsTitleSetting::Mode(_) => { + if has_feature("navigation.instant.preview") { + LinkTitles::External + } else { + LinkTitles::All + } + } + }, + strip_title_tags: match config.strip_title_tags { + AutorefsTitleSetting::Enabled(enabled) => enabled, + AutorefsTitleSetting::Mode(_) => { + !has_feature("content.tooltips") + } + }, + } + } +} + +impl Default for Settings { + fn default() -> Self { + Self::new(&AutorefsPluginConfig::default(), &[]) + } } // ---------------------------------------------------------------------------- @@ -266,6 +325,8 @@ pub struct Facts { /// (typically registered by loading inventories in mkdocstrings). #[derive(Clone, Debug, Default, PartialEq, Eq)] struct Resolver { + // URL selection and title behavior. + settings: Settings, // Primary URLs. primary: HashMap>, // Secondary URLs. @@ -285,11 +346,19 @@ struct Resolver { impl Autorefs { /// Resolves the private settings owned by this pipeline instance. pub fn new(config: &Config) -> Self { + let options = config + .project + .plugins + .autorefs + .as_ref() + .map(|plugin| plugin.config.clone()) + .unwrap_or_default(); Self { enabled: config.has_markdown_extension(EXTENSION_NAME), record_backlinks: config.records_backlinks(), cache: config.get_cache_dir(), config_hash: config.hash, + settings: Settings::new(&options, &config.project.theme.features), } } @@ -361,6 +430,7 @@ impl Autorefs { &self, facts: impl Iterator, ) -> Resolver { let mut registry = Resolver::new(); + registry.settings = self.settings.clone(); for facts in facts { registry.merge(facts); } @@ -734,7 +804,11 @@ impl Resolver { fn get_url_and_title_from_id( &self, identifier: &str, from_url: &str, ) -> Result<(String, Option), String> { - let mut url = self.get_url_from_id(identifier, from_url, true)?; + let mut url = self.get_url_from_id( + identifier, + from_url, + self.settings.resolve_closest, + )?; // Get title using URL as key (not identifier) let title = self.titles.get(&url).cloned(); @@ -898,18 +972,13 @@ impl Resolver { format!(" {}", remaining.join(" ")) }; - let tooltip = if optional { - original_title.as_deref().unwrap_or(identifier) - } else { - original_title.as_deref().unwrap_or_default() - }; - let title_attr = if !tooltip.is_empty() - && !format!("{title}").contains(tooltip) - { - format!(" title=\"{}\"", html_escape(tooltip)) - } else { - String::new() - }; + let title_attr = self.title_attribute( + identifier, + title, + original_title.as_deref(), + optional, + external, + ); format!( "{title}", @@ -935,6 +1004,46 @@ impl Resolver { } } + /// Builds a safely escaped title, preserving HTML only when configured. + fn title_attribute( + &self, identifier: &str, title: &str, original: Option<&str>, + optional: bool, external: bool, + ) -> String { + if self.settings.link_titles == LinkTitles::None + || self.settings.link_titles == LinkTitles::External && !external + { + return String::new(); + } + + let tooltip = if optional { + let identifier_text = html_escape(identifier); + let code = if self.settings.strip_title_tags { + identifier_text + } else { + format!("{identifier_text}") + }; + match original.filter(|title| !title.is_empty()) { + Some(title) if title.contains(identifier) => title.to_string(), + Some(title) => format!("{title} ({code})"), + None => code, + } + } else { + original.unwrap_or_default().to_string() + }; + + if tooltip.is_empty() + || format!("{title}").contains(&tooltip) + { + return String::new(); + } + let tooltip = if self.settings.strip_title_tags { + strip_html(&tooltip) + } else { + tooltip + }; + format!(" title=\"{}\"", html_escape(&tooltip)) + } + /// Expands page-local slots in one linear pass. fn replace_slots( &self, content: String, references: &References, from_url: &str, @@ -1137,6 +1246,23 @@ fn html_escape(text: &str) -> String { .replace('\'', "'") } +/// Strips title markup and decodes entities before HTML attribute escaping. +fn strip_html(input: &str) -> String { + let mut text = String::new(); + let mut emitter = + CallbackEmitter::new(|event: CallbackEvent<'_>, _: Span| { + if let CallbackEvent::String { value } = event { + text.push_str(&String::from_utf8_lossy(value)); + } + None:: + }); + emitter.naively_switch_states(true); + Tokenizer::new_with_emitter(input, emitter) + .finish() + .expect("string input is infallible"); + text +} + // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- @@ -1147,12 +1273,13 @@ mod tests { use std::sync::{Arc, OnceLock}; use crate::compat::mkdocs::html; + use crate::config::plugins::{AutorefsPluginConfig, AutorefsTitleSetting}; use crate::structure::nav::{Navigation, NavigationItem}; use crate::structure::toc::Section; use super::{ navigation_crumb, BacklinkCrumb, BacklinkData, BacklinkIndex, - BreadcrumbNode, Facts, Parser, References, Resolver, + BreadcrumbNode, Facts, Parser, References, Resolver, Settings, UnresolvedAutorefs, }; @@ -1202,6 +1329,202 @@ mod tests { .is_some()); } + #[test] + fn configured_primary_selection_preserves_secondary_resolution() { + let mut resolver = Resolver::new(); + let urls = vec!["elsewhere/#item".into(), "guide/near/#item".into()]; + resolver.primary.insert("item".into(), urls.clone()); + resolver.secondary.insert("alias".into(), urls); + + for (resolve_closest, expected) in + [(false, "../../elsewhere/#item"), (true, "../near/#item")] + { + resolver.settings.resolve_closest = resolve_closest; + assert_eq!( + resolver + .get_url_and_title_from_id("item", "guide/current/") + .unwrap() + .0, + expected, + ); + assert_eq!( + resolver + .get_url_and_title_from_id("alias", "guide/current/") + .unwrap() + .0, + "../near/#item", + ); + } + } + + #[test] + fn title_modes_apply_to_reference_slots() { + let input = concat!( + "Local", + "remote", + ); + for (mode, features, internal_title, external_title) in [ + (AutorefsTitleSetting::Enabled(true), vec![], true, true), + ( + AutorefsTitleSetting::Enabled(false), + vec!["navigation.instant.preview".into()], + false, + false, + ), + ( + AutorefsTitleSetting::Mode("external".into()), + vec![], + false, + true, + ), + ( + AutorefsTitleSetting::Mode("auto".into()), + vec![], + true, + true, + ), + ( + AutorefsTitleSetting::Mode("auto".into()), + vec!["navigation.instant.preview".into()], + false, + true, + ), + ] { + let mut resolver = Resolver::new(); + resolver.settings = Settings::new( + &AutorefsPluginConfig { + link_titles: mode, + ..Default::default() + }, + &features, + ); + resolver + .primary + .insert("local".into(), vec!["target/#local".into()]); + resolver + .titles + .insert("target/#local".into(), "Canonical local".into()); + resolver.inventory.insert( + "pkg.remote".into(), + "//example.com/#pkg.remote".into(), + ); + + let (content, references) = prepare(input); + let mut unresolved = UnresolvedAutorefs::default(); + let output = resolver.replace_slots( + content, + &references, + "guide/", + &mut unresolved, + ); + + assert_eq!( + output.contains("title=\"Canonical local\""), + internal_title + ); + assert_eq!(output.contains("title=\"pkg.remote\""), external_title); + assert!(output.contains("href=\"//example.com/#pkg.remote\"")); + assert!(output.contains("autorefs-external")); + assert!(unresolved.iter().next().is_none()); + } + } + + #[test] + fn title_html_modes_preserve_text_and_escape_attributes() { + let input = "Label"; + let rich = + "A rich & "quoted" title"; + let plain_attr = "title=\"A rich & "quoted" title\""; + let html_attr = "title=\"A <em>rich</em> &amp; &quot;quoted&quot; title<!-- hidden -->\""; + for (mode, features, expected) in [ + ( + AutorefsTitleSetting::Enabled(true), + vec!["content.tooltips".into()], + plain_attr, + ), + (AutorefsTitleSetting::Enabled(false), vec![], html_attr), + ( + AutorefsTitleSetting::Mode("auto".into()), + vec![], + plain_attr, + ), + ( + AutorefsTitleSetting::Mode("auto".into()), + vec!["content.tooltips".into()], + html_attr, + ), + ] { + let mut resolver = Resolver::new(); + resolver.settings = Settings::new( + &AutorefsPluginConfig { + strip_title_tags: mode, + ..Default::default() + }, + &features, + ); + resolver + .primary + .insert("item".into(), vec!["target/#item".into()]); + resolver.titles.insert("target/#item".into(), rich.into()); + + let (content, references) = prepare(input); + let mut unresolved = UnresolvedAutorefs::default(); + let output = resolver.replace_slots( + content, + &references, + "guide/", + &mut unresolved, + ); + + assert!(output.contains(expected), "{output}"); + assert!(output.ends_with(">Label")); + } + } + + #[test] + fn optional_titles_append_identifiers_and_suppress_redundancy() { + let mut resolver = Resolver::new(); + for (strip, expected) in [ + (true, " title=\"Canonical (pkg.item)\""), + ( + false, + " title=\"Canonical (<code>pkg.item</code>)\"", + ), + ] { + resolver.settings.strip_title_tags = strip; + assert_eq!( + resolver.title_attribute( + "pkg.item", + "item", + Some("Canonical"), + true, + false + ), + expected, + ); + assert_eq!( + resolver.title_attribute( + "pkg.item", + "pkg.item", + None, + true, + false + ), + "", + ); + assert_eq!( + resolver.title_attribute( + "pkg.item", + "Canonical", + Some("Canonical"), + false, + false + ), + "", + ); + } + } + fn prepare(input: &str) -> (String, References) { let mut parser = Parser::default(); let content = html::scan(input, &mut [&mut parser]) diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs index 79fc584..ede0d02 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs @@ -65,9 +65,17 @@ pub fn closest(from: &str, urls: &[String], _qualifier: &str) -> String { } } -/// Returns whether a URL has no HTTP(S) scheme. +/// Returns whether a URL has neither a scheme nor a network location. pub fn is_relative(url: &str) -> bool { - !(url.starts_with("http://") || url.starts_with("https://")) + if url.starts_with("//") { + return false; + } + !url.split_once(':').is_some_and(|(scheme, _)| { + scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') + }) + }) } /// Returns whether one URL path begins with another at a component boundary. @@ -103,9 +111,25 @@ fn parent(url: &str) -> Option { #[cfg(test)] mod tests { - use super::closest; + use super::{closest, is_relative}; use crate::compat::mkdocs::url::relative; + #[test] + fn distinguishes_external_url_forms() { + for url in [ + "https://example.com/", + "//example.com/", + "mailto:a@b.c", + "ftp://example.com/", + ] { + assert!(!is_relative(url), "{url}"); + } + for url in ["page/", "../page/", "#item", "page/#pkg:thing", "page/a:b"] + { + assert!(is_relative(url), "{url}"); + } + } + #[test] fn resolves_the_closest_url() { let cases = [ diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index 2d5dce1..6de8475 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -56,6 +56,10 @@ pub use tags::{ #[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] pub struct Plugins { + /// Autorefs plugin, when explicitly configured. + #[pyo3(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub autorefs: Option, /// Search plugin. pub search: SearchPlugin, /// Material meta plugin. @@ -80,6 +84,44 @@ pub struct Plugins { // ---------------------------------------------------------------------------- +/// Autorefs plugin. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct AutorefsPlugin { + /// Plugin configuration. + pub config: AutorefsPluginConfig, +} + +/// Autorefs configuration, also used when mkdocstrings enables it implicitly. +#[derive(Clone, Debug, Default, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct AutorefsPluginConfig { + /// Whether to select the closest of multiple primary targets. + pub resolve_closest: bool, + /// Whether link titles are enabled, automatic, or external-only. + pub link_titles: AutorefsTitleSetting, + /// Whether title HTML is stripped, or chosen based on theme features. + pub strip_title_tags: AutorefsTitleSetting, +} + +/// Boolean or named mode accepted by autorefs title settings. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[serde(untagged)] +pub enum AutorefsTitleSetting { + /// Explicitly enable or disable the setting. + Enabled(bool), + /// Automatic behavior, or external-only titles. + Mode(String), +} + +impl Default for AutorefsTitleSetting { + fn default() -> Self { + Self::Mode("auto".into()) + } +} + +// ---------------------------------------------------------------------------- + /// Awesome navigation plugin. #[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] diff --git a/python/tests/integration/test_autorefs.py b/python/tests/integration/test_autorefs.py new file mode 100644 index 0000000..02c4cc9 --- /dev/null +++ b/python/tests/integration/test_autorefs.py @@ -0,0 +1,234 @@ +# 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. + +"""Integration coverage for autorefs configuration and native rendering.""" + +from __future__ import annotations + +from html.parser import HTMLParser +from typing import TYPE_CHECKING, Any + +import pytest +import yaml + +import zensical + +if TYPE_CHECKING: + from pathlib import Path + + +class _Links(HTMLParser): + def __init__(self, content: str) -> None: + super().__init__(convert_charrefs=True) + self.links: list[dict[str, str | None]] = [] + self.feed(content) + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + attributes = dict(attrs) + if tag == "a" and "autorefs" in (attributes.get("class") or "").split(): + self.links.append(attributes) + + +def _write_project( + root: Path, + options: dict[str, Any] | None, + *, + features: tuple[str, ...] = (), +) -> Path: + docs = root / "docs" + guide = docs / "guide" + overrides = root / "overrides" + guide.mkdir(parents=True, exist_ok=True) + overrides.mkdir(exist_ok=True) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "a.md").write_text("# First {#shared}\n", encoding="utf-8") + (guide / "near.md").write_text("# Nearby {#shared}\n", encoding="utf-8") + (guide / "index.md").write_text( + "# Guide\n\n[Target][shared]\n\n" + 'API target\n', + encoding="utf-8", + ) + (overrides / "main.html").write_text("{{ page.content }}", encoding="utf-8") + extensions = ["attr_list"] + if options is None: + extensions.append("zensical.extensions.autorefs") + config = root / "mkdocs.yml" + config.write_text( + yaml.safe_dump( + { + "site_name": "Autorefs settings", + "theme": { + "custom_dir": str(overrides), + "features": list(features), + }, + "plugins": {"autorefs": options} if options is not None else {}, + "markdown_extensions": extensions, + } + ), + encoding="utf-8", + ) + return config + + +def _build_links( + root: Path, config: Path, *, clean: bool = True +) -> list[dict[str, str | None]]: + zensical.build(str(config), {"clean": clean, "strict": False}) + content = (root / "site" / "guide" / "index.html").read_text( + encoding="utf-8" + ) + return _Links(content).links + + +def test_resolve_closest_changes_between_builds(tmp_path: Path) -> None: + for index, (options, expected) in enumerate( + [ + ({}, "../a/#shared"), + ({"resolve_closest": True}, "near/#shared"), + ({"resolve_closest": False}, "../a/#shared"), + ] + ): + config = _write_project(tmp_path, options) + links = _build_links(tmp_path, config, clean=index == 0) + assert [link["href"] for link in links] == [expected, expected] + + +@pytest.mark.parametrize( + ("mode", "features", "has_title"), + [ + (True, (), True), + (True, ("navigation.instant.preview",), True), + (False, (), False), + ("external", (), False), + ("auto", (), True), + ("auto", ("navigation.instant.preview",), False), + ], +) +def test_link_titles( + tmp_path: Path, + mode: bool | str, + features: tuple[str, ...], + has_title: bool, +) -> None: + config = _write_project(tmp_path, {"link_titles": mode}, features=features) + links = _build_links(tmp_path, config) + assert len(links) == 2 + assert all(("title" in link) == has_title for link in links) + if has_title: + assert links[0]["title"] == "First" + + +@pytest.mark.parametrize( + ("mode", "features", "title"), + [ + (True, ("content.tooltips",), "First (shared)"), + (False, (), "First (shared)"), + ("auto", (), "First (shared)"), + ("auto", ("content.tooltips",), "First (shared)"), + ], +) +def test_strip_title_tags( + tmp_path: Path, + mode: bool | str, + features: tuple[str, ...], + title: str, +) -> None: + config = _write_project( + tmp_path, {"strip_title_tags": mode}, features=features + ) + links = _build_links(tmp_path, config) + assert links[1]["title"] == title + + +@pytest.mark.parametrize("preview", [False, True]) +def test_implicit_autorefs_uses_automatic_title_defaults( + tmp_path: Path, preview: bool +) -> None: + features = ("navigation.instant.preview",) if preview else () + config = _write_project(tmp_path, None, features=features) + links = _build_links(tmp_path, config) + assert len(links) == 2 + assert all(("title" in link) != preview for link in links) + + +@pytest.mark.parametrize("record_backlinks", [False, True]) +@pytest.mark.parametrize( + ("options", "href", "titles"), + [ + ( + { + "resolve_closest": True, + "link_titles": True, + "strip_title_tags": False, + }, + "near/#shared", + [ + "Nearby", + "Nearby (shared)", + "Nearby (shared)", + ], + ), + ( + {"resolve_closest": False, "link_titles": False}, + "../a/#shared", + [None, None, None], + ), + ( + { + "resolve_closest": False, + "link_titles": True, + "strip_title_tags": True, + }, + "../a/#shared", + ["First", "First (shared)", "First (shared)"], + ), + ], +) +def test_settings_apply_to_cached_and_template_references( + tmp_path: Path, + record_backlinks: bool, + options: dict[str, Any], + href: str, + titles: list[str | None], +) -> None: + # Exercise both registry paths with references in Markdown and a template. + config = _write_project(tmp_path, options) + project = yaml.safe_load(config.read_text(encoding="utf-8")) + project["markdown_extensions"].append( + {"zensical.extensions.autorefs": {"record_backlinks": record_backlinks}} + ) + config.write_text(yaml.safe_dump(project), encoding="utf-8") + reference = 'Template' + (tmp_path / "overrides" / "main.html").write_text( + "{{ page.content }}" + reference, + encoding="utf-8", + ) + + # The same settings must apply on the initial build and when reusing caches. + for clean in [True, False]: + links = _build_links(tmp_path, config, clean=clean) + + assert [link["href"] for link in links] == [href, href, href] + assert [link.get("title") for link in links] == titles diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index f428d67..3c0a975 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -279,7 +279,6 @@ class TestPluginShimming: self, tmp_path: Path ) -> None: plugins: dict[str, dict[str, Any]] = { - "autorefs": {}, "callouts": {}, "glightbox": {"auto": False}, "macros": {"render_by_default": False}, @@ -290,7 +289,6 @@ class TestPluginShimming: } baseline = self._parse_yaml(tmp_path, plugins=plugins) for name, options in { - "autorefs": {"link_titles": "external"}, "callouts": {"aliases": False, "breakless_lists": False}, "glightbox": {"slide_effect": "fade"}, "macros": {"force_render_paths": "guides/**"}, @@ -708,7 +706,26 @@ class TestPluginShimming: } config = self._parse_yaml(tmp_path, plugins={"autorefs": options}) assert AutorefsExtension.name in config["markdown_extensions"] - assert config["plugins"]["autorefs"]["config"] == {} + assert config["plugins"]["autorefs"]["config"] == options + + @pytest.mark.parametrize( + ("option", "value"), + [ + ("resolve_closest", True), + ("link_titles", False), + ("strip_title_tags", True), + ], + ) + def test_autorefs_settings_affect_rebuild_hash( + self, tmp_path: Path, option: str, value: bool + ) -> None: + baseline = self._parse_yaml(tmp_path, plugins={"autorefs": {}}) + config_file = tmp_path / "mkdocs.yml" + config_file.write_text( + _minimal_yaml(plugins={"autorefs": {option: value}}) + ) + configured = parse_config(str(config_file)) + assert configured["plugins_hash"] != baseline["plugins_hash"] def test_autorefs_disabled_not_added( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path diff --git a/python/tests/unit/test_plugin_config.py b/python/tests/unit/test_plugin_config.py index 12187bf..197a9ee 100644 --- a/python/tests/unit/test_plugin_config.py +++ b/python/tests/unit/test_plugin_config.py @@ -276,9 +276,6 @@ def test_rejects_invalid_blog_configuration(name: str, data: Any) -> None: @pytest.mark.parametrize( ("plugin", "option"), [ - ("autorefs", "resolve_closest"), - ("autorefs", "link_titles"), - ("autorefs", "strip_title_tags"), ("callouts", "aliases"), ("callouts", "breakless_lists"), ("callouts", "title_from_first_bold"), @@ -386,14 +383,32 @@ def test_silently_discards_unsupported_search_options( @pytest.mark.parametrize("name", SHIM_PLUGINS) def test_normalizes_null_shim_configuration(name: str) -> None: plugins = _convert_plugins({name: None}) - assert plugins[name]["config"] == {} + expected = ( + { + "resolve_closest": False, + "link_titles": "auto", + "strip_title_tags": "auto", + } + if name == "autorefs" + else {} + ) + assert plugins[name]["config"] == expected @pytest.mark.parametrize( ("name", "config"), [ - pytest.param("autorefs", {"enabled": False}, id="autorefs"), pytest.param("callouts", {"enabled": False}, id="callouts"), + pytest.param( + "autorefs", + { + "enabled": False, + "resolve_closest": True, + "link_titles": "external", + "strip_title_tags": False, + }, + id="autorefs", + ), pytest.param( "markdown-exec", {"enabled": False, "ansi": "off", "languages": ["python"]}, @@ -465,18 +480,50 @@ def test_accepts_supported_shim_options( assert plugins[name]["config"] == config +@pytest.mark.parametrize("plugin", ["autorefs", "material/autorefs"]) +@pytest.mark.parametrize( + ("option", "value"), + [ + ("resolve_closest", True), + ("resolve_closest", False), + ("link_titles", True), + ("link_titles", False), + ("link_titles", "auto"), + ("link_titles", "external"), + ("strip_title_tags", True), + ("strip_title_tags", False), + ("strip_title_tags", "auto"), + ], +) +def test_preserves_autorefs_settings( + plugin: str, option: str, value: Any +) -> None: + data = {option: value} + plugins = _convert_plugins({plugin: data}) + assert plugins["autorefs"]["config"][option] == value + assert data == {option: value} + + @pytest.mark.parametrize( "option", ["resolve_closest", "link_titles", "strip_title_tags"] ) -@pytest.mark.parametrize( - "value", [True, False, "auto", "external", 42, [], {}, None] -) -def test_silently_discards_unsupported_autorefs_options( - option: str, value: Any, capsys: pytest.CaptureFixture[str] -) -> None: - plugins = _convert_plugins({"autorefs": {"enabled": True, option: value}}) - assert plugins["autorefs"]["config"] == {"enabled": True} - assert capsys.readouterr().err == "" +@pytest.mark.parametrize("value", [0, 1, "invalid", [], {}]) +def test_rejects_invalid_autorefs_settings(option: str, value: Any) -> None: + with pytest.raises(ConfigurationError, match=f"autorefs {option} must be"): + _convert_plugins({"autorefs": {option: value}}) + + +def test_normalizes_null_autorefs_settings() -> None: + plugins = _convert_plugins( + { + "autorefs": { + "resolve_closest": None, + "link_titles": None, + "strip_title_tags": None, + } + } + ) + assert plugins == _convert_plugins({"autorefs": {}}) @pytest.mark.parametrize( @@ -535,6 +582,12 @@ def test_silently_discards_unsupported_autorefs_options( ), ("autorefs", {"enabled": "yes"}, "enabled must be a boolean"), ("callouts", {"enabled": "yes"}, "enabled must be a boolean"), + ("autorefs", {"resolve_closest": "auto"}, "resolve_closest must be"), + ( + "autorefs", + {"strip_title_tags": "external"}, + "strip_title_tags must be", + ), ("markdown-exec", {"ansi": "sometimes"}, "ansi must be"), ( "markdown-exec", diff --git a/python/zensical/config.py b/python/zensical/config.py index 8404e19..d3c84ea 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -117,12 +117,7 @@ DEFAULT_MARKDOWN_EXTENSIONS = { # Discard these before validation, hashing and forwarding to native modules or # Markdown extensions. Empty tuples mark plugins with no ignored options. _PLUGIN_UNSUPPORTED_OPTIONS = { - "autorefs": ( - # TODO: Configure native URL selection and link title rendering. - "resolve_closest", - "link_titles", - "strip_title_tags", - ), + "autorefs": (), "awesome-nav": (), "blog": (), "callouts": ( @@ -1980,11 +1975,33 @@ def _convert_plugins(value: Any, config: dict) -> dict: ) plugins["mike"] = mike - # Validate settings forwarded by the plugin-to-extension shims. + # Validate settings for plugins enabled through Markdown extensions. if "autorefs" in plugins: autorefs = plugins["autorefs"] - _reject_unknown_options("autorefs", autorefs, {"enabled"}) - _validate_boolean_options("autorefs", autorefs, ("enabled",)) + _reject_unknown_options( + "autorefs", + autorefs, + {"enabled", "resolve_closest", "link_titles", "strip_title_tags"}, + ) + set_default(autorefs, "resolve_closest", False) + set_default(autorefs, "link_titles", "auto") + set_default(autorefs, "strip_title_tags", "auto") + _validate_boolean_options( + "autorefs", autorefs, ("enabled", "resolve_closest") + ) + for name, modes in ( + ("link_titles", ("auto", "external")), + ("strip_title_tags", ("auto",)), + ): + setting = autorefs[name] + if not ( + isinstance(setting, bool) + or (isinstance(setting, str) and setting in modes) + ): + choices = ", ".join(repr(mode) for mode in modes) + raise ConfigurationError( + f"autorefs {name} must be a boolean or {choices}" + ) if "callouts" in plugins: callouts = plugins["callouts"]