mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 01:56:37 +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:
+167
-44
@@ -17,6 +17,12 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.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'];
|
||||
|
||||
@@ -41,6 +47,9 @@ Required:
|
||||
|
||||
Options:
|
||||
--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):
|
||||
{ handled, file, carbonize }`);
|
||||
@@ -64,18 +73,67 @@ Output (JSON):
|
||||
|
||||
// Find the file containing this session's markers
|
||||
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 }));
|
||||
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 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() })) {
|
||||
console.log(JSON.stringify({
|
||||
handled: false,
|
||||
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// 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) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
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 needsCarbonize = !!(cssContent || hasHelperAttrs);
|
||||
|
||||
// Build the replacement
|
||||
const restored = deindentContent(variantContent, indent);
|
||||
const replacement = [];
|
||||
|
||||
if (cssContent) {
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
|
||||
// JSX targets need the CSS body wrapped in a template literal so that the
|
||||
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
|
||||
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
|
||||
// Re-indent CSS content to match
|
||||
for (const cssLine of cssContent) {
|
||||
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 replacement = buildCarbonizeReplacement({
|
||||
indent,
|
||||
commentSyntax,
|
||||
isJsx,
|
||||
id,
|
||||
variantNum,
|
||||
cssContent,
|
||||
paramValues,
|
||||
restored,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, replaceRange.start),
|
||||
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
|
||||
acceptCli();
|
||||
}
|
||||
|
||||
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock };
|
||||
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
|
||||
|
||||
Reference in New Issue
Block a user