mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
@@ -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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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='));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user