Skip to main content

featherbit/mcp/
docs.rs

1//! Documentation pages embedded in the binary and served to agents as MCP
2//! resources (`featherbit://docs/...`). The docs site's Markdown is the
3//! single source of truth for plugin config keys, so agents read the same
4//! pages humans do — minus Docusaurus frontmatter and JSX.
5
6use regex::Regex;
7use rust_embed::Embed;
8use std::sync::OnceLock;
9
10/// `website/docs/` subset compiled into the binary (~300 KB).
11#[derive(Embed)]
12#[folder = "website/docs/"]
13#[include = "reference/plugins/*.md"]
14#[include = "concepts/*.md"]
15#[include = "reference/context-vars.md"]
16#[include = "reference/conditions.md"]
17#[include = "reference/templates.md"]
18// The how-to guides: a plugin page that says "see the Lua scripting guide"
19// is useless to an agent that cannot open it.
20#[include = "guides/*.md"]
21struct DocsAssets;
22
23/// URI prefix of every documentation resource.
24pub const DOCS_URI_PREFIX: &str = "featherbit://docs/";
25
26/// Which docs directory a page lives in.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DocSection {
29    Plugins,
30    Concepts,
31    Reference,
32    Guides,
33}
34
35impl DocSection {
36    fn slug(self) -> &'static str {
37        match self {
38            DocSection::Plugins => "plugins",
39            DocSection::Concepts => "concepts",
40            DocSection::Reference => "reference",
41            DocSection::Guides => "guides",
42        }
43    }
44    fn dir(self) -> &'static str {
45        match self {
46            DocSection::Plugins => "reference/plugins/",
47            DocSection::Concepts => "concepts/",
48            DocSection::Reference => "reference/",
49            DocSection::Guides => "guides/",
50        }
51    }
52    // Only reached via `read_uri`, which is `mcp`-only (below).
53    #[cfg_attr(not(feature = "mcp"), allow(dead_code))]
54    fn parse(slug: &str) -> Option<Self> {
55        match slug {
56            "plugins" => Some(DocSection::Plugins),
57            "concepts" => Some(DocSection::Concepts),
58            "reference" => Some(DocSection::Reference),
59            "guides" => Some(DocSection::Guides),
60            _ => None,
61        }
62    }
63}
64
65/// A listable page. Only constructed by `list_pages`, which is `mcp`-only.
66#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct DocPage {
69    pub uri: String,
70    pub title: String,
71    pub description: String,
72}
73
74/// The file behind a node type's page (`listener`/`client` share one).
75fn plugin_file(node_type: &str) -> String {
76    match node_type {
77        "listener" | "client" => "listener-client".to_string(),
78        other => other.to_string(),
79    }
80}
81
82fn raw(section: DocSection, name: &str) -> Option<String> {
83    if name.is_empty() || name == "index" || name.contains('/') || name.contains("..") {
84        return None;
85    }
86    let path = format!("{}{}.md", section.dir(), name);
87    // Normalize CRLF -> LF: the source files are LF in the repo, but a
88    // checkout with `core.autocrlf=true` (common on Windows) embeds them
89    // with CRLF, which would otherwise break every `\n`-anchored parse below.
90    DocsAssets::get(&path).map(|f| String::from_utf8_lossy(&f.data).replace("\r\n", "\n"))
91}
92
93/// Frontmatter `title`/`description` (both may be empty).
94fn frontmatter(md: &str) -> (String, String) {
95    let mut title = String::new();
96    let mut description = String::new();
97    if let Some(rest) = md.strip_prefix("---\n") {
98        if let Some(end) = rest.find("\n---") {
99            for line in rest[..end].lines() {
100                if let Some(v) = line.strip_prefix("title:") {
101                    title = v.trim().trim_matches('"').to_string();
102                } else if let Some(v) = line.strip_prefix("description:") {
103                    description = v.trim().trim_matches('"').to_string();
104                }
105            }
106        }
107    }
108    (title, description)
109}
110
111fn link_re() -> &'static Regex {
112    static RE: OnceLock<Regex> = OnceLock::new();
113    RE.get_or_init(|| {
114        Regex::new(r"\]\(((?:\.\./|\./)*)([A-Za-z0-9_./-]*?)([a-z0-9-]+)\.md(#[^)]*)?\)").unwrap()
115    })
116}
117
118/// Strips frontmatter (keeping the title as an H1), `import` lines, and JSX
119/// elements (single-line `<span …>…</span>` and multi-line `<Component … />`
120/// blocks), and rewrites relative `.md` links to `featherbit://docs/…` URIs.
121/// Fenced code blocks (opened/closed by a line whose trimmed form starts
122/// with ```` ``` ````) pass through verbatim — no import/JSX/link
123/// processing — since example bodies routinely contain `<Uppercase...>`
124/// tokens (e.g. an RFC 5424 `<PRI>...` syslog frame) that would otherwise be
125/// mistaken for an unterminated JSX block, silently swallowing everything
126/// after it for the rest of the page.
127///
128/// The directory hint picks the target section (`guides/`, `concepts/`,
129/// `plugins/`, `reference/`, else the page's own section). `guides/` links
130/// used to be left as raw Markdown because the guides were not embedded —
131/// which left an agent reading "see the Lua scripting guide" with no way to
132/// open it. The guides are resources now, so those links resolve too.
133pub fn clean(md: &str, section: DocSection) -> String {
134    let (title, _) = frontmatter(md);
135    let body = if let Some(rest) = md.strip_prefix("---\n") {
136        match rest.find("\n---") {
137            Some(end) => &rest[end + 4..],
138            None => md,
139        }
140    } else {
141        md
142    };
143
144    let mut out = String::new();
145    if !title.is_empty() {
146        out.push_str(&format!("# {title}\n"));
147    }
148    let mut in_jsx = false;
149    let mut in_fence = false;
150    for line in body.lines() {
151        let t = line.trim_start();
152        if t.starts_with("```") {
153            in_fence = !in_fence;
154            out.push_str(line);
155            out.push('\n');
156            continue;
157        }
158        if in_fence {
159            out.push_str(line);
160            out.push('\n');
161            continue;
162        }
163        if in_jsx {
164            if t.ends_with("/>") || t.starts_with("</") {
165                in_jsx = false;
166            }
167            continue;
168        }
169        if t.starts_with("import ") && t.ends_with(';') {
170            continue;
171        }
172        if t.starts_with('<') && t.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
173            // <UiShot … /> possibly spanning lines.
174            if !(t.ends_with("/>") || t.contains("</")) {
175                in_jsx = true;
176            }
177            continue;
178        }
179        if t.starts_with("<span className=") && t.ends_with("</span>") {
180            continue;
181        }
182        let rewritten = link_re().replace_all(line, |c: &regex::Captures| {
183            let dirs = &c[2];
184            let name = &c[3];
185            let target = if dirs.contains("guides/") {
186                DocSection::Guides
187            } else if dirs.contains("concepts/") {
188                DocSection::Concepts
189            } else if dirs.contains("plugins/") {
190                DocSection::Plugins
191            } else if dirs.contains("reference/") {
192                DocSection::Reference
193            } else {
194                section
195            };
196            format!("]({}{}/{})", DOCS_URI_PREFIX, target.slug(), name)
197        });
198        out.push_str(&rewritten);
199        out.push('\n');
200    }
201    // Collapse runs of blank lines left by removed elements.
202    let mut collapsed = String::with_capacity(out.len());
203    let mut blank = 0;
204    for line in out.lines() {
205        if line.trim().is_empty() {
206            blank += 1;
207            if blank > 1 {
208                continue;
209            }
210        } else {
211            blank = 0;
212        }
213        collapsed.push_str(line);
214        collapsed.push('\n');
215    }
216    collapsed
217}
218
219fn page(section: DocSection, name: &str) -> Option<String> {
220    raw(section, name).map(|md| clean(&md, section))
221}
222
223/// The cleaned page for a node type.
224pub fn plugin_page(node_type: &str) -> Option<String> {
225    page(DocSection::Plugins, &plugin_file(node_type))
226}
227
228/// A concepts page by file stem (e.g. `supernodes`).
229pub fn concept_page(name: &str) -> Option<String> {
230    page(DocSection::Concepts, name)
231}
232
233/// A reference page by file stem (`context-vars`, `conditions`, `templates`).
234/// Only reached via `read_uri`, which is `mcp`-only (the Admin API's
235/// `render_prompt` calls `plugin_page`/`concept_page` directly).
236#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
237pub fn reference_page(name: &str) -> Option<String> {
238    page(DocSection::Reference, name)
239}
240
241/// A how-to guide (`lua-scripting`, `debugging`, `routing`, …). Only reached
242/// via `read_uri`.
243#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
244pub fn guide_page(name: &str) -> Option<String> {
245    page(DocSection::Guides, name)
246}
247
248/// Resolves a `featherbit://docs/{section}/{name}` URI. Only used by the MCP
249/// `resources/read` handler in `src/mcp/server.rs`.
250#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
251pub fn read_uri(uri: &str) -> Option<String> {
252    let rest = uri.strip_prefix(DOCS_URI_PREFIX)?;
253    let (section, name) = rest.split_once('/')?;
254    let section = DocSection::parse(section)?;
255    match section {
256        DocSection::Plugins => plugin_page(name),
257        DocSection::Concepts => concept_page(name),
258        DocSection::Reference => reference_page(name),
259        DocSection::Guides => guide_page(name),
260    }
261}
262
263/// Every page, for `resources/list`. Only used by the MCP `resources/list`
264/// handler in `src/mcp/server.rs`.
265#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
266pub fn list_pages() -> Vec<DocPage> {
267    let mut pages = Vec::new();
268    for path in DocsAssets::iter() {
269        let path = path.as_ref();
270        let (section, stem) = if let Some(s) = path.strip_prefix("reference/plugins/") {
271            (DocSection::Plugins, s)
272        } else if let Some(s) = path.strip_prefix("concepts/") {
273            (DocSection::Concepts, s)
274        } else if let Some(s) = path.strip_prefix("reference/") {
275            (DocSection::Reference, s)
276        } else if let Some(s) = path.strip_prefix("guides/") {
277            (DocSection::Guides, s)
278        } else {
279            continue;
280        };
281        let Some(stem) = stem.strip_suffix(".md") else {
282            continue;
283        };
284        if stem == "index" {
285            continue;
286        }
287        let Some(file) = DocsAssets::get(path) else {
288            continue;
289        };
290        let md = String::from_utf8_lossy(&file.data).replace("\r\n", "\n");
291        let (title, description) = frontmatter(&md);
292        pages.push(DocPage {
293            uri: format!("{}{}/{}", DOCS_URI_PREFIX, section.slug(), stem),
294            title: if title.is_empty() {
295                stem.to_string()
296            } else {
297                title
298            },
299            description,
300        });
301    }
302    pages.sort_by(|a, b| a.uri.cmp(&b.uri));
303    pages
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn every_catalog_type_has_a_page() {
312        for entry in crate::admin::policies::plugin_catalog() {
313            let t = entry["type"].as_str().unwrap();
314            assert!(plugin_page(t).is_some(), "no docs page for node type '{t}'");
315        }
316    }
317
318    /// Every config key a plugin reads from its own `config` map must appear
319    /// on its page. `get_node_type` hands an agent that page and nothing
320    /// else, so an undocumented key is one it can never set — `key-auth`'s
321    /// `use_consumers` was invisible this way, and without it the plugin
322    /// cannot authenticate against consumers at all.
323    ///
324    /// Deliberately narrow: only `config.get("…")`/`cfg.get("…")` counts, so
325    /// `.get()` on headers, JSON responses or nested objects (documented with
326    /// dotted names) does not produce false alarms.
327    #[test]
328    fn every_config_key_a_plugin_reads_is_documented() {
329        let factory = include_str!("../plugins/mod.rs");
330        let arm = Regex::new(r#""([a-z0-9-]+)"\s*=>"#).unwrap();
331        let module = Regex::new(r"(?:native|script)::([a-z0-9_]+)::").unwrap();
332        let key = Regex::new(r#"\b(?:config|cfg)\s*\.\s*get\(\s*"([a-z0-9_]+)""#).unwrap();
333        let ws = Regex::new(r"\s*\n\s*").unwrap();
334
335        let arms: Vec<(String, usize)> = arm
336            .captures_iter(factory)
337            .map(|c| (c[1].to_string(), c.get(0).unwrap().end()))
338            .collect();
339        let plugin_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/plugins");
340        let mut findings: Vec<String> = Vec::new();
341        let mut scanned = 0usize;
342
343        for (i, (node_type, pos)) in arms.iter().enumerate() {
344            let end = arms.get(i + 1).map_or(factory.len(), |(_, p)| *p);
345            let Some(m) = module.captures(&factory[*pos..end]) else {
346                continue;
347            };
348            let file = walkdir(std::path::Path::new(plugin_dir), &format!("{}.rs", &m[1]));
349            let Some(file) = file else { continue };
350            let Ok(source) = std::fs::read_to_string(&file) else {
351                continue;
352            };
353            // Config is read in `from_config`; tests set keys too, and their
354            // literals would otherwise count as read keys.
355            let body = source.split("#[cfg(test)]").next().unwrap_or("");
356            let flat = ws.replace_all(body, " ");
357            let Some(page) = plugin_page(node_type) else {
358                continue;
359            };
360            for k in key.captures_iter(&flat) {
361                scanned += 1;
362                let name = &k[1];
363                let word = Regex::new(&format!(r"\b{}\b", regex::escape(name))).unwrap();
364                if !word.is_match(&page) {
365                    findings.push(format!("{node_type}: '{name}'"));
366                }
367            }
368        }
369
370        assert!(scanned > 300, "sanity: only {scanned} config keys scanned");
371        assert!(
372            findings.is_empty(),
373            "config keys a plugin reads but its docs page never mentions (get_node_type would leave an agent unable to set them): {findings:?}"
374        );
375    }
376
377    /// First file named `name` anywhere under `dir`.
378    fn walkdir(dir: &std::path::Path, name: &str) -> Option<std::path::PathBuf> {
379        for entry in std::fs::read_dir(dir).ok()? {
380            let path = entry.ok()?.path();
381            if path.is_dir() {
382                if let Some(found) = walkdir(&path, name) {
383                    return Some(found);
384                }
385            } else if path.file_name().is_some_and(|f| f == name) {
386                return Some(path);
387            }
388        }
389        None
390    }
391
392    /// Every page answers "what can go wrong here?" in the same place. An
393    /// agent wiring an `error` port has no other way to learn whether a node
394    /// can take it, or with which code.
395    #[test]
396    fn every_plugin_page_has_an_errors_section() {
397        let missing: Vec<String> = crate::admin::policies::plugin_catalog()
398            .iter()
399            .map(|p| p["type"].as_str().unwrap().to_string())
400            .filter(|t| {
401                plugin_page(t).is_some_and(|md| !md.lines().any(|l| l.trim() == "## Errors"))
402            })
403            .collect();
404        assert!(
405            missing.is_empty(),
406            "plugin pages with no '## Errors' section: {missing:?}"
407        );
408    }
409
410    /// Two ways an Errors section can lie, both caught here: a code the
411    /// plugin emits that the page never names, and a page claiming the node
412    /// never fails when its source can produce a `PluginExecutionError`. The
413    /// second is how `serverless-pre-function` slipped through — it emits no
414    /// code of its own but propagates the Lua runtime's.
415    #[test]
416    fn error_sections_match_what_plugins_can_emit() {
417        let factory = include_str!("../plugins/mod.rs");
418        let arm = Regex::new(r#""([a-z0-9-]+)"\s*=>"#).unwrap();
419        let module = Regex::new(r"(?:native|script)::([a-z0-9_]+)::").unwrap();
420        // An error code literal: SCREAMING_SNAKE with at least two segments.
421        let code = Regex::new(r#""([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)""#).unwrap();
422        let arms: Vec<(String, usize)> = arm
423            .captures_iter(factory)
424            .map(|c| (c[1].to_string(), c.get(0).unwrap().end()))
425            .collect();
426        let plugin_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/plugins");
427        let mut findings: Vec<String> = Vec::new();
428
429        for (i, (node_type, pos)) in arms.iter().enumerate() {
430            let end = arms.get(i + 1).map_or(factory.len(), |(_, p)| *p);
431            let Some(m) = module.captures(&factory[*pos..end]) else {
432                continue;
433            };
434            let Some(file) = walkdir(std::path::Path::new(plugin_dir), &format!("{}.rs", &m[1]))
435            else {
436                continue;
437            };
438            let Ok(source) = std::fs::read_to_string(&file) else {
439                continue;
440            };
441            let body = source.split("#[cfg(test)]").next().unwrap_or("");
442            let Some(page) = plugin_page(node_type) else {
443                continue;
444            };
445
446            for c in code.captures_iter(body) {
447                let name = &c[1];
448                // Not error codes: header names, content types, env vars.
449                if name.starts_with("HTTP_")
450                    || name.starts_with("CONTENT_")
451                    || name.starts_with("X_")
452                {
453                    continue;
454                }
455                if !page.contains(name) {
456                    findings.push(format!("{node_type}: emits '{name}', page never names it"));
457                }
458            }
459
460            let claims_never = page.contains("never fails at execution time");
461            let can_fail = body
462                .lines()
463                .any(|l| !l.trim_start().starts_with("//") && l.contains("PluginExecutionError"));
464            if claims_never && can_fail {
465                findings.push(format!(
466                    "{node_type}: page says it never fails, but its source produces a PluginExecutionError"
467                ));
468            }
469        }
470        assert!(findings.is_empty(), "{findings:#?}");
471    }
472
473    /// The how-to guides are resources too: a plugin page that points at the
474    /// Lua guide is only useful if the agent can open it.
475    #[test]
476    fn guides_are_readable_and_listed() {
477        let md = guide_page("lua-scripting").expect("lua-scripting guide");
478        assert!(md.contains("execute(ctx)"));
479        assert!(!md.contains("---\ntitle:"), "frontmatter stripped");
480        assert_eq!(
481            read_uri("featherbit://docs/guides/lua-scripting").as_deref(),
482            Some(md.as_str())
483        );
484        let uris: Vec<String> = list_pages().into_iter().map(|p| p.uri).collect();
485        for guide in ["lua-scripting", "debugging", "routing"] {
486            let uri = format!("featherbit://docs/guides/{guide}");
487            assert!(uris.contains(&uri), "{uri} not listed");
488        }
489        assert!(guide_page("nope").is_none());
490    }
491
492    /// The `script` reference page must carry the ctx shape itself: an agent
493    /// calling get_node_type("script") gets that page and nothing else.
494    #[test]
495    fn script_page_documents_the_context_table() {
496        let md = plugin_page("script").unwrap();
497        for needle in [
498            "ctx.request.headers",
499            "ctx.response.status_code",
500            "return the same table",
501            "LUA_UNMARSHAL_ERROR",
502            "featherbit://docs/guides/lua-scripting",
503        ] {
504            assert!(md.contains(needle), "script page is missing '{needle}'");
505        }
506    }
507
508    #[test]
509    fn plugin_page_is_cleaned() {
510        let md = plugin_page("limit-count").unwrap();
511        assert!(
512            md.starts_with("# limit-count\n"),
513            "{}",
514            &md[..80.min(md.len())]
515        );
516        assert!(!md.contains("---\ntitle:"));
517        assert!(!md.contains("plugin-chip"));
518        assert!(md.contains("| `count` |"));
519        assert!(
520            md.contains("featherbit://docs/plugins/rate-limit"),
521            "links rewritten"
522        );
523    }
524
525    #[test]
526    fn concept_page_drops_imports_and_jsx_blocks() {
527        let md = concept_page("supernodes").unwrap();
528        assert!(!md.contains("import UiShot"));
529        assert!(!md.contains("<UiShot"));
530        assert!(!md.contains("caption="));
531        assert!(md.contains("featherbit://docs/concepts/policies-and-graphs"));
532    }
533
534    #[test]
535    fn uri_mapping_and_listing() {
536        assert!(read_uri("featherbit://docs/plugins/key-auth").is_some());
537        assert!(read_uri("featherbit://docs/plugins/listener").is_some());
538        assert!(read_uri("featherbit://docs/plugins/client").is_some());
539        assert!(read_uri("featherbit://docs/reference/context-vars").is_some());
540        assert!(read_uri("featherbit://docs/plugins/index").is_none());
541        assert!(read_uri("featherbit://docs/nope/x").is_none());
542        assert!(read_uri("featherbit://policies/x").is_none());
543        let pages = list_pages();
544        assert!(pages
545            .iter()
546            .any(|p| p.uri == "featherbit://docs/plugins/limit-count" && p.title == "limit-count"));
547        assert!(pages.iter().all(|p| !p.uri.ends_with("/index")));
548        assert!(
549            pages
550                .iter()
551                .any(|p| p.uri == "featherbit://docs/concepts/supernodes"
552                    && !p.description.is_empty())
553        );
554    }
555
556    #[test]
557    fn clean_handles_edge_cases() {
558        let raw = "---\ntitle: T\ndescription: D\n---\n\nimport X from 'y';\n\n<span className=\"plugin-chip\">t</span>\n\nBody [link](./other.md) and [c](../../concepts/stores.md#a).\n\n<UiShot\n  name=\"x\"\n/>\n\nEnd\n";
559        let out = clean(raw, DocSection::Plugins);
560        assert_eq!(out, "# T\n\nBody [link](featherbit://docs/plugins/other) and [c](featherbit://docs/concepts/stores).\n\nEnd\n");
561    }
562
563    /// Guide links point at real resources now that the guides are embedded:
564    /// a `guides/` link must map to its own section, never to the page's.
565    #[test]
566    fn guides_links_become_guide_uris() {
567        let raw = "---\ntitle: T\ndescription: D\n---\n\nSee the [Admin API](../../guides/admin-api.md#endpoint-reference) guide.\n";
568        let out = clean(raw, DocSection::Plugins);
569        assert!(
570            out.contains("(featherbit://docs/guides/admin-api)"),
571            "{out}"
572        );
573        assert!(!out.contains("featherbit://docs/plugins/admin-api"));
574        assert!(read_uri("featherbit://docs/guides/admin-api").is_some());
575    }
576
577    /// `clean` must never panic on any page actually embedded in the binary.
578    #[test]
579    fn clean_never_panics_on_any_embedded_page() {
580        let pages = list_pages();
581        assert!(
582            pages.len() > 80,
583            "sanity: expected the full docs set, got {}",
584            pages.len()
585        );
586        for p in &pages {
587            let content = read_uri(&p.uri).unwrap_or_else(|| panic!("no content for {}", p.uri));
588            assert!(!content.is_empty(), "{} cleaned to empty content", p.uri);
589        }
590    }
591
592    /// `syslog`'s raw page wraps an RFC 5424 example (`<PRI>...`) in a fenced
593    /// code block. Before fenced blocks were exempted from JSX detection,
594    /// that line tripped the multi-line-JSX heuristic (`<Uppercase...>` with
595    /// no closing `/>`/`</` on the same line) and never found a closing tag,
596    /// silently dropping the closing fence plus everything after it —
597    /// the whole Configuration table included — for the rest of the file.
598    #[test]
599    fn syslog_page_survives_fenced_uppercase_tag() {
600        let md = plugin_page("syslog").unwrap();
601        assert!(
602            md.contains("<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID"),
603            "fenced RFC 5424 example line dropped: {}",
604            &md[..200.min(md.len())]
605        );
606        assert!(
607            md.contains("## Configuration"),
608            "Configuration heading dropped"
609        );
610        assert!(md.contains("| `host` |"), "Configuration table row dropped");
611    }
612
613    /// Structural invariant across every embedded plugin page: cleaning must
614    /// never truncate a page partway through, so any page whose raw source
615    /// has a `## Configuration` section must still have it after `clean()`.
616    /// This is the regression guard for silent wholesale content loss (as
617    /// happened with `syslog`) that `clean_never_panics_on_any_embedded_page`
618    /// alone can't catch, since a truncated-but-non-empty page still passes
619    /// that test.
620    #[test]
621    fn configuration_sections_survive_cleaning_for_every_plugin_page() {
622        let mut checked = 0;
623        for path in DocsAssets::iter() {
624            let path = path.as_ref();
625            let Some(stem) = path
626                .strip_prefix("reference/plugins/")
627                .and_then(|s| s.strip_suffix(".md"))
628            else {
629                continue;
630            };
631            if stem == "index" {
632                continue;
633            }
634            let raw_md = raw(DocSection::Plugins, stem).unwrap();
635            if raw_md.contains("## Configuration") {
636                checked += 1;
637                let cleaned = page(DocSection::Plugins, stem).unwrap();
638                assert!(
639                    cleaned.contains("## Configuration"),
640                    "'{stem}' lost its Configuration section during cleaning"
641                );
642            }
643        }
644        assert!(
645            checked > 80,
646            "sanity: expected most plugin pages to have a Configuration section, got {checked}"
647        );
648    }
649}