Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 133 additions & 2 deletions crates/live/src/live_accept.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! accept/discard of variant sessions: HTML/JSX wrapper carbonize, the
//! Svelte component inline path, receipts, source locks, buffer scrub.

use crate::inject::resolve_source_traits;
use crate::paths::{live_dir, safe_session_id};
use crate::pending_edits::{read_buffer, write_buffer};
use crate::roots::enter_live_root;
Expand Down Expand Up @@ -670,11 +671,33 @@ fn handle_discard_unlocked(id: &str, lines: &[String], target_file: &str) -> Res
Ok(())
}

/// The opening `<style>` tag of a carbonize block in `target_file`: the file
/// type's own `styleTag` with the session id filled in.
///
/// On `.astro` that tag carries `is:inline`, exactly like the variants block
/// it replaces. A bare `<style>` there is a different thing: Astro compiles
/// it into a `?astro&type=style&index=N` module, which renumbers the page's
/// own style modules for as long as the block exists and removes one again
/// when the cleanup deletes it. Astro's dev server answers the reload of
/// that vanished index with a 500 (7.3.6 and later) or serves the wrong
/// block's CSS under the surviving index (7.3.5 and earlier).
fn carbonize_style_open(target_file: &str, id: &str) -> String {
resolve_source_traits(target_file)
.style_tag
.replace("SESSION_ID", id)
}

/// JS: buildCarbonizeReplacement({...})
///
/// `style_open` is the opening `<style>` tag for the carbonize block. It is
/// the same tag the variants block was authored with for this file type
/// (`carbonize_style_open`), so accept never changes how the framework's
/// compiler treats the block.
fn build_carbonize_replacement(
indent: &str,
cs: (&str, &str),
is_jsx: bool,
style_open: &str,
id: &str,
variant_num: &str,
css: Option<&[String]>,
Expand All @@ -699,9 +722,9 @@ fn build_carbonize_replacement(
body_indent, co, id, cc
));
lines.push(format!(
"{}<style data-impeccable-css=\"{}\">{}",
"{}{}{}",
body_indent,
id,
style_open,
if is_jsx { "{`" } else { "" }
));
for css_line in css {
Expand Down Expand Up @@ -828,6 +851,7 @@ fn handle_accept_unlocked(
&indent,
cs,
is_jsx,
&carbonize_style_open(target_file, id),
id,
variant_num,
css_content.as_deref(),
Expand Down Expand Up @@ -1306,6 +1330,113 @@ mod discard_tests {
}
}

#[cfg(test)]
mod carbonize_tests {
use super::*;

fn wrapped(style_open: &str) -> Vec<String> {
[
" <main>",
" <!-- impeccable-variants-start ab12cd34 -->",
" <div data-impeccable-variants=\"ab12cd34\" data-impeccable-variant-count=\"2\" style=\"display: contents\">",
" <!-- Original -->",
" <div data-impeccable-variant=\"original\">",
" <h1 class=\"hero-title\">Title</h1>",
" </div>",
" <!-- Variants: insert below this line -->",
style_open,
" [data-impeccable-variant=\"1\"] > h1 { font-weight: 300; }",
" [data-impeccable-variant=\"2\"] > h1 { font-weight: 900; }",
" </style>",
" <div data-impeccable-variant=\"1\">",
" <h1 class=\"hero-title\">Title</h1>",
" </div>",
" <div data-impeccable-variant=\"2\" style=\"display: none\">",
" <h1 class=\"hero-title\">Title</h1>",
" </div>",
" </div>",
" <!-- impeccable-variants-end ab12cd34 -->",
" </main>",
"",
"<style>",
" .hero-title { font-size: 2rem; }",
"</style>",
]
.iter()
.map(|s| s.to_string())
.collect()
}

fn carbonize(file_name: &str, style_open: &str) -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("impeccable-carbonize-{}-{nanos}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(file_name);
let lines = wrapped(style_open);
std::fs::write(&file, lines.join("\n")).unwrap();
let outcome = handle_accept_unlocked("ab12cd34", "2", &lines, &file.to_string_lossy(), None, None);
let out = std::fs::read_to_string(&file).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
matches!(outcome, Ok(AcceptOutcome::Carbonized { carbonize: true, .. })),
"accept did not carbonize: {out}"
);
out
}

/// Every `<style ...>` opening tag in `source`, in order.
fn style_tags(source: &str) -> Vec<&str> {
source
.lines()
.map(str::trim)
.filter(|l| l.starts_with("<style"))
.collect()
}

/// Astro compiles a bare `<style>` into a style module and numbers the
/// modules in source order. The variants block is `is:inline`, so the
/// carbonize block has to be too: otherwise accept puts a compiled block
/// in front of the page's own and the cleanup takes it away again, and
/// the dev server fails the reload of the index that vanished.
#[test]
fn an_astro_carbonize_block_keeps_the_style_tag_inline() {
let out = carbonize("index.astro", " <style is:inline data-impeccable-css=\"ab12cd34\">");
assert_eq!(
style_tags(&out),
vec!["<style is:inline data-impeccable-css=\"ab12cd34\">", "<style>"],
"{out}"
);
assert!(out.contains("<!-- impeccable-carbonize-start ab12cd34 -->"), "{out}");
}

/// The tag comes from the file type, not from what the agent wrote: an
/// Astro variants block authored without `is:inline` still carbonizes
/// into the inline form.
#[test]
fn an_astro_carbonize_block_is_inline_whatever_the_variants_tag_was() {
let out = carbonize("index.astro", " <style data-impeccable-css=\"ab12cd34\">");
assert_eq!(
style_tags(&out),
vec!["<style is:inline data-impeccable-css=\"ab12cd34\">", "<style>"],
"{out}"
);
}

#[test]
fn an_html_carbonize_block_keeps_the_plain_style_tag() {
let out = carbonize("index.html", " <style data-impeccable-css=\"ab12cd34\">");
assert_eq!(
style_tags(&out),
vec!["<style data-impeccable-css=\"ab12cd34\">", "<style>"],
"{out}"
);
}
}

