mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
bake: a :scope child compound merges into the element anchor
`:scope > .card` describes the wrapper's only child, the accepted element itself, so the lasting rule is the anchor with what the compound adds (a class the anchor lacks, an attribute, a state), never bare `.card`, which after the append would style every card on the page. A type in the compound must be the anchor's own; an id anchor takes any. Pinned in the rewrite and accept tests; contract updated. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude Fable 5
parent
695d1bd515
commit
4b3a0e932d
+90
-12
@@ -139,11 +139,81 @@ fn split_first_compound(s: &str) -> (String, String) {
|
||||
(chars[..i].iter().collect(), chars[i..].iter().collect())
|
||||
}
|
||||
|
||||
/// The simple selectors of one compound (`div.card[open]:hover` ->
|
||||
/// `div`, `.card`, `[open]`, `:hover`): the leading type selector, if any,
|
||||
/// and the rest as tokens. Brackets, parentheses and quotes keep their
|
||||
/// contents together (`:not([hidden])`, `[data-x="a.b"]`).
|
||||
fn compound_parts(compound: &str) -> (Option<String>, Vec<String>) {
|
||||
let chars: Vec<char> = compound.chars().collect();
|
||||
let mut i = 0;
|
||||
let mut tag = String::new();
|
||||
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
|
||||
tag.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut depth = 0i32;
|
||||
let mut quote: Option<char> = None;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if let Some(q) = quote {
|
||||
cur.push(c);
|
||||
if c == q {
|
||||
quote = None;
|
||||
}
|
||||
} else if c == '"' || c == '\'' {
|
||||
quote = Some(c);
|
||||
cur.push(c);
|
||||
} else if c == '[' || c == '(' {
|
||||
depth += 1;
|
||||
cur.push(c);
|
||||
} else if c == ']' || c == ')' {
|
||||
depth -= 1;
|
||||
cur.push(c);
|
||||
} else if depth == 0 && (c == '.' || c == '#' || c == '[' || c == ':') && !cur.is_empty() {
|
||||
tokens.push(std::mem::take(&mut cur));
|
||||
cur.push(c);
|
||||
} else {
|
||||
cur.push(c);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(cur);
|
||||
}
|
||||
(if tag.is_empty() { None } else { Some(tag) }, tokens)
|
||||
}
|
||||
|
||||
/// The wrapper's only child is the element itself, so a `:scope > X`
|
||||
/// rule describes that element: the lasting selector is the anchor with
|
||||
/// whatever X adds (a class the anchor lacks, an attribute, a state), never
|
||||
/// bare X, which would style every X on the page. A type in X must be the
|
||||
/// anchor's own (an id anchor carries no type, so any is fine there).
|
||||
fn anchor_with_child(anchor: &str, child: &str) -> Result<String, String> {
|
||||
let (anchor_tag, anchor_tokens) = compound_parts(anchor);
|
||||
let (child_tag, child_tokens) = compound_parts(child);
|
||||
if let (Some(a), Some(c)) = (&anchor_tag, &child_tag) {
|
||||
if !a.eq_ignore_ascii_case(c) {
|
||||
return Err(format!("`:scope > {}` names a {} but the variant's root is a {}", child, c, a));
|
||||
}
|
||||
}
|
||||
let mut out = anchor.to_string();
|
||||
for token in child_tokens {
|
||||
if !anchor_tokens.iter().any(|t| t == &token) {
|
||||
out.push_str(&token);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One selector out of a `:scope` (or `[data-impeccable-variant="N"]`)
|
||||
/// prefixed rule, anchored on the element. A state on the wrapper
|
||||
/// (`:scope:hover`, `:scope[open]`) lands on the element, which is the
|
||||
/// wrapper's only child and takes its place after the unwrap. Err when
|
||||
/// `:scope` survives or the rewrite has no meaning.
|
||||
/// wrapper's only child and takes its place after the unwrap; a `:scope >`
|
||||
/// child compound is that element too, so it merges into the anchor
|
||||
/// instead of standing alone. Err when `:scope` survives or the rewrite
|
||||
/// has no meaning.
|
||||
pub fn rewrite_selector(selector: &str, anchor: &str) -> Result<String, String> {
|
||||
let s = trim(selector).to_string();
|
||||
let s = VARIANT_PREFIX_RE.replace(&s, ":scope").into_owned();
|
||||
@@ -155,12 +225,13 @@ pub fn rewrite_selector(selector: &str, anchor: &str) -> Result<String, String>
|
||||
format!("{}{}", anchor, state)
|
||||
} else if let Some(child) = after_trim.strip_prefix('>') {
|
||||
// `:scope > .x`, `:scope:hover > .x`: the wrapper's child is the
|
||||
// element itself, so the wrapper's state is the element's.
|
||||
// element itself, so the child compound merges into the anchor
|
||||
// and the wrapper's state is the element's.
|
||||
let (first, remainder) = split_first_compound(child.trim_start());
|
||||
if first.is_empty() {
|
||||
return Err(format!("selector has no child after :scope: {}", selector));
|
||||
}
|
||||
format!("{}{}{}", first, state, remainder)
|
||||
format!("{}{}{}", anchor_with_child(anchor, &first)?, state, remainder)
|
||||
} else if after_trim.starts_with(['+', '~']) {
|
||||
return Err(format!("sibling combinator on :scope has no meaning after unwrap: {}", selector));
|
||||
} else {
|
||||
@@ -467,17 +538,24 @@ mod tests {
|
||||
#[test]
|
||||
fn scope_selectors_rewrite_onto_the_element() {
|
||||
let a = "div.pricing-grid";
|
||||
assert_eq!(rewrite_selector(":scope > .pricing-grid", a).unwrap(), ".pricing-grid");
|
||||
assert_eq!(rewrite_selector(":scope > .pricing-grid .pricing-card", a).unwrap(), ".pricing-grid .pricing-card");
|
||||
assert_eq!(rewrite_selector(":scope > .pricing-grid", a).unwrap(), "div.pricing-grid");
|
||||
assert_eq!(rewrite_selector(":scope > .pricing-grid .pricing-card", a).unwrap(), "div.pricing-grid .pricing-card");
|
||||
assert_eq!(rewrite_selector(":scope .pricing-card", a).unwrap(), "div.pricing-grid .pricing-card");
|
||||
assert_eq!(rewrite_selector(":scope", a).unwrap(), "div.pricing-grid");
|
||||
assert_eq!(rewrite_selector(":scope:hover > .pricing-grid", a).unwrap(), ".pricing-grid:hover");
|
||||
assert_eq!(rewrite_selector(":scope:focus-within > .pricing-grid .card", a).unwrap(), ".pricing-grid:focus-within .card");
|
||||
assert_eq!(rewrite_selector(":scope[open] > .pricing-grid > .card", a).unwrap(), ".pricing-grid[open] > .card");
|
||||
assert_eq!(rewrite_selector(":scope:hover > .pricing-grid", a).unwrap(), "div.pricing-grid:hover");
|
||||
assert_eq!(rewrite_selector(":scope:focus-within > .pricing-grid .card", a).unwrap(), "div.pricing-grid:focus-within .card");
|
||||
assert_eq!(rewrite_selector(":scope[open] > .pricing-grid > .card", a).unwrap(), "div.pricing-grid[open] > .card");
|
||||
assert_eq!(rewrite_selector(":scope:hover .card", a).unwrap(), "div.pricing-grid:hover .card");
|
||||
assert_eq!(rewrite_selector(":scope:not([hidden])", a).unwrap(), "div.pricing-grid:not([hidden])");
|
||||
assert!(rewrite_selector(":scope:hover >", a).is_err());
|
||||
assert_eq!(rewrite_selector("[data-impeccable-variant=\"2\"] > .x", a).unwrap(), ".x");
|
||||
assert_eq!(rewrite_selector("[data-impeccable-variant=\"2\"] > .x", a).unwrap(), "div.pricing-grid.x");
|
||||
// The child compound is the element: a class it adds rides on the
|
||||
// anchor, a type must be the anchor's own, an id anchor takes any.
|
||||
assert_eq!(rewrite_selector(":scope > div.pricing-grid.wide[open]", a).unwrap(), "div.pricing-grid.wide[open]");
|
||||
assert_eq!(rewrite_selector(":scope > div", a).unwrap(), "div.pricing-grid");
|
||||
assert!(rewrite_selector(":scope > section.pricing-grid", a).is_err());
|
||||
assert_eq!(rewrite_selector(":scope > section.pricing", "#pricing").unwrap(), "#pricing.pricing");
|
||||
assert_eq!(rewrite_selector(":scope > .card:not([hidden])", a).unwrap(), "div.pricing-grid.card:not([hidden])");
|
||||
assert!(rewrite_selector(":scope + .x", a).is_err());
|
||||
assert!(rewrite_selector(".a :scope", a).is_err());
|
||||
}
|
||||
@@ -512,7 +590,7 @@ mod tests {
|
||||
"#;
|
||||
let (out, rules) = extract_variant_css(css, "2", "div.pricing-grid").unwrap();
|
||||
assert_eq!(rules, 3, "{out}");
|
||||
assert!(out.contains(".pricing-grid { gap: 32px; }"), "{out}");
|
||||
assert!(out.contains("div.pricing-grid { gap: 32px; }"), "{out}");
|
||||
assert!(out.contains("div.pricing-grid .pricing-card { border: 2px solid #111; }"), "{out}");
|
||||
assert!(out.contains("@media (max-width: 600px)"), "{out}");
|
||||
assert!(out.contains("@keyframes rise"), "{out}");
|
||||
@@ -537,7 +615,7 @@ mod tests {
|
||||
assert_eq!(rules, 4, "{out}");
|
||||
assert!(out.contains("@media (max-width: 600px)"), "{out}");
|
||||
assert!(out.contains("gap: 12px"), "the variant's breakpoint survives: {out}");
|
||||
assert!(out.contains(".pricing-grid:hover .card { border-color: #111; }"), "{out}");
|
||||
assert!(out.contains("div.pricing-grid:hover .card { border-color: #111; }"), "{out}");
|
||||
assert!(out.contains(".site-wide { color: red; }"), "{out}");
|
||||
assert!(!out.contains("8px") && !out.contains("4px"), "the other variant's rules are gone: {out}");
|
||||
assert!(!out.contains("data-impeccable"), "{out}");
|
||||
|
||||
@@ -1300,7 +1300,7 @@ mod bake_tests {
|
||||
let css = std::fs::read_to_string(dir.join("src/styles.css")).unwrap();
|
||||
assert!(css.starts_with(".pricing-grid { display: grid; gap: 20px; }\n"), "existing rules untouched: {css}");
|
||||
assert!(css.contains("/* impeccable generate ab12cd34: accepted variant 2 */"), "{css}");
|
||||
assert!(css.contains(".pricing-grid { gap: 32px; }"), "{css}");
|
||||
assert!(css.contains("div.pricing-grid { gap: 32px; }"), "{css}");
|
||||
assert!(css.contains("div.pricing-grid .pricing-card { border: 2px solid #111; }"), "{css}");
|
||||
assert!(!css.contains("8px") && !css.contains("gap: 0"), "other variants dropped: {css}");
|
||||
assert!(!css.contains(":scope") && !css.contains("data-impeccable"), "{css}");
|
||||
|
||||
@@ -1686,7 +1686,7 @@ Order in `live-accept.mjs`: receipt check → find `impeccable-variants-start <i
|
||||
- JSX: everything above wrapped in `<indent><div data-impeccable-carbonize="ID" style={{ display: "contents" }}>` … `</div>` with body indented 2 more, `<style …>{\`` / `\`}</style>`, `{/* … */}` comments, `style={{ display: 'contents' }}` on the variant div.
|
||||
Result `{handled:true, file: rel, carbonize:boolean, todo?:'REQUIRED before next poll: carbonize cleanup in <file>. See reference/live.md "Required after accept".', bakeSkipped?}`. Discard: replace range with deindented original → `{handled:true, file, carbonize:false}`.
|
||||
|
||||
**Mechanical bake** (`bake.rs`): only on `--bake` (never by default and never on `--no-bake`; the generate lane's accept carbonizes exactly like plain live's, so the agent integrates the accepted variant per live.md), a knob-free HTML/JSX accept is made permanent instead of leaving the carbonize block. Refused (falls back to the carbonize block, with `bakeSkipped:<reason>`) when: `--param-values` is non-empty; the accepted variant carries `data-impeccable-*` or `data-p-*` inside it; the preview CSS uses `var(--p-*)`, `data-p-*`, or `data-impeccable-params`; the variant's root is a component (`<PricingGrid>`, `<Card.Root>`: what it renders is unknown, and its `className` or `id` prop may never reach that element) or has neither an id nor a static class (`className={expr}`); a `:scope` cannot be rewritten (sibling combinators, `:scope` not at the front, nested `@scope`); the accepted variant declares no rule; or no destination stylesheet exists. The rewrite: the accepted `@scope ([data-impeccable-variant="N"])` block is flattened and every selector re-anchored on the root tag's selector (`#id`, else `tag.class.class`): `:scope > .x` → `.x`, `:scope .x` → `<anchor> .x`, `:scope:hover > .x` → `<anchor>:hover > .x`, bare `:scope` → `<anchor>`; Astro's `[data-impeccable-variant="N"] > .x` prefix the same way; nested `@media`/`@supports` inside the block keep their prelude; top-level `@keyframes`/`@font-face` are kept, other variants' blocks dropped. Destination: for `.jsx`/`.tsx` the `.css` file under the app root (skipping node_modules/.git/.impeccable/dist/build/coverage/framework caches, depth ≤ 6, `.min.css` and generated or git-ignored files excluded) with the most rules naming the anchor's id or classes, else the only `.css` file; for other files the page's own last `<style>` block when it has one, else the same search. The rules are **appended** under `/* impeccable generate <id>: accepted variant N */` (existing rules are never rewritten; a same-selector rule later in the cascade overrides declaration by declaration). The source is verified clean (`verifyAcceptedSource`) before anything is written; the stylesheet is written first, then the source with the variant unwrapped at the wrapper's indentation. Result `{handled:true, file, carbonize:false, baked:true, variant:'N', css:{file: rel|null (null = the page's own <style>), rules, anchor}, verify:{clean, findings}}`; the poll's completion for it is `complete`, so the session ends without `live-complete`. After accept with `--page-url`, buffered manual-edit ops whose original/new text appears as an exact text segment in the replaced original block are dropped from `pending-manual-edits.json`.
|
||||
**Mechanical bake** (`bake.rs`): only on `--bake` (never by default and never on `--no-bake`; the generate lane's accept carbonizes exactly like plain live's, so the agent integrates the accepted variant per live.md), a knob-free HTML/JSX accept is made permanent instead of leaving the carbonize block. Refused (falls back to the carbonize block, with `bakeSkipped:<reason>`) when: `--param-values` is non-empty; the accepted variant carries `data-impeccable-*` or `data-p-*` inside it; the preview CSS uses `var(--p-*)`, `data-p-*`, or `data-impeccable-params`; the variant's root is a component (`<PricingGrid>`, `<Card.Root>`: what it renders is unknown, and its `className` or `id` prop may never reach that element) or has neither an id nor a static class (`className={expr}`); a `:scope` cannot be rewritten (sibling combinators, `:scope` not at the front, nested `@scope`); the accepted variant declares no rule; or no destination stylesheet exists. The rewrite: the accepted `@scope ([data-impeccable-variant="N"])` block is flattened and every selector re-anchored on the root tag's selector (`#id`, else `tag.class.class`): `:scope > .x` → `<anchor>` merged with `.x` (the wrapper's only child is the element itself, so the child compound rides on the anchor, a class the anchor already has once, a type only when it is the anchor's own; `#id` anchors take any type), `:scope .x` → `<anchor> .x`, `:scope:hover > .x` → `<anchor>.x:hover`, bare `:scope` → `<anchor>`; Astro's `[data-impeccable-variant="N"] > .x` prefix the same way; nested `@media`/`@supports` inside the block keep their prelude; top-level `@keyframes`/`@font-face` are kept, other variants' blocks dropped. Destination: for `.jsx`/`.tsx` the `.css` file under the app root (skipping node_modules/.git/.impeccable/dist/build/coverage/framework caches, depth ≤ 6, `.min.css` and generated or git-ignored files excluded) with the most rules naming the anchor's id or classes, else the only `.css` file; for other files the page's own last `<style>` block when it has one, else the same search. The rules are **appended** under `/* impeccable generate <id>: accepted variant N */` (existing rules are never rewritten; a same-selector rule later in the cascade overrides declaration by declaration). The source is verified clean (`verifyAcceptedSource`) before anything is written; the stylesheet is written first, then the source with the variant unwrapped at the wrapper's indentation. Result `{handled:true, file, carbonize:false, baked:true, variant:'N', css:{file: rel|null (null = the page's own <style>), rules, anchor}, verify:{clean, findings}}`; the poll's completion for it is `complete`, so the session ends without `live-complete`. After accept with `--page-url`, buffered manual-edit ops whose original/new text appears as an exact text segment in the replaced original block are dropped from `pending-manual-edits.json`.
|
||||
|
||||
Receipt: on any `handled!==false` result write `accept-receipts/<id>.json` = `{id, operation:'accept'|'discard', variantId:'N'|null, result, completedAt}` (tmp+rename). Re-run with same op/variant → prior `result` + `{handled:true, alreadyApplied:true}`; different → `{handled:false, mode:'error', error:'accept_receipt_conflict', priorOperation, priorVariantId}`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user