seam-skeleton 0.5.38

HTML skeleton extraction pipeline for SeamJS CLI
Documentation
/* src/cli/skeleton/src/slot_warning.rs */

// Detect open-string slots in style/class contexts at build time.
// An open `{ "type": "string" }` in a style property or class attribute is
// almost certainly a bug — the author likely meant a constrained type (enum,
// integer, or a CSS-specific token).

use std::sync::OnceLock;

use regex::Regex;
use serde_json::Value;

fn slot_re() -> &'static Regex {
	static RE: OnceLock<Regex> = OnceLock::new();
	RE.get_or_init(|| {
		// Matches style slots: <!--seam:PATH:style:CSS_PROP-->
		// and class attr slots: <!--seam:PATH:attr:class-->
		Regex::new(r"<!--seam:([^:]+(?:\.[^:]+)*):(?:style:[\w-]+|attr:class)-->").expect("valid regex")
	})
}

/// Resolve a dot-separated path against a JTD schema.
/// Returns the schema node at that path, or None if unresolvable.
///
/// Path segments: regular keys look up `properties`/`optionalProperties`,
/// `$` descends into `elements` (array item type).
fn resolve_path<'a>(schema: &'a Value, path: &str) -> Option<&'a Value> {
	let mut current = schema;

	for segment in path.split('.') {
		if segment == "$" {
			current = current.get("elements")?;
		} else {
			// Try properties first, then optionalProperties
			current = current
				.get("properties")
				.and_then(|p| p.get(segment))
				.or_else(|| current.get("optionalProperties").and_then(|p| p.get(segment)))?;
		}
	}

	Some(current)
}

/// True when the schema node is an unconstrained string (no enum values).
fn is_open_string(schema: &Value) -> bool {
	schema.get("type").and_then(Value::as_str) == Some("string") && schema.get("enum").is_none()
}

/// Scan a template for style/class slots backed by open-string schema fields.
/// Returns a list of human-readable warning strings.
pub fn check_slot_types(template: &str, page_schema: &Value) -> Vec<String> {
	let re = slot_re();
	let mut warnings = Vec::new();

	for cap in re.captures_iter(template) {
		let full = cap.get(0).expect("capture group exists").as_str();
		let path = &cap[1];

		let Some(field_schema) = resolve_path(page_schema, path) else {
			continue;
		};

		if !is_open_string(field_schema) {
			continue;
		}

		// Determine context label from the marker
		let context = if full.contains(":style:") {
			let prop =
				full.rsplit(":style:").next().expect("style delimiter present").trim_end_matches("-->");
			format!("style property \"{prop}\"")
		} else {
			"class attribute".to_string()
		};

		warnings.push(format!(
      "slot \"{path}\" is an open string used as {context}\n\
       \x20\x20\x20\x20\x20\x20\x20\x20  hint: consider using an enum or numeric type in your schema"
    ));
	}

	warnings
}

#[cfg(test)]
mod tests {
	use super::*;
	use serde_json::json;

	fn page_schema() -> Value {
		json!({
			"properties": {
				"user": {
					"properties": {
						"name": { "type": "string" },
						"role": { "type": "string", "enum": ["admin", "member"] },
						"age": { "type": "uint32" }
					}
				},
				"repos": {
					"elements": {
						"properties": {
							"language": { "type": "string" },
							"stars": { "type": "uint32" },
							"color": { "type": "string", "enum": ["red", "blue", "green"] }
						}
					}
				}
			}
		})
	}

	#[test]
	fn warns_open_string_in_style() {
		let template = r#"<!--seam:user.name:style:font-size--><div>text</div>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert_eq!(warnings.len(), 1);
		assert!(warnings[0].contains("user.name"));
		assert!(warnings[0].contains("style property \"font-size\""));
	}

	#[test]
	fn warns_open_string_in_class() {
		let template = r#"<!--seam:user.name:attr:class--><div>text</div>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert_eq!(warnings.len(), 1);
		assert!(warnings[0].contains("user.name"));
		assert!(warnings[0].contains("class attribute"));
	}

	#[test]
	fn no_warning_for_enum_string() {
		let template = r#"<!--seam:user.role:attr:class--><div>text</div>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert!(warnings.is_empty());
	}

	#[test]
	fn no_warning_for_numeric_type() {
		let template = r#"<!--seam:user.age:style:font-size--><div>text</div>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert!(warnings.is_empty());
	}

	#[test]
	fn warns_array_element_open_string() {
		let template = r#"<!--seam:repos.$.language:attr:class--><div>text</div>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert_eq!(warnings.len(), 1);
		assert!(warnings[0].contains("repos.$.language"));
	}

	#[test]
	fn no_warning_for_array_element_enum() {
		let template = r#"<!--seam:repos.$.color:attr:class--><div>text</div>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert!(warnings.is_empty());
	}

	#[test]
	fn no_warning_for_text_slot() {
		// Text slots (no :style: or :attr:class) should not trigger warnings
		let template = r#"<!--seam:user.name--><div>text</div>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert!(warnings.is_empty());
	}

	#[test]
	fn no_warning_for_href_attr() {
		let template = r#"<!--seam:user.name:attr:href--><a>link</a>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert!(warnings.is_empty());
	}

	#[test]
	fn no_warning_without_schema() {
		let template = r#"<!--seam:user.name:style:color--><div>text</div>"#;
		let empty = json!({});
		let warnings = check_slot_types(template, &empty);
		assert!(warnings.is_empty());
	}

	#[test]
	fn multiple_warnings() {
		let template =
			r#"<!--seam:user.name:style:color--><!--seam:repos.$.language:attr:class--><div>text</div>"#;
		let warnings = check_slot_types(template, &page_schema());
		assert_eq!(warnings.len(), 2);
	}

	#[test]
	fn resolve_path_basic() {
		let schema = page_schema();
		let resolved = resolve_path(&schema, "user.name").unwrap();
		assert_eq!(resolved, &json!({ "type": "string" }));
	}

	#[test]
	fn resolve_path_array() {
		let schema = page_schema();
		let resolved = resolve_path(&schema, "repos.$.stars").unwrap();
		assert_eq!(resolved, &json!({ "type": "uint32" }));
	}

	#[test]
	fn resolve_path_missing() {
		let schema = page_schema();
		assert!(resolve_path(&schema, "nonexistent.field").is_none());
	}
}