//! JS: live-wrap.mjs helpers shared with live-insert.mjs: search-query //! construction, element location (opener / closer / text disambiguation), //! comment syntax and style mode from the framework registry, and the //! cssAuthoring contracts. use crate::inject::resolve_source_traits; use crate::source_search::{ find_source_file, is_generated_file, resolve_live_template_extensions, NEVER_SOURCE_DIRS, }; use impeccable_core::js::{is_js_whitespace, trim, WS}; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Value}; /// JS: argVal(args, flag) as live-wrap.mjs spells it: `--flag=value` first, /// then `--flag value`. pub fn arg_val_eq(args: &[String], flag: &str) -> Option { let prefix = format!("{}=", flag); for a in args { if let Some(v) = a.strip_prefix(&prefix) { return Some(v.to_string()); } } arg_val(args, flag) } /// JS: argVal(args, flag) as live-insert.mjs / live-accept.mjs spell it. pub fn arg_val(args: &[String], flag: &str) -> Option { let idx = args.iter().position(|a| a == flag)?; args.get(idx + 1).cloned() } /// JS: `str.match(/^(\s*)/)[1]` pub fn leading_ws(line: &str) -> String { line.chars().take_while(|c| is_js_whitespace(*c)).collect() } /// JS: minLeadingSpaces(lines) / deindentContent's minIndent pub fn min_leading_spaces(lines: &[String]) -> usize { let mut min: Option = None; for l in lines { if trim(l).is_empty() { continue; } let n = leading_ws(l).chars().count(); if min.map(|m| n < m).unwrap_or(true) { min = Some(n); } } min.unwrap_or(0) } /// JS `str.slice(n)` by UTF-16 units approximated by chars (leading /// whitespace is BMP). pub fn slice_chars(s: &str, n: usize) -> String { s.chars().skip(n).collect() } /// JS: splitClassList(classes) pub fn split_class_list(classes: &str) -> Vec { static SPLIT_RE: Lazy = Lazy::new(|| Regex::new(&format!("[,{}]+", &WS[1..WS.len() - 1])).unwrap()); SPLIT_RE .split(classes) .map(|c| trim(c).to_string()) .filter(|c| !c.is_empty()) .collect() } /// JS: buildSearchQueries(elementId, classes, tag, query) pub fn build_search_queries( element_id: Option<&str>, classes: Option<&str>, tag: Option<&str>, query: Option<&str>, ) -> Vec { let mut queries = Vec::new(); if let Some(id) = element_id { queries.push(format!("id=\"{}\"", id)); } if let Some(classes) = classes { let list = split_class_list(classes); if list.len() > 1 { let joined = list.join(" "); let mut sorted = list.clone(); // Array.prototype.sort is stable; longest first. sorted.sort_by(|a, b| b.encode_utf16().count().cmp(&a.encode_utf16().count())); queries.push(format!("class=\"{}\"", joined)); queries.push(format!("className=\"{}\"", joined)); for c in sorted { queries.push(c); } } else if list.len() == 1 { queries.push(list[0].clone()); } } if let (Some(tag), Some(classes)) = (tag, classes) { let list = split_class_list(classes); let first = list.first().map(String::as_str).unwrap_or("undefined"); queries.push(format!("<{} class=\"{}", tag, first)); queries.push(format!("<{} className=\"{}", tag, first)); } if let Some(q) = query { queries.push(q.to_string()); } queries } /// JS: OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/ (first match's tag) pub fn opener_tag(line: &str) -> Option { let chars: Vec = line.chars().collect(); let mut i = 0; while i < chars.len() { if chars[i] == '<' && i + 1 < chars.len() && chars[i + 1].is_ascii_alphabetic() { let mut j = i + 1; while j < chars.len() && chars[j].is_ascii_alphanumeric() { j += 1; } let ok = j >= chars.len() || is_js_whitespace(chars[j]) || chars[j] == '/' || chars[j] == '>'; if ok { return Some(chars[i + 1..j].iter().collect()); } } i += 1; } None } #[derive(Debug, Clone, Copy, PartialEq)] pub struct ElementMatch { pub start_line: usize, pub end_line: usize, } fn skip_line(line: &str) -> bool { let stripped = trim(line); stripped.starts_with("") } } pub fn comment_syntax_value(cs: (&str, &str)) -> Value { json!({ "open": cs.0, "close": cs.1 }) } /// JS: detectStyleMode(filePath) → (mode, styleTag) pub fn detect_style_mode(file_path: &str) -> (&'static str, &'static str) { let t = resolve_source_traits(file_path); (t.style_mode, t.style_tag) } /// JS: buildCssSelectorPrefixExamples(styleMode, count) pub fn build_css_selector_prefix_examples(style_mode: &str, count: i64) -> Vec { if style_mode != "astro-global-prefixed" { return Vec::new(); } (1..=count.max(0)) .map(|i| format!("[data-impeccable-variant=\"{}\"]", i)) .collect() } /// JS: buildCssAuthoring(styleMode, count) pub fn build_css_authoring(style_mode: (&str, &str), count: i64) -> Value { let (mode, style_tag) = style_mode; let numbers: Vec = (1..=count.max(0)).collect(); if mode == "astro-global-prefixed" { return json!({ "mode": mode, "styleTag": style_tag, "strategy": "global-prefixed", "rulePattern": "[data-impeccable-variant=\"N\"] > .variant-class { ... }", "selectorExamples": numbers.iter().map(|n| format!("[data-impeccable-variant=\"{}\"] > .variant-class", n)).collect::>(), "requirements": [ "Use the styleTag exactly; the is:inline attribute is required for this file.", "Put raw CSS directly between the styleTag opening and a plain close.", "Prefix every preview selector with the matching [data-impeccable-variant=\"N\"] selector.", "Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.", ], "forbidden": [ "Do not use @scope for this styleMode.", "Do not wrap style content in a JSX/TSX template literal ({` ... `}); that syntax is for .tsx/.jsx only.", "Do not put { immediately after the style opening tag; Astro parses { as expression syntax.", ], }); } json!({ "mode": mode, "styleTag": style_tag, "strategy": "scope-rule", "rulePattern": "@scope ([data-impeccable-variant=\"N\"]) { :scope > .variant-class { ... } }", "selectorExamples": numbers.iter().map(|n| format!("@scope ([data-impeccable-variant=\"{}\"]) {{ :scope > .variant-class {{ ... }} }}", n)).collect::>(), "requirements": [ "Use @scope blocks keyed to each [data-impeccable-variant=\"N\"] wrapper.", "Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.", "Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.", ], "forbidden": [ "Do not use global [data-impeccable-variant=\"N\"] selector prefixes for this styleMode.", "Do not add is:inline to the style tag for this styleMode.", ], }) } /// JS: findFileWithQuery(query, cwd, genOpts) pub fn find_file_with_query(query: &str, cwd: &str, include_generated: bool) -> Option { let extensions = resolve_live_template_extensions(cwd); let filter = |p: &str| include_generated || !is_generated_file(p, cwd); find_source_file(query, cwd, &extensions, &NEVER_SOURCE_DIRS, &filter) } /// `parseInt(x || '3')` for `--count`. pub fn parse_count(v: Option<&str>) -> i64 { let raw = match v { Some(s) if !s.is_empty() => s, _ => "3", }; let n = impeccable_core::js::parse_int(raw, 10); if n.is_nan() { // JS: NaN interpolates as "NaN" and Array.from({length: NaN}) is []. i64::MIN } else { n as i64 } } /// `String(count)` for interpolation: NaN prints as "NaN". pub fn count_text(count: i64) -> String { if count == i64::MIN { "NaN".to_string() } else { count.to_string() } } /// `count` as a length for `Array.from({ length: count })`. pub fn count_len(count: i64) -> i64 { if count == i64::MIN { 0 } else { count } }