diff --git a/crates/live/src/bake.rs b/crates/live/src/bake.rs index 954940b55..ce1be4ae0 100644 --- a/crates/live/src/bake.rs +++ b/crates/live/src/bake.rs @@ -109,6 +109,55 @@ fn is_css_ident(s: &str) -> bool { && !s.starts_with(|c: char| c.is_ascii_digit()) } +/// The lasting rules apply to every element the anchor matches, so a bake is +/// only right when that is the accepted element alone. The overlay counted +/// the anchor's matches on the page when Go fired (`element.anchor` and +/// `element.anchorMatches` on the generate event, for the anchor it built +/// from the element's own id or tag and classes); the source anchor must be +/// that same selector, and the count must be one. +fn verify_anchor_unique(anchor: &str, element: Option<&Map>) -> Result<(), String> { + let Some(element) = element else { + return Err(format!( + "{} cannot be verified unique on the page: the session's generate event carries no element descriptor", + anchor + )); + }; + let page_anchor = element.get("anchor").and_then(Value::as_str); + let matches = element.get("anchorMatches").and_then(Value::as_i64); + match (page_anchor, matches) { + (Some(page), Some(1)) if same_anchor(page, anchor) => Ok(()), + (Some(page), Some(n)) if same_anchor(page, anchor) => Err(format!( + "{} matches {} elements on the page; a lasting rule on it would restyle them all", + anchor, n + )), + (Some(page), Some(_)) => Err(format!( + "the element on the page is {} while the source anchors {}; the anchor cannot be verified unique", + page, anchor + )), + _ => Err(format!( + "{} cannot be verified unique on the page: the generate event has no anchor count", + anchor + )), + } +} + +/// `#id` anchors match exactly; `tag.class…` anchors match on the tag and +/// the class set, whatever order the two sides list the classes in. +fn same_anchor(a: &str, b: &str) -> bool { + if a.starts_with('#') || b.starts_with('#') { + return a == b; + } + let parts = |s: &str| -> (String, Vec) { + let mut it = s.split('.'); + let tag = it.next().unwrap_or("").to_string(); + let mut classes: Vec = it.map(String::from).collect(); + classes.sort(); + classes.dedup(); + (tag, classes) + }; + parts(a) == parts(b) +} + /// The first compound selector of `s` and what follows it (the following /// combinator or whitespace included), honouring brackets, parens, and /// quotes. `(s, "")` when there is no combinator. @@ -436,9 +485,11 @@ static HTML_STYLE_BLOCK_RE: Lazy = /// Plan the bake, or say why it is not mechanical. `css_lines` is the whole /// preview stylesheet (JSX template wrap already stripped), `restored` the -/// accepted variant at the wrapper's indentation, `source_after_unwrap` the -/// source file with the variant unwrapped (to find its own ``, `{/* … */}` comments, `style={{ display: 'contents' }}` on the variant div. Result `{handled:true, file: rel, carbonize:boolean, todo?:'REQUIRED before next poll: carbonize cleanup in . 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:`) 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 (``, ``: 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` → `` 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` → ` .x`, `:scope:hover > .x` → `.x:hover`, bare `:scope` → ``; 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 `