1use regex::Regex;
7use rust_embed::Embed;
8use std::sync::OnceLock;
9
10#[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#[include = "guides/*.md"]
21struct DocsAssets;
22
23pub const DOCS_URI_PREFIX: &str = "featherbit://docs/";
25
26#[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 #[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#[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
74fn 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 DocsAssets::get(&path).map(|f| String::from_utf8_lossy(&f.data).replace("\r\n", "\n"))
91}
92
93fn 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
118pub 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 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: ®ex::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 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
223pub fn plugin_page(node_type: &str) -> Option<String> {
225 page(DocSection::Plugins, &plugin_file(node_type))
226}
227
228pub fn concept_page(name: &str) -> Option<String> {
230 page(DocSection::Concepts, name)
231}
232
233#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
237pub fn reference_page(name: &str) -> Option<String> {
238 page(DocSection::Reference, name)
239}
240
241#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
244pub fn guide_page(name: &str) -> Option<String> {
245 page(DocSection::Guides, name)
246}
247
248#[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#[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 #[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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}