diff --git a/crates/zensical/src/python/collector/reference.rs b/crates/zensical/src/python/collector/reference.rs index f5ba8d3..60acb6e 100644 --- a/crates/zensical/src/python/collector/reference.rs +++ b/crates/zensical/src/python/collector/reference.rs @@ -35,7 +35,7 @@ mod footnote; mod link; pub use footnote::{FootnoteDefinition, FootnoteReference}; -pub use link::{Link, LinkDefinition, LinkReference}; +pub use link::{Link, LinkDefinition, LinkReference, LinkReferenceKind}; // ---------------------------------------------------------------------------- // Enums diff --git a/crates/zensical/src/python/issues.rs b/crates/zensical/src/python/issues.rs index c3e5cf8..a1a3389 100644 --- a/crates/zensical/src/python/issues.rs +++ b/crates/zensical/src/python/issues.rs @@ -35,8 +35,11 @@ use zrx::id::Id; use zrx::scheduler::{Key, Value}; use crate::config::validation::Validation; +use crate::structure::markdown::AutorefResolutions; -use super::collector::reference::Reference; +use super::collector::reference::{ + LinkReference, LinkReferenceKind, Reference, +}; use super::collector::{Anchors, References}; use super::span::Span; @@ -156,7 +159,9 @@ impl Issues { #[allow(clippy::too_many_lines)] pub fn new(iter: T) -> Self where - T: IntoIterator, (References, Anchors))>, + T: IntoIterator< + Item = (Key, (References, Anchors, AutorefResolutions)), + >, { let mut issues = Vec::new(); let mut contents = HashMap::default(); @@ -164,7 +169,7 @@ impl Issues { // Create link map and anchor map and find inner-page issues let mut link_map = HashMap::default(); let mut anchor_map = HashMap::default(); - for (key, (references, anchors)) in iter { + for (key, (references, anchors, autorefs)) in iter { let id = key.try_as_id().expect("invariant"); let path = id.location().into_owned(); @@ -243,6 +248,12 @@ impl Issues { let id = &markdown[link.id.start..link.id.end]; if link_defs.contains_key(&to_id(id)) { used_link_defs.insert(to_id(id)); + } else if autoref_id(markdown, link) + .is_some_and(|id| autorefs.is_resolved(id)) + { + // Autorefs resolved this reference while rendering + // the page, so it is valid despite having no link + // definition in the Markdown source. } else { issues.push(Issue::UnresolvedReference { path: path.clone().into(), @@ -564,6 +575,58 @@ impl<'a> IntoIterator for &'a Issues { // Functions // ---------------------------------------------------------------------------- +/// Returns the identifier passed to autorefs for a link reference. +fn autoref_id<'a>(markdown: &'a str, link: &LinkReference) -> Option<&'a str> { + if link.kind != LinkReferenceKind::Link { + return None; + } + + let id = &markdown[link.id.start..link.id.end]; + if link.id != link.text { + return Some(id); + } + + // Autorefs only processes collapsed references (`[text][]`), not + // shortcut references (`[text]`). For an implicit identifier consisting + // of one code span, it uses the code content as the exact identifier. + if markdown.get(link.text.end..link.end) != Some("][]") { + return None; + } + Some(code_span_contents(id).unwrap_or(id)) +} + +/// Returns the contents when a value consists of exactly one code span. +fn code_span_contents(value: &str) -> Option<&str> { + let delimiter_len = value.bytes().take_while(|byte| *byte == b'`').count(); + if delimiter_len == 0 || value.len() <= delimiter_len * 2 { + return None; + } + + let trailing_len = + value.bytes().rev().take_while(|byte| *byte == b'`').count(); + if trailing_len != delimiter_len { + return None; + } + + let contents = &value[delimiter_len..value.len() - delimiter_len]; + let mut bytes = contents.bytes().peekable(); + while let Some(byte) = bytes.next() { + if byte != b'`' { + continue; + } + + let mut run_len = 1; + while bytes.next_if_eq(&b'`').is_some() { + run_len += 1; + } + if run_len == delimiter_len { + return None; + } + } + + Some(contents.trim()) +} + /// Converts an id to a normalized form for comparison. fn to_id(id: &str) -> String { let iter = id.split_whitespace(); @@ -676,8 +739,62 @@ fn to_slash(path: &str) -> String { #[cfg(test)] mod tests { use super::{ - decode_markdown_href, is_invalid_markdown_path, is_markdown_path, + autoref_id, code_span_contents, decode_markdown_href, + is_invalid_markdown_path, is_markdown_path, }; + use crate::python::collector::reference::{ + LinkReference, LinkReferenceKind, + }; + + #[test] + fn implicit_code_span_is_unwrapped_for_autorefs() { + let markdown = "[`warnings.deprecated`][]"; + let id_end = markdown.len() - 3; + let link = LinkReference { + start: 0, + end: markdown.len(), + kind: LinkReferenceKind::Link, + text: (1..id_end).into(), + id: (1..id_end).into(), + }; + + assert_eq!(autoref_id(markdown, &link), Some("warnings.deprecated")); + } + + #[test] + fn only_implicit_code_spans_are_unwrapped_for_autorefs() { + let shortcut = "[`identifier`]"; + let id_end = shortcut.len() - 1; + let shortcut_link = LinkReference { + start: 0, + end: shortcut.len(), + kind: LinkReferenceKind::Link, + text: (1..id_end).into(), + id: (1..id_end).into(), + }; + assert_eq!(autoref_id(shortcut, &shortcut_link), None); + + let explicit = "[label][`identifier`]"; + let explicit_link = LinkReference { + start: 0, + end: explicit.len(), + kind: LinkReferenceKind::Link, + text: (1..6).into(), + id: (8..explicit.len() - 1).into(), + }; + assert_eq!(autoref_id(explicit, &explicit_link), Some("`identifier`")); + } + + #[test] + fn code_span_delimiters_must_match() { + assert_eq!( + code_span_contents("``identifier`value``"), + Some("identifier`value") + ); + assert_eq!(code_span_contents("` identifier `"), Some("identifier")); + assert_eq!(code_span_contents("`identifier``"), None); + assert_eq!(code_span_contents("`one` and `two`"), None); + } #[test] fn markdown_path_must_end_in_md_file() { diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs index a8a4d98..c791397 100644 --- a/crates/zensical/src/structure/markdown.rs +++ b/crates/zensical/src/structure/markdown.rs @@ -41,7 +41,7 @@ use crate::structure::toc::Section; mod autorefs; -pub use autorefs::Autorefs; +pub use autorefs::{AutorefResolutions, Autorefs}; // ---------------------------------------------------------------------------- // Constants diff --git a/crates/zensical/src/structure/markdown/autorefs.rs b/crates/zensical/src/structure/markdown/autorefs.rs index d76485b..6ac3099 100644 --- a/crates/zensical/src/structure/markdown/autorefs.rs +++ b/crates/zensical/src/structure/markdown/autorefs.rs @@ -25,7 +25,7 @@ //! Autorefs (mkdocstrings). -use ahash::HashMap; +use ahash::{HashMap, HashSet}; use pyo3::FromPyObject; use regex::{Captures, Regex}; use serde::{Deserialize, Serialize}; @@ -33,6 +33,7 @@ use std::path::Path; use std::string::ToString; use std::sync::LazyLock; use zrx::path::PathExt; +use zrx::stream::Value; // ---------------------------------------------------------------------------- // Constants @@ -201,6 +202,15 @@ fn is_relative_url(url: &str) -> bool { // Structs // ---------------------------------------------------------------------------- +/// Resolution results for autoreferences in a single page. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AutorefResolutions { + /// Identifiers that were resolved successfully. + resolved: HashSet, +} + +// ---------------------------------------------------------------------------- + /// Autorefs (mkdocstrings). /// /// We use three URL maps, one for "primary" URLs, one for "secondary" URLs, @@ -448,8 +458,12 @@ impl Autorefs { )) } - /// Replaces autorefs in the given content. - pub fn replace_in(&self, content: String, from_url: &str) -> String { + /// Replaces autorefs and collects their resolution results. + #[allow(clippy::single_match_else)] + pub fn replace_in( + &self, content: String, from_url: &str, + ) -> (String, AutorefResolutions) { + let mut resolutions = AutorefResolutions::default(); let output = AUTOREF_RE.replace_all(&content, |captures: &Captures| { let attrs_str = captures.name("attrs").map_or("", |m| m.as_str()); @@ -471,6 +485,8 @@ impl Autorefs { match self.get_url_and_title_from_ids(&identifiers, from_url) { Ok((url, original_title)) => { + resolutions.resolved.insert(identifier.clone()); + // Check if URL is external (not relative) let external = !is_relative_url(&url); @@ -554,7 +570,24 @@ impl Autorefs { } }); - output.to_string() + (output.to_string(), resolutions) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for AutorefResolutions {} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl AutorefResolutions { + /// Returns whether an identifier was resolved. + pub fn is_resolved(&self, identifier: &str) -> bool { + self.resolved.contains(identifier) } } @@ -625,4 +658,26 @@ mod tests { ); } } + + #[test] + fn autoref_resolutions_are_collected_while_replacing() { + let mut autorefs = Autorefs::new(); + autorefs + .primary + .insert("known".to_string(), vec!["reference/#known".to_string()]); + + let (output, resolutions) = autorefs.replace_in( + concat!( + "Known", + "Missing", + ) + .to_string(), + "guide/", + ); + + assert!(resolutions.is_resolved("known")); + assert!(!resolutions.is_resolved("missing")); + assert!(output.contains("href=\"../reference/#known\"")); + assert!(output.contains("[Missing][missing]")); + } } diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs index 047132b..cf96f93 100644 --- a/crates/zensical/src/structure/page.rs +++ b/crates/zensical/src/structure/page.rs @@ -38,7 +38,7 @@ use crate::config::Config; use crate::template::{Output, Template, GENERATOR}; use super::dynamic::Dynamic; -use super::markdown::Markdown; +use super::markdown::{AutorefResolutions, Markdown}; use super::nav::{Navigation, NavigationItem}; use super::search::SearchItem; use super::tag::Tag; @@ -197,7 +197,7 @@ impl Page { )] pub fn render( &mut self, config: &Config, nav: Navigation, - ) -> Result { + ) -> Result<(Output, AutorefResolutions), Error> { let name = self.meta.get("template").map(ToString::to_string); let template = Template::new( name.unwrap_or(String::from("main.html")), @@ -223,8 +223,9 @@ impl Page { page => self, })?; - // Replace autorefs, if any - Ok(Output::from(nav.autorefs.replace_in(output, &self.url))) + // Replace autorefs and retain their resolution results for validation + let (output, autorefs) = nav.autorefs.replace_in(output, &self.url); + Ok((Output::from(output), autorefs)) } /// Returns the tags of the page. diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index d58ba59..85f20c8 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -25,13 +25,15 @@ //! Workflow definitions +use ahash::{HashMap, HashSet}; use pyo3::types::PyAnyMethods; use pyo3::Python; use regex::Regex; use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::{Path, PathBuf}; use std::str::FromStr; -use std::sync::{Arc, LazyLock}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; use std::{fs, io}; use zrx::id::matcher::Matcher; use zrx::id::{id, Id}; @@ -41,7 +43,7 @@ use zrx::stream::{Barrier, Stream, Workflow}; use super::config::Config; use super::python::{Anchors, Issues, References}; -use super::structure::markdown::Markdown; +use super::structure::markdown::{AutorefResolutions, Markdown}; use super::structure::nav::Navigation; use super::structure::page::Page; use super::structure::search::SearchIndex; @@ -80,6 +82,62 @@ pub struct Main { strict: bool, } +/// Page-content generation used to align validation inputs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct PageGeneration(u64); + +impl zrx::scheduler::Value for PageGeneration {} + +/// Render generation used to align validation with rendered autoreferences. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ValidationGeneration(u64, u64); + +impl zrx::scheduler::Value for ValidationGeneration {} + +/// Navigation generation used to construct validation sites. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ValidationNavigation(u64); + +impl zrx::scheduler::Value for ValidationNavigation {} + +/// Site-wide validation pages shared across workflow products. +#[derive(Clone, Debug, PartialEq, Eq)] +struct SharedValidationPages(Arc); + +impl zrx::scheduler::Value for SharedValidationPages {} + +/// Site-wide validation inputs shared across rendered pages. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ValidationSite { + /// Monotonically increasing site snapshot sequence. + sequence: u64, + /// Navigation hash used when pages were rendered. + nav_hash: u64, + /// References, anchors, and generations for all pages. + pages: Arc, +} + +impl zrx::scheduler::Value for ValidationSite {} + +/// Validation state collected while pages are rendered. +#[derive(Debug, Default)] +struct ValidationState { + /// Current site snapshot sequence. + sequence: u64, + /// Whether issues for the current site snapshot were printed. + reported: bool, + /// Pages expected in the current site snapshot. + expected: HashSet<(Id, ValidationGeneration)>, + /// Pages that have not rendered for the current site snapshot. + missing: HashSet<(Id, ValidationGeneration)>, + /// Current site snapshot, if validation inputs are ready. + site: Option, + /// Autoref resolutions indexed by source page and generation. + resolutions: HashMap<(Id, ValidationGeneration), AutorefResolutions>, +} + +type ValidationPages = Vec<(Key, (References, (Anchors, PageGeneration)))>; + // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- @@ -100,13 +158,6 @@ impl Module for Main { let page = generate_page(&self.config, &markdown); let pages = page.select([wait_for_markdown(&self.config)]); - // Collect all anchors and references from pages, to validate links - if self.config.project.validation.is_enabled() { - let references = collect_references(&files); - let anchors = collect_anchors(&page); - validate(&self.config, self.strict, references, anchors); - } - // Generate navigation and search index let nav = generate_nav(&self.config, &pages); generate_search_index(&self.config, &nav, &pages); @@ -114,9 +165,23 @@ impl Module for Main { // Generate object inventory generate_object_inventory(&self.config, &pages); - // // Render static and extra templates, as well as pages + // Render static and extra templates, as well as pages. render_templates(&self.config, &files, &nav); - render_pages(&self.config, &page, &nav); + let autorefs = render_pages(&self.config, &page, &nav); + + // Validate and print issues after all page autorefs have resolved. + if self.config.project.validation.is_enabled() { + let references = collect_references(&self.config, &files); + let anchors = collect_anchors(&page); + validate( + &self.config, + self.strict, + references, + anchors, + autorefs, + &nav, + ); + } Ok(()) } } @@ -148,10 +213,15 @@ pub fn wait_for_markdown(config: &Config) -> (Key, Barrier) { /// Create a stream to collect references from all Markdown files. pub fn collect_references( - files: &Stream, + config: &Config, files: &Stream, ) -> Stream { - let matcher = - Arc::new(Matcher::from_str("zrs:::::**/*.md:").expect("invariant")); + let matcher = Arc::new( + Matcher::from_str(&format!( + "zrs::::{}:**/*.md:", + config.project.docs_dir + )) + .expect("invariant"), + ); // Create pipeline to collect references files @@ -159,21 +229,135 @@ pub fn collect_references( .map(|Source { path }| fs::read_to_string(&*path)?.parse()) } -/// Create a stream to collect anchors from pages. -pub fn collect_anchors(pages: &Stream) -> Stream { - pages.map(move |page: Page| page.content.parse()) +/// Create a stream to collect anchors and generations from pages. +pub fn collect_anchors( + pages: &Stream, +) -> Stream { + pages.map(move |page: Page| { + let generation = page_generation(&page); + Ok::<_, anyhow::Error>((page.content.parse()?, generation)) + }) } /// Create a stream to validate references against anchors. pub fn validate( config: &Config, strict: bool, refs: Stream, - anchors: Stream, + anchors: Stream, + autorefs: Stream, + nav: &Stream, ) { - let combined = refs.join(&anchors).select([wait_for_markdown(config)]); let validation = config.project.validation.clone(); - combined - .map(Issues::new) - .inspect(move |issues: &Issues| issues.print(&validation, strict)); + let pages = refs + .join(&anchors) + .select([wait_for_markdown(config)]) + .map(|pages: ValidationPages| SharedValidationPages(Arc::new(pages))); + let nav = nav.map(|nav: Navigation| ValidationNavigation(nav.hash)); + let sequence = Arc::new(AtomicU64::new(0)); + let pages = pages.join(&nav).map( + move |pages: SharedValidationPages, nav: ValidationNavigation| { + ValidationSite { + sequence: sequence.fetch_add(1, Ordering::Relaxed) + 1, + nav_hash: nav.0, + pages: pages.0, + } + }, + ); + let state = Arc::new(Mutex::new(ValidationState::default())); + + let site_state = Arc::clone(&state); + let site_validation = validation.clone(); + pages.map(move |site: ValidationSite| { + let issues = { + let mut state = site_state.lock().expect("invariant"); + update_validation_site(&mut state, site); + validation_issues(&mut state) + }; + if let Some(issues) = issues { + issues.print(&site_validation, strict)?; + } + Ok::<_, anyhow::Error>(()) + }); + + autorefs.map( + move |source: Id, + generation: ValidationGeneration, + autorefs: AutorefResolutions| { + let issues = { + let mut state = state.lock().expect("invariant"); + let current = (source, generation); + state.resolutions.insert(current.clone(), autorefs); + if state.expected.contains(¤t) { + state.missing.remove(¤t); + } + validation_issues(&mut state) + }; + if let Some(issues) = issues { + issues.print(&validation, strict)?; + } + Ok::<_, anyhow::Error>(()) + }, + ); +} + +/// Update validation state with a new site snapshot. +fn update_validation_site(state: &mut ValidationState, site: ValidationSite) { + if site.sequence <= state.sequence { + return; + } + + let expected = site + .pages + .iter() + .map(|(key, (_, (_, page_generation)))| { + let id = key.try_as_id().expect("invariant"); + let generation = + ValidationGeneration(page_generation.0, site.nav_hash); + (id.clone(), generation) + }) + .collect::>(); + state.resolutions.retain(|key, _| expected.contains(key)); + state.missing = expected + .iter() + .filter(|key| !state.resolutions.contains_key(*key)) + .cloned() + .collect(); + state.sequence = site.sequence; + state.reported = false; + state.expected = expected; + state.site = Some(site); +} + +/// Create issues once every page in the current snapshot has rendered. +fn validation_issues(state: &mut ValidationState) -> Option { + if state.reported || !state.missing.is_empty() { + return None; + } + + let site = state.site.as_ref()?; + let iter = site.pages.iter().cloned().map( + |(key, (references, (anchors, page_generation)))| { + let id = key.try_as_id().expect("invariant"); + let generation = + ValidationGeneration(page_generation.0, site.nav_hash); + let autorefs = state + .resolutions + .get(&(id.clone(), generation)) + .cloned() + .expect("invariant"); + (key, (references, anchors, autorefs)) + }, + ); + let issues = Issues::new(iter); + state.reported = true; + Some(issues) +} + +/// Compute a generation from page content relevant to validation. +fn page_generation(page: &Page) -> PageGeneration { + let mut hasher = DefaultHasher::new(); + page.content.hash(&mut hasher); + page.meta.hash(&mut hasher); + PageGeneration(hasher.finish()) } /// Create a stream to process static assets. @@ -348,7 +532,11 @@ pub fn generate_nav( ) -> Stream { let config = config.clone(); pages.map(move |pages: Vec<(Key, Page)>| { - Navigation::new(config.get_cache_dir(), config.project.nav.clone(), pages) + Navigation::new( + config.get_cache_dir(), + config.project.nav.clone(), + pages, + ) }) } @@ -469,35 +657,34 @@ pub fn render_templates( /// Render pages. pub fn render_pages( config: &Config, page: &Stream, nav: &Stream, -) -> Stream { +) -> Stream { let config = config.clone(); - page.product(nav) - .map(move |mut page: Page, nav: Navigation| { + let pages = page.map(|id: &Id, page: Page| (id.clone(), page)); + pages.product(nav).map( + move |(source, mut page): (Id, Page), nav: Navigation| { let id = page.url.clone(); // Compute hash of page content - let hash = { - let mut hasher = DefaultHasher::new(); - page.content.hash(&mut hasher); - page.meta.hash(&mut hasher); - hasher.finish() - }; + let page_generation = page_generation(&page); + let generation = ValidationGeneration(page_generation.0, nav.hash); // Render page if we don't have a recent cached version at our own // disposal. Otherwise, just return if the content did not change. - let args = (config.hash, nav.hash, hash); + let args = (config.hash, nav.hash, page_generation.0); cached( &config, id, args, |(_, _, _)| Ok(page.render(&config, nav)?), ) - .and_then(|data| { + .and_then(|(data, autorefs)| { let path = Path::new(&page.path); fs::create_dir_all(path.parent().expect("invariant"))?; - fs::write(path, &*data).map_err(Into::into) + fs::write(path, &*data)?; + Ok((source, generation, autorefs)) }) - }) + }, + ) } /// Creates a workflow for the given config. diff --git a/python/tests/integration/test_validation.py b/python/tests/integration/test_validation.py new file mode 100644 index 0000000..0b49612 --- /dev/null +++ b/python/tests/integration/test_validation.py @@ -0,0 +1,76 @@ +# 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. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import zensical + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def test_validation_reports_issues_after_rendering( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """Validation reports source and autoref issues after page rendering.""" + docs = tmp_path / "docs" + docs.mkdir() + (docs / "index.md").write_text( + "# Hello\n\n[normal](missing.md)\n\n[autoref][missing-id]\n", + encoding="utf-8", + ) + (docs / "other.md").write_text("# Other\n", encoding="utf-8") + (tmp_path / "watched.md").write_text( + "# Watched support file\n", encoding="utf-8" + ) + config = tmp_path / "zensical.toml" + config.write_text( + """ +[project] +site_name = "Test" +watch = ["watched.md"] + +[project.validation] +unresolved_references = true +""".lstrip(), + encoding="utf-8", + ) + + zensical.build(str(config), {"clean": True, "strict": False}) + + captured = capfd.readouterr() + assert "page does not exist" in captured.err + assert "unresolved link reference" in captured.err + assert "2 issues found" in captured.err + assert captured.err.count("2 issues found") == 1 + + (docs / "index.md").write_text("# Hello\n", encoding="utf-8") + zensical.build(str(config), {"clean": True, "strict": False}) + + captured = capfd.readouterr() + assert "No issues found" in captured.err + assert "2 issues found" not in captured.err