Skip to main content

seam_skeleton/
lib.rs

1/* src/cli/skeleton/src/lib.rs */
2#![cfg_attr(test, allow(clippy::unwrap_used))]
3
4mod document;
5mod extract;
6mod slot;
7mod template_invariant;
8
9pub use document::wrap_document;
10pub use extract::extract_template;
11pub use slot::sentinel_to_slots;
12pub use template_invariant::{TemplateInvariantViolation, check_template_invariants};
13
14pub mod ctr_check;
15pub mod slot_paths;
16pub mod slot_warning;
17
18use serde::Deserialize;
19
20/// Axis describes one structural dimension that affects template rendering.
21#[derive(Debug, Clone, Deserialize)]
22pub struct Axis {
23	pub path: String,
24	pub kind: String,
25	pub values: Vec<serde_json::Value>,
26}
27
28/// Vite dev server info, threaded through the build pipeline to replace
29/// static asset references with Vite-served modules.
30#[derive(Debug, Clone)]
31pub struct ViteDevInfo {
32	pub origin: String,
33	pub entry: String,
34}
35
36#[cfg(test)]
37mod tests {
38	use super::*;
39	use serde_json::json;
40
41	// -- Integration tests spanning multiple sub-modules --
42
43	fn make_axis(path: &str, kind: &str, values: Vec<serde_json::Value>) -> Axis {
44		Axis { path: path.to_string(), kind: kind.to_string(), values }
45	}
46
47	#[test]
48	fn full_pipeline_snapshot() {
49		let sentinel_html =
50			r#"<div><h1>%%SEAM:user.name%%</h1><p>%%SEAM:user.email%%</p> <span>Has avatar</span></div>"#;
51		let nulled_html = r#"<div><h1>%%SEAM:user.name%%</h1><p>%%SEAM:user.email%%</p></div>"#;
52
53		// Step 1: sentinel -> slots
54		let slotted = sentinel_to_slots(sentinel_html);
55		assert_eq!(
56			slotted,
57			r#"<div><h1><!--seam:user.name--></h1><p><!--seam:user.email--></p> <span>Has avatar</span></div>"#
58		);
59
60		let nulled_slotted = sentinel_to_slots(nulled_html);
61
62		// Step 2: template extraction via multi-variant diff
63		let axes = vec![make_axis("user.avatar", "nullable", vec![json!("present"), json!(null)])];
64		let variants = vec![slotted, nulled_slotted];
65		let template = extract_template(&axes, &variants);
66		assert!(template.contains("<!--seam:if:user.avatar-->"));
67		assert!(template.contains("<!--seam:endif:user.avatar-->"));
68		assert!(template.contains("<span>Has avatar</span>"));
69
70		// Step 3: document wrapping
71		let doc =
72			wrap_document(&template, &["app.css".into()], &["app.js".into()], false, None, "__seam");
73		assert!(doc.starts_with("<!DOCTYPE html>"));
74		assert!(doc.contains("__seam"));
75		assert!(doc.contains("<!--seam:user.name-->"));
76		assert!(doc.contains("<!--seam:if:user.avatar-->"));
77		assert!(doc.contains("app.css"));
78		assert!(doc.contains("app.js"));
79	}
80
81	#[test]
82	fn attribute_and_text_mixed_pipeline() {
83		let html = r#"<div><a href="%%SEAM:link.url%%">%%SEAM:link.text%%</a></div>"#;
84		let result = sentinel_to_slots(html);
85		let doc = wrap_document(&result, &[], &[], false, None, "__seam");
86		assert!(doc.contains("<!--seam:link.url:attr:href-->"));
87		assert!(doc.contains("<!--seam:link.text-->"));
88		assert!(!doc.contains("%%SEAM:"));
89	}
90
91	#[test]
92	fn float_metadata_stays_in_body_with_wrap_document() {
93		// With structured head, metadata elements in skeleton stay in body
94		// (head_meta is now provided separately from skeleton renderer)
95		let html = r#"<title>%%SEAM:t%%</title><meta name="desc" content="%%SEAM:d%%"><link rel="canonical" href="%%SEAM:u%%"><div><p>%%SEAM:body%%</p></div>"#;
96
97		let slotted = sentinel_to_slots(html);
98		assert!(slotted.contains("<!--seam:t-->"));
99		assert!(slotted.contains("<!--seam:d:attr:content-->"));
100		assert!(slotted.contains("<!--seam:u:attr:href-->"));
101
102		let doc =
103			wrap_document(&slotted, &["style.css".into()], &["app.js".into()], false, None, "__seam");
104		assert!(doc.starts_with("<!DOCTYPE html>"));
105
106		// All skeleton content stays in body (no extraction to <head>)
107		let root = &doc[doc.find("__seam").unwrap()..];
108		assert!(root.contains("<!--seam:t-->"), "title slot in body");
109		assert!(root.contains("<!--seam:d:attr:content-->"), "meta slot in body");
110		assert!(root.contains("<!--seam:body-->"), "body content in body");
111	}
112
113	#[test]
114	fn float_metadata_with_boolean_axis() {
115		// Conditional <meta> tag via nullable axis
116		let with_meta = sentinel_to_slots(
117			r#"<title>%%SEAM:t%%</title><meta name="og" content="%%SEAM:og%%"><p>%%SEAM:body%%</p>"#,
118		);
119		let without_meta = sentinel_to_slots(r#"<title>%%SEAM:t%%</title><p>%%SEAM:body%%</p>"#);
120
121		let axes = vec![make_axis("og", "nullable", vec![json!("present"), json!(null)])];
122		let variants = vec![with_meta, without_meta];
123		let template = extract_template(&axes, &variants);
124
125		assert!(template.contains("<!--seam:if:og-->"));
126		assert!(template.contains("<!--seam:endif:og-->"));
127		assert!(template.contains("<!--seam:t-->"));
128		assert!(template.contains("<!--seam:body-->"));
129
130		let doc = wrap_document(&template, &[], &[], false, None, "__seam");
131		// All content stays in body (head_meta provided separately)
132		let root = &doc[doc.find("__seam").unwrap()..];
133		assert!(root.contains("<!--seam:t-->"), "title slot in body");
134		assert!(root.contains("<!--seam:if:og-->"), "conditional in body");
135	}
136}