#[cfg(test)]
mod bake_tests {
use super::*;
Expand Down
1 change: 1 addition & 0 deletions docs/CLI-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1764,6 +1764,7 @@ Order in `live-accept.mjs`: receipt check → find `impeccable-variants-start <i
<indent> …variant lines…
<indent></div>
```
- Astro (`.astro`): as HTML, except the style tag is the file type's own `styleTag`, `<style is:inline data-impeccable-css="ID">`. The variants block it replaces is `is:inline` too, so accept and the cleanup never add or remove a style block that Astro compiles. A bare `<style>` here would become a `?astro&type=style&index=N` module ahead of the page's own, and deleting it again makes Astro's dev server answer the reload of the vanished index with a 500 (Astro 7.3.6+) or serve another block's CSS under the surviving index (earlier versions).
- 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}`.

Expand Down
2 changes: 1 addition & 1 deletion tests/framework-fixtures/astro-vite7/files/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
"preview": "astro preview"
},
"devDependencies": {
"astro": "7.3.5"
"astro": "^7.1.0"
}
}
6 changes: 5 additions & 1 deletion tests/oracle/cases/live-accept.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import fs from 'node:fs';
import path from 'node:path';
import {
LIVE_FILES, linkSvelte, lockPathFor, stageWrappedHtml, stageWrappedJsx, write, writeBuffer,
LIVE_FILES, linkSvelte, lockPathFor, stageWrappedAstro, stageWrappedHtml, stageWrappedJsx, write, writeBuffer,
} from '../live-helpers.mjs';

const F = LIVE_FILES;
Expand Down Expand Up @@ -45,6 +45,10 @@ export default [
fs.writeFileSync(p, fs.readFileSync(p, 'utf8').replace(/ {8}<style data-impeccable-css="ab12cd34">[\s\S]*?<\/style>\n/, ''));
},
}),
// --- Astro: the carbonize style block keeps `is:inline`, so accept never
// adds a compiled style module that the cleanup then removes ---
A('live-accept-astro-variant-2-steps', 'live-astro', ['--id', ID, '--variant', '2', '--param-values', '{"face":"serif"}'], { snap: ['src/**'], setup: stageWrappedAstro }),
A('live-accept-astro-discard', 'live-astro', ['--id', ID, '--discard'], { snap: ['src/**'], setup: stageWrappedAstro }),
A('live-accept-generated-file', 'live-html', ['--id', ID, '--variant', '1'], {
snap: ['dist/**'],
setup: (ws) => {
Expand Down
12 changes: 12 additions & 0 deletions tests/oracle/golden/live-accept-astro-discard.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"stdout": "{\"handled\":true,\"file\":\"src/pages/index.astro\",\"carbonize\":false}\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {
".impeccable/live/accept-receipts/ab12cd34.json": "{\n \"id\": \"ab12cd34\",\n \"operation\": \"discard\",\n \"variantId\": null,\n \"result\": {\n \"handled\": true,\n \"file\": \"src/pages/index.astro\",\n \"carbonize\": false\n },\n \"completedAt\": \"<ISO>\"\n}\n",
".impeccable/live/config.json": "{\n \"files\": [\"src/layouts/Layout.astro\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n",
"src/layouts/Layout.astro": "---\nexport interface Props { title: string }\nconst { title } = Astro.props;\n---\n<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <title>{title}</title>\n </head>\n <body>\n <slot />\n </body>\n</html>\n",
"src/pages/index.astro": "---\nimport Layout from '../layouts/Layout.astro';\n---\n<Layout title=\"Astro Fixture\">\n <main class=\"page\">\n <h1 class=\"hero-title\">Astro Fixture</h1>\n <p class=\"hero-hook\">Minimal Astro tree for oracle live-mode goldens.</p>\n <section id=\"features\" class=\"feature-grid\">\n <article class=\"feature-card\">One</article>\n <article class=\"feature-card\">Two</article>\n </section>\n </main>\n</Layout>\n"
}
}
12 changes: 12 additions & 0 deletions tests/oracle/golden/live-accept-astro-variant-2-steps.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"stdout": "{\"handled\":true,\"file\":\"src/pages/index.astro\",\"carbonize\":true,\"todo\":\"REQUIRED before next poll: carbonize cleanup in src/pages/index.astro. See reference/live.md \\\"Required after accept\\\".\"}\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {
".impeccable/live/accept-receipts/ab12cd34.json": "{\n \"id\": \"ab12cd34\",\n \"operation\": \"accept\",\n \"variantId\": \"2\",\n \"result\": {\n \"handled\": true,\n \"file\": \"src/pages/index.astro\",\n \"carbonize\": true,\n \"todo\": \"REQUIRED before next poll: carbonize cleanup in src/pages/index.astro. See reference/live.md \\\"Required after accept\\\".\"\n },\n \"completedAt\": \"<ISO>\"\n}\n",
".impeccable/live/config.json": "{\n \"files\": [\"src/layouts/Layout.astro\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n",
"src/layouts/Layout.astro": "---\nexport interface Props { title: string }\nconst { title } = Astro.props;\n---\n<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <title>{title}</title>\n </head>\n <body>\n <slot />\n </body>\n</html>\n",
"src/pages/index.astro": "---\nimport Layout from '../layouts/Layout.astro';\n---\n<Layout title=\"Astro Fixture\">\n <main class=\"page\">\n <!-- impeccable-carbonize-start ab12cd34 -->\n <style is:inline data-impeccable-css=\"ab12cd34\">\n [data-impeccable-variant=\"1\"] > h1 { font-weight: 300; color: oklch(var(--p-lightness, 0.5) 0.25 25); }\n [data-impeccable-variant=\"2\"] > h1 { font-weight: 900; }\n [data-impeccable-variant=\"2\"][data-p-face=\"serif\"] > h1 { font-family: ui-serif, serif; }\n [data-impeccable-variant=\"3\"] > h1 { font-weight: 600; text-transform: uppercase; }\n </style>\n <!-- impeccable-param-values ab12cd34: {\"face\":\"serif\"} -->\n <!-- impeccable-carbonize-end ab12cd34 -->\n <div data-impeccable-variant=\"2\" style=\"display: contents\">\n <h1 class=\"hero-title\">Astro Fixture</h1>\n </div>\n <p class=\"hero-hook\">Minimal Astro tree for oracle live-mode goldens.</p>\n <section id=\"features\" class=\"feature-grid\">\n <article class=\"feature-card\">One</article>\n <article class=\"feature-card\">Two</article>\n </section>\n </main>\n</Layout>\n"
}
}
25 changes: 25 additions & 0 deletions tests/oracle/live-helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,31 @@ export function stageWrappedHtml(ws, id = 'ab12cd34') {
fs.writeFileSync(file, src.replace(' ' + original, block));
}

/**
* live-astro src/pages/index.astro with the hero h1 wrapped and variants
* written the way an agent writes them for `styleMode: astro-global-prefixed`:
* an `is:inline` style tag and variant-prefixed selectors, no `@scope`.
*/
export function stageWrappedAstro(ws, id = 'ab12cd34') {
const file = path.join(ws, 'src', 'pages', 'index.astro');
const src = fs.readFileSync(file, 'utf8');
const original = '<h1 class="hero-title">Astro Fixture</h1>';
const indent = ' ';
const css = [
'[data-impeccable-variant="1"] > h1 { font-weight: 300; color: oklch(var(--p-lightness, 0.5) 0.25 25); }',
'[data-impeccable-variant="2"] > h1 { font-weight: 900; }',
'[data-impeccable-variant="2"][data-p-face="serif"] > h1 { font-family: ui-serif, serif; }',
'[data-impeccable-variant="3"] > h1 { font-weight: 600; text-transform: uppercase; }',
];
const block = wrappedBlock({ id, tag: 'h1', inner: original, original, indent })
.replace(/<style data-impeccable-css="[^"]*">[\s\S]*?<\/style>/, [
'<style is:inline data-impeccable-css="' + id + '">',
...css.map((l) => indent + ' ' + l),
indent + ' </style>',
].join('\n'));
fs.writeFileSync(file, src.replace(indent + original, block));
}

/** live-vite src/App.jsx with the hero h1 wrapped and variants written (JSX syntax). */
export function stageWrappedJsx(ws, id = 'ab12cd34') {
const file = path.join(ws, 'src', 'App.jsx');
Expand Down
Loading