Add Svelte-native live mode adapter (#179)

* Fix live preview state for framework components

* Complete stateful live preview coverage

* Record Svelte manual validation

* Fix Svelte live mode adapter

* Fix live Steer apply flow

* Fix Svelte live variant refresh recovery

* Fix live exit bar teardown

* Consolidate Svelte live DeepSeek sweep

* Reconcile Svelte live browser after main rebase

* Fix live accept review regressions

* Fix carbonize column-zero indentation

* Fix live poll lease expiry flake

* Fix Svelte shader preview capture
This commit is contained in:
Abdul Wahab
2026-06-02 00:08:57 -07:00
committed by GitHub
parent 69b5f3af49
commit 6163ca0529
212 changed files with 51520 additions and 4992 deletions
+24 -3
View File
@@ -113,7 +113,9 @@ node .agents/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default) ### Replace mode (default)
@@ -151,6 +153,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules. - `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -342,7 +363,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. **Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper: **How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html ```html
<div data-impeccable-variant="1" data-impeccable-params='[ <div data-impeccable-variant="1" data-impeccable-params='[
@@ -456,7 +477,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below. 1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element). 2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value. 3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source. 4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now. 5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again. After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
+167 -44
View File
@@ -17,6 +17,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options: Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup --page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON): Output (JSON):
{ handled, file, carbonize }`); { handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers // Find the file containing this session's markers
const found = findSessionFile(id, process.cwd()); const found = findSessionFile(id, process.cwd());
if (!found) { const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0); process.exit(0);
} }
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found; const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile); const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({ console.log(JSON.stringify({
handled: false, handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept // Accept
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) — the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) { function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines); const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' }; if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs); const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent); const restored = deindentContent(variantContent, indent);
const replacement = []; const replacement = buildCarbonizeReplacement({
indent,
if (cssContent) { commentSyntax,
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); isJsx,
// JSX targets need the CSS body wrapped in a template literal so that the id,
// `{` and `}` in CSS rules don't get parsed as JSX expressions. variantNum,
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); cssContent,
// Re-indent CSS content to match paramValues,
for (const cssLine of cssContent) { restored,
replacement.push(indent + cssLine.trimStart()); });
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [ const newLines = [
...lines.slice(0, replaceRange.start), ...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
} }
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsing helpers // Parsing helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli(); acceptCli();
} }
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error'; if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done'; return 'agent_done';
} }
@@ -17,11 +17,38 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs'; import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live-sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end'; const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/** /**
* Hard-excluded directory patterns. These are NEVER user-facing pages and * Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +110,14 @@ Output (JSON):
validateConfig(config); validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config); const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) { if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +143,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' })); console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1); process.exit(1);
} }
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +169,68 @@ Output (JSON):
}; };
}); });
const anyInserted = results.some((r) => r.inserted); const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results })); console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1); if (!anyInserted) process.exit(1);
} }
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Expand config.files (which may contain glob patterns) into a literal list * Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through; * of existing file paths relative to rootDir. Literal entries pass through;
@@ -21,6 +21,11 @@ import {
buildCssAuthoring, buildCssAuthoring,
buildCssSelectorPrefixExamples, buildCssSelectorPrefixExamples,
} from './live-wrap.mjs'; } from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']); const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile); const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*'; const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position); const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? ''; ?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({ console.log(JSON.stringify({
mode: 'insert', mode: 'insert',
position, position,
file: path.relative(process.cwd(), targetFile), file: relTargetFile,
insertLine: insertLine + 1, insertLine: insertLine + 1,
commentSyntax, commentSyntax,
styleMode: styleMode.mode, styleMode: styleMode.mode,
@@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs';
// that ceiling and loop in `pollOnce` to synthesize a long poll without // that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package. // depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000; export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now() ? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS; : PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) { if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.'); const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options: Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow) --file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message --help Show this help message
@@ -42,6 +42,10 @@ import {
} from './live-manual-edits-buffer.mjs'; } from './live-manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live-svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
function tombstoneTimedOutApplyId(eventId, details = {}) { function tombstoneTimedOutApplyId(eventId, details = {}) {
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
return entry.event; return entry.event;
} }
entry.leaseUntil = Date.now() + leaseMs; entry.leaseUntil = Date.now() + leaseMs;
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event; return entry.event;
} }
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
const acknowledged = state.pendingEvents[idx].event; const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1); state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush(); scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return acknowledged; return acknowledged;
} }
function findPendingEventById(id) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
return entry?.event || null;
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`; return `live-poll.mjs --reply ${id} done --data '<json>'`;
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
return summary; return summary;
} }
function summarizeActiveSessionForClient(snapshot = {}) {
return {
id: snapshot.id,
phase: snapshot.phase,
pageUrl: snapshot.pageUrl ?? null,
sourceFile: snapshot.sourceFile ?? null,
previewFile: snapshot.previewFile ?? null,
previewMode: snapshot.previewMode ?? null,
expectedVariants: snapshot.expectedVariants ?? 0,
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
};
}
function activeSessionSummaries() {
if (!state.sessionStore) return [];
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
}
function cancelQueuedAnonymousExitEvents() {
let removed = 0;
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
const event = state.pendingEvents[i]?.event;
if (event?.type !== 'exit' || event.id) continue;
state.pendingEvents.splice(i, 1);
removed += 1;
}
if (removed > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return removed;
}
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map(); const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
clearTimeout(state.leaseTimer); clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
} }
if (state.pendingPolls.length === 0) return;
const now = Date.now(); const now = Date.now();
const nextLeaseUntil = state.pendingEvents const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0) .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
state.leaseTimer = setTimeout(() => { state.leaseTimer = setTimeout(() => {
state.leaseTimer = null; state.leaseTimer = null;
flushPendingPolls(); flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now)); broadcastAgentPollingIfChanged();
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
} }
function flushPendingPolls() { function flushPendingPolls() {
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
} }
function agentPollingConnected() { function agentPollingConnected() {
return state.pendingPolls.length > 0; const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
} }
function broadcastAgentPollingIfChanged() { function broadcastAgentPollingIfChanged() {
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/status') { if (p === '/status') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; const sessions = activeSessionSummaries();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: 'ok', status: 'ok',
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/events' && req.method === 'GET') { if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
clearTimeout(state.exitTimer);
state.exitTimer = null;
cancelQueuedAnonymousExitEvents();
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
type: 'connected', type: 'connected',
hasProjectContext: hasProjectContext(), hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(), agentPolling: agentPollingConnected(),
activeSessions: activeSessionSummaries(),
}) + '\n\n'); }) + '\n\n');
state.sseClients.add(res); state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops. // Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
return; return;
} }
} }
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') { if (msg.type !== 'checkpoint') {
enqueueEvent(msg); enqueueEvent(msg);
} }
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
}); });
} }
function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
return base;
}
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
};
} catch {
return base;
}
}
function handlePollPost(req, res) { function handlePollPost(req, res) {
let body = ''; let body = '';
req.on('data', (c) => { body += c; }); req.on('data', (c) => { body += c; });
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return; return;
} }
const pendingEventBeforeAck = findPendingEventById(msg.id);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'steer_done_requires_file_or_message',
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id); const acknowledgedEvent = acknowledgePendingEvent(msg.id);
let skipJournalReply = false; let skipJournalReply = false;
let existingSession = null; let existingSession = null;
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
})); }));
return; return;
} }
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
if (state.sessionStore && msg.id && !skipJournalReply) { if (state.sessionStore && msg.id && !skipJournalReply) {
try { try {
const eventType = msg.type === 'steer_done' const eventType = msg.type === 'steer_done'
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
state.sessionStore.appendEvent({ state.sessionStore.appendEvent({
type: eventType, type: eventType,
id: msg.id, id: msg.id,
file: msg.file, file: replyFileMeta.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
message: msg.message, message: msg.message,
sourceEventType: acknowledgedEvent?.type, sourceEventType: acknowledgedEvent?.type,
carbonize: msg.data?.carbonize === true, carbonize: msg.data?.carbonize === true,
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
} }
flushPendingPolls(); flushPendingPolls();
// Forward the reply to the browser via SSE // Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
file: msg.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
}); });
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
let httpServer = null; let httpServer = null;
function shutdown() { function shutdown() {
cleanupSvelteComponentSessionsBeforeExit();
removeLiveServerInfo(process.cwd()); removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer); if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
@@ -2037,6 +2149,25 @@ function shutdown() {
process.exit(0); process.exit(0);
} }
function cleanupSvelteComponentSessionsBeforeExit() {
try {
removeAllSvelteComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
if (result.applied > 0 || result.failed > 0) {
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
cwd: process.cwd(), cwd: process.cwd(),
reason: 'manual_edit_server_start_recovered_abandoned_transaction', reason: 'manual_edit_server_start_recovered_abandoned_transaction',
}); });
applyLegacyDeferredAcceptsOnStartup();
restorePendingEventsFromStore(); restorePendingEventsFromStore();
pruneStaleManualApplyEvidence(process.cwd()); pruneStaleManualApplyEvidence(process.cwd());
const portArg = args.find(a => a.startsWith('--port=')); const portArg = args.find(a => a.startsWith('--port='));
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
phase: 'new', phase: 'new',
pageUrl: null, pageUrl: null,
sourceFile: null, sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0, expectedVariants: 0,
arrivedVariants: 0, arrivedVariants: 0,
visibleVariant: null, visibleVariant: null,
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'variants_ready': case 'variants_ready':
case 'agent_done': case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile; next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
if (event.carbonize === true) { if (event.carbonize === true) {
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
} }
break; break;
case 'checkpoint': case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase; next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision; next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner; next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant; next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues }; if (event.paramValues) next.paramValues = { ...event.paramValues };
} else { } else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'steer_done': case 'steer_done':
next.phase = 'steer_done'; next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'complete': case 'complete':
next.phase = 'completed'; next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -0,0 +1,826 @@
/**
* Svelte live-mode component injection helpers.
*
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
* variant back into the route source with props mapped to original bindings.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
export function shouldUseSvelteComponentInjection(filePath) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.svelte';
}
export function componentSessionDir(id, cwd = process.cwd()) {
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
}
export function manifestPathForSession(id, cwd = process.cwd()) {
return path.join(componentSessionDir(id, cwd), 'manifest.json');
}
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
return file;
}
/**
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
*/
export function extractMustacheExpressions(text) {
const expressions = [];
const seen = new Set();
const lines = String(text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('<!--')) continue;
let match;
MUSTACHE_RE.lastIndex = 0;
while ((match = MUSTACHE_RE.exec(line)) !== null) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
expressions.push(expr);
}
}
return expressions;
}
export function buildPropContract(expressions) {
return expressions.map((expr, index) => {
const derived = derivePropName(expr, index);
return {
prop: derived,
expr,
placeholder: `{${expr}}`,
};
});
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
return tail[1];
}
return `prop${index}`;
}
export function substituteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
}
return out;
}
export function substitutePropsWithExprs(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
}
return out;
}
export function parseSvelteComponentFile(content) {
const text = String(content || '');
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
const styleBlock = styleMatch ? styleMatch[0] : '';
const markup = styleMatch
? withoutScript.slice(0, styleMatch.index).trim()
: withoutScript.trim();
const cssLines = styleBlock
? styleBlock
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style\s*>$/i, '')
.split('\n')
.map((line) => line.trimEnd())
: [];
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
return { markup, cssLines, styleBlock };
}
function buildPropsScript(contract) {
if (contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const names = contract.map((c) => c.prop).join(', ');
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
function buildVariantStub(variantNum, originalWithProps, contract) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
: '';
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
}
function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
export function scaffoldSvelteComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const manifest = {
id,
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
};
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile,
insertLine,
position,
anchorStartLine,
anchorEndLine,
anchorLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const anchorMarkup = (anchorLines || []).join('\n');
const manifest = {
id,
mode: 'insert',
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
insertLine,
position,
anchorStartLine,
anchorEndLine,
originalMarkup: anchorMarkup,
anchorMarkup,
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: [],
};
}
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
const direct = manifestPathForSession(id, cwd);
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
return null;
}
export function readManifest(manifestPath) {
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return {
...data,
manifestPath,
};
}
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
if (!sourceFile || path.isAbsolute(sourceFile)) {
throw new Error('Invalid svelte-component source file');
}
const full = path.resolve(cwd, sourceFile);
const rel = path.relative(cwd, full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Svelte-component source file escapes project root');
}
if (!fs.existsSync(full)) {
throw new Error('Svelte-component source file not found: ' + sourceFile);
}
return full;
}
function appendCssToSvelteStyle(lines, cssLines) {
const closeIdx = findLastStyleCloseLine(lines);
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
if (closeIdx === -1) {
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
}
return [
...lines.slice(0, closeIdx),
...prepared,
...lines.slice(closeIdx),
];
}
function findLastStyleCloseLine(lines) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/<\/style\s*>/.test(lines[i])) return i;
}
return -1;
}
function bakeParamValuesInCss(cssLines, paramValues) {
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
return cssLines.map((line) => {
let out = line;
for (const [key, value] of Object.entries(paramValues)) {
const varName = `--p-${key}`;
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
}
return out;
});
}
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
const css = String((cssLines || []).join('\n'));
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
const rules = parseCssRules(css);
const output = [];
for (const rule of rules) {
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
}
return output.join('\n')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== '');
}
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
const prelude = rule.prelude.trim();
const body = rule.body.trim();
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
if (/^@scope\b/i.test(prelude)) {
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
const inner = parseCssRules(body);
for (const innerRule of inner) {
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
}
return;
}
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
if (!rewrittenPrelude) return;
output.push(formatCssRule(rewrittenPrelude, body));
}
function parseCssRules(css) {
const rules = [];
const text = String(css || '');
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
const preludeStart = i;
while (i < text.length && text[i] !== '{') i++;
if (i >= text.length) break;
const prelude = text.slice(preludeStart, i).trim();
i++;
const bodyStart = i;
let depth = 1;
let quote = null;
let comment = false;
while (i < text.length && depth > 0) {
const ch = text[i];
const next = text[i + 1];
if (comment) {
if (ch === '*' && next === '/') {
comment = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === quote) quote = null;
i++;
continue;
}
if (ch === '/' && next === '*') {
comment = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
if (prelude) rules.push({ prelude, body });
}
return rules;
}
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
const selectors = splitSelectorList(prelude);
const rewritten = [];
for (const selector of selectors) {
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
if (next) rewritten.push(next);
}
return rewritten.join(', ');
}
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
let out = selector.trim();
const hasVariant = /data-impeccable-variant/.test(out);
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
if (hasVariant) {
out = out.replace(variantSelectorRegex(variantNum), '');
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
}
const paramResult = rewriteParamSelectors(out, paramValues);
if (!paramResult.keep) return '';
out = paramResult.selector;
out = out
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
.replace(/\s+/g, ' ')
.trim();
out = out.replace(/^[>+~]\s*/, '').trim();
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
return out;
}
function rewriteParamSelectors(selector, paramValues) {
let keep = true;
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
const actual = paramValues[key];
if (expected != null && String(actual) !== String(expected)) {
keep = false;
return '';
}
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
keep = false;
return '';
}
return '';
});
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
}
function variantSelectorRegex(variantNum) {
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
}
function formatCssRule(selector, body) {
return `${selector} { ${body.trim()} }`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'svelte-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
if (manifest.mode === 'insert') {
return inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
});
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, start),
...indentedMarkup,
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
}) {
if (!svelteMarkupHasVisibleContent(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
}
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const restoredMarkup = String(markup || '')
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const insertIndex = Number(manifest.insertLine) - 1;
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
}
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, insertIndex),
...indentedMarkup,
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function svelteMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
const variantOpen = matchOpeningTag(markup);
const originalOpen = matchOpeningTag(originalMarkup);
if (!variantOpen || !originalOpen) return markup;
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
const variantAttrs = parseAttrSegments(variantOpen.attrs);
const originalAttrs = parseAttrSegments(originalOpen.attrs);
const additions = [];
let attrs = variantOpen.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const merged = mergeStaticClassAttr(originalClass, variantClass);
if (merged) {
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
variantAttrs.set('class', { ...variantClass, raw: merged });
}
} else if (originalClass && !variantClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class') continue;
if (!variantAttrs.has(name)) additions.push(attr.raw);
}
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
const nextOpen = variantOpen.prefix
+ variantOpen.tag
+ attrs
+ additions.map((attr) => ' ' + attr.trim()).join('')
+ variantOpen.close;
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
if (!match) return null;
return {
raw: match[0],
prefix: match[1],
tag: match[2],
attrs: match[3] || '',
close: match[4],
index: match.index || 0,
};
}
function parseAttrSegments(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
let match;
while ((match = re.exec(attrs))) {
const raw = match[0];
const name = match[1];
out.set(name, {
name,
raw,
start: match.index,
end: match.index + raw.length,
});
}
return out;
}
function mergeStaticClassAttr(originalClass, variantClass) {
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
if (!originalValue || !variantValue) return null;
const quote = variantValue[1];
const classes = [
...variantValue[2].split(/\s+/),
...originalValue[2].split(/\s+/),
].filter(Boolean);
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
}
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
const dir = componentSessionDir(id, cwd);
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
}
export function readDeferredAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return { accepts: [] };
}
}
export function writeDeferredAccept(entry, cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
const data = readDeferredAccepts(cwd);
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
const data = readDeferredAccepts(cwd);
const pending = Array.isArray(data.accepts) ? data.accepts : [];
const results = [];
const remaining = [];
for (const entry of pending) {
try {
const manifest = findSvelteComponentManifest(entry.id, cwd);
if (!manifest) {
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
remaining.push(entry);
continue;
}
const result = inlineSvelteComponentAccept(
manifest,
entry.variantNum,
entry.paramValues || null,
cwd,
);
results.push({ id: entry.id, ok: result.handled !== false, result });
if (result.handled === false) remaining.push(entry);
} catch (err) {
results.push({ id: entry.id, ok: false, error: err.message });
remaining.push(entry);
}
}
if (remaining.length > 0) {
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
} else {
try { fs.rmSync(file, { force: true }); } catch {}
}
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
}
export function buildSvelteComponentCssAuthoring(count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
return {
mode: 'svelte-component',
styleTag: null,
strategy: 'component-style-block',
rulePattern: '.semantic-class { ... }',
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
requirements: [
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
'Put variant CSS in the component <style> block using semantic class selectors.',
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
'Do not use @scope or data-impeccable-variant selectors in component files.',
'Do not edit the route source file during generation; only edit files under componentDir.',
],
forbidden: [
'Do not use @scope blocks in Svelte component variants.',
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
],
paramsFile: 'params.json',
};
}
@@ -0,0 +1,274 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,179 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-input',
'impeccable-live-configure-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+75 -23
View File
@@ -15,6 +15,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n'); .join('\n');
const originalIndented = reindentOriginal(' '); const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs; // Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which // JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
]; ];
// Replace the original element with the wrapper let outputFile = targetFile;
const newLines = [ let outputLines;
...lines.slice(0, startLine), let outputStartLine = startLine + 1;
...wrapperLines, let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
...lines.slice(endLine + 1), let insertLine;
]; let svelteSession = null;
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment). if (useSvelteComponent) {
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above // Svelte/SvelteKit resets component-local state on markup HMR updates.
// the insert marker (HTML: start-comment + outer-div + Original-comment + // Keep generation source-neutral: agents write real variant components
// original-div + content + close-original-div; JSX: outer-div + // under the generated componentDir, the browser mounts them into the live
// start-comment + Original-comment + original-div + content + // DOM, and live-accept.mjs inlines the accepted variant back into the route.
// close-original-div). Multi-line originals push the marker by their svelteSession = scaffoldSvelteComponentSession({
// extra line count. id,
const insertLine = startLine + 6 + (originalLines.length - 1); count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({ console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile), file: outputRelFile,
startLine: startLine + 1, // 1-indexed for the agent sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot) // wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is // is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset, // wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that // endLine pointed inside the wrapper for any picked element that
// spanned more than one source line. // spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed endLine: outputEndLine, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax, commentSyntax: commentSyntax,
styleMode: styleMode.mode, styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: styleMode.styleTag, styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count), cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count), cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length, originalLineCount: originalLines.length,
})); }));
} }
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean); return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
} }
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) { function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') { if (ext === '.jsx' || ext === '.tsx') {
+24 -3
View File
@@ -111,7 +111,9 @@ node .claude/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default) ### Replace mode (default)
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules. - `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. **Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper: **How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html ```html
<div data-impeccable-variant="1" data-impeccable-params='[ <div data-impeccable-variant="1" data-impeccable-params='[
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below. 1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element). 2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value. 3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source. 4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now. 5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again. After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
+167 -44
View File
@@ -17,6 +17,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options: Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup --page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON): Output (JSON):
{ handled, file, carbonize }`); { handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers // Find the file containing this session's markers
const found = findSessionFile(id, process.cwd()); const found = findSessionFile(id, process.cwd());
if (!found) { const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0); process.exit(0);
} }
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found; const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile); const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({ console.log(JSON.stringify({
handled: false, handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept // Accept
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) { function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines); const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' }; if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs); const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent); const restored = deindentContent(variantContent, indent);
const replacement = []; const replacement = buildCarbonizeReplacement({
indent,
if (cssContent) { commentSyntax,
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); isJsx,
// JSX targets need the CSS body wrapped in a template literal so that the id,
// `{` and `}` in CSS rules don't get parsed as JSX expressions. variantNum,
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); cssContent,
// Re-indent CSS content to match paramValues,
for (const cssLine of cssContent) { restored,
replacement.push(indent + cssLine.trimStart()); });
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [ const newLines = [
...lines.slice(0, replaceRange.start), ...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
} }
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsing helpers // Parsing helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli(); acceptCli();
} }
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error'; if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done'; return 'agent_done';
} }
@@ -17,11 +17,38 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs'; import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live-sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end'; const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/** /**
* Hard-excluded directory patterns. These are NEVER user-facing pages and * Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +110,14 @@ Output (JSON):
validateConfig(config); validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config); const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) { if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +143,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' })); console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1); process.exit(1);
} }
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +169,68 @@ Output (JSON):
}; };
}); });
const anyInserted = results.some((r) => r.inserted); const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results })); console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1); if (!anyInserted) process.exit(1);
} }
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Expand config.files (which may contain glob patterns) into a literal list * Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through; * of existing file paths relative to rootDir. Literal entries pass through;
@@ -21,6 +21,11 @@ import {
buildCssAuthoring, buildCssAuthoring,
buildCssSelectorPrefixExamples, buildCssSelectorPrefixExamples,
} from './live-wrap.mjs'; } from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']); const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile); const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*'; const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position); const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? ''; ?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({ console.log(JSON.stringify({
mode: 'insert', mode: 'insert',
position, position,
file: path.relative(process.cwd(), targetFile), file: relTargetFile,
insertLine: insertLine + 1, insertLine: insertLine + 1,
commentSyntax, commentSyntax,
styleMode: styleMode.mode, styleMode: styleMode.mode,
@@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs';
// that ceiling and loop in `pollOnce` to synthesize a long poll without // that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package. // depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000; export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now() ? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS; : PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) { if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.'); const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options: Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow) --file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message --help Show this help message
@@ -42,6 +42,10 @@ import {
} from './live-manual-edits-buffer.mjs'; } from './live-manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live-svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
function tombstoneTimedOutApplyId(eventId, details = {}) { function tombstoneTimedOutApplyId(eventId, details = {}) {
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
return entry.event; return entry.event;
} }
entry.leaseUntil = Date.now() + leaseMs; entry.leaseUntil = Date.now() + leaseMs;
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event; return entry.event;
} }
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
const acknowledged = state.pendingEvents[idx].event; const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1); state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush(); scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return acknowledged; return acknowledged;
} }
function findPendingEventById(id) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
return entry?.event || null;
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`; return `live-poll.mjs --reply ${id} done --data '<json>'`;
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
return summary; return summary;
} }
function summarizeActiveSessionForClient(snapshot = {}) {
return {
id: snapshot.id,
phase: snapshot.phase,
pageUrl: snapshot.pageUrl ?? null,
sourceFile: snapshot.sourceFile ?? null,
previewFile: snapshot.previewFile ?? null,
previewMode: snapshot.previewMode ?? null,
expectedVariants: snapshot.expectedVariants ?? 0,
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
};
}
function activeSessionSummaries() {
if (!state.sessionStore) return [];
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
}
function cancelQueuedAnonymousExitEvents() {
let removed = 0;
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
const event = state.pendingEvents[i]?.event;
if (event?.type !== 'exit' || event.id) continue;
state.pendingEvents.splice(i, 1);
removed += 1;
}
if (removed > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return removed;
}
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map(); const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
clearTimeout(state.leaseTimer); clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
} }
if (state.pendingPolls.length === 0) return;
const now = Date.now(); const now = Date.now();
const nextLeaseUntil = state.pendingEvents const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0) .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
state.leaseTimer = setTimeout(() => { state.leaseTimer = setTimeout(() => {
state.leaseTimer = null; state.leaseTimer = null;
flushPendingPolls(); flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now)); broadcastAgentPollingIfChanged();
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
} }
function flushPendingPolls() { function flushPendingPolls() {
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
} }
function agentPollingConnected() { function agentPollingConnected() {
return state.pendingPolls.length > 0; const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
} }
function broadcastAgentPollingIfChanged() { function broadcastAgentPollingIfChanged() {
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/status') { if (p === '/status') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; const sessions = activeSessionSummaries();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: 'ok', status: 'ok',
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/events' && req.method === 'GET') { if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
clearTimeout(state.exitTimer);
state.exitTimer = null;
cancelQueuedAnonymousExitEvents();
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
type: 'connected', type: 'connected',
hasProjectContext: hasProjectContext(), hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(), agentPolling: agentPollingConnected(),
activeSessions: activeSessionSummaries(),
}) + '\n\n'); }) + '\n\n');
state.sseClients.add(res); state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops. // Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
return; return;
} }
} }
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') { if (msg.type !== 'checkpoint') {
enqueueEvent(msg); enqueueEvent(msg);
} }
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
}); });
} }
function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
return base;
}
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
};
} catch {
return base;
}
}
function handlePollPost(req, res) { function handlePollPost(req, res) {
let body = ''; let body = '';
req.on('data', (c) => { body += c; }); req.on('data', (c) => { body += c; });
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return; return;
} }
const pendingEventBeforeAck = findPendingEventById(msg.id);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'steer_done_requires_file_or_message',
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id); const acknowledgedEvent = acknowledgePendingEvent(msg.id);
let skipJournalReply = false; let skipJournalReply = false;
let existingSession = null; let existingSession = null;
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
})); }));
return; return;
} }
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
if (state.sessionStore && msg.id && !skipJournalReply) { if (state.sessionStore && msg.id && !skipJournalReply) {
try { try {
const eventType = msg.type === 'steer_done' const eventType = msg.type === 'steer_done'
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
state.sessionStore.appendEvent({ state.sessionStore.appendEvent({
type: eventType, type: eventType,
id: msg.id, id: msg.id,
file: msg.file, file: replyFileMeta.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
message: msg.message, message: msg.message,
sourceEventType: acknowledgedEvent?.type, sourceEventType: acknowledgedEvent?.type,
carbonize: msg.data?.carbonize === true, carbonize: msg.data?.carbonize === true,
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
} }
flushPendingPolls(); flushPendingPolls();
// Forward the reply to the browser via SSE // Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
file: msg.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
}); });
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
let httpServer = null; let httpServer = null;
function shutdown() { function shutdown() {
cleanupSvelteComponentSessionsBeforeExit();
removeLiveServerInfo(process.cwd()); removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer); if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
@@ -2037,6 +2149,25 @@ function shutdown() {
process.exit(0); process.exit(0);
} }
function cleanupSvelteComponentSessionsBeforeExit() {
try {
removeAllSvelteComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
if (result.applied > 0 || result.failed > 0) {
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
cwd: process.cwd(), cwd: process.cwd(),
reason: 'manual_edit_server_start_recovered_abandoned_transaction', reason: 'manual_edit_server_start_recovered_abandoned_transaction',
}); });
applyLegacyDeferredAcceptsOnStartup();
restorePendingEventsFromStore(); restorePendingEventsFromStore();
pruneStaleManualApplyEvidence(process.cwd()); pruneStaleManualApplyEvidence(process.cwd());
const portArg = args.find(a => a.startsWith('--port=')); const portArg = args.find(a => a.startsWith('--port='));
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
phase: 'new', phase: 'new',
pageUrl: null, pageUrl: null,
sourceFile: null, sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0, expectedVariants: 0,
arrivedVariants: 0, arrivedVariants: 0,
visibleVariant: null, visibleVariant: null,
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'variants_ready': case 'variants_ready':
case 'agent_done': case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile; next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
if (event.carbonize === true) { if (event.carbonize === true) {
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
} }
break; break;
case 'checkpoint': case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase; next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision; next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner; next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant; next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues }; if (event.paramValues) next.paramValues = { ...event.paramValues };
} else { } else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'steer_done': case 'steer_done':
next.phase = 'steer_done'; next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'complete': case 'complete':
next.phase = 'completed'; next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -0,0 +1,826 @@
/**
* Svelte live-mode component injection helpers.
*
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
* variant back into the route source with props mapped to original bindings.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
export function shouldUseSvelteComponentInjection(filePath) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.svelte';
}
export function componentSessionDir(id, cwd = process.cwd()) {
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
}
export function manifestPathForSession(id, cwd = process.cwd()) {
return path.join(componentSessionDir(id, cwd), 'manifest.json');
}
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
return file;
}
/**
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
*/
export function extractMustacheExpressions(text) {
const expressions = [];
const seen = new Set();
const lines = String(text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('<!--')) continue;
let match;
MUSTACHE_RE.lastIndex = 0;
while ((match = MUSTACHE_RE.exec(line)) !== null) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
expressions.push(expr);
}
}
return expressions;
}
export function buildPropContract(expressions) {
return expressions.map((expr, index) => {
const derived = derivePropName(expr, index);
return {
prop: derived,
expr,
placeholder: `{${expr}}`,
};
});
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
return tail[1];
}
return `prop${index}`;
}
export function substituteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
}
return out;
}
export function substitutePropsWithExprs(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
}
return out;
}
export function parseSvelteComponentFile(content) {
const text = String(content || '');
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
const styleBlock = styleMatch ? styleMatch[0] : '';
const markup = styleMatch
? withoutScript.slice(0, styleMatch.index).trim()
: withoutScript.trim();
const cssLines = styleBlock
? styleBlock
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style\s*>$/i, '')
.split('\n')
.map((line) => line.trimEnd())
: [];
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
return { markup, cssLines, styleBlock };
}
function buildPropsScript(contract) {
if (contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const names = contract.map((c) => c.prop).join(', ');
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
function buildVariantStub(variantNum, originalWithProps, contract) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
: '';
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
}
function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
export function scaffoldSvelteComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const manifest = {
id,
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
};
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile,
insertLine,
position,
anchorStartLine,
anchorEndLine,
anchorLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const anchorMarkup = (anchorLines || []).join('\n');
const manifest = {
id,
mode: 'insert',
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
insertLine,
position,
anchorStartLine,
anchorEndLine,
originalMarkup: anchorMarkup,
anchorMarkup,
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: [],
};
}
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
const direct = manifestPathForSession(id, cwd);
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
return null;
}
export function readManifest(manifestPath) {
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return {
...data,
manifestPath,
};
}
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
if (!sourceFile || path.isAbsolute(sourceFile)) {
throw new Error('Invalid svelte-component source file');
}
const full = path.resolve(cwd, sourceFile);
const rel = path.relative(cwd, full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Svelte-component source file escapes project root');
}
if (!fs.existsSync(full)) {
throw new Error('Svelte-component source file not found: ' + sourceFile);
}
return full;
}
function appendCssToSvelteStyle(lines, cssLines) {
const closeIdx = findLastStyleCloseLine(lines);
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
if (closeIdx === -1) {
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
}
return [
...lines.slice(0, closeIdx),
...prepared,
...lines.slice(closeIdx),
];
}
function findLastStyleCloseLine(lines) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/<\/style\s*>/.test(lines[i])) return i;
}
return -1;
}
function bakeParamValuesInCss(cssLines, paramValues) {
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
return cssLines.map((line) => {
let out = line;
for (const [key, value] of Object.entries(paramValues)) {
const varName = `--p-${key}`;
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
}
return out;
});
}
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
const css = String((cssLines || []).join('\n'));
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
const rules = parseCssRules(css);
const output = [];
for (const rule of rules) {
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
}
return output.join('\n')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== '');
}
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
const prelude = rule.prelude.trim();
const body = rule.body.trim();
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
if (/^@scope\b/i.test(prelude)) {
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
const inner = parseCssRules(body);
for (const innerRule of inner) {
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
}
return;
}
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
if (!rewrittenPrelude) return;
output.push(formatCssRule(rewrittenPrelude, body));
}
function parseCssRules(css) {
const rules = [];
const text = String(css || '');
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
const preludeStart = i;
while (i < text.length && text[i] !== '{') i++;
if (i >= text.length) break;
const prelude = text.slice(preludeStart, i).trim();
i++;
const bodyStart = i;
let depth = 1;
let quote = null;
let comment = false;
while (i < text.length && depth > 0) {
const ch = text[i];
const next = text[i + 1];
if (comment) {
if (ch === '*' && next === '/') {
comment = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === quote) quote = null;
i++;
continue;
}
if (ch === '/' && next === '*') {
comment = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
if (prelude) rules.push({ prelude, body });
}
return rules;
}
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
const selectors = splitSelectorList(prelude);
const rewritten = [];
for (const selector of selectors) {
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
if (next) rewritten.push(next);
}
return rewritten.join(', ');
}
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
let out = selector.trim();
const hasVariant = /data-impeccable-variant/.test(out);
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
if (hasVariant) {
out = out.replace(variantSelectorRegex(variantNum), '');
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
}
const paramResult = rewriteParamSelectors(out, paramValues);
if (!paramResult.keep) return '';
out = paramResult.selector;
out = out
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
.replace(/\s+/g, ' ')
.trim();
out = out.replace(/^[>+~]\s*/, '').trim();
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
return out;
}
function rewriteParamSelectors(selector, paramValues) {
let keep = true;
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
const actual = paramValues[key];
if (expected != null && String(actual) !== String(expected)) {
keep = false;
return '';
}
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
keep = false;
return '';
}
return '';
});
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
}
function variantSelectorRegex(variantNum) {
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
}
function formatCssRule(selector, body) {
return `${selector} { ${body.trim()} }`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'svelte-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
if (manifest.mode === 'insert') {
return inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
});
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, start),
...indentedMarkup,
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
}) {
if (!svelteMarkupHasVisibleContent(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
}
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const restoredMarkup = String(markup || '')
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const insertIndex = Number(manifest.insertLine) - 1;
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
}
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, insertIndex),
...indentedMarkup,
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function svelteMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
const variantOpen = matchOpeningTag(markup);
const originalOpen = matchOpeningTag(originalMarkup);
if (!variantOpen || !originalOpen) return markup;
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
const variantAttrs = parseAttrSegments(variantOpen.attrs);
const originalAttrs = parseAttrSegments(originalOpen.attrs);
const additions = [];
let attrs = variantOpen.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const merged = mergeStaticClassAttr(originalClass, variantClass);
if (merged) {
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
variantAttrs.set('class', { ...variantClass, raw: merged });
}
} else if (originalClass && !variantClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class') continue;
if (!variantAttrs.has(name)) additions.push(attr.raw);
}
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
const nextOpen = variantOpen.prefix
+ variantOpen.tag
+ attrs
+ additions.map((attr) => ' ' + attr.trim()).join('')
+ variantOpen.close;
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
if (!match) return null;
return {
raw: match[0],
prefix: match[1],
tag: match[2],
attrs: match[3] || '',
close: match[4],
index: match.index || 0,
};
}
function parseAttrSegments(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
let match;
while ((match = re.exec(attrs))) {
const raw = match[0];
const name = match[1];
out.set(name, {
name,
raw,
start: match.index,
end: match.index + raw.length,
});
}
return out;
}
function mergeStaticClassAttr(originalClass, variantClass) {
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
if (!originalValue || !variantValue) return null;
const quote = variantValue[1];
const classes = [
...variantValue[2].split(/\s+/),
...originalValue[2].split(/\s+/),
].filter(Boolean);
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
}
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
const dir = componentSessionDir(id, cwd);
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
}
export function readDeferredAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return { accepts: [] };
}
}
export function writeDeferredAccept(entry, cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
const data = readDeferredAccepts(cwd);
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
const data = readDeferredAccepts(cwd);
const pending = Array.isArray(data.accepts) ? data.accepts : [];
const results = [];
const remaining = [];
for (const entry of pending) {
try {
const manifest = findSvelteComponentManifest(entry.id, cwd);
if (!manifest) {
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
remaining.push(entry);
continue;
}
const result = inlineSvelteComponentAccept(
manifest,
entry.variantNum,
entry.paramValues || null,
cwd,
);
results.push({ id: entry.id, ok: result.handled !== false, result });
if (result.handled === false) remaining.push(entry);
} catch (err) {
results.push({ id: entry.id, ok: false, error: err.message });
remaining.push(entry);
}
}
if (remaining.length > 0) {
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
} else {
try { fs.rmSync(file, { force: true }); } catch {}
}
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
}
export function buildSvelteComponentCssAuthoring(count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
return {
mode: 'svelte-component',
styleTag: null,
strategy: 'component-style-block',
rulePattern: '.semantic-class { ... }',
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
requirements: [
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
'Put variant CSS in the component <style> block using semantic class selectors.',
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
'Do not use @scope or data-impeccable-variant selectors in component files.',
'Do not edit the route source file during generation; only edit files under componentDir.',
],
forbidden: [
'Do not use @scope blocks in Svelte component variants.',
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
],
paramsFile: 'params.json',
};
}
@@ -0,0 +1,274 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,179 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-input',
'impeccable-live-configure-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+75 -23
View File
@@ -15,6 +15,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n'); .join('\n');
const originalIndented = reindentOriginal(' '); const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs; // Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which // JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
]; ];
// Replace the original element with the wrapper let outputFile = targetFile;
const newLines = [ let outputLines;
...lines.slice(0, startLine), let outputStartLine = startLine + 1;
...wrapperLines, let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
...lines.slice(endLine + 1), let insertLine;
]; let svelteSession = null;
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment). if (useSvelteComponent) {
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above // Svelte/SvelteKit resets component-local state on markup HMR updates.
// the insert marker (HTML: start-comment + outer-div + Original-comment + // Keep generation source-neutral: agents write real variant components
// original-div + content + close-original-div; JSX: outer-div + // under the generated componentDir, the browser mounts them into the live
// start-comment + Original-comment + original-div + content + // DOM, and live-accept.mjs inlines the accepted variant back into the route.
// close-original-div). Multi-line originals push the marker by their svelteSession = scaffoldSvelteComponentSession({
// extra line count. id,
const insertLine = startLine + 6 + (originalLines.length - 1); count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({ console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile), file: outputRelFile,
startLine: startLine + 1, // 1-indexed for the agent sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot) // wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is // is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset, // wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that // endLine pointed inside the wrapper for any picked element that
// spanned more than one source line. // spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed endLine: outputEndLine, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax, commentSyntax: commentSyntax,
styleMode: styleMode.mode, styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: styleMode.styleTag, styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count), cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count), cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length, originalLineCount: originalLines.length,
})); }));
} }
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean); return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
} }
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) { function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') { if (ext === '.jsx' || ext === '.tsx') {
+24 -3
View File
@@ -111,7 +111,9 @@ node .cursor/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default) ### Replace mode (default)
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules. - `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. **Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper: **How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html ```html
<div data-impeccable-variant="1" data-impeccable-params='[ <div data-impeccable-variant="1" data-impeccable-params='[
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below. 1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element). 2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value. 3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source. 4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now. 5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again. After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
+167 -44
View File
@@ -17,6 +17,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options: Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup --page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON): Output (JSON):
{ handled, file, carbonize }`); { handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers // Find the file containing this session's markers
const found = findSessionFile(id, process.cwd()); const found = findSessionFile(id, process.cwd());
if (!found) { const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0); process.exit(0);
} }
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found; const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile); const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({ console.log(JSON.stringify({
handled: false, handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept // Accept
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) { function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines); const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' }; if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs); const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent); const restored = deindentContent(variantContent, indent);
const replacement = []; const replacement = buildCarbonizeReplacement({
indent,
if (cssContent) { commentSyntax,
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); isJsx,
// JSX targets need the CSS body wrapped in a template literal so that the id,
// `{` and `}` in CSS rules don't get parsed as JSX expressions. variantNum,
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); cssContent,
// Re-indent CSS content to match paramValues,
for (const cssLine of cssContent) { restored,
replacement.push(indent + cssLine.trimStart()); });
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [ const newLines = [
...lines.slice(0, replaceRange.start), ...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
} }
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsing helpers // Parsing helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli(); acceptCli();
} }
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error'; if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done'; return 'agent_done';
} }
@@ -17,11 +17,38 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs'; import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live-sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end'; const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/** /**
* Hard-excluded directory patterns. These are NEVER user-facing pages and * Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +110,14 @@ Output (JSON):
validateConfig(config); validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config); const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) { if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +143,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' })); console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1); process.exit(1);
} }
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +169,68 @@ Output (JSON):
}; };
}); });
const anyInserted = results.some((r) => r.inserted); const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results })); console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1); if (!anyInserted) process.exit(1);
} }
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Expand config.files (which may contain glob patterns) into a literal list * Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through; * of existing file paths relative to rootDir. Literal entries pass through;
@@ -21,6 +21,11 @@ import {
buildCssAuthoring, buildCssAuthoring,
buildCssSelectorPrefixExamples, buildCssSelectorPrefixExamples,
} from './live-wrap.mjs'; } from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']); const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile); const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*'; const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position); const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? ''; ?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({ console.log(JSON.stringify({
mode: 'insert', mode: 'insert',
position, position,
file: path.relative(process.cwd(), targetFile), file: relTargetFile,
insertLine: insertLine + 1, insertLine: insertLine + 1,
commentSyntax, commentSyntax,
styleMode: styleMode.mode, styleMode: styleMode.mode,
@@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs';
// that ceiling and loop in `pollOnce` to synthesize a long poll without // that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package. // depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000; export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now() ? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS; : PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) { if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.'); const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options: Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow) --file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message --help Show this help message
@@ -42,6 +42,10 @@ import {
} from './live-manual-edits-buffer.mjs'; } from './live-manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live-svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
function tombstoneTimedOutApplyId(eventId, details = {}) { function tombstoneTimedOutApplyId(eventId, details = {}) {
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
return entry.event; return entry.event;
} }
entry.leaseUntil = Date.now() + leaseMs; entry.leaseUntil = Date.now() + leaseMs;
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event; return entry.event;
} }
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
const acknowledged = state.pendingEvents[idx].event; const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1); state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush(); scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return acknowledged; return acknowledged;
} }
function findPendingEventById(id) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
return entry?.event || null;
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`; return `live-poll.mjs --reply ${id} done --data '<json>'`;
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
return summary; return summary;
} }
function summarizeActiveSessionForClient(snapshot = {}) {
return {
id: snapshot.id,
phase: snapshot.phase,
pageUrl: snapshot.pageUrl ?? null,
sourceFile: snapshot.sourceFile ?? null,
previewFile: snapshot.previewFile ?? null,
previewMode: snapshot.previewMode ?? null,
expectedVariants: snapshot.expectedVariants ?? 0,
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
};
}
function activeSessionSummaries() {
if (!state.sessionStore) return [];
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
}
function cancelQueuedAnonymousExitEvents() {
let removed = 0;
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
const event = state.pendingEvents[i]?.event;
if (event?.type !== 'exit' || event.id) continue;
state.pendingEvents.splice(i, 1);
removed += 1;
}
if (removed > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return removed;
}
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map(); const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
clearTimeout(state.leaseTimer); clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
} }
if (state.pendingPolls.length === 0) return;
const now = Date.now(); const now = Date.now();
const nextLeaseUntil = state.pendingEvents const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0) .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
state.leaseTimer = setTimeout(() => { state.leaseTimer = setTimeout(() => {
state.leaseTimer = null; state.leaseTimer = null;
flushPendingPolls(); flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now)); broadcastAgentPollingIfChanged();
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
} }
function flushPendingPolls() { function flushPendingPolls() {
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
} }
function agentPollingConnected() { function agentPollingConnected() {
return state.pendingPolls.length > 0; const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
} }
function broadcastAgentPollingIfChanged() { function broadcastAgentPollingIfChanged() {
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/status') { if (p === '/status') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; const sessions = activeSessionSummaries();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: 'ok', status: 'ok',
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/events' && req.method === 'GET') { if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
clearTimeout(state.exitTimer);
state.exitTimer = null;
cancelQueuedAnonymousExitEvents();
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
type: 'connected', type: 'connected',
hasProjectContext: hasProjectContext(), hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(), agentPolling: agentPollingConnected(),
activeSessions: activeSessionSummaries(),
}) + '\n\n'); }) + '\n\n');
state.sseClients.add(res); state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops. // Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
return; return;
} }
} }
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') { if (msg.type !== 'checkpoint') {
enqueueEvent(msg); enqueueEvent(msg);
} }
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
}); });
} }
function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
return base;
}
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
};
} catch {
return base;
}
}
function handlePollPost(req, res) { function handlePollPost(req, res) {
let body = ''; let body = '';
req.on('data', (c) => { body += c; }); req.on('data', (c) => { body += c; });
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return; return;
} }
const pendingEventBeforeAck = findPendingEventById(msg.id);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'steer_done_requires_file_or_message',
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id); const acknowledgedEvent = acknowledgePendingEvent(msg.id);
let skipJournalReply = false; let skipJournalReply = false;
let existingSession = null; let existingSession = null;
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
})); }));
return; return;
} }
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
if (state.sessionStore && msg.id && !skipJournalReply) { if (state.sessionStore && msg.id && !skipJournalReply) {
try { try {
const eventType = msg.type === 'steer_done' const eventType = msg.type === 'steer_done'
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
state.sessionStore.appendEvent({ state.sessionStore.appendEvent({
type: eventType, type: eventType,
id: msg.id, id: msg.id,
file: msg.file, file: replyFileMeta.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
message: msg.message, message: msg.message,
sourceEventType: acknowledgedEvent?.type, sourceEventType: acknowledgedEvent?.type,
carbonize: msg.data?.carbonize === true, carbonize: msg.data?.carbonize === true,
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
} }
flushPendingPolls(); flushPendingPolls();
// Forward the reply to the browser via SSE // Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
file: msg.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
}); });
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
let httpServer = null; let httpServer = null;
function shutdown() { function shutdown() {
cleanupSvelteComponentSessionsBeforeExit();
removeLiveServerInfo(process.cwd()); removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer); if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
@@ -2037,6 +2149,25 @@ function shutdown() {
process.exit(0); process.exit(0);
} }
function cleanupSvelteComponentSessionsBeforeExit() {
try {
removeAllSvelteComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
if (result.applied > 0 || result.failed > 0) {
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
cwd: process.cwd(), cwd: process.cwd(),
reason: 'manual_edit_server_start_recovered_abandoned_transaction', reason: 'manual_edit_server_start_recovered_abandoned_transaction',
}); });
applyLegacyDeferredAcceptsOnStartup();
restorePendingEventsFromStore(); restorePendingEventsFromStore();
pruneStaleManualApplyEvidence(process.cwd()); pruneStaleManualApplyEvidence(process.cwd());
const portArg = args.find(a => a.startsWith('--port=')); const portArg = args.find(a => a.startsWith('--port='));
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
phase: 'new', phase: 'new',
pageUrl: null, pageUrl: null,
sourceFile: null, sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0, expectedVariants: 0,
arrivedVariants: 0, arrivedVariants: 0,
visibleVariant: null, visibleVariant: null,
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'variants_ready': case 'variants_ready':
case 'agent_done': case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile; next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
if (event.carbonize === true) { if (event.carbonize === true) {
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
} }
break; break;
case 'checkpoint': case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase; next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision; next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner; next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant; next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues }; if (event.paramValues) next.paramValues = { ...event.paramValues };
} else { } else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'steer_done': case 'steer_done':
next.phase = 'steer_done'; next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'complete': case 'complete':
next.phase = 'completed'; next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -0,0 +1,826 @@
/**
* Svelte live-mode component injection helpers.
*
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
* variant back into the route source with props mapped to original bindings.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
export function shouldUseSvelteComponentInjection(filePath) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.svelte';
}
export function componentSessionDir(id, cwd = process.cwd()) {
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
}
export function manifestPathForSession(id, cwd = process.cwd()) {
return path.join(componentSessionDir(id, cwd), 'manifest.json');
}
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
return file;
}
/**
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
*/
export function extractMustacheExpressions(text) {
const expressions = [];
const seen = new Set();
const lines = String(text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('<!--')) continue;
let match;
MUSTACHE_RE.lastIndex = 0;
while ((match = MUSTACHE_RE.exec(line)) !== null) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
expressions.push(expr);
}
}
return expressions;
}
export function buildPropContract(expressions) {
return expressions.map((expr, index) => {
const derived = derivePropName(expr, index);
return {
prop: derived,
expr,
placeholder: `{${expr}}`,
};
});
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
return tail[1];
}
return `prop${index}`;
}
export function substituteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
}
return out;
}
export function substitutePropsWithExprs(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
}
return out;
}
export function parseSvelteComponentFile(content) {
const text = String(content || '');
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
const styleBlock = styleMatch ? styleMatch[0] : '';
const markup = styleMatch
? withoutScript.slice(0, styleMatch.index).trim()
: withoutScript.trim();
const cssLines = styleBlock
? styleBlock
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style\s*>$/i, '')
.split('\n')
.map((line) => line.trimEnd())
: [];
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
return { markup, cssLines, styleBlock };
}
function buildPropsScript(contract) {
if (contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const names = contract.map((c) => c.prop).join(', ');
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
function buildVariantStub(variantNum, originalWithProps, contract) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
: '';
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
}
function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
export function scaffoldSvelteComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const manifest = {
id,
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
};
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile,
insertLine,
position,
anchorStartLine,
anchorEndLine,
anchorLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const anchorMarkup = (anchorLines || []).join('\n');
const manifest = {
id,
mode: 'insert',
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
insertLine,
position,
anchorStartLine,
anchorEndLine,
originalMarkup: anchorMarkup,
anchorMarkup,
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: [],
};
}
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
const direct = manifestPathForSession(id, cwd);
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
return null;
}
export function readManifest(manifestPath) {
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return {
...data,
manifestPath,
};
}
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
if (!sourceFile || path.isAbsolute(sourceFile)) {
throw new Error('Invalid svelte-component source file');
}
const full = path.resolve(cwd, sourceFile);
const rel = path.relative(cwd, full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Svelte-component source file escapes project root');
}
if (!fs.existsSync(full)) {
throw new Error('Svelte-component source file not found: ' + sourceFile);
}
return full;
}
function appendCssToSvelteStyle(lines, cssLines) {
const closeIdx = findLastStyleCloseLine(lines);
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
if (closeIdx === -1) {
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
}
return [
...lines.slice(0, closeIdx),
...prepared,
...lines.slice(closeIdx),
];
}
function findLastStyleCloseLine(lines) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/<\/style\s*>/.test(lines[i])) return i;
}
return -1;
}
function bakeParamValuesInCss(cssLines, paramValues) {
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
return cssLines.map((line) => {
let out = line;
for (const [key, value] of Object.entries(paramValues)) {
const varName = `--p-${key}`;
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
}
return out;
});
}
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
const css = String((cssLines || []).join('\n'));
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
const rules = parseCssRules(css);
const output = [];
for (const rule of rules) {
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
}
return output.join('\n')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== '');
}
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
const prelude = rule.prelude.trim();
const body = rule.body.trim();
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
if (/^@scope\b/i.test(prelude)) {
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
const inner = parseCssRules(body);
for (const innerRule of inner) {
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
}
return;
}
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
if (!rewrittenPrelude) return;
output.push(formatCssRule(rewrittenPrelude, body));
}
function parseCssRules(css) {
const rules = [];
const text = String(css || '');
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
const preludeStart = i;
while (i < text.length && text[i] !== '{') i++;
if (i >= text.length) break;
const prelude = text.slice(preludeStart, i).trim();
i++;
const bodyStart = i;
let depth = 1;
let quote = null;
let comment = false;
while (i < text.length && depth > 0) {
const ch = text[i];
const next = text[i + 1];
if (comment) {
if (ch === '*' && next === '/') {
comment = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === quote) quote = null;
i++;
continue;
}
if (ch === '/' && next === '*') {
comment = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
if (prelude) rules.push({ prelude, body });
}
return rules;
}
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
const selectors = splitSelectorList(prelude);
const rewritten = [];
for (const selector of selectors) {
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
if (next) rewritten.push(next);
}
return rewritten.join(', ');
}
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
let out = selector.trim();
const hasVariant = /data-impeccable-variant/.test(out);
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
if (hasVariant) {
out = out.replace(variantSelectorRegex(variantNum), '');
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
}
const paramResult = rewriteParamSelectors(out, paramValues);
if (!paramResult.keep) return '';
out = paramResult.selector;
out = out
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
.replace(/\s+/g, ' ')
.trim();
out = out.replace(/^[>+~]\s*/, '').trim();
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
return out;
}
function rewriteParamSelectors(selector, paramValues) {
let keep = true;
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
const actual = paramValues[key];
if (expected != null && String(actual) !== String(expected)) {
keep = false;
return '';
}
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
keep = false;
return '';
}
return '';
});
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
}
function variantSelectorRegex(variantNum) {
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
}
function formatCssRule(selector, body) {
return `${selector} { ${body.trim()} }`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'svelte-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
if (manifest.mode === 'insert') {
return inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
});
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, start),
...indentedMarkup,
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
}) {
if (!svelteMarkupHasVisibleContent(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
}
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const restoredMarkup = String(markup || '')
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const insertIndex = Number(manifest.insertLine) - 1;
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
}
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, insertIndex),
...indentedMarkup,
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function svelteMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
const variantOpen = matchOpeningTag(markup);
const originalOpen = matchOpeningTag(originalMarkup);
if (!variantOpen || !originalOpen) return markup;
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
const variantAttrs = parseAttrSegments(variantOpen.attrs);
const originalAttrs = parseAttrSegments(originalOpen.attrs);
const additions = [];
let attrs = variantOpen.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const merged = mergeStaticClassAttr(originalClass, variantClass);
if (merged) {
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
variantAttrs.set('class', { ...variantClass, raw: merged });
}
} else if (originalClass && !variantClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class') continue;
if (!variantAttrs.has(name)) additions.push(attr.raw);
}
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
const nextOpen = variantOpen.prefix
+ variantOpen.tag
+ attrs
+ additions.map((attr) => ' ' + attr.trim()).join('')
+ variantOpen.close;
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
if (!match) return null;
return {
raw: match[0],
prefix: match[1],
tag: match[2],
attrs: match[3] || '',
close: match[4],
index: match.index || 0,
};
}
function parseAttrSegments(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
let match;
while ((match = re.exec(attrs))) {
const raw = match[0];
const name = match[1];
out.set(name, {
name,
raw,
start: match.index,
end: match.index + raw.length,
});
}
return out;
}
function mergeStaticClassAttr(originalClass, variantClass) {
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
if (!originalValue || !variantValue) return null;
const quote = variantValue[1];
const classes = [
...variantValue[2].split(/\s+/),
...originalValue[2].split(/\s+/),
].filter(Boolean);
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
}
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
const dir = componentSessionDir(id, cwd);
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
}
export function readDeferredAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return { accepts: [] };
}
}
export function writeDeferredAccept(entry, cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
const data = readDeferredAccepts(cwd);
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
const data = readDeferredAccepts(cwd);
const pending = Array.isArray(data.accepts) ? data.accepts : [];
const results = [];
const remaining = [];
for (const entry of pending) {
try {
const manifest = findSvelteComponentManifest(entry.id, cwd);
if (!manifest) {
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
remaining.push(entry);
continue;
}
const result = inlineSvelteComponentAccept(
manifest,
entry.variantNum,
entry.paramValues || null,
cwd,
);
results.push({ id: entry.id, ok: result.handled !== false, result });
if (result.handled === false) remaining.push(entry);
} catch (err) {
results.push({ id: entry.id, ok: false, error: err.message });
remaining.push(entry);
}
}
if (remaining.length > 0) {
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
} else {
try { fs.rmSync(file, { force: true }); } catch {}
}
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
}
export function buildSvelteComponentCssAuthoring(count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
return {
mode: 'svelte-component',
styleTag: null,
strategy: 'component-style-block',
rulePattern: '.semantic-class { ... }',
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
requirements: [
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
'Put variant CSS in the component <style> block using semantic class selectors.',
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
'Do not use @scope or data-impeccable-variant selectors in component files.',
'Do not edit the route source file during generation; only edit files under componentDir.',
],
forbidden: [
'Do not use @scope blocks in Svelte component variants.',
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
],
paramsFile: 'params.json',
};
}
@@ -0,0 +1,274 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,179 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-input',
'impeccable-live-configure-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+75 -23
View File
@@ -15,6 +15,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n'); .join('\n');
const originalIndented = reindentOriginal(' '); const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs; // Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which // JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
]; ];
// Replace the original element with the wrapper let outputFile = targetFile;
const newLines = [ let outputLines;
...lines.slice(0, startLine), let outputStartLine = startLine + 1;
...wrapperLines, let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
...lines.slice(endLine + 1), let insertLine;
]; let svelteSession = null;
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment). if (useSvelteComponent) {
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above // Svelte/SvelteKit resets component-local state on markup HMR updates.
// the insert marker (HTML: start-comment + outer-div + Original-comment + // Keep generation source-neutral: agents write real variant components
// original-div + content + close-original-div; JSX: outer-div + // under the generated componentDir, the browser mounts them into the live
// start-comment + Original-comment + original-div + content + // DOM, and live-accept.mjs inlines the accepted variant back into the route.
// close-original-div). Multi-line originals push the marker by their svelteSession = scaffoldSvelteComponentSession({
// extra line count. id,
const insertLine = startLine + 6 + (originalLines.length - 1); count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({ console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile), file: outputRelFile,
startLine: startLine + 1, // 1-indexed for the agent sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot) // wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is // is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset, // wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that // endLine pointed inside the wrapper for any picked element that
// spanned more than one source line. // spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed endLine: outputEndLine, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax, commentSyntax: commentSyntax,
styleMode: styleMode.mode, styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: styleMode.styleTag, styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count), cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count), cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length, originalLineCount: originalLines.length,
})); }));
} }
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean); return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
} }
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) { function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') { if (ext === '.jsx' || ext === '.tsx') {
+24 -3
View File
@@ -111,7 +111,9 @@ node .gemini/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default) ### Replace mode (default)
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules. - `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. **Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper: **How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html ```html
<div data-impeccable-variant="1" data-impeccable-params='[ <div data-impeccable-variant="1" data-impeccable-params='[
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below. 1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element). 2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value. 3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source. 4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now. 5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again. After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
+167 -44
View File
@@ -17,6 +17,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options: Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup --page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON): Output (JSON):
{ handled, file, carbonize }`); { handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers // Find the file containing this session's markers
const found = findSessionFile(id, process.cwd()); const found = findSessionFile(id, process.cwd());
if (!found) { const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0); process.exit(0);
} }
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found; const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile); const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({ console.log(JSON.stringify({
handled: false, handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept // Accept
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) { function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines); const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' }; if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs); const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent); const restored = deindentContent(variantContent, indent);
const replacement = []; const replacement = buildCarbonizeReplacement({
indent,
if (cssContent) { commentSyntax,
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); isJsx,
// JSX targets need the CSS body wrapped in a template literal so that the id,
// `{` and `}` in CSS rules don't get parsed as JSX expressions. variantNum,
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); cssContent,
// Re-indent CSS content to match paramValues,
for (const cssLine of cssContent) { restored,
replacement.push(indent + cssLine.trimStart()); });
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [ const newLines = [
...lines.slice(0, replaceRange.start), ...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
} }
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsing helpers // Parsing helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli(); acceptCli();
} }
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error'; if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done'; return 'agent_done';
} }
@@ -17,11 +17,38 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs'; import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live-sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end'; const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/** /**
* Hard-excluded directory patterns. These are NEVER user-facing pages and * Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +110,14 @@ Output (JSON):
validateConfig(config); validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config); const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) { if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +143,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' })); console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1); process.exit(1);
} }
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +169,68 @@ Output (JSON):
}; };
}); });
const anyInserted = results.some((r) => r.inserted); const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results })); console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1); if (!anyInserted) process.exit(1);
} }
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Expand config.files (which may contain glob patterns) into a literal list * Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through; * of existing file paths relative to rootDir. Literal entries pass through;
@@ -21,6 +21,11 @@ import {
buildCssAuthoring, buildCssAuthoring,
buildCssSelectorPrefixExamples, buildCssSelectorPrefixExamples,
} from './live-wrap.mjs'; } from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']); const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile); const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*'; const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position); const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? ''; ?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({ console.log(JSON.stringify({
mode: 'insert', mode: 'insert',
position, position,
file: path.relative(process.cwd(), targetFile), file: relTargetFile,
insertLine: insertLine + 1, insertLine: insertLine + 1,
commentSyntax, commentSyntax,
styleMode: styleMode.mode, styleMode: styleMode.mode,
@@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs';
// that ceiling and loop in `pollOnce` to synthesize a long poll without // that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package. // depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000; export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now() ? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS; : PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) { if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.'); const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options: Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow) --file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message --help Show this help message
@@ -42,6 +42,10 @@ import {
} from './live-manual-edits-buffer.mjs'; } from './live-manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live-svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
function tombstoneTimedOutApplyId(eventId, details = {}) { function tombstoneTimedOutApplyId(eventId, details = {}) {
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
return entry.event; return entry.event;
} }
entry.leaseUntil = Date.now() + leaseMs; entry.leaseUntil = Date.now() + leaseMs;
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event; return entry.event;
} }
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
const acknowledged = state.pendingEvents[idx].event; const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1); state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush(); scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return acknowledged; return acknowledged;
} }
function findPendingEventById(id) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
return entry?.event || null;
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`; return `live-poll.mjs --reply ${id} done --data '<json>'`;
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
return summary; return summary;
} }
function summarizeActiveSessionForClient(snapshot = {}) {
return {
id: snapshot.id,
phase: snapshot.phase,
pageUrl: snapshot.pageUrl ?? null,
sourceFile: snapshot.sourceFile ?? null,
previewFile: snapshot.previewFile ?? null,
previewMode: snapshot.previewMode ?? null,
expectedVariants: snapshot.expectedVariants ?? 0,
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
};
}
function activeSessionSummaries() {
if (!state.sessionStore) return [];
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
}
function cancelQueuedAnonymousExitEvents() {
let removed = 0;
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
const event = state.pendingEvents[i]?.event;
if (event?.type !== 'exit' || event.id) continue;
state.pendingEvents.splice(i, 1);
removed += 1;
}
if (removed > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return removed;
}
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map(); const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
clearTimeout(state.leaseTimer); clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
} }
if (state.pendingPolls.length === 0) return;
const now = Date.now(); const now = Date.now();
const nextLeaseUntil = state.pendingEvents const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0) .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
state.leaseTimer = setTimeout(() => { state.leaseTimer = setTimeout(() => {
state.leaseTimer = null; state.leaseTimer = null;
flushPendingPolls(); flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now)); broadcastAgentPollingIfChanged();
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
} }
function flushPendingPolls() { function flushPendingPolls() {
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
} }
function agentPollingConnected() { function agentPollingConnected() {
return state.pendingPolls.length > 0; const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
} }
function broadcastAgentPollingIfChanged() { function broadcastAgentPollingIfChanged() {
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/status') { if (p === '/status') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; const sessions = activeSessionSummaries();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: 'ok', status: 'ok',
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/events' && req.method === 'GET') { if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
clearTimeout(state.exitTimer);
state.exitTimer = null;
cancelQueuedAnonymousExitEvents();
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
type: 'connected', type: 'connected',
hasProjectContext: hasProjectContext(), hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(), agentPolling: agentPollingConnected(),
activeSessions: activeSessionSummaries(),
}) + '\n\n'); }) + '\n\n');
state.sseClients.add(res); state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops. // Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
return; return;
} }
} }
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') { if (msg.type !== 'checkpoint') {
enqueueEvent(msg); enqueueEvent(msg);
} }
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
}); });
} }
function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
return base;
}
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
};
} catch {
return base;
}
}
function handlePollPost(req, res) { function handlePollPost(req, res) {
let body = ''; let body = '';
req.on('data', (c) => { body += c; }); req.on('data', (c) => { body += c; });
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return; return;
} }
const pendingEventBeforeAck = findPendingEventById(msg.id);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'steer_done_requires_file_or_message',
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id); const acknowledgedEvent = acknowledgePendingEvent(msg.id);
let skipJournalReply = false; let skipJournalReply = false;
let existingSession = null; let existingSession = null;
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
})); }));
return; return;
} }
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
if (state.sessionStore && msg.id && !skipJournalReply) { if (state.sessionStore && msg.id && !skipJournalReply) {
try { try {
const eventType = msg.type === 'steer_done' const eventType = msg.type === 'steer_done'
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
state.sessionStore.appendEvent({ state.sessionStore.appendEvent({
type: eventType, type: eventType,
id: msg.id, id: msg.id,
file: msg.file, file: replyFileMeta.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
message: msg.message, message: msg.message,
sourceEventType: acknowledgedEvent?.type, sourceEventType: acknowledgedEvent?.type,
carbonize: msg.data?.carbonize === true, carbonize: msg.data?.carbonize === true,
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
} }
flushPendingPolls(); flushPendingPolls();
// Forward the reply to the browser via SSE // Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
file: msg.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
}); });
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
let httpServer = null; let httpServer = null;
function shutdown() { function shutdown() {
cleanupSvelteComponentSessionsBeforeExit();
removeLiveServerInfo(process.cwd()); removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer); if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
@@ -2037,6 +2149,25 @@ function shutdown() {
process.exit(0); process.exit(0);
} }
function cleanupSvelteComponentSessionsBeforeExit() {
try {
removeAllSvelteComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
if (result.applied > 0 || result.failed > 0) {
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
cwd: process.cwd(), cwd: process.cwd(),
reason: 'manual_edit_server_start_recovered_abandoned_transaction', reason: 'manual_edit_server_start_recovered_abandoned_transaction',
}); });
applyLegacyDeferredAcceptsOnStartup();
restorePendingEventsFromStore(); restorePendingEventsFromStore();
pruneStaleManualApplyEvidence(process.cwd()); pruneStaleManualApplyEvidence(process.cwd());
const portArg = args.find(a => a.startsWith('--port=')); const portArg = args.find(a => a.startsWith('--port='));
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
phase: 'new', phase: 'new',
pageUrl: null, pageUrl: null,
sourceFile: null, sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0, expectedVariants: 0,
arrivedVariants: 0, arrivedVariants: 0,
visibleVariant: null, visibleVariant: null,
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'variants_ready': case 'variants_ready':
case 'agent_done': case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile; next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
if (event.carbonize === true) { if (event.carbonize === true) {
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
} }
break; break;
case 'checkpoint': case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase; next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision; next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner; next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant; next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues }; if (event.paramValues) next.paramValues = { ...event.paramValues };
} else { } else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'steer_done': case 'steer_done':
next.phase = 'steer_done'; next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'complete': case 'complete':
next.phase = 'completed'; next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -0,0 +1,826 @@
/**
* Svelte live-mode component injection helpers.
*
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
* variant back into the route source with props mapped to original bindings.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
export function shouldUseSvelteComponentInjection(filePath) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.svelte';
}
export function componentSessionDir(id, cwd = process.cwd()) {
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
}
export function manifestPathForSession(id, cwd = process.cwd()) {
return path.join(componentSessionDir(id, cwd), 'manifest.json');
}
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
return file;
}
/**
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
*/
export function extractMustacheExpressions(text) {
const expressions = [];
const seen = new Set();
const lines = String(text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('<!--')) continue;
let match;
MUSTACHE_RE.lastIndex = 0;
while ((match = MUSTACHE_RE.exec(line)) !== null) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
expressions.push(expr);
}
}
return expressions;
}
export function buildPropContract(expressions) {
return expressions.map((expr, index) => {
const derived = derivePropName(expr, index);
return {
prop: derived,
expr,
placeholder: `{${expr}}`,
};
});
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
return tail[1];
}
return `prop${index}`;
}
export function substituteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
}
return out;
}
export function substitutePropsWithExprs(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
}
return out;
}
export function parseSvelteComponentFile(content) {
const text = String(content || '');
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
const styleBlock = styleMatch ? styleMatch[0] : '';
const markup = styleMatch
? withoutScript.slice(0, styleMatch.index).trim()
: withoutScript.trim();
const cssLines = styleBlock
? styleBlock
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style\s*>$/i, '')
.split('\n')
.map((line) => line.trimEnd())
: [];
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
return { markup, cssLines, styleBlock };
}
function buildPropsScript(contract) {
if (contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const names = contract.map((c) => c.prop).join(', ');
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
function buildVariantStub(variantNum, originalWithProps, contract) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
: '';
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
}
function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
export function scaffoldSvelteComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const manifest = {
id,
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
};
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile,
insertLine,
position,
anchorStartLine,
anchorEndLine,
anchorLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const anchorMarkup = (anchorLines || []).join('\n');
const manifest = {
id,
mode: 'insert',
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
insertLine,
position,
anchorStartLine,
anchorEndLine,
originalMarkup: anchorMarkup,
anchorMarkup,
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: [],
};
}
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
const direct = manifestPathForSession(id, cwd);
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
return null;
}
export function readManifest(manifestPath) {
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return {
...data,
manifestPath,
};
}
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
if (!sourceFile || path.isAbsolute(sourceFile)) {
throw new Error('Invalid svelte-component source file');
}
const full = path.resolve(cwd, sourceFile);
const rel = path.relative(cwd, full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Svelte-component source file escapes project root');
}
if (!fs.existsSync(full)) {
throw new Error('Svelte-component source file not found: ' + sourceFile);
}
return full;
}
function appendCssToSvelteStyle(lines, cssLines) {
const closeIdx = findLastStyleCloseLine(lines);
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
if (closeIdx === -1) {
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
}
return [
...lines.slice(0, closeIdx),
...prepared,
...lines.slice(closeIdx),
];
}
function findLastStyleCloseLine(lines) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/<\/style\s*>/.test(lines[i])) return i;
}
return -1;
}
function bakeParamValuesInCss(cssLines, paramValues) {
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
return cssLines.map((line) => {
let out = line;
for (const [key, value] of Object.entries(paramValues)) {
const varName = `--p-${key}`;
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
}
return out;
});
}
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
const css = String((cssLines || []).join('\n'));
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
const rules = parseCssRules(css);
const output = [];
for (const rule of rules) {
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
}
return output.join('\n')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== '');
}
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
const prelude = rule.prelude.trim();
const body = rule.body.trim();
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
if (/^@scope\b/i.test(prelude)) {
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
const inner = parseCssRules(body);
for (const innerRule of inner) {
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
}
return;
}
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
if (!rewrittenPrelude) return;
output.push(formatCssRule(rewrittenPrelude, body));
}
function parseCssRules(css) {
const rules = [];
const text = String(css || '');
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
const preludeStart = i;
while (i < text.length && text[i] !== '{') i++;
if (i >= text.length) break;
const prelude = text.slice(preludeStart, i).trim();
i++;
const bodyStart = i;
let depth = 1;
let quote = null;
let comment = false;
while (i < text.length && depth > 0) {
const ch = text[i];
const next = text[i + 1];
if (comment) {
if (ch === '*' && next === '/') {
comment = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === quote) quote = null;
i++;
continue;
}
if (ch === '/' && next === '*') {
comment = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
if (prelude) rules.push({ prelude, body });
}
return rules;
}
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
const selectors = splitSelectorList(prelude);
const rewritten = [];
for (const selector of selectors) {
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
if (next) rewritten.push(next);
}
return rewritten.join(', ');
}
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
let out = selector.trim();
const hasVariant = /data-impeccable-variant/.test(out);
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
if (hasVariant) {
out = out.replace(variantSelectorRegex(variantNum), '');
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
}
const paramResult = rewriteParamSelectors(out, paramValues);
if (!paramResult.keep) return '';
out = paramResult.selector;
out = out
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
.replace(/\s+/g, ' ')
.trim();
out = out.replace(/^[>+~]\s*/, '').trim();
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
return out;
}
function rewriteParamSelectors(selector, paramValues) {
let keep = true;
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
const actual = paramValues[key];
if (expected != null && String(actual) !== String(expected)) {
keep = false;
return '';
}
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
keep = false;
return '';
}
return '';
});
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
}
function variantSelectorRegex(variantNum) {
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
}
function formatCssRule(selector, body) {
return `${selector} { ${body.trim()} }`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'svelte-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
if (manifest.mode === 'insert') {
return inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
});
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, start),
...indentedMarkup,
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
}) {
if (!svelteMarkupHasVisibleContent(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
}
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const restoredMarkup = String(markup || '')
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const insertIndex = Number(manifest.insertLine) - 1;
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
}
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, insertIndex),
...indentedMarkup,
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function svelteMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
const variantOpen = matchOpeningTag(markup);
const originalOpen = matchOpeningTag(originalMarkup);
if (!variantOpen || !originalOpen) return markup;
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
const variantAttrs = parseAttrSegments(variantOpen.attrs);
const originalAttrs = parseAttrSegments(originalOpen.attrs);
const additions = [];
let attrs = variantOpen.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const merged = mergeStaticClassAttr(originalClass, variantClass);
if (merged) {
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
variantAttrs.set('class', { ...variantClass, raw: merged });
}
} else if (originalClass && !variantClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class') continue;
if (!variantAttrs.has(name)) additions.push(attr.raw);
}
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
const nextOpen = variantOpen.prefix
+ variantOpen.tag
+ attrs
+ additions.map((attr) => ' ' + attr.trim()).join('')
+ variantOpen.close;
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
if (!match) return null;
return {
raw: match[0],
prefix: match[1],
tag: match[2],
attrs: match[3] || '',
close: match[4],
index: match.index || 0,
};
}
function parseAttrSegments(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
let match;
while ((match = re.exec(attrs))) {
const raw = match[0];
const name = match[1];
out.set(name, {
name,
raw,
start: match.index,
end: match.index + raw.length,
});
}
return out;
}
function mergeStaticClassAttr(originalClass, variantClass) {
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
if (!originalValue || !variantValue) return null;
const quote = variantValue[1];
const classes = [
...variantValue[2].split(/\s+/),
...originalValue[2].split(/\s+/),
].filter(Boolean);
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
}
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
const dir = componentSessionDir(id, cwd);
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
}
export function readDeferredAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return { accepts: [] };
}
}
export function writeDeferredAccept(entry, cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
const data = readDeferredAccepts(cwd);
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
const data = readDeferredAccepts(cwd);
const pending = Array.isArray(data.accepts) ? data.accepts : [];
const results = [];
const remaining = [];
for (const entry of pending) {
try {
const manifest = findSvelteComponentManifest(entry.id, cwd);
if (!manifest) {
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
remaining.push(entry);
continue;
}
const result = inlineSvelteComponentAccept(
manifest,
entry.variantNum,
entry.paramValues || null,
cwd,
);
results.push({ id: entry.id, ok: result.handled !== false, result });
if (result.handled === false) remaining.push(entry);
} catch (err) {
results.push({ id: entry.id, ok: false, error: err.message });
remaining.push(entry);
}
}
if (remaining.length > 0) {
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
} else {
try { fs.rmSync(file, { force: true }); } catch {}
}
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
}
export function buildSvelteComponentCssAuthoring(count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
return {
mode: 'svelte-component',
styleTag: null,
strategy: 'component-style-block',
rulePattern: '.semantic-class { ... }',
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
requirements: [
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
'Put variant CSS in the component <style> block using semantic class selectors.',
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
'Do not use @scope or data-impeccable-variant selectors in component files.',
'Do not edit the route source file during generation; only edit files under componentDir.',
],
forbidden: [
'Do not use @scope blocks in Svelte component variants.',
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
],
paramsFile: 'params.json',
};
}
@@ -0,0 +1,274 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,179 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-input',
'impeccable-live-configure-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+75 -23
View File
@@ -15,6 +15,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n'); .join('\n');
const originalIndented = reindentOriginal(' '); const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs; // Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which // JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
]; ];
// Replace the original element with the wrapper let outputFile = targetFile;
const newLines = [ let outputLines;
...lines.slice(0, startLine), let outputStartLine = startLine + 1;
...wrapperLines, let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
...lines.slice(endLine + 1), let insertLine;
]; let svelteSession = null;
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment). if (useSvelteComponent) {
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above // Svelte/SvelteKit resets component-local state on markup HMR updates.
// the insert marker (HTML: start-comment + outer-div + Original-comment + // Keep generation source-neutral: agents write real variant components
// original-div + content + close-original-div; JSX: outer-div + // under the generated componentDir, the browser mounts them into the live
// start-comment + Original-comment + original-div + content + // DOM, and live-accept.mjs inlines the accepted variant back into the route.
// close-original-div). Multi-line originals push the marker by their svelteSession = scaffoldSvelteComponentSession({
// extra line count. id,
const insertLine = startLine + 6 + (originalLines.length - 1); count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({ console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile), file: outputRelFile,
startLine: startLine + 1, // 1-indexed for the agent sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot) // wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is // is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset, // wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that // endLine pointed inside the wrapper for any picked element that
// spanned more than one source line. // spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed endLine: outputEndLine, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax, commentSyntax: commentSyntax,
styleMode: styleMode.mode, styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: styleMode.styleTag, styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count), cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count), cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length, originalLineCount: originalLines.length,
})); }));
} }
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean); return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
} }
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) { function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') { if (ext === '.jsx' || ext === '.tsx') {
+24 -3
View File
@@ -111,7 +111,9 @@ node .github/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default) ### Replace mode (default)
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules. - `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. **Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper: **How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html ```html
<div data-impeccable-variant="1" data-impeccable-params='[ <div data-impeccable-variant="1" data-impeccable-params='[
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below. 1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element). 2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value. 3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source. 4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now. 5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again. After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
+167 -44
View File
@@ -17,6 +17,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options: Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup --page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON): Output (JSON):
{ handled, file, carbonize }`); { handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers // Find the file containing this session's markers
const found = findSessionFile(id, process.cwd()); const found = findSessionFile(id, process.cwd());
if (!found) { const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0); process.exit(0);
} }
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found; const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile); const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({ console.log(JSON.stringify({
handled: false, handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept // Accept
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) { function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines); const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' }; if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs); const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent); const restored = deindentContent(variantContent, indent);
const replacement = []; const replacement = buildCarbonizeReplacement({
indent,
if (cssContent) { commentSyntax,
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); isJsx,
// JSX targets need the CSS body wrapped in a template literal so that the id,
// `{` and `}` in CSS rules don't get parsed as JSX expressions. variantNum,
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); cssContent,
// Re-indent CSS content to match paramValues,
for (const cssLine of cssContent) { restored,
replacement.push(indent + cssLine.trimStart()); });
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [ const newLines = [
...lines.slice(0, replaceRange.start), ...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
} }
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsing helpers // Parsing helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli(); acceptCli();
} }
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error'; if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done'; return 'agent_done';
} }
@@ -17,11 +17,38 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs'; import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live-sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end'; const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/** /**
* Hard-excluded directory patterns. These are NEVER user-facing pages and * Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +110,14 @@ Output (JSON):
validateConfig(config); validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config); const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) { if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +143,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' })); console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1); process.exit(1);
} }
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +169,68 @@ Output (JSON):
}; };
}); });
const anyInserted = results.some((r) => r.inserted); const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results })); console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1); if (!anyInserted) process.exit(1);
} }
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Expand config.files (which may contain glob patterns) into a literal list * Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through; * of existing file paths relative to rootDir. Literal entries pass through;
@@ -21,6 +21,11 @@ import {
buildCssAuthoring, buildCssAuthoring,
buildCssSelectorPrefixExamples, buildCssSelectorPrefixExamples,
} from './live-wrap.mjs'; } from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']); const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile); const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*'; const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position); const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? ''; ?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({ console.log(JSON.stringify({
mode: 'insert', mode: 'insert',
position, position,
file: path.relative(process.cwd(), targetFile), file: relTargetFile,
insertLine: insertLine + 1, insertLine: insertLine + 1,
commentSyntax, commentSyntax,
styleMode: styleMode.mode, styleMode: styleMode.mode,
@@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs';
// that ceiling and loop in `pollOnce` to synthesize a long poll without // that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package. // depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000; export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now() ? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS; : PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) { if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.'); const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options: Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow) --file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message --help Show this help message
@@ -42,6 +42,10 @@ import {
} from './live-manual-edits-buffer.mjs'; } from './live-manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live-svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
function tombstoneTimedOutApplyId(eventId, details = {}) { function tombstoneTimedOutApplyId(eventId, details = {}) {
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
return entry.event; return entry.event;
} }
entry.leaseUntil = Date.now() + leaseMs; entry.leaseUntil = Date.now() + leaseMs;
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event; return entry.event;
} }
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
const acknowledged = state.pendingEvents[idx].event; const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1); state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush(); scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return acknowledged; return acknowledged;
} }
function findPendingEventById(id) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
return entry?.event || null;
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`; return `live-poll.mjs --reply ${id} done --data '<json>'`;
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
return summary; return summary;
} }
function summarizeActiveSessionForClient(snapshot = {}) {
return {
id: snapshot.id,
phase: snapshot.phase,
pageUrl: snapshot.pageUrl ?? null,
sourceFile: snapshot.sourceFile ?? null,
previewFile: snapshot.previewFile ?? null,
previewMode: snapshot.previewMode ?? null,
expectedVariants: snapshot.expectedVariants ?? 0,
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
};
}
function activeSessionSummaries() {
if (!state.sessionStore) return [];
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
}
function cancelQueuedAnonymousExitEvents() {
let removed = 0;
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
const event = state.pendingEvents[i]?.event;
if (event?.type !== 'exit' || event.id) continue;
state.pendingEvents.splice(i, 1);
removed += 1;
}
if (removed > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return removed;
}
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map(); const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
clearTimeout(state.leaseTimer); clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
} }
if (state.pendingPolls.length === 0) return;
const now = Date.now(); const now = Date.now();
const nextLeaseUntil = state.pendingEvents const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0) .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
state.leaseTimer = setTimeout(() => { state.leaseTimer = setTimeout(() => {
state.leaseTimer = null; state.leaseTimer = null;
flushPendingPolls(); flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now)); broadcastAgentPollingIfChanged();
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
} }
function flushPendingPolls() { function flushPendingPolls() {
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
} }
function agentPollingConnected() { function agentPollingConnected() {
return state.pendingPolls.length > 0; const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
} }
function broadcastAgentPollingIfChanged() { function broadcastAgentPollingIfChanged() {
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/status') { if (p === '/status') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; const sessions = activeSessionSummaries();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: 'ok', status: 'ok',
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/events' && req.method === 'GET') { if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
clearTimeout(state.exitTimer);
state.exitTimer = null;
cancelQueuedAnonymousExitEvents();
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
type: 'connected', type: 'connected',
hasProjectContext: hasProjectContext(), hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(), agentPolling: agentPollingConnected(),
activeSessions: activeSessionSummaries(),
}) + '\n\n'); }) + '\n\n');
state.sseClients.add(res); state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops. // Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
return; return;
} }
} }
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') { if (msg.type !== 'checkpoint') {
enqueueEvent(msg); enqueueEvent(msg);
} }
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
}); });
} }
function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
return base;
}
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
};
} catch {
return base;
}
}
function handlePollPost(req, res) { function handlePollPost(req, res) {
let body = ''; let body = '';
req.on('data', (c) => { body += c; }); req.on('data', (c) => { body += c; });
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return; return;
} }
const pendingEventBeforeAck = findPendingEventById(msg.id);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'steer_done_requires_file_or_message',
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id); const acknowledgedEvent = acknowledgePendingEvent(msg.id);
let skipJournalReply = false; let skipJournalReply = false;
let existingSession = null; let existingSession = null;
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
})); }));
return; return;
} }
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
if (state.sessionStore && msg.id && !skipJournalReply) { if (state.sessionStore && msg.id && !skipJournalReply) {
try { try {
const eventType = msg.type === 'steer_done' const eventType = msg.type === 'steer_done'
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
state.sessionStore.appendEvent({ state.sessionStore.appendEvent({
type: eventType, type: eventType,
id: msg.id, id: msg.id,
file: msg.file, file: replyFileMeta.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
message: msg.message, message: msg.message,
sourceEventType: acknowledgedEvent?.type, sourceEventType: acknowledgedEvent?.type,
carbonize: msg.data?.carbonize === true, carbonize: msg.data?.carbonize === true,
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
} }
flushPendingPolls(); flushPendingPolls();
// Forward the reply to the browser via SSE // Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
file: msg.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
}); });
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
let httpServer = null; let httpServer = null;
function shutdown() { function shutdown() {
cleanupSvelteComponentSessionsBeforeExit();
removeLiveServerInfo(process.cwd()); removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer); if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
@@ -2037,6 +2149,25 @@ function shutdown() {
process.exit(0); process.exit(0);
} }
function cleanupSvelteComponentSessionsBeforeExit() {
try {
removeAllSvelteComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
if (result.applied > 0 || result.failed > 0) {
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
cwd: process.cwd(), cwd: process.cwd(),
reason: 'manual_edit_server_start_recovered_abandoned_transaction', reason: 'manual_edit_server_start_recovered_abandoned_transaction',
}); });
applyLegacyDeferredAcceptsOnStartup();
restorePendingEventsFromStore(); restorePendingEventsFromStore();
pruneStaleManualApplyEvidence(process.cwd()); pruneStaleManualApplyEvidence(process.cwd());
const portArg = args.find(a => a.startsWith('--port=')); const portArg = args.find(a => a.startsWith('--port='));
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
phase: 'new', phase: 'new',
pageUrl: null, pageUrl: null,
sourceFile: null, sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0, expectedVariants: 0,
arrivedVariants: 0, arrivedVariants: 0,
visibleVariant: null, visibleVariant: null,
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'variants_ready': case 'variants_ready':
case 'agent_done': case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile; next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
if (event.carbonize === true) { if (event.carbonize === true) {
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
} }
break; break;
case 'checkpoint': case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase; next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision; next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner; next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant; next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues }; if (event.paramValues) next.paramValues = { ...event.paramValues };
} else { } else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'steer_done': case 'steer_done':
next.phase = 'steer_done'; next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'complete': case 'complete':
next.phase = 'completed'; next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -0,0 +1,826 @@
/**
* Svelte live-mode component injection helpers.
*
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
* variant back into the route source with props mapped to original bindings.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
export function shouldUseSvelteComponentInjection(filePath) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.svelte';
}
export function componentSessionDir(id, cwd = process.cwd()) {
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
}
export function manifestPathForSession(id, cwd = process.cwd()) {
return path.join(componentSessionDir(id, cwd), 'manifest.json');
}
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
return file;
}
/**
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
*/
export function extractMustacheExpressions(text) {
const expressions = [];
const seen = new Set();
const lines = String(text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('<!--')) continue;
let match;
MUSTACHE_RE.lastIndex = 0;
while ((match = MUSTACHE_RE.exec(line)) !== null) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
expressions.push(expr);
}
}
return expressions;
}
export function buildPropContract(expressions) {
return expressions.map((expr, index) => {
const derived = derivePropName(expr, index);
return {
prop: derived,
expr,
placeholder: `{${expr}}`,
};
});
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
return tail[1];
}
return `prop${index}`;
}
export function substituteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
}
return out;
}
export function substitutePropsWithExprs(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
}
return out;
}
export function parseSvelteComponentFile(content) {
const text = String(content || '');
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
const styleBlock = styleMatch ? styleMatch[0] : '';
const markup = styleMatch
? withoutScript.slice(0, styleMatch.index).trim()
: withoutScript.trim();
const cssLines = styleBlock
? styleBlock
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style\s*>$/i, '')
.split('\n')
.map((line) => line.trimEnd())
: [];
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
return { markup, cssLines, styleBlock };
}
function buildPropsScript(contract) {
if (contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const names = contract.map((c) => c.prop).join(', ');
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
function buildVariantStub(variantNum, originalWithProps, contract) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
: '';
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
}
function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
export function scaffoldSvelteComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const manifest = {
id,
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
};
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile,
insertLine,
position,
anchorStartLine,
anchorEndLine,
anchorLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const anchorMarkup = (anchorLines || []).join('\n');
const manifest = {
id,
mode: 'insert',
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
insertLine,
position,
anchorStartLine,
anchorEndLine,
originalMarkup: anchorMarkup,
anchorMarkup,
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: [],
};
}
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
const direct = manifestPathForSession(id, cwd);
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
return null;
}
export function readManifest(manifestPath) {
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return {
...data,
manifestPath,
};
}
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
if (!sourceFile || path.isAbsolute(sourceFile)) {
throw new Error('Invalid svelte-component source file');
}
const full = path.resolve(cwd, sourceFile);
const rel = path.relative(cwd, full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Svelte-component source file escapes project root');
}
if (!fs.existsSync(full)) {
throw new Error('Svelte-component source file not found: ' + sourceFile);
}
return full;
}
function appendCssToSvelteStyle(lines, cssLines) {
const closeIdx = findLastStyleCloseLine(lines);
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
if (closeIdx === -1) {
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
}
return [
...lines.slice(0, closeIdx),
...prepared,
...lines.slice(closeIdx),
];
}
function findLastStyleCloseLine(lines) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/<\/style\s*>/.test(lines[i])) return i;
}
return -1;
}
function bakeParamValuesInCss(cssLines, paramValues) {
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
return cssLines.map((line) => {
let out = line;
for (const [key, value] of Object.entries(paramValues)) {
const varName = `--p-${key}`;
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
}
return out;
});
}
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
const css = String((cssLines || []).join('\n'));
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
const rules = parseCssRules(css);
const output = [];
for (const rule of rules) {
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
}
return output.join('\n')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== '');
}
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
const prelude = rule.prelude.trim();
const body = rule.body.trim();
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
if (/^@scope\b/i.test(prelude)) {
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
const inner = parseCssRules(body);
for (const innerRule of inner) {
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
}
return;
}
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
if (!rewrittenPrelude) return;
output.push(formatCssRule(rewrittenPrelude, body));
}
function parseCssRules(css) {
const rules = [];
const text = String(css || '');
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
const preludeStart = i;
while (i < text.length && text[i] !== '{') i++;
if (i >= text.length) break;
const prelude = text.slice(preludeStart, i).trim();
i++;
const bodyStart = i;
let depth = 1;
let quote = null;
let comment = false;
while (i < text.length && depth > 0) {
const ch = text[i];
const next = text[i + 1];
if (comment) {
if (ch === '*' && next === '/') {
comment = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === quote) quote = null;
i++;
continue;
}
if (ch === '/' && next === '*') {
comment = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
if (prelude) rules.push({ prelude, body });
}
return rules;
}
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
const selectors = splitSelectorList(prelude);
const rewritten = [];
for (const selector of selectors) {
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
if (next) rewritten.push(next);
}
return rewritten.join(', ');
}
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
let out = selector.trim();
const hasVariant = /data-impeccable-variant/.test(out);
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
if (hasVariant) {
out = out.replace(variantSelectorRegex(variantNum), '');
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
}
const paramResult = rewriteParamSelectors(out, paramValues);
if (!paramResult.keep) return '';
out = paramResult.selector;
out = out
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
.replace(/\s+/g, ' ')
.trim();
out = out.replace(/^[>+~]\s*/, '').trim();
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
return out;
}
function rewriteParamSelectors(selector, paramValues) {
let keep = true;
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
const actual = paramValues[key];
if (expected != null && String(actual) !== String(expected)) {
keep = false;
return '';
}
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
keep = false;
return '';
}
return '';
});
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
}
function variantSelectorRegex(variantNum) {
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
}
function formatCssRule(selector, body) {
return `${selector} { ${body.trim()} }`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'svelte-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
if (manifest.mode === 'insert') {
return inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
});
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, start),
...indentedMarkup,
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
}) {
if (!svelteMarkupHasVisibleContent(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
}
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const restoredMarkup = String(markup || '')
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const insertIndex = Number(manifest.insertLine) - 1;
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
}
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, insertIndex),
...indentedMarkup,
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function svelteMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
const variantOpen = matchOpeningTag(markup);
const originalOpen = matchOpeningTag(originalMarkup);
if (!variantOpen || !originalOpen) return markup;
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
const variantAttrs = parseAttrSegments(variantOpen.attrs);
const originalAttrs = parseAttrSegments(originalOpen.attrs);
const additions = [];
let attrs = variantOpen.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const merged = mergeStaticClassAttr(originalClass, variantClass);
if (merged) {
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
variantAttrs.set('class', { ...variantClass, raw: merged });
}
} else if (originalClass && !variantClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class') continue;
if (!variantAttrs.has(name)) additions.push(attr.raw);
}
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
const nextOpen = variantOpen.prefix
+ variantOpen.tag
+ attrs
+ additions.map((attr) => ' ' + attr.trim()).join('')
+ variantOpen.close;
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
if (!match) return null;
return {
raw: match[0],
prefix: match[1],
tag: match[2],
attrs: match[3] || '',
close: match[4],
index: match.index || 0,
};
}
function parseAttrSegments(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
let match;
while ((match = re.exec(attrs))) {
const raw = match[0];
const name = match[1];
out.set(name, {
name,
raw,
start: match.index,
end: match.index + raw.length,
});
}
return out;
}
function mergeStaticClassAttr(originalClass, variantClass) {
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
if (!originalValue || !variantValue) return null;
const quote = variantValue[1];
const classes = [
...variantValue[2].split(/\s+/),
...originalValue[2].split(/\s+/),
].filter(Boolean);
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
}
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
const dir = componentSessionDir(id, cwd);
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
}
export function readDeferredAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return { accepts: [] };
}
}
export function writeDeferredAccept(entry, cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
const data = readDeferredAccepts(cwd);
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
const data = readDeferredAccepts(cwd);
const pending = Array.isArray(data.accepts) ? data.accepts : [];
const results = [];
const remaining = [];
for (const entry of pending) {
try {
const manifest = findSvelteComponentManifest(entry.id, cwd);
if (!manifest) {
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
remaining.push(entry);
continue;
}
const result = inlineSvelteComponentAccept(
manifest,
entry.variantNum,
entry.paramValues || null,
cwd,
);
results.push({ id: entry.id, ok: result.handled !== false, result });
if (result.handled === false) remaining.push(entry);
} catch (err) {
results.push({ id: entry.id, ok: false, error: err.message });
remaining.push(entry);
}
}
if (remaining.length > 0) {
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
} else {
try { fs.rmSync(file, { force: true }); } catch {}
}
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
}
export function buildSvelteComponentCssAuthoring(count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
return {
mode: 'svelte-component',
styleTag: null,
strategy: 'component-style-block',
rulePattern: '.semantic-class { ... }',
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
requirements: [
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
'Put variant CSS in the component <style> block using semantic class selectors.',
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
'Do not use @scope or data-impeccable-variant selectors in component files.',
'Do not edit the route source file during generation; only edit files under componentDir.',
],
forbidden: [
'Do not use @scope blocks in Svelte component variants.',
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
],
paramsFile: 'params.json',
};
}
@@ -0,0 +1,274 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,179 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-input',
'impeccable-live-configure-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+75 -23
View File
@@ -15,6 +15,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n'); .join('\n');
const originalIndented = reindentOriginal(' '); const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs; // Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which // JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
]; ];
// Replace the original element with the wrapper let outputFile = targetFile;
const newLines = [ let outputLines;
...lines.slice(0, startLine), let outputStartLine = startLine + 1;
...wrapperLines, let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
...lines.slice(endLine + 1), let insertLine;
]; let svelteSession = null;
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment). if (useSvelteComponent) {
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above // Svelte/SvelteKit resets component-local state on markup HMR updates.
// the insert marker (HTML: start-comment + outer-div + Original-comment + // Keep generation source-neutral: agents write real variant components
// original-div + content + close-original-div; JSX: outer-div + // under the generated componentDir, the browser mounts them into the live
// start-comment + Original-comment + original-div + content + // DOM, and live-accept.mjs inlines the accepted variant back into the route.
// close-original-div). Multi-line originals push the marker by their svelteSession = scaffoldSvelteComponentSession({
// extra line count. id,
const insertLine = startLine + 6 + (originalLines.length - 1); count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({ console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile), file: outputRelFile,
startLine: startLine + 1, // 1-indexed for the agent sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot) // wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is // is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset, // wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that // endLine pointed inside the wrapper for any picked element that
// spanned more than one source line. // spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed endLine: outputEndLine, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax, commentSyntax: commentSyntax,
styleMode: styleMode.mode, styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: styleMode.styleTag, styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count), cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count), cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length, originalLineCount: originalLines.length,
})); }));
} }
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean); return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
} }
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) { function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') { if (ext === '.jsx' || ext === '.tsx') {
+5 -1
View File
@@ -40,14 +40,16 @@ Thumbs.db
# Impeccable-owned project files are split: generated sidecars/config may be # Impeccable-owned project files are split: generated sidecars/config may be
# tracked, but runtime recovery state and local assets should stay local. # tracked, but runtime recovery state and local assets should stay local.
.impeccable/live/server.json .impeccable/live/server.json
.impeccable/hook.cache.json
.impeccable/live/sessions/ .impeccable/live/sessions/
.impeccable/live/previews/
.impeccable/live/annotations/ .impeccable/live/annotations/
.impeccable/live/cache/ .impeccable/live/cache/
.impeccable/live/manual-edit-apply-transaction.json .impeccable/live/manual-edit-apply-transaction.json
.impeccable/live/manual-edit-events.jsonl .impeccable/live/manual-edit-events.jsonl
.impeccable/live/manual-edit-evidence/ .impeccable/live/manual-edit-evidence/
.impeccable/live/pending-manual-edits.json .impeccable/live/pending-manual-edits.json
.impeccable/hook.cache.json .impeccable/live/deferred-svelte-component-accepts.json
.impeccable/history/ .impeccable/history/
# Per-run critique snapshots are local artifacts. ignore.md (also under # Per-run critique snapshots are local artifacts. ignore.md (also under
# this dir) carries deferrals the user may want to share, so it's # this dir) carries deferrals the user may want to share, so it's
@@ -58,6 +60,8 @@ Thumbs.db
# Legacy live mode session file + annotation screenshots # Legacy live mode session file + annotation screenshots
.impeccable-live.json .impeccable-live.json
.impeccable-live/ .impeccable-live/
src/lib/impeccable/ImpeccableLiveRoot.svelte
src/lib/impeccable/__runtime.js
# Legacy per-project live mode injection config. New installs use # Legacy per-project live mode injection config. New installs use
# .impeccable/live/config.json in the project root instead. # .impeccable/live/config.json in the project root instead.
+24 -3
View File
@@ -111,7 +111,9 @@ node .kiro/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default) ### Replace mode (default)
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules. - `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. **Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper: **How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html ```html
<div data-impeccable-variant="1" data-impeccable-params='[ <div data-impeccable-variant="1" data-impeccable-params='[
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below. 1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element). 2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value. 3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source. 4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now. 5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again. After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
+167 -44
View File
@@ -17,6 +17,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options: Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup --page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON): Output (JSON):
{ handled, file, carbonize }`); { handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers // Find the file containing this session's markers
const found = findSessionFile(id, process.cwd()); const found = findSessionFile(id, process.cwd());
if (!found) { const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0); process.exit(0);
} }
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found; const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile); const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({ console.log(JSON.stringify({
handled: false, handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept // Accept
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) { function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines); const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' }; if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs); const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent); const restored = deindentContent(variantContent, indent);
const replacement = []; const replacement = buildCarbonizeReplacement({
indent,
if (cssContent) { commentSyntax,
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); isJsx,
// JSX targets need the CSS body wrapped in a template literal so that the id,
// `{` and `}` in CSS rules don't get parsed as JSX expressions. variantNum,
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); cssContent,
// Re-indent CSS content to match paramValues,
for (const cssLine of cssContent) { restored,
replacement.push(indent + cssLine.trimStart()); });
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [ const newLines = [
...lines.slice(0, replaceRange.start), ...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
} }
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsing helpers // Parsing helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli(); acceptCli();
} }
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error'; if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done'; return 'agent_done';
} }
@@ -17,11 +17,38 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs'; import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live-sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end'; const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/** /**
* Hard-excluded directory patterns. These are NEVER user-facing pages and * Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +110,14 @@ Output (JSON):
validateConfig(config); validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config); const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) { if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +143,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' })); console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1); process.exit(1);
} }
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +169,68 @@ Output (JSON):
}; };
}); });
const anyInserted = results.some((r) => r.inserted); const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results })); console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1); if (!anyInserted) process.exit(1);
} }
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Expand config.files (which may contain glob patterns) into a literal list * Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through; * of existing file paths relative to rootDir. Literal entries pass through;
@@ -21,6 +21,11 @@ import {
buildCssAuthoring, buildCssAuthoring,
buildCssSelectorPrefixExamples, buildCssSelectorPrefixExamples,
} from './live-wrap.mjs'; } from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']); const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile); const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*'; const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position); const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? ''; ?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({ console.log(JSON.stringify({
mode: 'insert', mode: 'insert',
position, position,
file: path.relative(process.cwd(), targetFile), file: relTargetFile,
insertLine: insertLine + 1, insertLine: insertLine + 1,
commentSyntax, commentSyntax,
styleMode: styleMode.mode, styleMode: styleMode.mode,
@@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs';
// that ceiling and loop in `pollOnce` to synthesize a long poll without // that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package. // depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000; export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now() ? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS; : PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) { if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.'); const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options: Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow) --file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message --help Show this help message
+139 -7
View File
@@ -42,6 +42,10 @@ import {
} from './live-manual-edits-buffer.mjs'; } from './live-manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live-svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
function tombstoneTimedOutApplyId(eventId, details = {}) { function tombstoneTimedOutApplyId(eventId, details = {}) {
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
return entry.event; return entry.event;
} }
entry.leaseUntil = Date.now() + leaseMs; entry.leaseUntil = Date.now() + leaseMs;
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event; return entry.event;
} }
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
const acknowledged = state.pendingEvents[idx].event; const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1); state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush(); scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return acknowledged; return acknowledged;
} }
function findPendingEventById(id) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
return entry?.event || null;
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`; return `live-poll.mjs --reply ${id} done --data '<json>'`;
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
return summary; return summary;
} }
function summarizeActiveSessionForClient(snapshot = {}) {
return {
id: snapshot.id,
phase: snapshot.phase,
pageUrl: snapshot.pageUrl ?? null,
sourceFile: snapshot.sourceFile ?? null,
previewFile: snapshot.previewFile ?? null,
previewMode: snapshot.previewMode ?? null,
expectedVariants: snapshot.expectedVariants ?? 0,
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
};
}
function activeSessionSummaries() {
if (!state.sessionStore) return [];
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
}
function cancelQueuedAnonymousExitEvents() {
let removed = 0;
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
const event = state.pendingEvents[i]?.event;
if (event?.type !== 'exit' || event.id) continue;
state.pendingEvents.splice(i, 1);
removed += 1;
}
if (removed > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return removed;
}
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map(); const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
clearTimeout(state.leaseTimer); clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
} }
if (state.pendingPolls.length === 0) return;
const now = Date.now(); const now = Date.now();
const nextLeaseUntil = state.pendingEvents const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0) .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
state.leaseTimer = setTimeout(() => { state.leaseTimer = setTimeout(() => {
state.leaseTimer = null; state.leaseTimer = null;
flushPendingPolls(); flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now)); broadcastAgentPollingIfChanged();
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
} }
function flushPendingPolls() { function flushPendingPolls() {
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
} }
function agentPollingConnected() { function agentPollingConnected() {
return state.pendingPolls.length > 0; const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
} }
function broadcastAgentPollingIfChanged() { function broadcastAgentPollingIfChanged() {
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/status') { if (p === '/status') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; const sessions = activeSessionSummaries();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: 'ok', status: 'ok',
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/events' && req.method === 'GET') { if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
clearTimeout(state.exitTimer);
state.exitTimer = null;
cancelQueuedAnonymousExitEvents();
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
type: 'connected', type: 'connected',
hasProjectContext: hasProjectContext(), hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(), agentPolling: agentPollingConnected(),
activeSessions: activeSessionSummaries(),
}) + '\n\n'); }) + '\n\n');
state.sseClients.add(res); state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops. // Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
return; return;
} }
} }
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') { if (msg.type !== 'checkpoint') {
enqueueEvent(msg); enqueueEvent(msg);
} }
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
}); });
} }
function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
return base;
}
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
};
} catch {
return base;
}
}
function handlePollPost(req, res) { function handlePollPost(req, res) {
let body = ''; let body = '';
req.on('data', (c) => { body += c; }); req.on('data', (c) => { body += c; });
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return; return;
} }
const pendingEventBeforeAck = findPendingEventById(msg.id);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'steer_done_requires_file_or_message',
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id); const acknowledgedEvent = acknowledgePendingEvent(msg.id);
let skipJournalReply = false; let skipJournalReply = false;
let existingSession = null; let existingSession = null;
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
})); }));
return; return;
} }
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
if (state.sessionStore && msg.id && !skipJournalReply) { if (state.sessionStore && msg.id && !skipJournalReply) {
try { try {
const eventType = msg.type === 'steer_done' const eventType = msg.type === 'steer_done'
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
state.sessionStore.appendEvent({ state.sessionStore.appendEvent({
type: eventType, type: eventType,
id: msg.id, id: msg.id,
file: msg.file, file: replyFileMeta.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
message: msg.message, message: msg.message,
sourceEventType: acknowledgedEvent?.type, sourceEventType: acknowledgedEvent?.type,
carbonize: msg.data?.carbonize === true, carbonize: msg.data?.carbonize === true,
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
} }
flushPendingPolls(); flushPendingPolls();
// Forward the reply to the browser via SSE // Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
file: msg.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
}); });
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
let httpServer = null; let httpServer = null;
function shutdown() { function shutdown() {
cleanupSvelteComponentSessionsBeforeExit();
removeLiveServerInfo(process.cwd()); removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer); if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
@@ -2037,6 +2149,25 @@ function shutdown() {
process.exit(0); process.exit(0);
} }
function cleanupSvelteComponentSessionsBeforeExit() {
try {
removeAllSvelteComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
if (result.applied > 0 || result.failed > 0) {
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
cwd: process.cwd(), cwd: process.cwd(),
reason: 'manual_edit_server_start_recovered_abandoned_transaction', reason: 'manual_edit_server_start_recovered_abandoned_transaction',
}); });
applyLegacyDeferredAcceptsOnStartup();
restorePendingEventsFromStore(); restorePendingEventsFromStore();
pruneStaleManualApplyEvidence(process.cwd()); pruneStaleManualApplyEvidence(process.cwd());
const portArg = args.find(a => a.startsWith('--port=')); const portArg = args.find(a => a.startsWith('--port='));
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
phase: 'new', phase: 'new',
pageUrl: null, pageUrl: null,
sourceFile: null, sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0, expectedVariants: 0,
arrivedVariants: 0, arrivedVariants: 0,
visibleVariant: null, visibleVariant: null,
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'variants_ready': case 'variants_ready':
case 'agent_done': case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile; next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
if (event.carbonize === true) { if (event.carbonize === true) {
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
} }
break; break;
case 'checkpoint': case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase; next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision; next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner; next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant; next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues }; if (event.paramValues) next.paramValues = { ...event.paramValues };
} else { } else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'steer_done': case 'steer_done':
next.phase = 'steer_done'; next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'complete': case 'complete':
next.phase = 'completed'; next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -0,0 +1,826 @@
/**
* Svelte live-mode component injection helpers.
*
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
* variant back into the route source with props mapped to original bindings.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
export function shouldUseSvelteComponentInjection(filePath) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.svelte';
}
export function componentSessionDir(id, cwd = process.cwd()) {
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
}
export function manifestPathForSession(id, cwd = process.cwd()) {
return path.join(componentSessionDir(id, cwd), 'manifest.json');
}
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
return file;
}
/**
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
*/
export function extractMustacheExpressions(text) {
const expressions = [];
const seen = new Set();
const lines = String(text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('<!--')) continue;
let match;
MUSTACHE_RE.lastIndex = 0;
while ((match = MUSTACHE_RE.exec(line)) !== null) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
expressions.push(expr);
}
}
return expressions;
}
export function buildPropContract(expressions) {
return expressions.map((expr, index) => {
const derived = derivePropName(expr, index);
return {
prop: derived,
expr,
placeholder: `{${expr}}`,
};
});
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
return tail[1];
}
return `prop${index}`;
}
export function substituteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
}
return out;
}
export function substitutePropsWithExprs(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
}
return out;
}
export function parseSvelteComponentFile(content) {
const text = String(content || '');
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
const styleBlock = styleMatch ? styleMatch[0] : '';
const markup = styleMatch
? withoutScript.slice(0, styleMatch.index).trim()
: withoutScript.trim();
const cssLines = styleBlock
? styleBlock
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style\s*>$/i, '')
.split('\n')
.map((line) => line.trimEnd())
: [];
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
return { markup, cssLines, styleBlock };
}
function buildPropsScript(contract) {
if (contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const names = contract.map((c) => c.prop).join(', ');
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
function buildVariantStub(variantNum, originalWithProps, contract) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
: '';
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
}
function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
export function scaffoldSvelteComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const manifest = {
id,
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
};
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile,
insertLine,
position,
anchorStartLine,
anchorEndLine,
anchorLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const anchorMarkup = (anchorLines || []).join('\n');
const manifest = {
id,
mode: 'insert',
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
insertLine,
position,
anchorStartLine,
anchorEndLine,
originalMarkup: anchorMarkup,
anchorMarkup,
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: [],
};
}
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
const direct = manifestPathForSession(id, cwd);
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
return null;
}
export function readManifest(manifestPath) {
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return {
...data,
manifestPath,
};
}
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
if (!sourceFile || path.isAbsolute(sourceFile)) {
throw new Error('Invalid svelte-component source file');
}
const full = path.resolve(cwd, sourceFile);
const rel = path.relative(cwd, full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Svelte-component source file escapes project root');
}
if (!fs.existsSync(full)) {
throw new Error('Svelte-component source file not found: ' + sourceFile);
}
return full;
}
function appendCssToSvelteStyle(lines, cssLines) {
const closeIdx = findLastStyleCloseLine(lines);
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
if (closeIdx === -1) {
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
}
return [
...lines.slice(0, closeIdx),
...prepared,
...lines.slice(closeIdx),
];
}
function findLastStyleCloseLine(lines) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/<\/style\s*>/.test(lines[i])) return i;
}
return -1;
}
function bakeParamValuesInCss(cssLines, paramValues) {
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
return cssLines.map((line) => {
let out = line;
for (const [key, value] of Object.entries(paramValues)) {
const varName = `--p-${key}`;
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
}
return out;
});
}
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
const css = String((cssLines || []).join('\n'));
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
const rules = parseCssRules(css);
const output = [];
for (const rule of rules) {
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
}
return output.join('\n')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== '');
}
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
const prelude = rule.prelude.trim();
const body = rule.body.trim();
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
if (/^@scope\b/i.test(prelude)) {
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
const inner = parseCssRules(body);
for (const innerRule of inner) {
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
}
return;
}
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
if (!rewrittenPrelude) return;
output.push(formatCssRule(rewrittenPrelude, body));
}
function parseCssRules(css) {
const rules = [];
const text = String(css || '');
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
const preludeStart = i;
while (i < text.length && text[i] !== '{') i++;
if (i >= text.length) break;
const prelude = text.slice(preludeStart, i).trim();
i++;
const bodyStart = i;
let depth = 1;
let quote = null;
let comment = false;
while (i < text.length && depth > 0) {
const ch = text[i];
const next = text[i + 1];
if (comment) {
if (ch === '*' && next === '/') {
comment = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === quote) quote = null;
i++;
continue;
}
if (ch === '/' && next === '*') {
comment = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
if (prelude) rules.push({ prelude, body });
}
return rules;
}
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
const selectors = splitSelectorList(prelude);
const rewritten = [];
for (const selector of selectors) {
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
if (next) rewritten.push(next);
}
return rewritten.join(', ');
}
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
let out = selector.trim();
const hasVariant = /data-impeccable-variant/.test(out);
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
if (hasVariant) {
out = out.replace(variantSelectorRegex(variantNum), '');
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
}
const paramResult = rewriteParamSelectors(out, paramValues);
if (!paramResult.keep) return '';
out = paramResult.selector;
out = out
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
.replace(/\s+/g, ' ')
.trim();
out = out.replace(/^[>+~]\s*/, '').trim();
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
return out;
}
function rewriteParamSelectors(selector, paramValues) {
let keep = true;
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
const actual = paramValues[key];
if (expected != null && String(actual) !== String(expected)) {
keep = false;
return '';
}
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
keep = false;
return '';
}
return '';
});
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
}
function variantSelectorRegex(variantNum) {
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
}
function formatCssRule(selector, body) {
return `${selector} { ${body.trim()} }`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'svelte-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
if (manifest.mode === 'insert') {
return inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
});
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, start),
...indentedMarkup,
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
}) {
if (!svelteMarkupHasVisibleContent(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
}
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const restoredMarkup = String(markup || '')
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const insertIndex = Number(manifest.insertLine) - 1;
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
}
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, insertIndex),
...indentedMarkup,
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function svelteMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
const variantOpen = matchOpeningTag(markup);
const originalOpen = matchOpeningTag(originalMarkup);
if (!variantOpen || !originalOpen) return markup;
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
const variantAttrs = parseAttrSegments(variantOpen.attrs);
const originalAttrs = parseAttrSegments(originalOpen.attrs);
const additions = [];
let attrs = variantOpen.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const merged = mergeStaticClassAttr(originalClass, variantClass);
if (merged) {
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
variantAttrs.set('class', { ...variantClass, raw: merged });
}
} else if (originalClass && !variantClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class') continue;
if (!variantAttrs.has(name)) additions.push(attr.raw);
}
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
const nextOpen = variantOpen.prefix
+ variantOpen.tag
+ attrs
+ additions.map((attr) => ' ' + attr.trim()).join('')
+ variantOpen.close;
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
if (!match) return null;
return {
raw: match[0],
prefix: match[1],
tag: match[2],
attrs: match[3] || '',
close: match[4],
index: match.index || 0,
};
}
function parseAttrSegments(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
let match;
while ((match = re.exec(attrs))) {
const raw = match[0];
const name = match[1];
out.set(name, {
name,
raw,
start: match.index,
end: match.index + raw.length,
});
}
return out;
}
function mergeStaticClassAttr(originalClass, variantClass) {
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
if (!originalValue || !variantValue) return null;
const quote = variantValue[1];
const classes = [
...variantValue[2].split(/\s+/),
...originalValue[2].split(/\s+/),
].filter(Boolean);
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
}
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
const dir = componentSessionDir(id, cwd);
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
}
export function readDeferredAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return { accepts: [] };
}
}
export function writeDeferredAccept(entry, cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
const data = readDeferredAccepts(cwd);
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
const data = readDeferredAccepts(cwd);
const pending = Array.isArray(data.accepts) ? data.accepts : [];
const results = [];
const remaining = [];
for (const entry of pending) {
try {
const manifest = findSvelteComponentManifest(entry.id, cwd);
if (!manifest) {
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
remaining.push(entry);
continue;
}
const result = inlineSvelteComponentAccept(
manifest,
entry.variantNum,
entry.paramValues || null,
cwd,
);
results.push({ id: entry.id, ok: result.handled !== false, result });
if (result.handled === false) remaining.push(entry);
} catch (err) {
results.push({ id: entry.id, ok: false, error: err.message });
remaining.push(entry);
}
}
if (remaining.length > 0) {
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
} else {
try { fs.rmSync(file, { force: true }); } catch {}
}
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
}
export function buildSvelteComponentCssAuthoring(count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
return {
mode: 'svelte-component',
styleTag: null,
strategy: 'component-style-block',
rulePattern: '.semantic-class { ... }',
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
requirements: [
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
'Put variant CSS in the component <style> block using semantic class selectors.',
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
'Do not use @scope or data-impeccable-variant selectors in component files.',
'Do not edit the route source file during generation; only edit files under componentDir.',
],
forbidden: [
'Do not use @scope blocks in Svelte component variants.',
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
],
paramsFile: 'params.json',
};
}
@@ -0,0 +1,274 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,179 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-input',
'impeccable-live-configure-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+75 -23
View File
@@ -15,6 +15,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n'); .join('\n');
const originalIndented = reindentOriginal(' '); const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs; // Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which // JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
]; ];
// Replace the original element with the wrapper let outputFile = targetFile;
const newLines = [ let outputLines;
...lines.slice(0, startLine), let outputStartLine = startLine + 1;
...wrapperLines, let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
...lines.slice(endLine + 1), let insertLine;
]; let svelteSession = null;
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment). if (useSvelteComponent) {
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above // Svelte/SvelteKit resets component-local state on markup HMR updates.
// the insert marker (HTML: start-comment + outer-div + Original-comment + // Keep generation source-neutral: agents write real variant components
// original-div + content + close-original-div; JSX: outer-div + // under the generated componentDir, the browser mounts them into the live
// start-comment + Original-comment + original-div + content + // DOM, and live-accept.mjs inlines the accepted variant back into the route.
// close-original-div). Multi-line originals push the marker by their svelteSession = scaffoldSvelteComponentSession({
// extra line count. id,
const insertLine = startLine + 6 + (originalLines.length - 1); count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({ console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile), file: outputRelFile,
startLine: startLine + 1, // 1-indexed for the agent sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot) // wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is // is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset, // wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that // endLine pointed inside the wrapper for any picked element that
// spanned more than one source line. // spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed endLine: outputEndLine, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax, commentSyntax: commentSyntax,
styleMode: styleMode.mode, styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: styleMode.styleTag, styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count), cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count), cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length, originalLineCount: originalLines.length,
})); }));
} }
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean); return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
} }
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) { function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') { if (ext === '.jsx' || ext === '.tsx') {
+24 -3
View File
@@ -111,7 +111,9 @@ node .opencode/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count E
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default) ### Replace mode (default)
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules. - `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. **Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper: **How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html ```html
<div data-impeccable-variant="1" data-impeccable-params='[ <div data-impeccable-variant="1" data-impeccable-params='[
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below. 1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element). 2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value. 3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source. 4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now. 5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again. After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
@@ -17,6 +17,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options: Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup --page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON): Output (JSON):
{ handled, file, carbonize }`); { handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers // Find the file containing this session's markers
const found = findSessionFile(id, process.cwd()); const found = findSessionFile(id, process.cwd());
if (!found) { const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0); process.exit(0);
} }
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found; const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile); const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({ console.log(JSON.stringify({
handled: false, handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept // Accept
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) { function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines); const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' }; if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs); const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent); const restored = deindentContent(variantContent, indent);
const replacement = []; const replacement = buildCarbonizeReplacement({
indent,
if (cssContent) { commentSyntax,
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); isJsx,
// JSX targets need the CSS body wrapped in a template literal so that the id,
// `{` and `}` in CSS rules don't get parsed as JSX expressions. variantNum,
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); cssContent,
// Re-indent CSS content to match paramValues,
for (const cssLine of cssContent) { restored,
replacement.push(indent + cssLine.trimStart()); });
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [ const newLines = [
...lines.slice(0, replaceRange.start), ...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
} }
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsing helpers // Parsing helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli(); acceptCli();
} }
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error'; if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done'; return 'agent_done';
} }
@@ -17,11 +17,38 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs'; import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live-sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end'; const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/** /**
* Hard-excluded directory patterns. These are NEVER user-facing pages and * Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +110,14 @@ Output (JSON):
validateConfig(config); validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config); const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) { if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +143,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' })); console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1); process.exit(1);
} }
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +169,68 @@ Output (JSON):
}; };
}); });
const anyInserted = results.some((r) => r.inserted); const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results })); console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1); if (!anyInserted) process.exit(1);
} }
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Expand config.files (which may contain glob patterns) into a literal list * Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through; * of existing file paths relative to rootDir. Literal entries pass through;
@@ -21,6 +21,11 @@ import {
buildCssAuthoring, buildCssAuthoring,
buildCssSelectorPrefixExamples, buildCssSelectorPrefixExamples,
} from './live-wrap.mjs'; } from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']); const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile); const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*'; const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position); const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? ''; ?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({ console.log(JSON.stringify({
mode: 'insert', mode: 'insert',
position, position,
file: path.relative(process.cwd(), targetFile), file: relTargetFile,
insertLine: insertLine + 1, insertLine: insertLine + 1,
commentSyntax, commentSyntax,
styleMode: styleMode.mode, styleMode: styleMode.mode,
@@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs';
// that ceiling and loop in `pollOnce` to synthesize a long poll without // that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package. // depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000; export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now() ? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS; : PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) { if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.'); const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options: Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow) --file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message --help Show this help message
@@ -42,6 +42,10 @@ import {
} from './live-manual-edits-buffer.mjs'; } from './live-manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live-svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
function tombstoneTimedOutApplyId(eventId, details = {}) { function tombstoneTimedOutApplyId(eventId, details = {}) {
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
return entry.event; return entry.event;
} }
entry.leaseUntil = Date.now() + leaseMs; entry.leaseUntil = Date.now() + leaseMs;
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event; return entry.event;
} }
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
const acknowledged = state.pendingEvents[idx].event; const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1); state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush(); scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return acknowledged; return acknowledged;
} }
function findPendingEventById(id) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
return entry?.event || null;
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`; return `live-poll.mjs --reply ${id} done --data '<json>'`;
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
return summary; return summary;
} }
function summarizeActiveSessionForClient(snapshot = {}) {
return {
id: snapshot.id,
phase: snapshot.phase,
pageUrl: snapshot.pageUrl ?? null,
sourceFile: snapshot.sourceFile ?? null,
previewFile: snapshot.previewFile ?? null,
previewMode: snapshot.previewMode ?? null,
expectedVariants: snapshot.expectedVariants ?? 0,
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
};
}
function activeSessionSummaries() {
if (!state.sessionStore) return [];
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
}
function cancelQueuedAnonymousExitEvents() {
let removed = 0;
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
const event = state.pendingEvents[i]?.event;
if (event?.type !== 'exit' || event.id) continue;
state.pendingEvents.splice(i, 1);
removed += 1;
}
if (removed > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return removed;
}
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map(); const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
clearTimeout(state.leaseTimer); clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
} }
if (state.pendingPolls.length === 0) return;
const now = Date.now(); const now = Date.now();
const nextLeaseUntil = state.pendingEvents const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0) .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
state.leaseTimer = setTimeout(() => { state.leaseTimer = setTimeout(() => {
state.leaseTimer = null; state.leaseTimer = null;
flushPendingPolls(); flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now)); broadcastAgentPollingIfChanged();
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
} }
function flushPendingPolls() { function flushPendingPolls() {
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
} }
function agentPollingConnected() { function agentPollingConnected() {
return state.pendingPolls.length > 0; const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
} }
function broadcastAgentPollingIfChanged() { function broadcastAgentPollingIfChanged() {
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/status') { if (p === '/status') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; const sessions = activeSessionSummaries();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: 'ok', status: 'ok',
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/events' && req.method === 'GET') { if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
clearTimeout(state.exitTimer);
state.exitTimer = null;
cancelQueuedAnonymousExitEvents();
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
type: 'connected', type: 'connected',
hasProjectContext: hasProjectContext(), hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(), agentPolling: agentPollingConnected(),
activeSessions: activeSessionSummaries(),
}) + '\n\n'); }) + '\n\n');
state.sseClients.add(res); state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops. // Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
return; return;
} }
} }
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') { if (msg.type !== 'checkpoint') {
enqueueEvent(msg); enqueueEvent(msg);
} }
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
}); });
} }
function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
return base;
}
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
};
} catch {
return base;
}
}
function handlePollPost(req, res) { function handlePollPost(req, res) {
let body = ''; let body = '';
req.on('data', (c) => { body += c; }); req.on('data', (c) => { body += c; });
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return; return;
} }
const pendingEventBeforeAck = findPendingEventById(msg.id);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'steer_done_requires_file_or_message',
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id); const acknowledgedEvent = acknowledgePendingEvent(msg.id);
let skipJournalReply = false; let skipJournalReply = false;
let existingSession = null; let existingSession = null;
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
})); }));
return; return;
} }
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
if (state.sessionStore && msg.id && !skipJournalReply) { if (state.sessionStore && msg.id && !skipJournalReply) {
try { try {
const eventType = msg.type === 'steer_done' const eventType = msg.type === 'steer_done'
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
state.sessionStore.appendEvent({ state.sessionStore.appendEvent({
type: eventType, type: eventType,
id: msg.id, id: msg.id,
file: msg.file, file: replyFileMeta.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
message: msg.message, message: msg.message,
sourceEventType: acknowledgedEvent?.type, sourceEventType: acknowledgedEvent?.type,
carbonize: msg.data?.carbonize === true, carbonize: msg.data?.carbonize === true,
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
} }
flushPendingPolls(); flushPendingPolls();
// Forward the reply to the browser via SSE // Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
file: msg.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
}); });
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
let httpServer = null; let httpServer = null;
function shutdown() { function shutdown() {
cleanupSvelteComponentSessionsBeforeExit();
removeLiveServerInfo(process.cwd()); removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer); if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
@@ -2037,6 +2149,25 @@ function shutdown() {
process.exit(0); process.exit(0);
} }
function cleanupSvelteComponentSessionsBeforeExit() {
try {
removeAllSvelteComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
if (result.applied > 0 || result.failed > 0) {
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
cwd: process.cwd(), cwd: process.cwd(),
reason: 'manual_edit_server_start_recovered_abandoned_transaction', reason: 'manual_edit_server_start_recovered_abandoned_transaction',
}); });
applyLegacyDeferredAcceptsOnStartup();
restorePendingEventsFromStore(); restorePendingEventsFromStore();
pruneStaleManualApplyEvidence(process.cwd()); pruneStaleManualApplyEvidence(process.cwd());
const portArg = args.find(a => a.startsWith('--port=')); const portArg = args.find(a => a.startsWith('--port='));
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
phase: 'new', phase: 'new',
pageUrl: null, pageUrl: null,
sourceFile: null, sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0, expectedVariants: 0,
arrivedVariants: 0, arrivedVariants: 0,
visibleVariant: null, visibleVariant: null,
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'variants_ready': case 'variants_ready':
case 'agent_done': case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile; next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
if (event.carbonize === true) { if (event.carbonize === true) {
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
} }
break; break;
case 'checkpoint': case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase; next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision; next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner; next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant; next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues }; if (event.paramValues) next.paramValues = { ...event.paramValues };
} else { } else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'steer_done': case 'steer_done':
next.phase = 'steer_done'; next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break; break;
case 'complete': case 'complete':
next.phase = 'completed'; next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null; next.pendingEventSeq = null;
next.pendingEvent = null; next.pendingEvent = null;
break; break;
@@ -0,0 +1,826 @@
/**
* Svelte live-mode component injection helpers.
*
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
* variant back into the route source with props mapped to original bindings.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
export function shouldUseSvelteComponentInjection(filePath) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.svelte';
}
export function componentSessionDir(id, cwd = process.cwd()) {
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
}
export function manifestPathForSession(id, cwd = process.cwd()) {
return path.join(componentSessionDir(id, cwd), 'manifest.json');
}
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
return file;
}
/**
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
*/
export function extractMustacheExpressions(text) {
const expressions = [];
const seen = new Set();
const lines = String(text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('<!--')) continue;
let match;
MUSTACHE_RE.lastIndex = 0;
while ((match = MUSTACHE_RE.exec(line)) !== null) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
expressions.push(expr);
}
}
return expressions;
}
export function buildPropContract(expressions) {
return expressions.map((expr, index) => {
const derived = derivePropName(expr, index);
return {
prop: derived,
expr,
placeholder: `{${expr}}`,
};
});
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
return tail[1];
}
return `prop${index}`;
}
export function substituteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
}
return out;
}
export function substitutePropsWithExprs(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
}
return out;
}
export function parseSvelteComponentFile(content) {
const text = String(content || '');
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
const styleBlock = styleMatch ? styleMatch[0] : '';
const markup = styleMatch
? withoutScript.slice(0, styleMatch.index).trim()
: withoutScript.trim();
const cssLines = styleBlock
? styleBlock
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style\s*>$/i, '')
.split('\n')
.map((line) => line.trimEnd())
: [];
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
return { markup, cssLines, styleBlock };
}
function buildPropsScript(contract) {
if (contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const names = contract.map((c) => c.prop).join(', ');
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
function buildVariantStub(variantNum, originalWithProps, contract) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
: '';
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
}
function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
export function scaffoldSvelteComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const manifest = {
id,
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
};
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile,
insertLine,
position,
anchorStartLine,
anchorEndLine,
anchorLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const anchorMarkup = (anchorLines || []).join('\n');
const manifest = {
id,
mode: 'insert',
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
insertLine,
position,
anchorStartLine,
anchorEndLine,
originalMarkup: anchorMarkup,
anchorMarkup,
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: [],
};
}
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
const direct = manifestPathForSession(id, cwd);
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
return null;
}
export function readManifest(manifestPath) {
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return {
...data,
manifestPath,
};
}
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
if (!sourceFile || path.isAbsolute(sourceFile)) {
throw new Error('Invalid svelte-component source file');
}
const full = path.resolve(cwd, sourceFile);
const rel = path.relative(cwd, full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Svelte-component source file escapes project root');
}
if (!fs.existsSync(full)) {
throw new Error('Svelte-component source file not found: ' + sourceFile);
}
return full;
}
function appendCssToSvelteStyle(lines, cssLines) {
const closeIdx = findLastStyleCloseLine(lines);
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
if (closeIdx === -1) {
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
}
return [
...lines.slice(0, closeIdx),
...prepared,
...lines.slice(closeIdx),
];
}
function findLastStyleCloseLine(lines) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/<\/style\s*>/.test(lines[i])) return i;
}
return -1;
}
function bakeParamValuesInCss(cssLines, paramValues) {
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
return cssLines.map((line) => {
let out = line;
for (const [key, value] of Object.entries(paramValues)) {
const varName = `--p-${key}`;
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
}
return out;
});
}
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
const css = String((cssLines || []).join('\n'));
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
const rules = parseCssRules(css);
const output = [];
for (const rule of rules) {
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
}
return output.join('\n')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== '');
}
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
const prelude = rule.prelude.trim();
const body = rule.body.trim();
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
if (/^@scope\b/i.test(prelude)) {
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
const inner = parseCssRules(body);
for (const innerRule of inner) {
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
}
return;
}
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
if (!rewrittenPrelude) return;
output.push(formatCssRule(rewrittenPrelude, body));
}
function parseCssRules(css) {
const rules = [];
const text = String(css || '');
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
const preludeStart = i;
while (i < text.length && text[i] !== '{') i++;
if (i >= text.length) break;
const prelude = text.slice(preludeStart, i).trim();
i++;
const bodyStart = i;
let depth = 1;
let quote = null;
let comment = false;
while (i < text.length && depth > 0) {
const ch = text[i];
const next = text[i + 1];
if (comment) {
if (ch === '*' && next === '/') {
comment = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === quote) quote = null;
i++;
continue;
}
if (ch === '/' && next === '*') {
comment = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
if (prelude) rules.push({ prelude, body });
}
return rules;
}
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
const selectors = splitSelectorList(prelude);
const rewritten = [];
for (const selector of selectors) {
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
if (next) rewritten.push(next);
}
return rewritten.join(', ');
}
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
let out = selector.trim();
const hasVariant = /data-impeccable-variant/.test(out);
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
if (hasVariant) {
out = out.replace(variantSelectorRegex(variantNum), '');
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
}
const paramResult = rewriteParamSelectors(out, paramValues);
if (!paramResult.keep) return '';
out = paramResult.selector;
out = out
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
.replace(/\s+/g, ' ')
.trim();
out = out.replace(/^[>+~]\s*/, '').trim();
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
return out;
}
function rewriteParamSelectors(selector, paramValues) {
let keep = true;
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
const actual = paramValues[key];
if (expected != null && String(actual) !== String(expected)) {
keep = false;
return '';
}
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
keep = false;
return '';
}
return '';
});
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
}
function variantSelectorRegex(variantNum) {
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
}
function formatCssRule(selector, body) {
return `${selector} { ${body.trim()} }`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'svelte-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
if (manifest.mode === 'insert') {
return inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
});
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, start),
...indentedMarkup,
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
}) {
if (!svelteMarkupHasVisibleContent(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
}
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const restoredMarkup = String(markup || '')
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const insertIndex = Number(manifest.insertLine) - 1;
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
}
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, insertIndex),
...indentedMarkup,
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function svelteMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
const variantOpen = matchOpeningTag(markup);
const originalOpen = matchOpeningTag(originalMarkup);
if (!variantOpen || !originalOpen) return markup;
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
const variantAttrs = parseAttrSegments(variantOpen.attrs);
const originalAttrs = parseAttrSegments(originalOpen.attrs);
const additions = [];
let attrs = variantOpen.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const merged = mergeStaticClassAttr(originalClass, variantClass);
if (merged) {
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
variantAttrs.set('class', { ...variantClass, raw: merged });
}
} else if (originalClass && !variantClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class') continue;
if (!variantAttrs.has(name)) additions.push(attr.raw);
}
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
const nextOpen = variantOpen.prefix
+ variantOpen.tag
+ attrs
+ additions.map((attr) => ' ' + attr.trim()).join('')
+ variantOpen.close;
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
if (!match) return null;
return {
raw: match[0],
prefix: match[1],
tag: match[2],
attrs: match[3] || '',
close: match[4],
index: match.index || 0,
};
}
function parseAttrSegments(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
let match;
while ((match = re.exec(attrs))) {
const raw = match[0];
const name = match[1];
out.set(name, {
name,
raw,
start: match.index,
end: match.index + raw.length,
});
}
return out;
}
function mergeStaticClassAttr(originalClass, variantClass) {
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
if (!originalValue || !variantValue) return null;
const quote = variantValue[1];
const classes = [
...variantValue[2].split(/\s+/),
...originalValue[2].split(/\s+/),
].filter(Boolean);
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
}
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
const dir = componentSessionDir(id, cwd);
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
}
export function readDeferredAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return { accepts: [] };
}
}
export function writeDeferredAccept(entry, cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
const data = readDeferredAccepts(cwd);
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
const data = readDeferredAccepts(cwd);
const pending = Array.isArray(data.accepts) ? data.accepts : [];
const results = [];
const remaining = [];
for (const entry of pending) {
try {
const manifest = findSvelteComponentManifest(entry.id, cwd);
if (!manifest) {
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
remaining.push(entry);
continue;
}
const result = inlineSvelteComponentAccept(
manifest,
entry.variantNum,
entry.paramValues || null,
cwd,
);
results.push({ id: entry.id, ok: result.handled !== false, result });
if (result.handled === false) remaining.push(entry);
} catch (err) {
results.push({ id: entry.id, ok: false, error: err.message });
remaining.push(entry);
}
}
if (remaining.length > 0) {
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
} else {
try { fs.rmSync(file, { force: true }); } catch {}
}
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
}
export function buildSvelteComponentCssAuthoring(count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
return {
mode: 'svelte-component',
styleTag: null,
strategy: 'component-style-block',
rulePattern: '.semantic-class { ... }',
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
requirements: [
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
'Put variant CSS in the component <style> block using semantic class selectors.',
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
'Do not use @scope or data-impeccable-variant selectors in component files.',
'Do not edit the route source file during generation; only edit files under componentDir.',
],
forbidden: [
'Do not use @scope blocks in Svelte component variants.',
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
],
paramsFile: 'params.json',
};
}
@@ -0,0 +1,274 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,179 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-configure-input-wrap',
'impeccable-live-input',
'impeccable-live-configure-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
@@ -15,6 +15,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent))) .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n'); .join('\n');
const originalIndented = reindentOriginal(' '); const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs; // Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which // JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
]; ];
// Replace the original element with the wrapper let outputFile = targetFile;
const newLines = [ let outputLines;
...lines.slice(0, startLine), let outputStartLine = startLine + 1;
...wrapperLines, let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
...lines.slice(endLine + 1), let insertLine;
]; let svelteSession = null;
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment). if (useSvelteComponent) {
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above // Svelte/SvelteKit resets component-local state on markup HMR updates.
// the insert marker (HTML: start-comment + outer-div + Original-comment + // Keep generation source-neutral: agents write real variant components
// original-div + content + close-original-div; JSX: outer-div + // under the generated componentDir, the browser mounts them into the live
// start-comment + Original-comment + original-div + content + // DOM, and live-accept.mjs inlines the accepted variant back into the route.
// close-original-div). Multi-line originals push the marker by their svelteSession = scaffoldSvelteComponentSession({
// extra line count. id,
const insertLine = startLine + 6 + (originalLines.length - 1); count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({ console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile), file: outputRelFile,
startLine: startLine + 1, // 1-indexed for the agent sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot) // wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is // is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset, // wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that // endLine pointed inside the wrapper for any picked element that
// spanned more than one source line. // spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed endLine: outputEndLine, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax, commentSyntax: commentSyntax,
styleMode: styleMode.mode, styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: styleMode.styleTag, styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count), cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count), cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length, originalLineCount: originalLines.length,
})); }));
} }
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean); return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
} }
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) { function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') { if (ext === '.jsx' || ext === '.tsx') {
+24 -3
View File
@@ -111,7 +111,9 @@ node .pi/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT_C
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default) ### Replace mode (default)
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules. - `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. **Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper: **How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html ```html
<div data-impeccable-variant="1" data-impeccable-params='[ <div data-impeccable-variant="1" data-impeccable-params='[
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below. 1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element). 2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value. 3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source. 4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now. 5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again. After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
+167 -44
View File
@@ -17,6 +17,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs'; import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live-svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options: Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup --page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON): Output (JSON):
{ handled, file, carbonize }`); { handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers // Find the file containing this session's markers
const found = findSessionFile(id, process.cwd()); const found = findSessionFile(id, process.cwd());
if (!found) { const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0); process.exit(0);
} }
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found; const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile); const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({ console.log(JSON.stringify({
handled: false, handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept // Accept
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) { function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines); const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' }; if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs); const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent); const restored = deindentContent(variantContent, indent);
const replacement = []; const replacement = buildCarbonizeReplacement({
indent,
if (cssContent) { commentSyntax,
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); isJsx,
// JSX targets need the CSS body wrapped in a template literal so that the id,
// `{` and `}` in CSS rules don't get parsed as JSX expressions. variantNum,
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); cssContent,
// Re-indent CSS content to match paramValues,
for (const cssLine of cssContent) { restored,
replacement.push(indent + cssLine.trimStart()); });
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [ const newLines = [
...lines.slice(0, replaceRange.start), ...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
} }
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsing helpers // Parsing helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli(); acceptCli();
} }
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error'; if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done'; return 'agent_done';
} }
+99 -1
View File
@@ -17,11 +17,38 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs'; import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live-sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end'; const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/** /**
* Hard-excluded directory patterns. These are NEVER user-facing pages and * Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +110,14 @@ Output (JSON):
validateConfig(config); validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config); const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) { if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +143,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' })); console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1); process.exit(1);
} }
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => { const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile); const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +169,68 @@ Output (JSON):
}; };
}); });
const anyInserted = results.some((r) => r.inserted); const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results })); console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1); if (!anyInserted) process.exit(1);
} }
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** /**
* Expand config.files (which may contain glob patterns) into a literal list * Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through; * of existing file paths relative to rootDir. Literal entries pass through;
+41 -1
View File
@@ -21,6 +21,11 @@ import {
buildCssAuthoring, buildCssAuthoring,
buildCssSelectorPrefixExamples, buildCssSelectorPrefixExamples,
} from './live-wrap.mjs'; } from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live-svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']); const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile); const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*'; const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position); const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? ''; ?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({ console.log(JSON.stringify({
mode: 'insert', mode: 'insert',
position, position,
file: path.relative(process.cwd(), targetFile), file: relTargetFile,
insertLine: insertLine + 1, insertLine: insertLine + 1,
commentSyntax, commentSyntax,
styleMode: styleMode.mode, styleMode: styleMode.mode,
+3 -2
View File
@@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs';
// that ceiling and loop in `pollOnce` to synthesize a long poll without // that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package. // depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000; export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now() ? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS; : PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) { if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.'); const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options: Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow) --file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message --help Show this help message
+139 -7
View File
@@ -42,6 +42,10 @@ import {
} from './live-manual-edits-buffer.mjs'; } from './live-manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live-svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
function tombstoneTimedOutApplyId(eventId, details = {}) { function tombstoneTimedOutApplyId(eventId, details = {}) {
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
return entry.event; return entry.event;
} }
entry.leaseUntil = Date.now() + leaseMs; entry.leaseUntil = Date.now() + leaseMs;
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event; return entry.event;
} }
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
const acknowledged = state.pendingEvents[idx].event; const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1); state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush(); scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return acknowledged; return acknowledged;
} }
function findPendingEventById(id) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
return entry?.event || null;
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`; return `live-poll.mjs --reply ${id} done --data '<json>'`;
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
return summary; return summary;
} }
function summarizeActiveSessionForClient(snapshot = {}) {
return {
id: snapshot.id,
phase: snapshot.phase,
pageUrl: snapshot.pageUrl ?? null,
sourceFile: snapshot.sourceFile ?? null,
previewFile: snapshot.previewFile ?? null,
previewMode: snapshot.previewMode ?? null,
expectedVariants: snapshot.expectedVariants ?? 0,
arrivedVariants: snapshot.arrivedVariants ?? 0,
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
};
}
function activeSessionSummaries() {
if (!state.sessionStore) return [];
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
}
function cancelQueuedAnonymousExitEvents() {
let removed = 0;
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
const event = state.pendingEvents[i]?.event;
if (event?.type !== 'exit' || event.id) continue;
state.pendingEvents.splice(i, 1);
removed += 1;
}
if (removed > 0) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
}
return removed;
}
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map(); const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
clearTimeout(state.leaseTimer); clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
} }
if (state.pendingPolls.length === 0) return;
const now = Date.now(); const now = Date.now();
const nextLeaseUntil = state.pendingEvents const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0) .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
state.leaseTimer = setTimeout(() => { state.leaseTimer = setTimeout(() => {
state.leaseTimer = null; state.leaseTimer = null;
flushPendingPolls(); flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now)); broadcastAgentPollingIfChanged();
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
} }
function flushPendingPolls() { function flushPendingPolls() {
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
} }
function agentPollingConnected() { function agentPollingConnected() {
return state.pendingPolls.length > 0; const now = Date.now();
return state.pendingPolls.length > 0
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
} }
function broadcastAgentPollingIfChanged() { function broadcastAgentPollingIfChanged() {
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/status') { if (p === '/status') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; const sessions = activeSessionSummaries();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: 'ok', status: 'ok',
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
if (p === '/events' && req.method === 'GET') { if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
clearTimeout(state.exitTimer);
state.exitTimer = null;
cancelQueuedAnonymousExitEvents();
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
type: 'connected', type: 'connected',
hasProjectContext: hasProjectContext(), hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(), agentPolling: agentPollingConnected(),
activeSessions: activeSessionSummaries(),
}) + '\n\n'); }) + '\n\n');
state.sseClients.add(res); state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops. // Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
return; return;
} }
} }
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') { if (msg.type !== 'checkpoint') {
enqueueEvent(msg); enqueueEvent(msg);
} }
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
}); });
} }
function sessionFileMetadataFromPollReply(file) {
if (!file || typeof file !== 'string') return { file };
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
let full;
try {
full = path.resolve(process.cwd(), normalized);
const rel = path.relative(process.cwd(), full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
} catch {
return base;
}
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
};
} catch {
return base;
}
}
function handlePollPost(req, res) { function handlePollPost(req, res) {
let body = ''; let body = '';
req.on('data', (c) => { body += c; }); req.on('data', (c) => { body += c; });
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return; return;
} }
const pendingEventBeforeAck = findPendingEventById(msg.id);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'steer_done_requires_file_or_message',
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id); const acknowledgedEvent = acknowledgePendingEvent(msg.id);
let skipJournalReply = false; let skipJournalReply = false;
let existingSession = null; let existingSession = null;
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
})); }));
return; return;
} }
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
if (state.sessionStore && msg.id && !skipJournalReply) { if (state.sessionStore && msg.id && !skipJournalReply) {
try { try {
const eventType = msg.type === 'steer_done' const eventType = msg.type === 'steer_done'
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
state.sessionStore.appendEvent({ state.sessionStore.appendEvent({
type: eventType, type: eventType,
id: msg.id, id: msg.id,
file: msg.file, file: replyFileMeta.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
message: msg.message, message: msg.message,
sourceEventType: acknowledgedEvent?.type, sourceEventType: acknowledgedEvent?.type,
carbonize: msg.data?.carbonize === true, carbonize: msg.data?.carbonize === true,
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
} }
flushPendingPolls(); flushPendingPolls();
// Forward the reply to the browser via SSE // Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); broadcast({
type: msg.type || 'done',
id: msg.id,
message: msg.message,
file: msg.file,
sourceFile: replyFileMeta.sourceFile,
previewFile: replyFileMeta.previewFile,
previewMode: replyFileMeta.previewMode,
data: msg.data,
});
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
}); });
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
let httpServer = null; let httpServer = null;
function shutdown() { function shutdown() {
cleanupSvelteComponentSessionsBeforeExit();
removeLiveServerInfo(process.cwd()); removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer); if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null; state.leaseTimer = null;
@@ -2037,6 +2149,25 @@ function shutdown() {
process.exit(0); process.exit(0);
} }
function cleanupSvelteComponentSessionsBeforeExit() {
try {
removeAllSvelteComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
if (result.applied > 0 || result.failed > 0) {
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
cwd: process.cwd(), cwd: process.cwd(),
reason: 'manual_edit_server_start_recovered_abandoned_transaction', reason: 'manual_edit_server_start_recovered_abandoned_transaction',
}); });
applyLegacyDeferredAcceptsOnStartup();
restorePendingEventsFromStore(); restorePendingEventsFromStore();
pruneStaleManualApplyEvidence(process.cwd()); pruneStaleManualApplyEvidence(process.cwd());
const portArg = args.find(a => a.startsWith('--port=')); const portArg = args.find(a => a.startsWith('--port='));

Some files were not shown because too many files have changed in this diff Show More