Improve Live progressive responsiveness

Add transactional progressive publication, durable cancellation, responsive accept cleanup, and framework-safe Svelte and Nuxt previews.\n\nAI-assisted: OpenAI Codex.
This commit is contained in:
Paul Bakaus
2026-07-12 17:54:50 -07:00
parent ff67ad359e
commit 2106a2881f
40 changed files with 3833 additions and 206 deletions
+99 -10
View File
@@ -17,14 +17,21 @@ import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import { withSourceLockSync } from './live/source-lock.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live/svelte-component.mjs';
import {
findVueComponentManifest,
inlineVueComponentAccept,
retireVueComponentSession,
} from './live/vue-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
const ACCEPT_LOCK_WAIT_MS = 1_000;
// ---------------------------------------------------------------------------
// CLI
@@ -74,17 +81,80 @@ Output (JSON):
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
const vueComponentManifest = found || svelteComponentManifest ? null : findVueComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
if (!found && !svelteComponentManifest && !vueComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
if (vueComponentManifest) {
if (isDiscard) {
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
'discard:' + id,
() => {
retireVueComponentSession(id, process.cwd());
return { handled: true };
},
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = { handled: false, error: err.message };
}
console.log(JSON.stringify({
...result,
file: vueComponentManifest.sourceFile,
carbonize: false,
previewMode: 'vue-component',
componentDir: vueComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
'accept:' + id,
() => inlineVueComponentAccept(vueComponentManifest, variantNum, process.cwd()),
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: vueComponentManifest.sourceFile,
sourceFile: vueComponentManifest.sourceFile,
previewMode: 'vue-component',
componentDir: vueComponentManifest.componentDir,
carbonize: false,
};
}
console.log(JSON.stringify(result));
return;
}
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
'discard:' + id,
() => {
removeSvelteComponentSession(id, process.cwd());
return { handled: true };
},
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = { handled: false, error: err.message };
}
console.log(JSON.stringify({
handled: true,
...result,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
@@ -95,11 +165,16 @@ Output (JSON):
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
result = withSourceLockSync(
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
'accept:' + id,
() => inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
),
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = {
@@ -235,7 +310,14 @@ function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalB
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
function handleDiscard(id, _lines, targetFile) {
return withSourceLockSync(targetFile, 'discard:' + id, () => {
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
return handleDiscardUnlocked(id, lines, targetFile);
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
}
function handleDiscardUnlocked(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
@@ -330,7 +412,14 @@ function reindentContent(contentLines, fromIndent, toIndent) {
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
return withSourceLockSync(targetFile, 'accept:' + id, () => {
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
return handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues);
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
}
function handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
+181 -55
View File
@@ -126,6 +126,7 @@
let expectedVariants = 0;
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
let svelteComponentSession = null;
let svelteRuntimePromise = null;
let pendingSvelteComponentRetryObserver = null;
@@ -252,6 +253,13 @@
barConnected: !!barEl?.isConnected,
hasSvelteComponentSession: !!svelteComponentSession,
mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0,
pickActive,
pendingApplyInFlight,
hoveredElement: hoveredElement ? {
tag: hoveredElement.tagName,
classes: hoveredElement.className,
pickable: pickable(hoveredElement),
} : null,
pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver,
recoveryWaitingForAnchor,
evtSourceReadyState: evtSource ? evtSource.readyState : null,
@@ -1966,6 +1974,7 @@
*/
function setLiveState(next) {
state = next;
document.documentElement.dataset.impeccableLiveState = next;
syncPageInteractionCursor();
}
@@ -2516,18 +2525,23 @@
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
// per-variant counter would lie. Say what's true.
status.textContent = recoveryWaitingForAnchor
? 'Variants ready. Reveal the selected element to resume.'
: (arrivedVariants < expectedVariants
? 'Generating ' + expectedVariants + ' variants...'
: 'Done');
: generationStatusText();
row.appendChild(status);
return row;
}
function generationStatusText() {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return 'Done';
if (generationPhase === 'picked_up') return 'Agent picked up the request...';
if (generationPhase === 'scaffolding') return 'Finding the source...';
if (generationPhase === 'source_ready') return 'Source ready. Generating...';
if (generationPhase === 'scaffold_fallback') return 'Agent is locating the source...';
return 'Generating ' + expectedVariants + ' variants...';
}
// Cycling row
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
@@ -2557,7 +2571,7 @@
color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.id = PREFIX + '-variant-counter';
counter.textContent = visibleVariant + '/' + arrivedVariants;
counter.textContent = visibleVariant + '/' + expectedVariants;
row.appendChild(counter);
// Next
@@ -2614,6 +2628,15 @@
// Spacer
row.appendChild(el('div', { flex: '1' }));
if (arrivedVariants < expectedVariants) {
const remaining = expectedVariants - arrivedVariants;
const progress = el('span', {
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
});
progress.textContent = remaining + ' more arriving...';
row.appendChild(progress);
}
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
const accept = el('button', {
padding: '5px 14px', borderRadius: '5px',
@@ -2628,7 +2651,11 @@
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
if (arrivedVariants === 0) {
accept.style.opacity = '0.3';
accept.style.pointerEvents = 'none';
accept.title = 'Accept becomes available when the first variant arrives';
}
row.appendChild(accept);
// Discard
@@ -4835,6 +4862,10 @@
return String(filePath || '').endsWith('manifest.json');
}
function isFrameworkComponentPreviewMode(mode) {
return mode === 'svelte-component' || mode === 'vue-component';
}
function parseOriginalMarkupElement(originalMarkup) {
const parser = new DOMParser();
const doc = parser.parseFromString('<div id="impeccable-anchor">' + originalMarkup + '</div>', 'text/html');
@@ -5029,9 +5060,21 @@
}
}
function loadSvelteRuntime(runtimeModule) {
function resolveComponentModuleUrl(manifest, modulePath) {
const pathValue = String(modulePath || '');
if (manifest?.previewMode === 'vue-component' && pathValue.startsWith('/@fs/')) {
// Nuxt mounts Vite below buildAssetsDir. Sending /@fs directly to the
// page origin reaches Nitro's route fallback and returns text/html.
const assetsDir = String(window.__NUXT__?.config?.app?.buildAssetsDir || '/_nuxt/');
const base = assetsDir.endsWith('/') ? assetsDir : assetsDir + '/';
return new URL(base + pathValue.slice('/@fs/'.length), location.origin).href;
}
return new URL(pathValue, location.origin).href;
}
function loadSvelteRuntime(runtimeModule, manifest) {
const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js';
const url = new URL(modulePath, location.origin).href;
const url = resolveComponentModuleUrl(manifest, modulePath);
if (!svelteRuntimePromise) {
svelteRuntimePromise = import(/* @vite-ignore */ url);
}
@@ -5065,7 +5108,8 @@
async function loadSvelteComponentVariantSource(manifest, variantNum) {
const dir = String(manifest?.componentDir || '').replace(/^\/+/, '');
if (!dir || !variantNum) return '';
const sourcePath = dir + '/v' + variantNum + '.svelte';
const extension = manifest.componentExtension || (manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
const sourcePath = dir + '/v' + variantNum + '.' + extension;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath);
try {
const res = await fetch(url);
@@ -5084,6 +5128,7 @@
async function applySvelteComponentVariantStyle(variantNum) {
if (!svelteComponentSession || !variantNum) return;
const { manifest, sessionId } = svelteComponentSession;
if (manifest?.previewMode === 'vue-component') return;
const source = await loadSvelteComponentVariantSource(manifest, variantNum);
const css = extractSvelteComponentStyle(source);
removeSvelteComponentVariantStyle(svelteComponentSession);
@@ -5221,7 +5266,7 @@
if (!sourceOriginal) return values;
const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl);
for (const entry of contract) {
const token = '{' + entry.expr + '}';
const token = entry.previewToken || ('{' + entry.expr + '}');
values[entry.prop] = map.get(token) || '';
}
return values;
@@ -5233,9 +5278,12 @@
try {
const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement;
svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null;
const runtime = await loadSvelteRuntime(manifest.runtimeModule);
const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte';
const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now();
const runtime = await loadSvelteRuntime(manifest.runtimeModule, manifest);
const extension = manifest.componentExtension || (manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
const moduleBase = manifest.componentModuleBase
|| ('/' + String(manifest.componentDir || '').replace(/^\/+/, ''));
const modulePath = String(moduleBase).replace(/\/+$/, '') + '/v' + variantNum + '.' + extension;
const moduleUrl = resolveComponentModuleUrl(manifest, modulePath) + '?t=' + Date.now();
const mod = await import(/* @vite-ignore */ moduleUrl);
const Component = mod.default;
if (svelteComponentSession.mountedInstance && runtime.unmount) {
@@ -5275,7 +5323,7 @@
if (svelteComponentSession?.sessionId === sessionId) {
svelteComponentSession.swapAnchor = null;
}
console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err);
console.error('[impeccable] Failed to mount component variant ' + variantNum + ' for ' + sessionId + ':', err);
return false;
}
}
@@ -5340,21 +5388,26 @@
if (manifest.id !== sessionId) return;
const paramsByVariant = await loadSvelteComponentParams(manifest);
const availableVariants = Number(manifest.arrivedVariants) || Number(manifest.count) || 1;
const componentPreviewMode = isFrameworkComponentPreviewMode(manifest.previewMode)
? manifest.previewMode
: 'svelte-component';
currentSessionId = sessionId;
expectedVariants = Number(manifest.count) || expectedVariants || 1;
rememberSessionFileMeta({
sourceFile: manifest.sourceFile,
previewFile: manifestPath,
previewMode: 'svelte-component',
previewMode: componentPreviewMode,
});
if (state !== 'CYCLING') setLiveState('GENERATING');
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper && svelteComponentSession?.sessionId === sessionId) {
recoveryWaitingForAnchor = false;
svelteComponentSession.manifest = manifest;
svelteComponentSession.paramsByVariant = paramsByVariant;
arrivedVariants = Number(manifest.count) || expectedVariants || 1;
expectedVariants = arrivedVariants;
arrivedVariants = availableVariants;
expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1;
await mountSvelteComponentVariant(visibleVariant || 1);
setLiveState('CYCLING');
@@ -5366,14 +5419,14 @@
const liveEl = findLiveElementForSvelteManifest(manifest);
if (!liveEl?.parentElement) {
console.warn('[impeccable] Could not find original element in live DOM.');
arrivedVariants = Number(manifest.count) || expectedVariants || 1;
expectedVariants = arrivedVariants;
arrivedVariants = availableVariants;
expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
const saved = loadSession();
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants
? visibleVariant
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true });
enterRecoveryWaitingForAnchor({ checkpointReason: 'component_preview_anchor_missing', trackScroll: true });
waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest });
return;
}
@@ -5381,7 +5434,7 @@
const wrapper = document.createElement('div');
wrapper.dataset.impeccableVariants = sessionId;
wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1);
wrapper.dataset.impeccablePreview = 'svelte-component';
wrapper.dataset.impeccablePreview = componentPreviewMode;
wrapper.style.display = 'contents';
const mountTarget = document.createElement('div');
@@ -5419,8 +5472,8 @@
recoveryWaitingForAnchor = false;
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
arrivedVariants = Number(manifest.count) || expectedVariants || 1;
expectedVariants = arrivedVariants;
arrivedVariants = availableVariants;
expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
const saved = loadSession();
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
@@ -5445,9 +5498,9 @@
refreshParamsPanel();
positionBar();
saveSession();
console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.');
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount Svelte component variants:', err);
console.error('[impeccable] Failed to mount component-preview variants:', err);
abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.');
}
}
@@ -6049,6 +6102,7 @@
updating = true;
arrivedVariants = count;
generationPhase = arrivedVariants >= expectedVariants ? 'variants_ready' : 'variants_progress';
if (visibleVariant === 0 && arrivedVariants > 0) {
const saved = loadSession();
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
@@ -6064,7 +6118,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (arrivedVariants > 0) {
setLiveState('CYCLING');
recoveryWaitingForAnchor = false;
hideShaderOverlay();
@@ -6072,13 +6126,18 @@
updateSelectedElement();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
if (arrivedVariants >= expectedVariants && expectedVariants > 0) refreshParamsPanel();
else hideParamsPanel();
positionBar();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
saveSession();
queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
sendCheckpoint(
arrivedVariants >= expectedVariants && expectedVariants > 0
? 'variants_ready'
: 'variants_progress',
);
updating = false;
});
@@ -6158,6 +6217,34 @@
case 'agent_polling':
syncAgentPollingUi(!!msg.connected);
break;
case 'agent_phase':
if (msg.id === currentSessionId && state === 'GENERATING') {
generationPhase = msg.phase || generationPhase;
updateBarContent('generating');
}
break;
case 'variant_progress':
if (msg.id === currentSessionId) {
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
}
break;
case 'steer_done':
maybeCompleteSteer(msg);
break;
@@ -6175,6 +6262,10 @@
case 'done':
if (maybeCompleteSteer(msg)) break;
rememberSessionFileMeta(msg);
if (msg.id === currentSessionId && isFrameworkComponentPreviewMode(currentPreviewMode) && currentPreviewFile) {
injectSvelteComponentsFromManifest(currentPreviewFile, msg.id);
break;
}
// Variants already arrived via HMR → normal transition.
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') {
@@ -6215,9 +6306,10 @@
if (maybeCompleteAcceptedSession(msg)) break;
break;
case 'agent_done':
// Carbonize accepts are not terminal until live-complete.mjs sends
// the final complete event. Keep the browser in its recoverable
// saving state while the source cleanup is still in flight.
// The deterministic accept has already committed the reviewed DOM
// and fenced generation. Carbonize may continue in the background;
// it must not hold the foreground picker hostage.
if (msg.data?.carbonize === true && maybeCompleteAcceptedSession(msg)) break;
break;
case 'discarded':
if (msg.id && msg.id === currentSessionId) {
@@ -6684,6 +6776,7 @@
expectedVariants = selectedCount;
arrivedVariants = 0;
visibleVariant = 0;
generationPhase = 'queued';
resetSessionFileMeta();
// Flip to GENERATING immediately so the bar morphs without waiting on
@@ -6759,6 +6852,7 @@
expectedVariants = selectedCount;
arrivedVariants = 0;
visibleVariant = 0;
generationPhase = 'queued';
resetSessionFileMeta();
selectedElement = placeholderElement;
insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement);
@@ -6975,9 +7069,9 @@
// preview mounts are covered by the same shader regression checks.
const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase();
if (adapter === 'svelte' || adapter === 'sveltekit') return true;
if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true;
if (isFrameworkComponentPreviewMode(currentPreviewMode) || svelteComponentSession) return true;
const wrapper = el?.closest?.('[data-impeccable-variants]');
return wrapper?.dataset?.impeccablePreview === 'svelte-component';
return isFrameworkComponentPreviewMode(wrapper?.dataset?.impeccablePreview);
}
function paintsShaderProxySurface(node) {
@@ -7111,7 +7205,10 @@
// presentation-only. Wait only for the helper to accept the event before
// starting CPU-heavy capture; this yields the browser task and prevents
// rasterization from delaying the fetch itself.
if (!hasAnnotations) await sendEvent(basePayload);
if (!hasAnnotations) {
basePayload.clientSentAt = Date.now();
await sendEvent(basePayload);
}
let screenshotPath;
let blob;
@@ -7150,6 +7247,7 @@
// Annotated requests must wait for capture + upload because the screenshot
// is semantic input. Plain requests were already dispatched above.
if (hasAnnotations) {
basePayload.clientSentAt = Date.now();
sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
}
}
@@ -7531,7 +7629,6 @@ void main() {
if (variantSelectionPromise) {
try { await variantSelectionPromise; } catch { /* failed selection falls back below */ }
}
if (!currentSessionId || arrivedVariants === 0) return;
const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
const acceptPayload = {
@@ -7539,7 +7636,9 @@ void main() {
id: currentSessionId,
variantId: String(visibleVariant),
pageUrl: location.pathname,
clientSentAt: Date.now(),
};
if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues };
@@ -7553,7 +7652,7 @@ void main() {
const acceptedSessionId = currentSessionId;
const acceptedVariant = visibleVariant;
const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId
|| acceptWrapper?.dataset?.impeccablePreview === 'svelte-component';
|| isFrameworkComponentPreviewMode(acceptWrapper?.dataset?.impeccablePreview);
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
setLiveState('SAVING');
@@ -7568,7 +7667,17 @@ void main() {
saveSession();
sendEvent(acceptPayload, { throwOnError: true })
.then(() => {})
.then(() => {
const pending = pendingAcceptedSession;
if (!pending || pending.id !== acceptedSessionId) return;
// POST /events returns only after the accept intent is durable and the
// generation epoch is fenced. Source promotion/carbonize can finish in
// the background; the foreground picker is free immediately.
markSessionHandled();
setLiveState('CONFIRMED');
document.documentElement.dataset.impeccableAcceptToPickingMs = String(Date.now() - acceptPayload.clientSentAt);
scheduleAcceptCleanup(pending);
})
.catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING');
@@ -7597,17 +7706,26 @@ void main() {
}
function scheduleAcceptCleanup(accepted) {
setTimeout(function() {
if (!accepted?.isSvelteComponent && !acceptedDomAlreadyClean(accepted)) {
setTimeout(function() {
if (pendingAcceptedSession?.id !== accepted?.id) return;
if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted);
cleanupAcceptedSession();
}, 1800);
return;
queueMicrotask(function() {
if (pendingAcceptedSession?.id !== accepted?.id) return;
// Svelte previews live in an adapter-owned mount rather than in source
// wrapper markup. Promote the mounted variant before releasing the
// session so the old adapter instance cannot linger behind the next
// Pick → Go loop while carbonize finishes in the background.
if (accepted?.isSvelteComponent) {
commitAcceptedSvelteComponentToDom(accepted.id);
}
cleanupAcceptedSession();
}, 1200);
});
// Let React/Vue/Svelte own the HMR reconciliation. Mutating their DOM in
// the same turn as the source update causes removeChild/NotFoundError
// races. Static servers still need a fallback, but it must not keep Live
// in SAVING or block the user's next pick.
if (!accepted?.isSvelteComponent) {
setTimeout(function() {
if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted);
}, 1200);
}
}
function snapshotAcceptedVariantDom(sessionId, variantId) {
@@ -7711,6 +7829,8 @@ void main() {
clearSession();
resetSessionFileMeta();
selectedElement = null;
hoveredElement = null;
pagePickSkipClick = false;
currentSessionId = null;
selectedAction = 'impeccable';
pendingAcceptedSession = null;
@@ -7776,8 +7896,8 @@ void main() {
const previewFile = normalizeSessionPath(meta.previewFile);
const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null);
if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) {
currentPreviewMode = 'svelte-component';
if (isFrameworkComponentPreviewMode(previewMode) || isSvelteComponentManifestPath(file)) {
currentPreviewMode = isFrameworkComponentPreviewMode(previewMode) ? previewMode : 'svelte-component';
currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile);
currentSourceFile = sourceFile || currentSourceFile;
return;
@@ -7870,7 +7990,7 @@ void main() {
saveSession();
queueCheckpoint(reason || 'browser_restore_without_wrapper');
const restoreFile = currentPreviewMode === 'svelte-component'
const restoreFile = isFrameworkComponentPreviewMode(currentPreviewMode)
? currentPreviewFile
: (currentSourceFile || currentPreviewFile);
if (restoreFile) {
@@ -7883,7 +8003,7 @@ void main() {
function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false;
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
}
@@ -7974,6 +8094,8 @@ void main() {
clearSession();
resetSessionFileMeta();
selectedElement = null;
hoveredElement = null;
pagePickSkipClick = false;
currentSessionId = null;
selectedAction = 'impeccable';
renderEditBadge('hidden');
@@ -8058,7 +8180,7 @@ void main() {
// would strand the bar in CYCLING at 0/0. If there's no live in-memory mount
// for this wrapper, it's an orphan (reload / failed mount): drop it and let
// the live-server's SSE re-inject the manifest if the session is still live.
if (wrapper.dataset.impeccablePreview === 'svelte-component'
if (isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)
&& svelteComponentSession?.sessionId !== sessionId) {
wrapper.remove();
if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true;
@@ -8067,7 +8189,7 @@ void main() {
return false;
}
if (wrapper.dataset.impeccablePreview === 'svelte-component') {
if (isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) {
if (!svelteComponentSession?.mountedVariant) {
return true;
}
@@ -8115,7 +8237,7 @@ void main() {
insertPlaceholderSnapshot = saved.insertPlaceholder;
}
const resumedState = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
// Find the visible variant's content element for highlight positioning.
const isInsert = wrapper.dataset.impeccableMode === 'insert';
@@ -8139,7 +8261,11 @@ void main() {
// hid. Now that state is CYCLING, re-fire.
if (state === 'CYCLING') refreshParamsPanel();
saveSession();
queueCheckpoint('browser_resumed');
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress');
} else {
queueCheckpoint('browser_resumed');
}
// Start observing for more variants AFTER initial setup
if (variantObserver) variantObserver.disconnect();
+142 -4
View File
@@ -27,6 +27,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
@@ -46,10 +48,15 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'app/.impeccable-live/',
'src/.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
'plugins/impeccable-live.client.ts',
'app/plugins/impeccable-live.client.ts',
'src/plugins/impeccable-live.client.ts',
]);
/**
@@ -113,6 +120,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -120,6 +128,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -145,13 +159,28 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1);
}
const gitIgnore = ensureLiveGitIgnores(process.cwd());
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
);
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, project: nuxt });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'nuxt',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
@@ -175,12 +204,12 @@ Output (JSON):
if (!anyInserted) process.exit(1);
}
export function ensureLiveGitIgnores(cwd = process.cwd()) {
export function ensureLiveGitIgnores(cwd = process.cwd(), extraPatterns = []) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns]),
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
@@ -202,10 +231,119 @@ export function ensureLiveGitIgnores(cwd = process.cwd()) {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
patterns: [...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns])],
};
}
// ---------------------------------------------------------------------------
// Nuxt adapter
//
// A script element placed in app.vue is compiled as Vue-rendered DOM and is
// not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
// generated, dev-only, and outside user-authored source: Live creates one
// marked .client.ts plugin on start and removes it on stop.
// ---------------------------------------------------------------------------
export function detectNuxtProject(cwd = process.cwd()) {
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/.test(entry.name))
?.name;
if (!configFile) return null;
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
let appDir = '';
if (literalSrcDir) {
const candidate = literalSrcDir[2]
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
const normalized = path.posix.normalize(candidate);
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
appDir = normalized === '.' ? '' : normalized;
}
} else if (
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
) {
appDir = 'app';
}
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
return { configFile, appDir, pluginFile };
}
export function buildNuxtPlugin(port) {
return `/* ${NUXT_PLUGIN_MARKER} */
const liveSrc = 'http://localhost:${port}/live.js';
const liveSelector = 'script[data-impeccable-live-nuxt]';
export default defineNuxtPlugin(() => {
if (!import.meta.dev || typeof document === 'undefined') return;
const expectedSrc = new URL(liveSrc, window.location.href).href;
let script = document.querySelector(liveSelector);
if (script?.src === expectedSrc) return;
script?.remove();
script = document.createElement('script');
script.src = liveSrc;
script.async = true;
script.dataset.impeccableLiveNuxt = '';
document.head.appendChild(script);
import.meta.hot?.dispose(() => {
if (script?.isConnected) script.remove();
});
});
/* /${NUXT_PLUGIN_MARKER} */
`;
}
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
return {
file: project.pluginFile,
error: 'nuxt_plugin_conflict',
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
};
}
const content = buildNuxtPlugin(port);
fs.mkdirSync(path.dirname(absFile), { recursive: true });
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
return {
file: project.pluginFile,
inserted: true,
changed: content !== existing,
devOnly: true,
};
}
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
if (!fs.existsSync(absFile)) {
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
}
const content = fs.readFileSync(absFile, 'utf-8');
if (!content.includes(NUXT_PLUGIN_MARKER)) {
return {
file: project.pluginFile,
removed: false,
error: 'nuxt_plugin_conflict',
hint: `${project.pluginFile} is not managed by Impeccable Live`,
};
}
fs.unlinkSync(absFile);
const pluginDir = path.dirname(absFile);
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
return { file: project.pluginFile, removed: true };
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
+3 -2
View File
@@ -38,8 +38,8 @@ function readServerInfo() {
return record.info;
}
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
return { token, id, type, message, file, data };
export function buildPollReplyPayload(token, { id, type, message, file, data, sourceEventType }) {
return { token, id, type, message, file, data, sourceEventType };
}
export function manualApplyPollBanner(event = {}) {
@@ -207,6 +207,7 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
await postReply(base, token, {
id: event.id,
type: completionType,
sourceEventType: event.type,
message: event._acceptResult?.error,
file: event._acceptResult?.file,
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import {
prepareGenerationArtifact,
publishGenerationArtifact,
} from './live/generation-publisher.mjs';
const args = process.argv.slice(2);
const result = args.includes('--prepare')
? prepareGenerationArtifact({
id: arg(args, '--id'),
sourceFile: arg(args, '--file'),
})
: publishGenerationArtifact({
id: arg(args, '--id'),
epoch: Number(arg(args, '--epoch')),
sourceFile: arg(args, '--file'),
artifactFile: arg(args, '--artifact'),
expectedSourceHash: arg(args, '--expected-source-hash'),
arrivedVariants: optionalNumber(arg(args, '--arrived')),
expectedVariants: optionalNumber(arg(args, '--expected')),
});
console.log(JSON.stringify(result));
if (!result.ok) process.exitCode = 2;
function arg(values, name) {
const index = values.indexOf(name);
return index >= 0 ? values[index + 1] : undefined;
}
function optionalNumber(value) {
if (value === undefined) return undefined;
const number = Number(value);
return Number.isInteger(number) ? number : undefined;
}
+158 -15
View File
@@ -29,6 +29,7 @@ import {
resolveLiveBrowserScriptParts,
} from './live/browser-script-parts.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
import { runGenerationPreflight } from './live/generation-preflight.mjs';
import { validateEvent } from './live/event-validation.mjs';
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
@@ -51,6 +52,7 @@ import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live/svelte-component.mjs';
import { removeAllVueComponentSessions } from './live/vue-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -157,28 +159,137 @@ function restorePendingEventsFromStore() {
}
function findAvailablePendingEvent(now = Date.now()) {
for (const entry of state.pendingEvents) {
if (entry.leaseUntil && entry.leaseUntil > now) continue;
return entry;
}
return null;
return state.pendingEvents
.filter((entry) => !(entry.leaseUntil && entry.leaseUntil > now))
.sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.seq - b.seq)[0] || null;
}
function eventPriority(event = {}) {
if (event.type === 'accept' || event.type === 'discard' || event.type === 'exit') return 0;
if (event.type === 'manual_edit_apply' || event.type === 'steer') return 1;
if (event.type === 'generate') return 2;
return 3;
}
function leaseEvent(entry, leaseMs) {
prepareGenerateEventForLease(entry);
if (!entry.event?.id) {
const idx = state.pendingEvents.indexOf(entry);
if (idx !== -1) state.pendingEvents.splice(idx, 1);
return entry.event;
}
entry.leaseUntil = Date.now() + leaseMs;
recordGenerateDelivery(entry);
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event;
}
function acknowledgePendingEvent(id) {
function recordGenerateDelivery(entry) {
const event = entry?.event;
if (!event || event.type !== 'generate' || event.generationReadyAt) return;
const at = Date.now();
entry.event = { ...event, generationReadyAt: at };
state.sessionStore?.appendEvent(entry.event);
recordAgentPhase(event.id, 'generation_ready', { at });
}
function prepareGenerateEventForLease(entry) {
const event = entry?.event;
if (!event || event.type !== 'generate' || event.scaffoldAttempted) return;
recordAgentPhase(event.id, 'picked_up');
recordAgentPhase(event.id, 'scaffolding');
const result = runGenerationPreflight(event, {
cwd: process.cwd(),
scriptsDir: __dirname,
});
entry.event = {
...event,
scaffoldAttempted: true,
scaffoldDurationMs: result.durationMs ?? null,
...(result.ok ? { scaffold: result.scaffold } : { scaffoldError: result.error || result.reason }),
};
state.sessionStore?.appendEvent(entry.event);
recordAgentPhase(event.id, result.ok ? 'source_ready' : 'scaffold_fallback', {
durationMs: result.durationMs ?? null,
previewMode: result.scaffold?.previewMode || 'source',
});
}
function recordAgentPhase(id, phase, details = {}) {
if (!id) return;
const event = {
type: 'agent_phase',
id,
phase,
at: Date.now(),
...details,
};
state.sessionStore?.appendEvent(event);
broadcast(event);
}
function recordGenerationCheckpoint(event) {
if (!event?.id || event.type !== 'checkpoint') return;
if (generationIsFenced(event.id)) return;
const arrived = Number(event.arrivedVariants) || 0;
const expected = Number(event.expectedVariants) || 0;
if (arrived <= 0 || expected <= 0) return;
const previewMode = event.previewMode || 'source';
const previewFile = event.previewFile || event.file;
if (previewFile) {
broadcast({
type: 'variant_progress',
id: event.id,
file: previewFile,
sourceFile: event.sourceFile || (previewMode === 'source' ? previewFile : undefined),
previewFile,
previewMode,
arrivedVariants: arrived,
expectedVariants: expected,
});
}
const details = {
arrivedVariants: arrived,
expectedVariants: expected,
checkpointReason: event.reason || null,
};
const at = Date.now();
if (!generationPhaseAlreadyRecorded(event.id, 'first_reviewable')) {
recordAgentPhase(event.id, 'first_reviewable', { ...details, at });
}
if (arrived >= expected && !generationPhaseAlreadyRecorded(event.id, 'all_variants_ready')) {
recordAgentPhase(event.id, 'all_variants_ready', { ...details, at });
}
}
function generationIsFenced(id) {
if (!state.sessionStore || !id) return false;
try {
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
return snapshot?.generationCanceled === true;
} catch {
return false;
}
}
function generationPhaseAlreadyRecorded(id, phase) {
if (!state.sessionStore) return false;
try {
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
return !!snapshot?.generationTimings?.[phase];
} catch {
return false;
}
}
function acknowledgePendingEvent(id, sourceEventType) {
if (!id) return false;
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
const idx = state.pendingEvents.findIndex((entry) => (
entry.event?.id === id
&& (!sourceEventType || entry.event?.type === sourceEventType)
));
if (idx === -1) return false;
const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1);
@@ -187,9 +298,12 @@ function acknowledgePendingEvent(id) {
return acknowledged;
}
function findPendingEventById(id) {
function findPendingEventById(id, sourceEventType) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
const entry = state.pendingEvents.find((item) => (
item.event?.id === id
&& (!sourceEventType || item.event?.type === sourceEventType)
));
return entry?.event || null;
}
@@ -225,6 +339,8 @@ function summarizeActiveSessionForClient(snapshot = {}) {
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
generationCanceled: snapshot.generationCanceled === true,
cancelReason: snapshot.cancelReason ?? null,
};
}
@@ -698,6 +814,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
return;
}
}
recordGenerationCheckpoint(msg);
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
@@ -784,7 +901,9 @@ function sessionFileMetadataFromPollReply(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;
if (!normalized.includes('node_modules/.impeccable-live/')
&& !normalized.includes('src/lib/impeccable/')
&& !normalized.includes('/.impeccable-live/')) return base;
let full;
try {
@@ -797,18 +916,33 @@ function sessionFileMetadataFromPollReply(file) {
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
if (!['svelte-component', 'vue-component'].includes(manifest?.previewMode) || !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',
previewMode: manifest.previewMode,
};
} catch {
return base;
}
}
function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
const pendingTypes = new Set(
pendingEvents
.filter((entry) => entry.event?.id === msg.id)
.map((entry) => entry.event?.type),
);
if (msg.type === 'discarded' || msg.type === 'discard') return 'discard';
if (msg.type === 'complete') return pendingTypes.has('accept') ? 'accept' : (pendingTypes.has('generate') ? 'generate' : undefined);
if (msg.type === 'steer_done') return 'steer';
// `agent_done` can be the automatic acknowledgement for a carbonize Accept.
// New pollers send sourceEventType explicitly; default to generate only for
// older callers so a late worker cannot acknowledge a queued Accept.
return msg.type === 'agent_done' || msg.type === 'done' ? 'generate' : undefined;
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
@@ -869,7 +1003,8 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return;
}
const pendingEventBeforeAck = findPendingEventById(msg.id);
const sourceEventType = msg.sourceEventType || inferSourceEventType(msg);
const pendingEventBeforeAck = findPendingEventById(msg.id, sourceEventType);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -879,7 +1014,7 @@ function handlePollPost(req, res) {
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id);
const acknowledgedEvent = acknowledgePendingEvent(msg.id, sourceEventType);
let skipJournalReply = false;
let existingSession = null;
if (!acknowledgedEvent && state.sessionStore && msg.id) {
@@ -971,6 +1106,11 @@ function cleanupSvelteComponentSessionsBeforeExit() {
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
try {
removeAllVueComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Vue component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
@@ -1083,7 +1223,10 @@ if (args.includes('--background')) {
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
// The detached child is typically listening in 35-45ms. A 200ms polling
// floor dominated configured cold Live startup; poll cheaply and return
// as soon as the child has written its ready record.
await new Promise(r => setTimeout(r, 5));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
+61 -15
View File
@@ -20,6 +20,11 @@ import {
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
import {
buildVueComponentCssAuthoring,
scaffoldVueComponentSession,
shouldUseVueComponentInjection,
} from './live/vue-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -160,11 +165,29 @@ The agent should insert variant HTML at insertLine.`);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
const normalizedText = String(text).replace(/\s+/g, ' ').trim();
if (normalizedText.length < 8) {
// Very short labels cannot disambiguate siblings reliably. Preserve
// the legacy behavior for these low-information picker events.
match = candidates[0];
} else {
// Rendered text that is absent from every candidate usually means
// the source uses expressions or component props. Picking the first
// same-class sibling silently edits the wrong instance (observed on
// Astro result cards), so stop and surface every candidate instead.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
reason: 'rendered_text_not_in_source',
file: path.relative(process.cwd(), targetFile),
candidates: candidates.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Rendered text does not occur in any matching source branch. The element may use dynamic props or expressions; inspect the candidates and wrap the intended instance manually.',
}));
process.exit(1);
}
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
@@ -269,6 +292,8 @@ The agent should insert variant HTML at insertLine.`);
const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
const useVueComponent = !useSvelteComponent && shouldUseVueComponentInjection(targetFile);
const useFrameworkComponent = useSvelteComponent || useVueComponent;
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
@@ -315,6 +340,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let vueSession = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -334,6 +360,23 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (useVueComponent) {
// Nuxt route-module HMR can invalidate the active page while a generated
// wrapper is only partially written. Stage real Vue SFCs in an app-local
// dev module tree and leave the route untouched until Accept.
vueSession = scaffoldVueComponentSession({
id,
count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), vueSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -356,15 +399,18 @@ The agent should insert variant HTML at insertLine.`);
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
const vueComponentAuthoring = useVueComponent ? buildVueComponentCssAuthoring(count) : null;
const componentSession = svelteSession || vueSession;
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : useVueComponent ? 'vue-component' : undefined;
console.log(JSON.stringify({
file: outputRelFile,
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,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode: componentPreviewMode,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// 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
@@ -374,10 +420,10 @@ The agent should insert variant HTML at insertLine.`);
endLine: outputEndLine, // 1-indexed
insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax,
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
styleMode: componentPreviewMode || styleMode.mode,
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: svelteComponentAuthoring || vueComponentAuthoring || buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length,
}));
}
@@ -0,0 +1,91 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
if (!target.elementId && !target.classes) return null;
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
}
export function runGenerationPreflight(event, {
cwd = process.cwd(),
scriptsDir,
execFileSyncImpl = execFileSync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
const startedAt = performance.now();
try {
const stdout = execFileSyncImpl(process.execPath, command.args, {
cwd,
encoding: 'utf-8',
timeout: timeoutMs,
stdio: ['ignore', 'pipe', 'pipe'],
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
};
} catch (error) {
return {
ok: false,
mode: command.mode,
durationMs: performance.now() - startedAt,
error: compactError(error),
};
}
}
function replaceTarget(event) {
return normalizeTarget(event.element || {});
}
function insertTarget(event) {
return {
...normalizeTarget(event.insert?.anchor || {}),
position: event.insert?.position === 'before' ? 'before' : 'after',
};
}
function normalizeTarget(target) {
const classes = Array.isArray(target.classes)
? target.classes.join(' ')
: String(target.classes || '').trim();
const text = typeof target.textContent === 'string'
? target.textContent.trim().slice(0, 80)
: '';
return {
elementId: target.id || target.elementId || undefined,
classes: classes || undefined,
tag: target.tagName || target.tag || undefined,
text: text || undefined,
};
}
function compactError(error) {
const stderr = error?.stderr ? String(error.stderr).trim() : '';
const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed';
return String(message).slice(0, 500);
}
+549
View File
@@ -0,0 +1,549 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { createLiveSessionStore } from './session-store.mjs';
import { withSourceLockSync } from './source-lock.mjs';
import { getLiveDir } from '../lib/impeccable-paths.mjs';
export function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd() } = {}) {
if (!id) return failure('missing_session_id');
if (!sourceFile) return failure('missing_file');
const requestedPath = resolveInside(cwd, sourceFile);
if (!requestedPath || !fs.existsSync(requestedPath)) return failure(requestedPath ? 'source_missing' : 'path_outside_project');
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
if (componentTarget?.error) return componentTarget;
const sourcePath = componentTarget?.sourcePath || requestedPath;
try {
return withSourceLockSync(sourcePath, 'generation-prepare:' + id, () => {
const store = createLiveSessionStore({ cwd, sessionId: id });
const snapshot = store.getSnapshot(id, { includeCompleted: true });
if (!snapshot?.updatedAt) return failure('session_missing');
if (snapshot.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
}
const source = fs.readFileSync(sourcePath, 'utf-8');
const revision = Number(snapshot.publishedRevision || 0) + 1;
const artifactDir = path.join(getLiveDir(cwd), 'artifacts');
if (componentTarget) {
return prepareComponentArtifact({
id,
revision,
snapshot,
source,
sourcePath,
requestedPath,
target: componentTarget,
artifactDir,
cwd,
});
}
const extension = path.extname(sourcePath) || '.html';
const artifactPath = path.join(artifactDir, id + '-r' + revision + extension);
fs.mkdirSync(artifactDir, { recursive: true });
fs.writeFileSync(artifactPath, source, 'utf-8');
return {
ok: true,
id,
epoch: Number(snapshot.generationEpoch || 1),
revision,
sourceFile: relative(cwd, sourcePath),
artifactFile: relative(cwd, artifactPath),
expectedSourceHash: sha256(source),
};
}, { cwd });
} catch (error) {
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
return failure('prepare_failed', { message: error?.message || String(error) });
}
}
export function publishGenerationArtifact({
id,
epoch,
sourceFile,
artifactFile,
expectedSourceHash,
arrivedVariants,
expectedVariants,
cwd = process.cwd(),
} = {}) {
if (!id) return failure('missing_session_id');
if (!Number.isInteger(epoch) || epoch < 1) return failure('invalid_generation_epoch');
if (!sourceFile || !artifactFile) return failure('missing_file');
const requestedPath = resolveInside(cwd, sourceFile);
const artifactPath = resolveInside(cwd, artifactFile);
if (!requestedPath || !artifactPath) return failure('path_outside_project');
if (!fs.existsSync(requestedPath)) return failure('source_missing');
if (!fs.existsSync(artifactPath)) return failure('artifact_missing');
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
if (componentTarget?.error) return componentTarget;
const artifactManifest = readJson(artifactPath);
const isComponentArtifact = isComponentPreviewMode(artifactManifest?.previewMode);
if (Boolean(componentTarget) !== isComponentArtifact) {
return failure('artifact_preview_mode_mismatch');
}
if (componentTarget && componentTarget.manifest.previewMode !== artifactManifest?.previewMode) {
return failure('artifact_preview_mode_mismatch');
}
const sourcePath = componentTarget?.sourcePath || requestedPath;
try {
return withSourceLockSync(sourcePath, 'generation:' + id + ':' + epoch, () => {
const store = createLiveSessionStore({ cwd, sessionId: id });
const snapshot = store.getSnapshot(id, { includeCompleted: true });
if (!snapshot?.updatedAt) return failure('session_missing');
if (snapshot.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
}
if (Number(snapshot.generationEpoch || 1) !== epoch) {
return failure('stale_generation_epoch', { expectedEpoch: snapshot.generationEpoch || 1 });
}
const current = fs.readFileSync(sourcePath, 'utf-8');
const currentHash = sha256(current);
if (!expectedSourceHash || currentHash !== expectedSourceHash) {
return failure('source_hash_mismatch', { actualSourceHash: currentHash });
}
if (componentTarget) {
return publishComponentArtifact({
id,
epoch,
snapshot,
target: componentTarget,
artifactManifest,
artifactPath,
sourcePath,
arrivedVariants,
expectedVariants,
store,
cwd,
});
}
const artifact = fs.readFileSync(artifactPath, 'utf-8');
if (!artifact.includes('data-impeccable-variants="' + id + '"')) {
return failure('artifact_missing_session_wrapper');
}
const delivered = countDeliveredVariants(artifact);
if (delivered < 1) return failure('artifact_has_no_variants');
if (Number.isInteger(arrivedVariants) && delivered < arrivedVariants) {
return failure('artifact_variant_count_mismatch', { delivered });
}
const priorArrived = Math.max(0, Number(snapshot.arrivedVariants || 0));
for (let variant = 1; variant <= priorArrived; variant++) {
const currentVariant = extractVariantBlock(current, variant);
const artifactVariant = extractVariantBlock(artifact, variant);
if (!currentVariant || !artifactVariant) {
return failure('published_variant_missing', { variant });
}
if (sha256(currentVariant) !== sha256(artifactVariant)) {
return failure('published_variant_changed', { variant });
}
}
const currentPreviewCss = extractPreviewCss(current, id);
const artifactPreviewCss = extractPreviewCss(artifact, id);
if (priorArrived > 0 && currentPreviewCss && !artifactPreviewCss.startsWith(currentPreviewCss)) {
return failure('published_variant_css_changed');
}
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
if (commitSnapshot?.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: commitSnapshot.phase });
}
if (Number(commitSnapshot?.generationEpoch || 1) !== epoch) {
return failure('stale_generation_epoch', { expectedEpoch: commitSnapshot?.generationEpoch || 1 });
}
const artifactHash = sha256(artifact);
atomicReplace(sourcePath, artifact);
const revision = Number(commitSnapshot.publishedRevision || 0) + 1;
store.appendEvent({
type: 'variant_published',
id,
generationEpoch: epoch,
revision,
digest: artifactHash,
sourceFile: relative(cwd, sourcePath),
arrivedVariants: delivered,
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
at: Date.now(),
});
return {
ok: true,
id,
epoch,
revision,
digest: artifactHash,
sourceFile: relative(cwd, sourcePath),
arrivedVariants: delivered,
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
};
}, { cwd });
} catch (error) {
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
return failure('publish_failed', { message: error?.message || String(error) });
}
}
function prepareComponentArtifact({
id,
revision,
snapshot,
source,
sourcePath,
requestedPath,
target,
artifactDir,
cwd,
}) {
const artifactComponentDir = path.join(
artifactDir,
id + '-r' + revision + '-' + target.manifest.previewMode + '-' + process.pid + '-' + Date.now(),
);
fs.mkdirSync(artifactComponentDir, { recursive: true });
copyDirectoryFiles(target.componentPath, artifactComponentDir);
const artifactPath = path.join(artifactComponentDir, 'manifest.json');
const artifactManifest = {
...target.manifest,
componentDir: relative(cwd, artifactComponentDir),
};
fs.writeFileSync(artifactPath, JSON.stringify(artifactManifest, null, 2) + '\n', 'utf-8');
return {
ok: true,
id,
epoch: Number(snapshot.generationEpoch || 1),
revision,
sourceFile: relative(cwd, requestedPath),
targetSourceFile: relative(cwd, sourcePath),
artifactFile: relative(cwd, artifactPath),
componentDir: relative(cwd, artifactComponentDir),
previewMode: target.manifest.previewMode,
expectedSourceHash: sha256(source),
};
}
function publishComponentArtifact({
id,
epoch,
snapshot,
target,
artifactManifest,
artifactPath,
sourcePath,
arrivedVariants,
expectedVariants,
store,
cwd,
}) {
if (!artifactManifest || typeof artifactManifest !== 'object') {
return failure('artifact_manifest_invalid');
}
if (artifactManifest.id !== id || target.manifest.id !== id) {
return failure('artifact_session_mismatch');
}
const artifactComponentPath = resolveInside(cwd, artifactManifest.componentDir);
if (!artifactComponentPath || path.resolve(artifactComponentPath) !== path.dirname(artifactPath)) {
return failure('artifact_component_dir_mismatch');
}
if (!isDescendant(path.join(getLiveDir(cwd), 'artifacts'), artifactComponentPath)) {
return failure('artifact_not_staged');
}
const immutableMismatch = componentManifestMismatch(target.manifest, artifactManifest);
if (immutableMismatch) {
return failure('artifact_manifest_changed', { field: immutableMismatch });
}
const expected = Number(expectedVariants || target.manifest.count || snapshot.expectedVariants || 0);
const declared = optionalPositiveInteger(artifactManifest.arrivedVariants);
const delivered = Number.isInteger(arrivedVariants) ? arrivedVariants : declared;
if (!Number.isInteger(delivered) || delivered < 1) return failure('artifact_has_no_variants');
if (expected > 0 && delivered > expected) {
return failure('artifact_variant_count_mismatch', { delivered, expected });
}
if (declared !== null && declared !== delivered) {
return failure('artifact_variant_count_mismatch', { delivered: declared, expected: delivered });
}
const priorArrived = Math.max(
optionalPositiveInteger(target.manifest.arrivedVariants) || 0,
Number(snapshot.arrivedVariants || 0),
);
if (delivered < priorArrived) {
return failure('artifact_variant_count_regressed', { delivered, priorArrived });
}
const componentExtension = target.manifest.componentExtension
|| (target.manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
const variantContents = [];
for (let variant = 1; variant <= delivered; variant++) {
const artifactVariantPath = path.join(artifactComponentPath, 'v' + variant + '.' + componentExtension);
if (!regularFileInside(artifactComponentPath, artifactVariantPath)) {
return failure('artifact_variant_missing', { variant });
}
const content = fs.readFileSync(artifactVariantPath, 'utf-8');
if (!content.trim()) return failure('artifact_variant_empty', { variant });
const targetVariantPath = path.join(target.componentPath, 'v' + variant + '.' + componentExtension);
if (variant <= priorArrived && !regularFileInside(target.componentPath, targetVariantPath)) {
return failure('published_variant_missing', { variant });
}
if (variant <= priorArrived) {
const prior = fs.readFileSync(targetVariantPath, 'utf-8');
if (sha256(prior) !== sha256(content)) {
return failure('published_variant_changed', { variant });
}
}
variantContents.push({ variant, content, targetPath: targetVariantPath });
}
const artifactParamsPath = path.join(artifactComponentPath, 'params.json');
let paramsContent = null;
if (fs.existsSync(artifactParamsPath)) {
if (!regularFileInside(artifactComponentPath, artifactParamsPath)) {
return failure('artifact_params_invalid');
}
paramsContent = fs.readFileSync(artifactParamsPath, 'utf-8');
const params = parseJson(paramsContent);
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return failure('artifact_params_invalid');
}
}
// Components and optional params become reachable before the manifest
// advertises them. Committing the manifest last makes publication atomic
// from the browser's point of view while the source lock excludes Accept.
fs.mkdirSync(target.componentPath, { recursive: true });
for (const variant of variantContents) {
if (variant.variant > priorArrived) atomicReplace(variant.targetPath, variant.content);
}
if (paramsContent !== null) {
atomicReplace(path.join(target.componentPath, 'params.json'), paramsContent);
}
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
if (commitSnapshot?.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: commitSnapshot.phase });
}
if (Number(commitSnapshot?.generationEpoch || 1) !== epoch) {
return failure('stale_generation_epoch', { expectedEpoch: commitSnapshot?.generationEpoch || 1 });
}
const publishedManifest = {
...target.manifest,
componentDir: relative(cwd, target.componentPath),
arrivedVariants: delivered,
};
delete publishedManifest.manifestPath;
const manifestContent = JSON.stringify(publishedManifest, null, 2) + '\n';
atomicReplace(target.manifestPath, manifestContent);
const digest = digestComponentPublication(manifestContent, variantContents, paramsContent);
const revision = Number(snapshot.publishedRevision || 0) + 1;
const sourceFile = relative(cwd, sourcePath);
const previewFile = relative(cwd, target.manifestPath);
store.appendEvent({
type: 'variant_published',
id,
generationEpoch: epoch,
revision,
digest,
sourceFile,
previewFile,
previewMode: target.manifest.previewMode,
arrivedVariants: delivered,
expectedVariants: expected || delivered,
at: Date.now(),
});
return {
ok: true,
id,
epoch,
revision,
digest,
sourceFile,
previewFile,
previewMode: target.manifest.previewMode,
componentDir: relative(cwd, target.componentPath),
arrivedVariants: delivered,
expectedVariants: expected || delivered,
};
}
const COMPONENT_MANIFEST_FIELDS = [
'id',
'mode',
'previewMode',
'sourceFile',
'sourceStartLine',
'sourceEndLine',
'insertLine',
'position',
'anchorStartLine',
'anchorEndLine',
'count',
'propContract',
'originalMarkup',
'anchorMarkup',
'runtimeModule',
'componentModuleBase',
'framework',
'componentExtension',
];
function readComponentPublicationTarget(manifestPath, cwd, id) {
if (path.basename(manifestPath) !== 'manifest.json') return null;
const manifest = readJson(manifestPath);
if (!manifest || !isComponentPreviewMode(manifest.previewMode)) return null;
if (manifest.id !== id) return failure('artifact_session_mismatch');
const sourcePath = resolveInside(cwd, manifest.sourceFile);
const componentPath = resolveInside(cwd, manifest.componentDir);
if (!sourcePath || !componentPath) return failure('path_outside_project');
if (!fs.existsSync(sourcePath)) return failure('source_missing');
if (path.resolve(componentPath) !== path.dirname(manifestPath)) {
return failure('manifest_component_dir_mismatch');
}
return { manifest, manifestPath, sourcePath, componentPath };
}
function componentManifestMismatch(target, artifact) {
for (const field of COMPONENT_MANIFEST_FIELDS) {
if (JSON.stringify(target[field] ?? null) !== JSON.stringify(artifact[field] ?? null)) return field;
}
return null;
}
function isComponentPreviewMode(value) {
return value === 'svelte-component' || value === 'vue-component';
}
function copyDirectoryFiles(sourceDir, targetDir) {
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
if (!entry.isFile() || entry.isSymbolicLink()) continue;
fs.copyFileSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name));
}
}
function regularFileInside(root, file) {
const rel = path.relative(root, file);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
try {
return fs.lstatSync(file).isFile();
} catch {
return false;
}
}
function isDescendant(root, candidate) {
const rel = path.relative(root, candidate);
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function digestComponentPublication(manifestContent, variants, paramsContent) {
const hash = createHash('sha256');
hash.update(manifestContent);
for (const variant of variants) {
hash.update('\0v' + variant.variant + '\0');
hash.update(variant.content);
}
if (paramsContent !== null) hash.update('\0params\0' + paramsContent);
return hash.digest('hex');
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return null;
}
}
function parseJson(value) {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function optionalPositiveInteger(value) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : null;
}
function countDeliveredVariants(source) {
const matches = source.match(/<div\b[^>]*\bdata-impeccable-variant=(?:"|')(?!original(?:"|'))[^"']+(?:"|')[^>]*>/g);
return matches?.length || 0;
}
function extractVariantBlock(source, variant) {
const open = /<div\b[^>]*>/gi;
let match;
let start = -1;
const attr = new RegExp("\\bdata-impeccable-variant=(?:\"" + variant + "\"|'" + variant + "')");
while ((match = open.exec(source))) {
if (attr.test(match[0])) {
start = match.index;
break;
}
}
if (start < 0) return null;
const token = /<div\b[^>]*\/\s*>|<div\b[^>]*>|<\/div\s*>/gi;
token.lastIndex = start;
let depth = 0;
while ((match = token.exec(source))) {
if (/^<\/div/i.test(match[0])) {
depth -= 1;
if (depth === 0) return source.slice(start, token.lastIndex);
} else if (!/\/\s*>$/.test(match[0])) {
depth += 1;
}
}
return null;
}
function extractPreviewCss(source, id) {
const escapedId = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const open = new RegExp("<style\\b[^>]*\\bdata-impeccable-css=(?:\"" + escapedId + "\"|'" + escapedId + "')[^>]*>", 'i');
const match = open.exec(source);
if (!match) return '';
const start = match.index + match[0].length;
const end = source.indexOf('</style>', start);
if (end < 0) return '';
return source.slice(start, end)
.replace(/^\s*\{\s*`\s*/, '')
.replace(/\s*`\s*\}\s*$/, '')
.trim();
}
function atomicReplace(target, content) {
let mode = 0o666;
try { mode = fs.statSync(target).mode; } catch {}
const temp = target + '.impeccable-publish-' + process.pid + '-' + Date.now();
try {
fs.writeFileSync(temp, content, { encoding: 'utf-8', mode });
fs.renameSync(temp, target);
} finally {
try { fs.unlinkSync(temp); } catch {}
}
}
function resolveInside(cwd, value) {
const resolved = path.resolve(cwd, value);
const rel = path.relative(cwd, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
return resolved;
}
function relative(cwd, value) {
return path.relative(cwd, value).split(path.sep).join('/');
}
function failure(error, details = {}) {
return { ok: false, error, ...details };
}
+84 -2
View File
@@ -3,6 +3,13 @@ import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
const GENERATION_FENCED_PHASES = new Set([
'accept_requested',
'discard_requested',
'carbonize_required',
'completed',
'discarded',
]);
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
const rootDir = getLiveSessionsDir(cwd);
@@ -38,7 +45,10 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
fs.copyFileSync(legacyJournalPath, journalPath);
}
const prior = loadCachedOrRebuild(normalized.id);
// Publisher/complete helpers can append from a separate process while
// the server is alive. Rebuild here so sequence numbers and phase
// fences never come from a stale in-memory cache.
const prior = rebuildSnapshotFromJournal(getReadableJournalPath(normalized.id), normalized.id);
const seq = prior.nextSeq;
const entry = {
seq,
@@ -119,6 +129,14 @@ function baseSnapshot(id) {
activeOwner: null,
sourceMarkers: {},
fallbackMode: null,
generationPhase: null,
generationTimings: {},
generationEpoch: 1,
publishedRevision: 0,
deliveredVariants: {},
generationCanceled: false,
generationCanceledAt: null,
cancelReason: null,
annotationArtifacts: [],
diagnostics: [],
updatedAt: null,
@@ -158,6 +176,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
...snapshot,
paramValues: { ...(snapshot.paramValues || {}) },
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
generationTimings: { ...(snapshot.generationTimings || {}) },
deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
diagnostics: [...(snapshot.diagnostics || [])],
updatedAt: entry.ts || new Date().toISOString(),
@@ -170,14 +190,66 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
switch (event.type) {
case 'generate':
next.phase = 'generate_requested';
next.generationEpoch = Number(event.generationEpoch || next.generationEpoch || 1);
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.expectedVariants = event.count ?? next.expectedVariants;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
break;
case 'variant_published':
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
next.diagnostics.push({
error: 'late_generation_event_ignored',
type: event.type,
phase: next.phase,
revision: event.revision ?? null,
});
break;
}
if (Number(event.generationEpoch || 0) !== Number(next.generationEpoch || 1)) {
next.diagnostics.push({
error: 'stale_generation_epoch_ignored',
epoch: event.generationEpoch ?? null,
expectedEpoch: next.generationEpoch || 1,
});
break;
}
next.phase = 'variants_progress';
next.publishedRevision = Math.max(next.publishedRevision || 0, Number(event.revision || 0));
next.arrivedVariants = Math.max(next.arrivedVariants || 0, Number(event.arrivedVariants || 0));
next.expectedVariants = Number(event.expectedVariants || next.expectedVariants || 0);
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.revision) {
next.deliveredVariants[String(event.revision)] = {
digest: event.digest || null,
arrivedVariants: Number(event.arrivedVariants || 0),
publishedAt: event.at || null,
};
}
break;
case 'agent_phase':
next.generationPhase = event.phase ?? next.generationPhase;
if (event.phase) {
next.generationTimings[event.phase] = {
at: event.at ?? (Date.parse(entry.ts || '') || null),
durationMs: event.durationMs ?? null,
};
}
break;
case 'variants_ready':
case 'agent_done':
if ((next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase))
&& !(event.type === 'agent_done' && event.carbonize === true && next.phase === 'accept_requested')) {
next.diagnostics.push({
error: 'late_generation_event_ignored',
type: event.type,
phase: next.phase,
});
break;
}
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
@@ -194,7 +266,7 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
}
break;
case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
@@ -215,6 +287,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'accept':
case 'accept_intent':
next.phase = 'accept_requested';
next.generationCanceled = true;
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.cancelReason = 'accept';
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
if (event.paramValues) next.paramValues = { ...event.paramValues };
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
@@ -243,6 +318,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break;
case 'discard':
next.phase = 'discard_requested';
next.generationCanceled = true;
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.cancelReason = 'discard';
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
@@ -260,6 +338,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
next.pendingEvent = null;
break;
case 'agent_error':
if (next.generationCanceled && event.sourceEventType === 'generate') {
next.diagnostics.push({ error: 'late_generation_event_ignored', type: event.type, phase: next.phase });
break;
}
next.phase = 'agent_error';
next.pendingEventSeq = null;
next.pendingEvent = null;
+56
View File
@@ -0,0 +1,56 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { getLiveDir } from '../lib/impeccable-paths.mjs';
const STALE_LOCK_MS = 60_000;
export function sourceLockPath(file, cwd = process.cwd()) {
const digest = createHash('sha256').update(path.resolve(cwd, file)).digest('hex').slice(0, 24);
return path.join(getLiveDir(cwd), 'locks', digest + '.lock');
}
export function withSourceLockSync(file, owner, fn, {
cwd = process.cwd(),
waitMs = 0,
retryMs = 5,
} = {}) {
const lockPath = sourceLockPath(file, cwd);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
let fd;
while (fd === undefined) {
clearStaleLock(lockPath);
try {
fd = fs.openSync(lockPath, 'wx');
fs.writeFileSync(fd, JSON.stringify({ owner, pid: process.pid, at: Date.now(), file: path.resolve(cwd, file) }) + '\n');
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
if (Date.now() >= deadline) {
const locked = new Error('source_locked');
locked.code = 'SOURCE_LOCKED';
locked.lockPath = lockPath;
throw locked;
}
sleepSync(Math.max(1, Math.min(Number(retryMs) || 5, deadline - Date.now())));
}
}
try {
return fn();
} finally {
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
try { fs.unlinkSync(lockPath); } catch {}
}
}
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function clearStaleLock(lockPath) {
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) fs.unlinkSync(lockPath);
} catch {}
}
+343
View File
@@ -0,0 +1,343 @@
/**
* Nuxt/Vue live-mode component previews.
*
* Generation writes real Vue SFCs into a generated app-local module tree.
* Nuxt/Vite compiles those modules without touching the active route; Accept
* is the only operation that writes the user's .vue source.
*/
import fs from 'node:fs';
import path from 'node:path';
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectNuxtVueProject(cwd = process.cwd()) {
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && NUXT_CONFIG_RE.test(entry.name))?.name;
if (!configFile) return null;
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
const srcDirMatch = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
let appDir = fs.existsSync(path.join(cwd, 'app')) ? 'app' : '';
if (srcDirMatch) {
const candidate = path.posix.normalize(srcDirMatch[2].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
if (candidate !== '..' && !candidate.startsWith('../') && !path.isAbsolute(candidate)) {
appDir = candidate === '.' ? '' : candidate;
}
}
const componentRoot = [appDir, '.impeccable-live'].filter(Boolean).join('/');
return { configFile, appDir, componentRoot };
}
export function shouldUseVueComponentInjection(filePath, cwd = process.cwd()) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_VUE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.vue' && !!detectNuxtVueProject(cwd);
}
export function vueComponentSessionDir(id, cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) throw new Error('Nuxt project not found');
return path.join(cwd, project.componentRoot, id);
}
export function vueManifestPathForSession(id, cwd = process.cwd()) {
return path.join(vueComponentSessionDir(id, cwd), 'manifest.json');
}
function ensureVueRuntime(cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) throw new Error('Nuxt project not found');
const rel = `${project.componentRoot}/__runtime.js`;
const file = path.join(cwd, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
const source = `import { createApp } from 'vue';\n\nexport function mount(Component, options = {}) {\n const app = createApp(Component, options.props || {});\n app.mount(options.target);\n return app;\n}\n\nexport async function unmount(app) {\n app?.unmount?.();\n}\n`;
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) fs.writeFileSync(file, source, 'utf-8');
return nuxtViteFsModulePath(file, cwd);
}
/**
* Nuxt mounts Vite beneath its build-assets base (normally `/_nuxt/`).
* Keep the manifest path base-agnostic and let the browser prepend the
* runtime's actual buildAssetsDir. A page-route URL such as
* `/app/.impeccable-live/x.vue` is handled by Nitro and returns HTML.
*/
export function nuxtViteFsModulePath(file, cwd = process.cwd()) {
const absolute = path.resolve(cwd, file).split(path.sep).join('/');
const relative = path.relative(cwd, absolute);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Nuxt live module must stay inside the project root');
}
return '/@fs/' + absolute.replace(/^\/+/, '');
}
export function extractVueExpressions(markup) {
const out = [];
const seen = new Set();
const re = /\{\{\s*([^{}]+?)\s*\}\}/g;
let match;
while ((match = re.exec(String(markup || '')))) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
out.push({ expr, token: match[0] });
}
return out;
}
function buildVuePropContract(expressions) {
return expressions.map(({ expr, token }, index) => ({
prop: derivePropName(expr, index),
expr,
placeholder: token,
// DOMParser sees Vue interpolation `{{ user.name }}` as text containing
// the inner `{ user.name }` token; preserve its whitespace for the
// browser's source-text → rendered-text map.
previewToken: token.slice(1, -1),
}));
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:^|\.|\[)([A-Za-z_$][\w$]*)\s*\]?$/);
return tail?.[1] || `prop${index}`;
}
function substituteVueExpressions(markup, contract) {
let out = String(markup || '');
for (const entry of contract) out = out.split(entry.placeholder).join(`{{ ${entry.prop} }}`);
return out;
}
function buildVueVariantStub(variant, markup, contract) {
const props = contract.length > 0
? `<script setup>\ndefineProps({\n${contract.map((entry) => ` ${entry.prop}: { default: '' },`).join('\n')}\n});\n</script>\n\n`
: '';
return `${props}<template>\n${markup.trim()}\n</template>\n\n<style scoped>\n/* Variant ${variant}: add scoped CSS here */\n</style>\n`;
}
export function scaffoldVueComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
const runtimeModule = ensureVueRuntime(cwd);
const dir = vueComponentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const propContract = buildVuePropContract(extractVueExpressions(originalMarkup));
const previewMarkup = substituteVueExpressions(originalMarkup, propContract);
const manifest = {
id,
previewMode: 'vue-component',
framework: 'vue',
componentExtension: 'vue',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
componentModuleBase: nuxtViteFsModulePath(dir, cwd),
runtimeModule,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let variant = 1; variant <= count; variant++) {
const file = path.join(dir, `v${variant}.vue`);
if (!fs.existsSync(file)) fs.writeFileSync(file, buildVueVariantStub(variant, previewMarkup, propContract), 'utf-8');
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract,
};
}
export function findVueComponentManifest(id, cwd = process.cwd()) {
let direct;
try { direct = vueManifestPathForSession(id, cwd); } catch { return null; }
if (!fs.existsSync(direct)) return null;
try {
const manifest = JSON.parse(fs.readFileSync(direct, 'utf-8'));
return manifest?.id === id && manifest?.previewMode === 'vue-component'
? { ...manifest, manifestPath: direct }
: null;
} catch {
return null;
}
}
function parseVueSfc(source) {
const text = String(source || '');
const template = text.match(/<template\b[^>]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
const style = text.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i)?.[1]?.trim() || '';
return { template, cssLines: style ? style.split('\n').map((line) => line.trimEnd()) : [] };
}
function restoreVueExpressions(markup, contract) {
let out = String(markup || '');
for (const entry of contract || []) {
out = out.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(entry.prop)}\\s*\\}\\}`, 'g'), entry.placeholder);
}
return out;
}
export function inlineVueComponentAccept(manifest, variantNum, cwd = process.cwd()) {
const sourcePath = resolveInside(cwd, manifest.sourceFile);
const componentDir = resolveInside(cwd, manifest.componentDir);
const variantPath = componentDir && path.join(componentDir, `v${variantNum}.vue`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'vue-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!sourcePath || !componentDir || !variantPath || !fs.existsSync(sourcePath) || !fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { template, cssLines } = parseVueSfc(fs.readFileSync(variantPath, 'utf-8'));
if (!template) return { handled: false, error: 'Accepted Vue variant has no template', ...resultBase };
if (/\bdata-impeccable-[\w-]*\s*=/.test(template)) {
return { handled: false, error: 'Accepted Vue variant contains preview-only attributes', ...resultBase };
}
const sourceLines = fs.readFileSync(sourcePath, 'utf-8').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 mergedTemplate = mergeOriginalVueAttrs(template, manifest.originalMarkup || '');
const markupLines = restoreVueExpressions(mergedTemplate, manifest.propContract)
.split('\n')
.map((line) => line.trim() ? indent + line.trimStart() : '');
let next = [...sourceLines.slice(0, start), ...markupLines, ...sourceLines.slice(end + 1)];
const meaningfulCss = cssLines.filter((line) => line.trim() && !/^\/\*\s*Variant \d+:/.test(line.trim()));
if (meaningfulCss.length > 0) next = appendVueStyle(next, meaningfulCss);
fs.writeFileSync(sourcePath, next.join('\n'), 'utf-8');
retireVueComponentSession(manifest.id, cwd);
return { handled: true, ...resultBase };
}
function appendVueStyle(lines, cssLines) {
let close = -1;
for (let index = lines.length - 1; index >= 0; index--) {
if (/<\/style\s*>/.test(lines[index])) { close = index; break; }
}
const block = ['', ...cssLines.map((line) => line.trim() ? ' ' + line.trimStart() : '')];
if (close < 0) return [...lines, '', '<style scoped>', ...block.slice(1), '</style>'];
return [...lines.slice(0, close), ...block, ...lines.slice(close)];
}
function mergeOriginalVueAttrs(markup, originalMarkup) {
const variant = matchOpeningTag(markup);
const original = matchOpeningTag(originalMarkup);
if (!variant || !original || variant.tag.toLowerCase() !== original.tag.toLowerCase()) return markup;
const variantAttrs = parseStaticAttrs(variant.attrs);
const originalAttrs = parseStaticAttrs(original.attrs);
const additions = [];
let attrs = variant.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const classes = [
...variantClass.value.split(/\s+/),
...originalClass.value.split(/\s+/),
].filter(Boolean);
const replacement = `class=${variantClass.quote}${[...new Set(classes)].join(' ')}${variantClass.quote}`;
attrs = attrs.slice(0, variantClass.start) + replacement + attrs.slice(variantClass.end);
} else if (originalClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class' || variantAttrs.has(name)) continue;
additions.push(attr.raw);
}
const open = `<${variant.tag}${attrs}${additions.map((attr) => ' ' + attr.trim()).join('')}${variant.close}`;
return markup.slice(0, variant.index) + open + markup.slice(variant.index + variant.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
return match ? {
raw: match[0],
tag: match[1],
attrs: match[2] || '',
close: match[3],
index: match.index || 0,
} : null;
}
function parseStaticAttrs(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)\s*=\s*(["'])(.*?)\2/g;
let match;
while ((match = re.exec(attrs))) {
out.set(match[1], {
raw: match[0],
value: match[3],
quote: match[2],
start: match.index,
end: match.index + match[0].length,
});
}
return out;
}
export function removeVueComponentSession(id, cwd = process.cwd()) {
try { fs.rmSync(vueComponentSessionDir(id, cwd), { recursive: true, force: true }); } catch { /* best effort */ }
}
/**
* Make an accepted/discarded session undiscoverable immediately while keeping
* Vue modules that Vite has in its graph alive until Live shuts down. Deleting
* an imported SFC mid-session makes Nuxt's HMR client attempt to reload a
* missing module and emit a console error. The generated directory remains
* ignored and removeAllVueComponentSessions removes it on server shutdown.
*/
export function retireVueComponentSession(id, cwd = process.cwd()) {
let dir;
try { dir = vueComponentSessionDir(id, cwd); } catch { return; }
for (const name of ['manifest.json', 'params.json']) {
try { fs.rmSync(path.join(dir, name), { force: true }); } catch { /* best effort */ }
}
}
export function removeAllVueComponentSessions(cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) return;
const root = path.join(cwd, project.componentRoot);
if (!fs.existsSync(root)) return;
fs.rmSync(root, { recursive: true, force: true });
}
export function buildVueComponentCssAuthoring(count) {
return {
mode: 'vue-component',
count,
requirements: [
'Write each variant as a real Vue SFC in componentDir/vN.vue.',
'Keep one root element inside <template> and put variant CSS in <style scoped>.',
'Keep propContract bindings as {{ propName }} instead of snapshot text.',
'Do not add data-impeccable-* attributes.',
],
forbidden: ['Rewriting sourceFile during preview', 'data-impeccable-* attributes', 'Off-brand replacement content'],
};
}
function resolveInside(cwd, value) {
if (!value || path.isAbsolute(value)) return null;
const full = path.resolve(cwd, value);
const rel = path.relative(cwd, full);
return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : full;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}