Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept

A ground-up hardening of live mode, driven by a production session in a
nested-app monorepo that hit six distinct failure classes. Full design
rationale in docs/LIVE-REWRITE-PLAN.md; every Codex-reported failure now
has a mechanical fix and a regression test.

Roots: live/roots.mjs resolves appRoot/repoRoot/contextRoot once at boot
(keyed on dev-server configs, not monorepo brand markers), persists a
manifest, and every live CLI re-anchors onto it at startup, so a helper
run from the wrong directory can no longer fork session state. Context
files are discovered upward to the git root.

Render truth: variant_mounted / variant_mount_failed events give the
journal per-variant mount state; failures reach the agent's poll queue,
raise a persistent error card with Retry (no more localStorage wipe), and
an attach probe names root/dev-server mismatches explicitly. The browser
rehydrates from the server when localStorage is gone.

Svelte: the scaffolder now parses with the app's own svelte 5 compiler.
Control flow survives (an each collection crosses the contract as one
structured prop), keyed each blocks hydrate synthetic keys, and anything
a detached preview cannot support falls back to source-preview instead of
shipping a wrong scaffold. Preview modules live in per-publish revision
directories, defeating stale transform caches.

Accept: CSS is reconciled, not appended. Matching selectors are replaced,
params bake from params.json kinds, the compiler's unused-selector pass
prunes superseded rules (pre-existing dead rules protected), a selector-
loss postcondition refuses any write that would drop hand-written rules,
and live-complete refuses to finish while live plumbing remains in source.

Also: framework registry (live/frameworks/) with a crash-safe injection
journal, session-store snapshot caching with read-only reads, protocol
enum consolidation, steer Send button, honest DESIGN-panel empty states.

Testing: new unit suites (roots, AST scaffolder, accept CSS, accept
pipeline, framework conformance); e2e now fails on preview-tree 404s,
proves computed-style mount for every variant, drives the Tune panel
through baked params, and injects failures (broken mounts, republish,
storage loss). New runtime fixtures: monorepo-nested-vite (repo root !=
app root) and vite8-sveltekit-stateful (each blocks + state). Nightly
full-matrix cron. An independent adversarial review pass preceded this
commit; its blocker and major findings are fixed and regression-tested.

This work was produced with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-27 15:09:40 -07:00
co-authored by Claude Code
parent 839dd10079
commit 17dabf4b7e
72 changed files with 9254 additions and 862 deletions
+2
View File
@@ -27,6 +27,7 @@ import {
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live/svelte-component.mjs';
import { enterLiveRoot } from './live/roots.mjs';
const ACCEPT_LOCK_WAIT_MS = 1_000;
// Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same
@@ -946,6 +947,7 @@ function argVal(args, flag) {
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
enterLiveRoot();
acceptCli();
}
File diff suppressed because it is too large Load Diff
+33 -1
View File
@@ -3,8 +3,12 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
import fs from 'node:fs';
import path from 'node:path';
import { createLiveSessionStore } from './live/session-store.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { enterLiveRoot } from './live/roots.mjs';
import { verifyAcceptedFile } from './live/accept-verify.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
@@ -15,6 +19,7 @@ function parseArgs(argv) {
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
else if (arg === '--force') out.force = true;
else if (arg === '--help' || arg === '-h') out.help = true;
}
return out;
@@ -23,10 +28,36 @@ function parseArgs(argv) {
export async function completeCli() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.id) {
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE] [--force]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.\nCompletion is refused while the session's source file still carries live-mode leftovers\n(markers, data-p-* attributes, unbaked --p-* vars); fix the file or pass --force.`);
process.exit(args.help ? 0 : 1);
}
// The carbonize contract used to be prose; this makes it mechanical. A
// "complete" while the source still carries live plumbing is how markers
// and dead param branches accumulated across sessions.
if (args.status === 'complete' && !args.force) {
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
const sourceFile = snapshot?.sourceFile;
const absSource = sourceFile ? path.resolve(process.cwd(), sourceFile) : null;
const relSource = absSource ? path.relative(process.cwd(), absSource) : null;
const insideProject = relSource !== null && relSource !== '' && !relSource.startsWith('..') && !path.isAbsolute(relSource);
if (insideProject && !relSource.startsWith('node_modules' + path.sep) && !relSource.startsWith('node_modules/')) {
const verify = verifyAcceptedFile(fs, absSource);
if (!verify.clean) {
console.log(JSON.stringify({
ok: false,
error: 'source_dirty',
id: args.id,
file: sourceFile,
findings: verify.findings,
hint: 'The accepted source still carries live-mode leftovers. Finish the carbonize cleanup (bake params, remove markers and data-p-* attributes), then run live-complete again. Use --force only if a finding is a false positive.',
}, null, 2));
process.exit(1);
}
}
}
const serverInfo = readServerInfo();
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
if (serverResult?.ok) {
@@ -71,5 +102,6 @@ async function completeThroughServer(info, args) {
const _running = process.argv[1];
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
enterLiveRoot();
completeCli();
}
+137 -412
View File
@@ -7,6 +7,11 @@
* every subsequent run, this script handles insert/remove deterministically
* with zero LLM involvement.
*
* Framework knowledge lives in `live/frameworks/` — detection order, adapters,
* the generic tag strategy, and the per-extension authoring traits live-wrap
* reads. This file is the CLI around it: resolve config, resolve the
* framework, heal orphaned artifacts, apply or remove, record the journal.
*
* Usage:
* node live-inject.mjs --port PORT [--token TOKEN] # Insert the live script tag
* node live-inject.mjs --remove # Remove the live script tag
@@ -23,22 +28,36 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
describeInjectArtifacts,
frameworkIgnorePatterns,
resolveFramework,
resolveSourceTraits,
} from './live/frameworks/index.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
clearInjectJournal,
healInjectJournal,
recordInjection,
} from './live/frameworks/journal.mjs';
import {
buildTagBlock,
insertTag,
patchCspMeta,
removeTag,
revertCspMeta,
} from './live/frameworks/tag-strategy.mjs';
import { buildLiveScriptSrc } from './live/frameworks/script-src.mjs';
import { enterLiveRoot } from './live/roots.mjs';
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';
// Resolved lazily so the enterLiveRoot() chdir in the CLI guard below takes
// effect first; module scope runs before the guard.
let CONFIG_PATH_CACHED = null;
function CONFIG_PATH_GET() {
if (!CONFIG_PATH_CACHED) {
CONFIG_PATH_CACHED = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
}
return CONFIG_PATH_CACHED;
}
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
@@ -47,6 +66,9 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.pending.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/roots.json',
'.impeccable/live/app-root.json',
'.impeccable/live/inject-journal.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
@@ -102,60 +124,61 @@ Output (JSON):
}
if (args.includes('--check')) {
if (!fs.existsSync(CONFIG_PATH)) {
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
// Deliberately read-only: --check runs from status paths and must never
// mutate the tree. Journal reconciliation happens on the inject run.
if (!fs.existsSync(CONFIG_PATH_GET())) {
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH_GET() }));
process.exit(0);
}
let cfg;
try {
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH_GET(), 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH_GET() }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH_GET() }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH_GET() }));
return;
}
// Load config
if (!fs.existsSync(CONFIG_PATH)) {
console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
if (!fs.existsSync(CONFIG_PATH_GET())) {
console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH_GET() }));
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
const config = JSON.parse(fs.readFileSync(CONFIG_PATH_GET(), 'utf-8'));
validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
const cwd = process.cwd();
const resolvedFiles = resolveFiles(cwd, config);
const resolved = resolveFramework(cwd, config);
const isAdapter = resolved?.framework.inject.kind === 'adapter';
if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
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;
if (isAdapter) {
const adapterResult = resolved.framework.inject.remove({ cwd, config, project: resolved.project });
const ok = !(adapterResult && adapterResult.error);
// Anything the adapter could not reach (its detection may have shifted
// since the session started) is still on the journal.
const { healed } = healInjectJournal(cwd);
clearInjectJournal(cwd);
console.log(JSON.stringify({
ok,
adapter: resolved.framework.name,
results: [adapterResult],
healed: healed.length ? healed : undefined,
}));
if (!ok) process.exitCode = 1;
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
const absFile = path.resolve(cwd, relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const detagged = removeTag(content, config.commentSyntax);
@@ -168,7 +191,9 @@ Output (JSON):
cspReverted: updated !== detagged,
};
});
console.log(JSON.stringify({ ok: true, results }));
const { healed } = healInjectJournal(cwd);
clearInjectJournal(cwd);
console.log(JSON.stringify({ ok: true, results, healed: healed.length ? healed : undefined }));
return;
}
@@ -183,47 +208,55 @@ Output (JSON):
// /live.js handler authorizes the browser fetch. `live.mjs` always passes it.
const tokenIdx = args.indexOf('--token');
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, token, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
// Reconcile before writing anything. Artifacts this run is about to own are
// kept (so a repeat inject stays byte-idempotent); artifacts left behind by
// a session that never got to stop are healed.
const plannedArtifacts = describeInjectArtifacts(resolved, { cwd, files: resolvedFiles });
const { healed } = healInjectJournal(cwd, { keep: plannedArtifacts.map((a) => a.path) });
const gitIgnore = ensureLiveGitIgnores(cwd, frameworkIgnorePatterns(resolved));
// In a nested-app repo the roots pointer lives at the REPO root, outside the
// reach of the appRoot-relative ignore block above; give that directory its
// own local excludes so the pointer (absolute host paths) never gets staged.
try {
const rootsManifest = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'roots.json'), 'utf-8'));
if (rootsManifest?.repoRoot && path.resolve(rootsManifest.repoRoot) !== path.resolve(cwd)) {
ensureLiveGitIgnores(rootsManifest.repoRoot);
}
} catch { /* no manifest: single-root project */ }
if (isAdapter) {
const adapterResult = resolved.framework.inject.apply({
cwd,
port,
adapter: 'tanstack-start',
token,
config,
project: resolved.project,
});
const ok = !(adapterResult && adapterResult.error);
if (ok) recordInjection(cwd, { framework: resolved.framework.name, port, artifacts: plannedArtifacts });
console.log(JSON.stringify({
ok,
port,
adapter: resolved.framework.name,
gitIgnore,
results: [adapterResult],
healed: healed.length ? healed : undefined,
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'nuxt',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
if (!ok) process.exitCode = 1;
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
const absFile = path.resolve(cwd, relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
const withTag = insertTag(withoutOld, config, port, relFile, token);
// Per-file, not per-project: a Vite app can hold an .astro partial, and a
// framework project's entry template is often plain HTML.
const scriptAttrs = resolveSourceTraits(relFile).injectScriptAttrs;
const withTag = insertTag(withoutOld, config, port, token, scriptAttrs);
if (withTag === withoutOld) {
return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
}
@@ -236,7 +269,19 @@ Output (JSON):
};
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
const writtenFiles = new Set(results.filter((r) => r.inserted).map((r) => r.file));
recordInjection(cwd, {
framework: resolved?.framework.name,
port,
artifacts: plannedArtifacts.filter((a) => writtenFiles.has(a.path)),
});
console.log(JSON.stringify({
ok: anyInserted,
port,
gitIgnore,
results,
healed: healed.length ? healed : undefined,
}));
if (!anyInserted) process.exit(1);
}
@@ -271,115 +316,6 @@ export function ensureLiveGitIgnores(cwd = process.cwd(), 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, token) {
return `/* ${NUXT_PLUGIN_MARKER} */
const liveSrc = '${buildLiveScriptSrc(port, token)}';
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, token, 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, token);
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) {
@@ -527,242 +463,31 @@ function validateConfig(cfg) {
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
/**
* Build the /live.js src the browser loads. When a token is supplied it rides
* as a `?token=...` query param so the server's token-gated /live.js handler
* authorizes the fetch. Shared by every injection path (HTML/JSX script tag,
* the Nuxt plugin, the SvelteKit root component) so they stay in sync.
*/
export function buildLiveScriptSrc(port, token) {
const base = 'http://localhost:' + port + '/live.js';
return token ? base + '?token=' + encodeURIComponent(token) : base;
}
function buildTagBlock(syntax, port, filePath, token) {
const open = commentOpen(syntax);
const close = commentClose(syntax);
// Astro processes <script> tags by default and rewrites src to its own
// bundled URL. is:inline opts out so the literal external src survives.
const isAstro = typeof filePath === 'string' && filePath.endsWith('.astro');
const scriptAttrs = isAstro ? 'is:inline ' : '';
return (
open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
'<script ' + scriptAttrs + 'src="' + buildLiveScriptSrc(port, token) + '"></script>\n' +
open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
);
}
function detectLineEnding(content) {
if (content.includes('\r\n')) return '\r\n';
if (content.includes('\r')) return '\r';
return '\n';
}
function normalizeLineEndings(content, lineEnding) {
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
}
function readLineEndingAt(content, index) {
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
if (content[index] === '\n') return '\n';
if (content[index] === '\r') return '\r';
return '';
}
function insertTag(content, config, port, filePath, token) {
const lineEnding = detectLineEnding(content);
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding);
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
// belong at the end, and the same literal can appear earlier in code blocks
// within rendered documentation pages.
if (config.insertBefore) {
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
// Preserve an existing trailing newline if the anchor already has one.
// Slice the remainder from the original anchor offset, not prefix.length:
// in the no-newline case prefix is one char longer than the anchor (the
// appended '\n'), so slicing by prefix.length would drop the first real
// character after the anchor (#227).
const existingNewline = readLineEndingAt(content, after);
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
const rest = content.slice(after + existingNewline.length);
return prefix + block + rest;
}
/**
* Remove the live script block. Matches either HTML or JSX comment markers
* regardless of config (so stale tags from a wrong config can still be cleaned).
*
* Indent-preserving: captures any whitespace immediately preceding the opener
* marker and re-emits it in place of the removed block. `insertTag` inserted
* the block *after* the original line's indent and *before* the anchor (e.g.
* `</body>`), which moved the indent onto the opener line and left the anchor
* unindented. Replacing the whole block (plus its trailing newline) with just
* the captured indent hands the indent back to the anchor that follows.
*/
function removeTag(content, _syntax) {
const patterns = [
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
];
for (const pat of patterns) {
let changed = false;
let next = content;
do {
content = next;
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
if (/[\r\n]/.test(trailing)) return leadingIndent;
return leadingIndent || trailing || '';
});
if (next !== content) changed = true;
} while (next !== content);
if (changed) return next;
}
return content;
}
// ---------------------------------------------------------------------------
// Content-Security-Policy meta-tag patcher
//
// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
// the cross-origin load of /live.js (and the SSE/POST connection back to
// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
//
// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
// and stash the original `content` value in a `data-impeccable-csp-original`
// attribute (base64) so revert is exact.
//
// On remove: detect the marker attribute, decode it, restore the original
// content value verbatim, drop the marker.
//
// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
// shared helpers) is NOT patched here — those need framework-specific config
// edits and are handled via the existing detect-csp.mjs reference output.
// Only the in-source meta-tag form gets the auto-patch.
// ---------------------------------------------------------------------------
const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
function findCspMetaTags(content) {
const out = [];
const tagRe = /<meta\s+([^>]*?)\/?>/gis;
let m;
while ((m = tagRe.exec(content)) !== null) {
const attrs = m[1];
if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
}
return out;
}
function getAttr(attrs, name) {
const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
const m = attrs.match(re);
return m ? { quote: m[1], value: m[2], full: m[0] } : null;
}
function appendOriginToDirective(csp, directive, origin) {
const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
const m = csp.match(re);
if (m) {
const tokens = m[4].trim().split(/\s+/);
if (tokens.includes(origin)) return csp;
return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
}
// Directive missing — add it. Use 'self' + origin so we don't inadvertently
// narrow the policy compared to the default-src fallback (most users with
// an explicit CSP have 'self' there).
return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
}
export function patchCspMeta(content, port) {
const tags = findCspMetaTags(content);
if (tags.length === 0) return content;
const origin = `http://localhost:${port}`;
// Walk last-to-first so prior splices don't invalidate later indices.
let result = content;
for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
const attrs = tag.attrs;
if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
const contentAttr = getAttr(attrs, 'content');
if (!contentAttr) continue;
const original = contentAttr.value;
let patched = original;
patched = appendOriginToDirective(patched, 'script-src', origin);
patched = appendOriginToDirective(patched, 'connect-src', origin);
// The shader overlay during 'generating' creates a screenshot via
// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
// those. Add `blob:` so the overlay doesn't throw a CSP violation.
patched = appendOriginToDirective(patched, 'img-src', 'blob:');
if (patched === original) continue;
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
}
return result;
}
export function revertCspMeta(content) {
const tags = findCspMetaTags(content);
if (tags.length === 0) return content;
let result = content;
for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
if (!origAttr) continue;
const contentAttr = getAttr(tag.attrs, 'content');
if (!contentAttr) continue;
let originalValue;
try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
catch { continue; }
const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
// Drop the marker attribute and any single space immediately preceding it.
newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
const newTag = tag.full.replace(tag.attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
}
return result;
}
// ---------------------------------------------------------------------------
// Auto-execute
// ---------------------------------------------------------------------------
const _running = process.argv[1];
if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
enterLiveRoot();
injectCli();
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
// patchCspMeta + revertCspMeta are exported above where they're defined.
// Re-exported so long-standing importers (live.mjs, the adapter modules, the
// test suites) keep their entry points while the implementations live in
// live/frameworks/.
export {
buildLiveScriptSrc,
buildTagBlock,
insertTag,
patchCspMeta,
removeTag,
revertCspMeta,
validateConfig,
};
export {
applyNuxtLiveAdapter,
buildNuxtPlugin,
detectNuxtProject,
removeNuxtLiveAdapter,
} from './live/frameworks/nuxt.mjs';
+2
View File
@@ -26,6 +26,7 @@ import {
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
import { enterLiveRoot } from './live/roots.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -286,5 +287,6 @@ Output (JSON):
const _running = process.argv[1];
if (_running?.endsWith('live-insert.mjs') || _running?.endsWith('live-insert.mjs/')) {
enterLiveRoot();
insertCli();
}
+2
View File
@@ -14,6 +14,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { enterLiveRoot } from './live/roots.mjs';
// Absolute path to a sibling script in this skill's scripts dir, so runtime
// error hints print a directly-runnable command instead of a placeholder.
@@ -412,5 +413,6 @@ export function normalizePollTypes(value) {
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
enterLiveRoot();
pollCli();
}
+39 -10
View File
@@ -4,6 +4,7 @@
*/
import { createLiveSessionStore } from './live/session-store.mjs';
import { enterLiveRoot } from './live/roots.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
@@ -49,6 +50,28 @@ function collectManualApplyFiles(batch) {
return [...new Set(files.filter((file) => typeof file === 'string' && file.length > 0))].sort();
}
/**
* The browser's render truth, folded into a small block the agent reads before
* it decides what to do. `arrivedVariants` only says the agent published;
* `renderState` says whether any of it reached a screen.
*/
export function renderSummary(snapshot = {}) {
return {
renderState: snapshot.renderState ?? null,
mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
};
}
export function mountFailureAction(snapshot = {}) {
const failures = Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [];
const latest = failures[failures.length - 1];
if (!latest) return null;
const where = latest.url ? ` from ${latest.url}` : '';
const why = latest.error ? ` (${latest.error})` : '';
return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply EVENT_ID done --file <manifest or source path> for the queued variant_mount_failed event (or republish) so the browser retries.`;
}
function parseArgs(argv) {
const out = { id: null };
for (let i = 0; i < argv.length; i++) {
@@ -75,20 +98,26 @@ export async function resumeCli() {
}
const pending = snapshot.pendingEvent || null;
const nextAction = pending
? pending.type === 'manual_edit_apply'
? manualApplyResumeHint(pending)
: `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
: snapshot.phase === 'carbonize_required'
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
: snapshot.phase === 'accept_requested'
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
const render = renderSummary(snapshot);
// A failed render outranks the generic pending-event hint: the agent needs to
// know the user is staring at an error card, not at variants. A leased manual
// Apply still outranks both, because abandoning that lease loses user edits.
const mountAction = render.renderState === 'failed' ? mountFailureAction(snapshot) : null;
const nextAction = pending?.type === 'manual_edit_apply'
? manualApplyResumeHint(pending)
: mountAction || (pending
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
: snapshot.phase === 'carbonize_required'
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
: snapshot.phase === 'accept_requested'
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`);
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, render, nextAction }, null, 2));
}
const _running = process.argv[1];
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
enterLiveRoot();
resumeCli();
}
+117 -16
View File
@@ -33,7 +33,10 @@ import { runGenerationPreflight } from './live/generation-preflight.mjs';
import { validateEvent } from './live/event-validation.mjs';
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
LIVE_COMMANDS,
VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST,
} from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -51,24 +54,46 @@ import {
} from './live/manual-apply.mjs';
import {
applyDeferredSvelteComponentAccepts,
bumpSvelteComponentPreviewRevision,
removeAllSvelteComponentSessions,
sweepInactiveSvelteComponentSessions,
} from './live/svelte-component.mjs';
import { enterLiveRoot } from './live/roots.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
// DESIGN.json fallback for existing projects.
const PROJECT_CONTEXT = loadContext(process.cwd());
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
: null;
// Anchor the whole process on the live roots manifest before anything derives
// a path from cwd. A server started from the wrong directory re-roots itself
// onto the appRoot the boot decided on instead of minting a second project.
const LIVE_ROOTS = enterLiveRoot(process.cwd());
// PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server
// that outlives an `impeccable document` run (or a context file created after
// boot) reports current truth instead of a boot-time snapshot. The roots
// manifest wins when the ambient resolution misses (nested app inheriting
// repo-level context files).
function resolveProjectContext() {
const ctx = loadContext(process.cwd());
const designPath = ctx.designPath
? path.resolve(process.cwd(), ctx.designPath)
: (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null);
const hasProduct = ctx.hasProduct
|| !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath));
return {
...ctx,
hasProduct,
hasDesign: !!designPath,
resolvedDesignPath: designPath,
contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(),
designContextDir: ctx.designContextDir
|| (designPath ? path.dirname(designPath) : null),
};
}
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
// The browser checkpoints for several unrelated reasons (see checkpointPayload
// in live-browser.js). Only these two report that variant availability changed,
// and only they may drive variant_progress / the *_reviewable phases.
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST);
// ---------------------------------------------------------------------------
// Port detection
@@ -445,6 +470,11 @@ function summarizeActiveSessionForClient(snapshot = {}) {
generationCompletedAt: snapshot.generationCompletedAt ?? null,
generationCanceled: snapshot.generationCanceled === true,
cancelReason: snapshot.cancelReason ?? null,
// Render truth, so a browser with no localStorage can rehydrate to the
// same comparison the server already knows about.
mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
renderState: snapshot.renderState ?? null,
};
}
@@ -618,7 +648,7 @@ function hasProjectContext() {
// PRODUCT.md carries brand voice / anti-references — that's what determines
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
// concern, surfaced by the design panel's own empty state.
return !!PROJECT_CONTEXT.hasProduct;
return !!resolveProjectContext().hasProduct;
}
function statOrNull(filePath) {
@@ -827,8 +857,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const mdPath = DESIGN_MD_PATH;
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
const projectContext = resolveProjectContext();
const mdPath = projectContext.resolvedDesignPath;
const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd());
const mdStat = statOrNull(mdPath);
const jsonStat = statOrNull(jsonPath);
@@ -997,7 +1028,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
if (msg.type !== 'checkpoint') {
// `variant_mounted` is the happy path: it is journaled above so the
// snapshot carries render truth, but there is nothing for the agent to
// do about it, so it stays out of the poll queue and off the SSE bus.
// `variant_mount_failed` is the opposite: the agent published something
// the browser could not render, and only the agent can fix it, so it
// goes to the queue as a first-class event.
if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted') {
enqueueEvent(msg);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1099,7 +1136,8 @@ function sessionFileMetadataFromPollReply(file) {
const base = { file: normalized };
const metadataFile = normalized;
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
if (!metadataFile.includes('node_modules/.impeccable-live/')
if (!metadataFile.includes('.impeccable/live/previews/')
&& !metadataFile.includes('node_modules/.impeccable-live/')
&& !metadataFile.includes('src/lib/impeccable/')
&& !metadataFile.includes('/.impeccable-live/')) return base;
@@ -1139,7 +1177,14 @@ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
// `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.
if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
if (msg.type === 'agent_done' || msg.type === 'done') {
// A `done` reply to a mount failure is the republish that unblocks the
// browser. Without this the ack would look for a `generate` that was
// already retired, the mount-failure event would stay queued, and the next
// poll would hand the same failure back to the agent forever.
if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed';
return 'generate';
}
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
// never sets sourceEventType on it (the poller is a fresh process that cannot
// know what it leased). Returning undefined here makes acknowledgePendingEvent
@@ -1264,6 +1309,15 @@ function handlePollPost(req, res) {
return;
}
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
// A publish (done reply carrying a component manifest) snapshots the
// variant files into a fresh revision dir before the browser is told:
// the import path changes every publish, so no transform cache can pin a
// stale compile of a republished module (node_modules is unwatched).
if (replyFileMeta.previewMode === 'svelte-component'
&& msg.id
&& (msg.type === 'done' || !msg.type)) {
try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ }
}
if (state.sessionStore && msg.id && !skipJournalReply) {
try {
const eventType = msg.type === 'steer_done'
@@ -1335,6 +1389,51 @@ function cleanupSvelteComponentSessionsBeforeExit() {
}
}
/**
* A previous run that died without its shutdown hook leaves preview component
* dirs behind. Drop the ones whose session the store no longer considers
* active; anything still active is mid-generation and must survive a restart.
*/
function sweepOrphanSvelteComponentSessionsOnStartup() {
try {
const activeIds = (state.sessionStore?.listActiveSessions() || [])
.map((snapshot) => snapshot?.id)
.filter(Boolean);
const result = sweepInactiveSvelteComponentSessions(activeIds, process.cwd());
if (result.removed.length > 0 || result.removedRoot) {
console.log('[impeccable] swept orphaned Svelte component sessions:', JSON.stringify(result));
}
} catch (err) {
console.warn('[impeccable] Svelte component session sweep failed:', err.message);
}
}
// Accept receipts are a short-lived idempotency record for a single accept.
// Nothing reads one after the session that wrote it is gone, so they only need
// to outlive a crash-and-retry window.
const ACCEPT_RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
function sweepStaleAcceptReceiptsOnStartup() {
try {
const dir = path.join(getLiveDir(process.cwd()), 'accept-receipts');
if (!fs.existsSync(dir)) return;
const cutoff = Date.now() - ACCEPT_RECEIPT_MAX_AGE_MS;
let removed = 0;
for (const name of fs.readdirSync(dir)) {
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
const file = path.join(dir, name);
try {
if (fs.statSync(file).mtimeMs >= cutoff) continue;
fs.rmSync(file, { force: true });
removed++;
} catch { /* non-fatal */ }
}
if (removed > 0) console.log(`[impeccable] removed ${removed} accept receipt(s) older than 14 days`);
} catch (err) {
console.warn('[impeccable] accept receipt retention sweep failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
@@ -1474,6 +1573,8 @@ manualApply.rollbackTransaction({
reason: 'manual_edit_server_start_recovered_abandoned_transaction',
});
applyLegacyDeferredAcceptsOnStartup();
sweepOrphanSvelteComponentSessionsOnStartup();
sweepStaleAcceptReceiptsOnStartup();
restorePendingEventsFromStore();
manualApply.pruneStaleEvidence();
const portArg = args.find(a => a.startsWith('--port='));
+10 -4
View File
@@ -5,7 +5,8 @@
import { createLiveSessionStore } from './live/session-store.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
import { manualApplyResumeHint, mountFailureAction, renderSummary } from './live-resume.mjs';
import { enterLiveRoot } from './live/roots.mjs';
function readServerInfo() {
return readLiveServerInfo(process.cwd())?.info || null;
@@ -28,6 +29,8 @@ export async function statusCli() {
const store = createLiveSessionStore({ cwd: process.cwd() });
const activeSessions = store.listActiveSessions();
const manualApply = findPendingManualApply(server, activeSessions);
const sessions = server?.activeSessions || activeSessions;
const renderFailure = sessions.find((session) => session?.renderState === 'failed') || null;
const payload = {
liveServer: server ? {
status: server.status,
@@ -36,14 +39,16 @@ export async function statusCli() {
agentPolling: server.agentPolling,
pendingEvents: server.pendingEvents,
} : null,
activeSessions: server?.activeSessions || activeSessions,
recoveryHint: recoveryHint({ server, manualApply }),
activeSessions: sessions,
render: sessions.map((session) => ({ id: session?.id ?? null, ...renderSummary(session) })),
recoveryHint: recoveryHint({ server, manualApply, renderFailure }),
};
console.log(JSON.stringify(payload, null, 2));
}
function recoveryHint({ server, manualApply }) {
function recoveryHint({ server, manualApply, renderFailure }) {
if (manualApply) return manualApplyResumeHint(manualApply);
if (renderFailure) return mountFailureAction(renderFailure);
if (server) {
return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.';
}
@@ -61,5 +66,6 @@ function findPendingManualApply(server, activeSessions) {
const _running = process.argv[1];
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
enterLiveRoot();
statusCli();
}
+49 -31
View File
@@ -17,11 +17,13 @@ import { isGeneratedFile } from './lib/is-generated.mjs';
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import { findSourceFile } from './live/source-search.mjs';
import { resolveSourceTraits } from './live/frameworks/index.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
import { enterLiveRoot } from './live/roots.mjs';
export async function wrapCli() {
const args = process.argv.slice(2);
@@ -293,8 +295,10 @@ The agent should insert variant HTML at insertLine.`);
.join('\n');
const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
const useFrameworkComponent = useSvelteComponent;
// The registry says which files get component preview; the svelte-component
// module keeps the env escape hatch that turns it off.
const useSvelteComponent = resolveSourceTraits(targetFile).preview === 'component'
&& shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
@@ -343,12 +347,18 @@ The agent should insert variant HTML at insertLine.`);
let svelteSession = null;
let deferredWrapper = null;
let sveltePreviewFallback = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
// Keep generation source-neutral: agents write real variant components
// under the generated componentDir, the browser mounts them into the live
// DOM, and live-accept.mjs inlines the accepted variant back into the route.
svelteSession = scaffoldSvelteComponentSession({
//
// The scaffold is AST-based and refuses markup a detached preview cannot
// support (component tags, bind:/use:, await blocks, bound nested each).
// Refusal falls back to the plain source-preview wrapper below: an
// HMR-resetting but CORRECT preview beats a detached wrong one.
const scaffolded = scaffoldSvelteComponentSession({
id,
count,
sourceFile: relTargetFile,
@@ -357,10 +367,18 @@ The agent should insert variant HTML at insertLine.`);
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
if (scaffolded && scaffolded.fallback === 'source-preview') {
sveltePreviewFallback = scaffolded.reason || 'unsupported markup';
} else {
svelteSession = scaffolded;
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
}
}
if (svelteSession) {
// component preview: outputs already set above
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
@@ -396,15 +414,19 @@ 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 componentPreviewActive = !!svelteSession;
const svelteComponentAuthoring = componentPreviewActive ? buildSvelteComponentCssAuthoring(count) : null;
const componentSession = svelteSession;
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : undefined;
const componentPreviewMode = componentPreviewActive ? 'svelte-component' : undefined;
const previewMode = componentPreviewMode;
console.log(JSON.stringify({
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
sourceFile: componentPreviewActive ? relTargetFile : undefined,
previewMode,
previewFallback: sveltePreviewFallback
? { from: 'svelte-component', reason: sveltePreviewFallback }
: undefined,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
@@ -414,8 +436,8 @@ The agent should insert variant HTML at insertLine.`);
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
sourceStartLine: componentPreviewActive ? startLine + 1 : undefined,
sourceEndLine: componentPreviewActive ? 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
@@ -426,8 +448,8 @@ The agent should insert variant HTML at insertLine.`);
insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax,
styleMode: componentPreviewMode || styleMode.mode,
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
styleTag: componentPreviewActive ? null : styleMode.styleTag,
cssSelectorPrefixExamples: componentPreviewActive ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: svelteComponentAuthoring || buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length,
}));
@@ -630,27 +652,22 @@ function attrEscapeDouble(str) {
.replace(/>/g, '&gt;');
}
/**
* Comment syntax, style mode, and preview strategy all come from the framework
* registry, keyed on the target file's extension: `.jsx`/`.tsx` author JSX
* comments, `.astro` needs global-prefixed preview CSS because Astro scopes
* component styles away from the generated wrappers, `.svelte` gets component
* preview. See live/frameworks/index.mjs for why extension and not project.
*/
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
return resolveSourceTraits(filePath).commentSyntax === 'jsx'
? { open: '{/*', close: '*/}' }
: { open: '<!--', close: '-->' };
}
function detectStyleMode(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.astro') {
return {
mode: 'astro-global-prefixed',
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
};
}
return {
mode: 'scoped',
styleTag: '<style data-impeccable-css="SESSION_ID">',
};
const traits = resolveSourceTraits(filePath);
return { mode: traits.styleMode, styleTag: traits.styleTag };
}
function buildCssSelectorPrefixExamples(styleMode, count) {
@@ -890,6 +907,7 @@ function findClosingLine(lines, start) {
// Auto-execute when run directly (node live-wrap.mjs ...)
const _running = process.argv[1];
if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {
enterLiveRoot();
wrapCli();
}
+53 -23
View File
@@ -21,10 +21,11 @@ import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext, resolveTargetSelection } from './context.mjs';
import { resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { resolveLiveTarget } from './live-target.mjs';
import { resolveRoots, writeRootsManifest } from './live/roots.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -60,6 +61,8 @@ The agent should then:
process.exit(0);
}
// Legacy workspace-monorepo selection first: it carries richer candidate
// metadata (context inheritance status) than the roots scan.
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
if (targetSelection) {
console.log(JSON.stringify({
@@ -71,11 +74,31 @@ The agent should then:
process.exit(0);
}
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
const activeCwd = ctx.projectRoot;
const rootsResult = resolveRoots({
cwd: liveTarget.originalCwd,
targetPath: liveTarget.absoluteTargetPath,
});
if (rootsResult.selection) {
console.log(JSON.stringify({
ok: false,
error: 'target_selection_required',
targetCandidates: rootsResult.selection.candidates,
hint: 'Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target <path into that app>.',
}, null, 2));
process.exit(0);
}
const roots = rootsResult.manifest;
const activeCwd = roots.appRoot;
const outputTargetPath = liveTarget.targetPath || null;
const missingContext = missingLiveContext(ctx);
// Gate on readable CONTENT, not path existence, so an empty or unreadable
// PRODUCT.md routes to init instead of passing the gate and then reporting
// hasProduct: false in the same payload.
const product = safeRead(roots.productPath);
const design = safeRead(roots.designPath);
const missingContext = [];
if (!product) missingContext.push('PRODUCT.md');
if (!design) missingContext.push('DESIGN.md');
if (missingContext.length > 0) {
console.log(JSON.stringify({
ok: false,
@@ -83,14 +106,18 @@ The agent should then:
missing: missingContext,
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
productPath: ctx.productPath,
designPath: ctx.designPath,
projectRoot: roots.appRoot,
repoRoot: roots.repoRoot,
productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
}, null, 2));
process.exit(0);
}
// Persist the decision before anything else spawns, so every helper the
// agent runs later (from any cwd inside the repo) lands on the same roots.
writeRootsManifest(roots);
// 1. Check config (fail fast if missing — no point starting anything else)
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
const checkResult = safeParse(checkOut);
@@ -98,8 +125,8 @@ The agent should then:
console.log(JSON.stringify({
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
projectRoot: roots.appRoot,
repoRoot: roots.repoRoot,
}));
process.exit(0);
}
@@ -143,22 +170,25 @@ The agent should then:
liveConfigPath: checkResult.path,
configDrift: drift,
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
hasDesign: ctx.hasDesign,
design: ctx.design,
designPath: ctx.designPath,
projectRoot: roots.appRoot,
repoRoot: roots.repoRoot,
roots,
hasProduct: !!product,
product,
productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
hasDesign: !!design,
design,
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
}, null, 2));
}
function missingLiveContext(ctx) {
const missing = [];
if (!ctx.hasProduct) missing.push('PRODUCT.md');
if (!ctx.hasDesign) missing.push('DESIGN.md');
return missing;
function safeRead(p) {
if (!p) return null;
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
}
function relOrNull(base, p) {
return p ? path.relative(base, p) : null;
}
/**
+597
View File
@@ -0,0 +1,597 @@
/**
* Accept-time CSS reconciliation for live mode.
*
* The old accept path appended the chosen variant's whole <style> body in
* front of the component's existing rules, which preserved every superseded
* declaration (the "old divider borders survive the accept" bug) and left
* dead parameter branches in source. This module makes acceptance a merge:
*
* reconcileCss replace rules whose selectors match, append new ones
* bakeParamValues collapse --p-* vars and [data-p-*] branches to the
* user's chosen values, driven by the declared param
* kinds from params.json (not regex sniffing)
* pruneUnusedSelectors use the framework compiler's own unused-selector
* warnings to delete rules the accepted markup no longer
* references
*
* The parser is hand-rolled on purpose: skill scripts run standalone inside
* user projects and cannot rely on this repo's node_modules. It is a small
* recursive block parser (comment- and string-aware), not a spec-complete
* CSS parser; everything it emits round-trips byte-for-byte through raw
* slices except the rules deliberately changed.
*/
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
/**
* Parse a stylesheet into a flat tree.
* Node shapes:
* { type: 'rule', prelude, body, start, end, preludeStart }
* { type: 'at', name, prelude, children|body, start, end } (children when
* the block contains rules: media/supports/layer/container/scope)
* { type: 'comment', text, start, end }
*/
export function parseStylesheet(css, offset = 0) {
const text = String(css || '');
const nodes = [];
let i = 0;
const skipWs = () => { while (i < text.length && /\s/.test(text[i])) i++; };
while (i < text.length) {
skipWs();
if (i >= text.length) break;
if (text[i] === '/' && text[i + 1] === '*') {
const start = i;
const close = text.indexOf('*/', i + 2);
i = close === -1 ? text.length : close + 2;
nodes.push({ type: 'comment', text: text.slice(start, i), start: offset + start, end: offset + i });
continue;
}
const preludeStart = i;
const boundary = scanToBlockOrStatementEnd(text, i);
if (boundary.kind === 'none') break; // trailing garbage / declarations at top level
if (boundary.kind === 'statement') {
// Block-less at-statement (@import, @charset, @layer names;). Emitted
// as its own node so the FOLLOWING rule still indexes for
// reconciliation instead of being folded into this prelude.
const raw = text.slice(preludeStart, boundary.index + 1).trim();
if (raw) {
nodes.push({
type: 'at',
name: (raw.match(/^@([A-Za-z-]+)/) || [])[1] || '',
prelude: raw.replace(/;$/, ''),
statement: true,
start: offset + preludeStart,
end: offset + boundary.index + 1,
});
}
i = boundary.index + 1;
continue;
}
const braceIdx = boundary.index;
const prelude = text.slice(preludeStart, braceIdx).trim();
const bodyStart = braceIdx + 1;
const bodyEnd = scanBlockEnd(text, bodyStart);
const body = text.slice(bodyStart, bodyEnd);
const nodeEnd = Math.min(text.length, bodyEnd + 1);
if (prelude.startsWith('@')) {
const name = (prelude.match(/^@([A-Za-z-]+)/) || [])[1] || '';
if (['media', 'supports', 'layer', 'container', 'scope'].includes(name)) {
nodes.push({
type: 'at',
name,
prelude,
children: parseStylesheet(body, offset + bodyStart),
start: offset + preludeStart,
end: offset + nodeEnd,
});
} else {
nodes.push({
type: 'at',
name,
prelude,
body,
start: offset + preludeStart,
end: offset + nodeEnd,
});
}
} else if (prelude) {
nodes.push({
type: 'rule',
prelude,
body,
start: offset + preludeStart,
end: offset + nodeEnd,
preludeStart: offset + preludeStart,
});
}
i = nodeEnd;
}
return nodes;
}
/**
* Scan for the next structural boundary: the `{` opening a block, or the `;`
* ending a block-less at-statement, whichever comes first (string- and
* comment-aware). Returns { kind: 'block' | 'statement' | 'none', index }.
*/
function scanToBlockOrStatementEnd(text, from) {
let i = from;
let quote = null;
while (i < text.length) {
const ch = text[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === '/' && text[i + 1] === '*') {
const close = text.indexOf('*/', i + 2);
i = close === -1 ? text.length : close + 1;
} else if (ch === '{') {
return { kind: 'block', index: i };
} else if (ch === ';') {
return { kind: 'statement', index: i };
}
i++;
}
return { kind: 'none', index: -1 };
}
function scanBlockEnd(text, from) {
let i = from;
let depth = 1;
let quote = null;
while (i < text.length) {
const ch = text[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === '/' && text[i + 1] === '*') {
const close = text.indexOf('*/', i + 2);
i = close === -1 ? text.length : close + 1;
} else if (ch === '{') {
depth++;
} else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
i++;
}
return text.length;
}
export function serializeNodes(nodes, indent = '') {
const out = [];
for (const node of nodes) {
if (node.type === 'comment') {
out.push(indent + node.text);
} else if (node.type === 'rule') {
out.push(`${indent}${node.prelude} {${formatBody(node.body, indent)}}`);
} else if (node.type === 'at' && node.children) {
out.push(`${indent}${node.prelude} {`);
out.push(serializeNodes(node.children, indent + ' '));
out.push(`${indent}}`);
} else if (node.type === 'at' && node.statement) {
out.push(`${indent}${node.prelude};`);
} else if (node.type === 'at') {
out.push(`${indent}${node.prelude} {${formatBody(node.body, indent)}}`);
}
}
return out.join('\n');
}
function formatBody(body, indent) {
const trimmed = String(body || '').trim();
if (!trimmed) return ' ';
const lines = trimmed.split('\n').map((l) => l.trim()).filter(Boolean);
if (lines.length === 1 && lines[0].length < 60) return ` ${lines[0]} `;
return '\n' + lines.map((l) => `${indent} ${l}`).join('\n') + `\n${indent}`;
}
export function normalizeSelector(prelude) {
return String(prelude || '')
.replace(/\s+/g, ' ')
.replace(/\s*([>+~,])\s*/g, '$1')
.trim();
}
// ---------------------------------------------------------------------------
// Reconciliation
// ---------------------------------------------------------------------------
/**
* Merge variant CSS into existing CSS. Rules whose (at-context, normalized
* selector) match an existing rule REPLACE that rule's body in place; new
* rules append at the end under their at-context. Returns { css, replaced,
* appended }.
*/
export function reconcileCss(existingCss, variantCss) {
const existing = parseStylesheet(existingCss);
const incoming = parseStylesheet(variantCss);
let replaced = 0;
let appended = 0;
const mergeLevel = (existingNodes, incomingNodes) => {
const index = new Map();
for (const node of existingNodes) {
if (node.type === 'rule') index.set(normalizeSelector(node.prelude), node);
}
const atIndex = new Map();
for (const node of existingNodes) {
if (node.type === 'at' && node.children) atIndex.set(normalizeSelector(node.prelude), node);
}
// Baking can leave several incoming rules with the same selector (e.g. a
// base rule plus a stripped param branch). The first one REPLACES the
// existing body; later same-selector rules extend it, never clobber it.
const touched = new Set();
for (const node of incomingNodes) {
if (node.type === 'comment') continue;
if (node.type === 'rule') {
const key = normalizeSelector(node.prelude);
const match = index.get(key);
if (match) {
if (touched.has(key)) {
match.body = `${match.body.trim()}\n${node.body.trim()}`;
} else if (match.body.trim() !== node.body.trim()) {
match.body = node.body;
replaced++;
}
touched.add(key);
} else {
existingNodes.push({ ...node });
index.set(key, existingNodes[existingNodes.length - 1]);
touched.add(key);
appended++;
}
} else if (node.type === 'at' && node.children) {
const key = normalizeSelector(node.prelude);
const match = atIndex.get(key);
if (match) {
mergeLevel(match.children, node.children);
} else {
existingNodes.push({ ...node });
atIndex.set(key, existingNodes[existingNodes.length - 1]);
appended++;
}
} else {
existingNodes.push({ ...node });
appended++;
}
}
};
mergeLevel(existing, incoming);
return { css: serializeNodes(existing), replaced, appended };
}
// ---------------------------------------------------------------------------
// Parameter baking
// ---------------------------------------------------------------------------
/**
* Replace every `var(--p-<id>, fallback)` / `var(--p-<id>)` occurrence with a
* literal value. Paren-aware: fallbacks containing calc()/nested vars are
* handled, unlike the old `[^)]+` regex.
*/
export function substituteParamVar(css, id, value) {
const text = String(css || '');
const needle = `var(--p-${id}`;
let out = '';
let i = 0;
while (i < text.length) {
const idx = text.indexOf(needle, i);
if (idx === -1) { out += text.slice(i); break; }
const after = idx + needle.length;
// Must be end of the var name: `)` or `,`.
if (after < text.length && text[after] !== ')' && text[after] !== ',') {
out += text.slice(i, after);
i = after;
continue;
}
let j = after;
let depth = 1; // we are inside var(
while (j < text.length && depth > 0) {
if (text[j] === '(') depth++;
else if (text[j] === ')') depth--;
j++;
}
out += text.slice(i, idx) + String(value);
i = j;
}
return out;
}
function normalizeToggleForVar(value) {
return value === true || value === 'true' || value === 1 || value === '1' || value === 'on' ? '1' : '0';
}
function isToggleOn(value) {
return normalizeToggleForVar(value) === '1';
}
/**
* Strip `[data-p-<id>="value"]` / `[data-p-<id>]` attribute selectors from a
* selector, deciding survival by the chosen value:
* returns null when the selector targets a non-chosen branch (drop it),
* otherwise the selector with the attribute test removed and any emptied
* :global() wrappers cleaned up.
*/
export function stripParamSelector(selector, id, kind, chosenValue) {
const attrRe = new RegExp(`\\[data-p-${escapeRegExp(id)}(?:=(["'])(.*?)\\1)?\\]`, 'g');
let drop = false;
let out = String(selector).replace(attrRe, (_m, _q, expected) => {
if (kind === 'steps') {
if (expected == null || String(expected) === String(chosenValue)) return '';
drop = true;
return '';
}
// toggle: attribute presence means "on".
if (expected != null && String(expected) !== String(chosenValue) && !isToggleOn(chosenValue)) {
drop = true;
return '';
}
if (expected == null && !isToggleOn(chosenValue)) {
drop = true;
return '';
}
return '';
});
if (drop) return null;
out = out
.replace(/:global\(\s*\)/g, '')
.replace(/\s+/g, ' ')
.replace(/^\s*[>+~]\s*/, '')
.trim();
return out || null;
}
/**
* Bake chosen parameter values into CSS. `params` is the declared parameter
* list for the accepted variant (from params.json); `values` maps id ->
* chosen value (falling back to each param's declared default).
*/
export function bakeParamValues(css, params = [], values = {}) {
let nodes = parseStylesheet(css);
const chosen = new Map();
for (const param of params || []) {
if (!param || !param.id) continue;
const has = values && Object.prototype.hasOwnProperty.call(values, param.id);
chosen.set(param.id, { kind: param.kind, value: has ? values[param.id] : param.default });
}
// Values sent for params that were never declared still bake as ranges,
// so an out-of-sync manifest degrades to the old behavior, not to silence.
for (const [id, value] of Object.entries(values || {})) {
if (!chosen.has(id)) chosen.set(id, { kind: 'range', value });
}
const bakeBody = (body) => {
let out = String(body || '');
for (const [id, { kind, value }] of chosen) {
const literal = kind === 'toggle' ? normalizeToggleForVar(value) : String(value);
out = substituteParamVar(out, id, literal);
}
// Strip the readiness sentinel as a DECLARATION, not a line: a one-line
// rule carrying the sentinel plus real declarations must keep the rest.
return out
.replace(/(^|;)\s*--impeccable-variant-ready\s*:[^;{}]*/g, '$1')
.replace(/;\s*;/g, ';')
.replace(/^\s*;\s*/, '');
};
const transform = (list) => {
const result = [];
for (const node of list) {
if (node.type === 'at' && node.children) {
const children = transform(node.children);
if (children.length > 0) result.push({ ...node, children });
continue;
}
if (node.type !== 'rule') {
if (node.type === 'at') result.push({ ...node, body: bakeBody(node.body) });
else result.push(node);
continue;
}
const selectors = splitSelectorList(node.prelude);
const kept = [];
for (let selector of selectors) {
let alive = true;
for (const [id, { kind, value }] of chosen) {
if (kind !== 'steps' && kind !== 'toggle') continue;
if (!selector.includes(`data-p-${id}`)) continue;
const next = stripParamSelector(selector, id, kind, value);
if (next == null) { alive = false; break; }
selector = next;
}
if (alive && selector.trim()) kept.push(selector.trim());
}
if (kept.length === 0) continue;
const body = bakeBody(node.body);
if (!body.trim()) continue;
result.push({ ...node, prelude: kept.join(', '), body });
}
return result;
};
nodes = transform(nodes);
return serializeNodes(nodes);
}
export function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
const text = String(prelude || '');
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(text.slice(start, i));
start = i + 1;
}
}
selectors.push(text.slice(start));
return selectors.map((s) => s.trim()).filter(Boolean);
}
// ---------------------------------------------------------------------------
// Compiler-driven pruning
// ---------------------------------------------------------------------------
/**
* Remove selectors the framework compiler reports as unused from a full
* component source. `compileFn` is the app's svelte compile; warnings with
* code `css_unused_selector` carry character offsets into the source.
* `skipSelectors` protects selectors that were already unused before the
* accept: pre-existing dead rules are the user's code, not live-mode debris.
* Returns { source, removed } where removed lists the pruned selector texts.
*/
export function collectUnusedSelectors(componentSource, compileFn) {
try {
const { warnings } = compileFn(String(componentSource || ''), { generate: false });
return new Set((warnings || [])
.filter((w) => w.code === 'css_unused_selector'
&& Number.isInteger(w.start?.character)
&& Number.isInteger(w.end?.character))
.map((w) => String(componentSource).slice(w.start.character, w.end.character).trim()));
} catch {
return new Set();
}
}
export function pruneUnusedSelectors(componentSource, compileFn, { skipSelectors } = {}) {
let source = String(componentSource || '');
const removed = [];
const skip = skipSelectors instanceof Set ? skipSelectors : new Set(skipSelectors || []);
for (let pass = 0; pass < 3; pass++) {
let warnings;
try {
({ warnings } = compileFn(source, { generate: false }));
} catch {
return { source, removed }; // never let pruning break an accept
}
const unused = (warnings || [])
.filter((w) => w.code === 'css_unused_selector'
&& Number.isInteger(w.start?.character)
&& Number.isInteger(w.end?.character))
.filter((w) => !skip.has(source.slice(w.start.character, w.end.character).trim()))
.sort((a, b) => b.start.character - a.start.character);
if (unused.length === 0) break;
let next = source;
for (const warning of unused) {
const result = removeSelectorAt(next, warning.start.character, warning.end.character);
if (result.changed) {
removed.push(result.selector);
next = result.source;
}
}
if (next === source) break;
source = next;
}
return { source, removed };
}
/**
* Remove the selector at [start, end) from its rule. When it is the rule's
* only selector, remove the whole rule (prelude through closing brace).
*/
function removeSelectorAt(source, start, end) {
const selector = source.slice(start, end);
// Find the rule boundaries around the selector.
const braceIdx = source.indexOf('{', end);
if (braceIdx === -1) return { changed: false, selector, source };
const bodyEnd = scanBlockEnd(source, braceIdx + 1);
// Prelude spans backward from the brace to the previous } ; { or style open.
let preludeStart = start;
for (let i = start - 1; i >= 0; i--) {
const ch = source[i];
if (ch === '}' || ch === '{' || ch === ';' || ch === '>') { preludeStart = i + 1; break; }
if (i === 0) preludeStart = 0;
}
const prelude = source.slice(preludeStart, braceIdx);
const selectors = splitSelectorList(prelude);
const target = selector.trim();
const kept = selectors.filter((s) => s !== target);
if (kept.length === selectors.length) {
// Offsets did not line up with a full selector in the list; be safe.
return { changed: false, selector, source };
}
if (kept.length === 0) {
// Remove the entire rule including trailing newline.
let ruleEnd = Math.min(source.length, bodyEnd + 1);
while (ruleEnd < source.length && source[ruleEnd] === '\n') ruleEnd++;
let ruleStart = preludeStart;
while (ruleStart > 0 && (source[ruleStart - 1] === ' ' || source[ruleStart - 1] === '\t')) ruleStart--;
return { changed: true, selector: target, source: source.slice(0, ruleStart) + source.slice(ruleEnd) };
}
const indent = (prelude.match(/^\s*/) || [''])[0];
return {
changed: true,
selector: target,
source: source.slice(0, preludeStart) + indent + kept.join(', ') + ' ' + source.slice(braceIdx, source.length),
};
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Collect every normalized selector in a CSS text, including inside nested
* at-blocks. Used by the accept postcondition: a selector present before the
* accept may only disappear if the compiler reported it unused; anything
* else means the parser or reconciler damaged the user's file, and the write
* must be refused rather than silently committed.
*/
export function collectAllSelectors(css, out = new Set()) {
for (const node of parseStylesheet(css)) {
if (node.type === 'rule') {
for (const selector of splitSelectorList(node.prelude)) out.add(normalizeSelector(selector));
} else if (node.type === 'at' && node.children) {
for (const child of node.children) {
if (child.type === 'rule') {
for (const selector of splitSelectorList(child.prelude)) out.add(normalizeSelector(selector));
} else if (child.type === 'at' && child.children) {
collectSelectorsFromNodes(child.children, out);
}
}
}
}
return out;
}
function collectSelectorsFromNodes(nodes, out) {
for (const node of nodes) {
if (node.type === 'rule') {
for (const selector of splitSelectorList(node.prelude)) out.add(normalizeSelector(selector));
} else if (node.type === 'at' && node.children) {
collectSelectorsFromNodes(node.children, out);
}
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Postcondition scanner for accepted/carbonized source. The carbonize
* contract used to exist only as prose in reference/live.md; nothing checked
* that an accept actually left the file clean, so dead param branches,
* preview attributes, and marker comments accumulated across sessions. This
* scanner is the mechanical form of that contract. live-complete refuses to
* mark a carbonize session complete while the file is dirty, and the
* mechanical Svelte accept runs it on its own output as a self-check.
*/
const FORBIDDEN = [
{ marker: 'impeccable-variants-start', why: 'variant wrapper comment left in source' },
{ marker: 'impeccable-variants-end', why: 'variant wrapper comment left in source' },
{ marker: 'impeccable-carbonize-start', why: 'carbonize block not rewritten into permanent form' },
{ marker: 'impeccable-carbonize-end', why: 'carbonize block not rewritten into permanent form' },
{ marker: 'impeccable-param-values', why: 'param-values comment not baked and removed' },
{ marker: 'data-impeccable-', why: 'live-mode plumbing attribute left on markup' },
{ marker: 'data-p-', why: 'preview parameter attribute left on markup' },
{ marker: 'var(--p-', why: 'preview parameter variable not baked to a literal' },
{ marker: '--impeccable-variant-ready', why: 'preview readiness sentinel left in CSS' },
];
/**
* Scan file text for live-mode leftovers. Returns { clean, findings } where
* each finding is { marker, line, excerpt, why }.
*/
export function verifyAcceptedSource(text) {
const findings = [];
const lines = String(text || '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const { marker, why } of FORBIDDEN) {
if (line.includes(marker)) {
findings.push({
marker,
line: i + 1,
excerpt: line.trim().slice(0, 120),
why,
});
}
}
}
return { clean: findings.length === 0, findings };
}
/** Convenience wrapper for CLI callers: read + scan, tolerating a missing file. */
export function verifyAcceptedFile(fs, filePath) {
let text;
try {
text = fs.readFileSync(filePath, 'utf-8');
} catch {
return { clean: true, findings: [], missing: true };
}
return { ...verifyAcceptedSource(text), missing: false };
}
+54 -7
View File
@@ -5,17 +5,26 @@
import { canCreateInsert } from './insert-ui.mjs';
// The accepted visual action values come from the canonical vocabulary so the
// validator, the picker UI, and the marketing demo never drift. Imported (not
// just re-exported) so it is also in scope for the validators below.
import { VISUAL_ACTIONS } from './vocabulary.mjs';
export { VISUAL_ACTIONS };
// The accepted protocol values come from the canonical vocabulary so the
// validator, the store, the server, and the picker UI never drift. Imported
// (not just re-exported) so they are also in scope for the validators below.
import { AGENT_PHASES, CLIENT_EVENT_TYPES, VISUAL_ACTIONS } from './vocabulary.mjs';
export { AGENT_PHASES, CLIENT_EVENT_TYPES, VISUAL_ACTIONS };
const AGENT_PHASE_SET = new Set(AGENT_PHASES);
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
const INSERT_POSITIONS = new Set(['before', 'after']);
const FORBIDDEN_MANUAL_EDIT_TEXT_CHARS = ['<', '{', '}', '`'];
// Mount acknowledgements carry a module URL and a raw exception message from
// the page. Both are attacker-adjacent (any script on the page can POST them
// with the token it can already read), so they are length-capped before they
// reach the journal.
export const MOUNT_URL_MAX_LENGTH = 2000;
export const MOUNT_ERROR_MAX_LENGTH = 1000;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
@@ -92,6 +101,36 @@ function validateManualEditEvent(msg, label) {
return null;
}
function isValidMountVariant(value) {
return Number.isInteger(value) && value >= 1 && value <= 999;
}
/**
* Mount acknowledgements are the browser's answer to "did the thing you
* published actually render". They are validated strictly because the render
* truth in the session snapshot is built from them: a malformed ack that slid
* through would report a variant as mounted that never was.
*/
function validateMountAck(msg) {
if (!isValidId(msg.id)) return 'variant_mounted: missing or malformed id';
if (!isValidMountVariant(msg.variant)) return 'variant_mounted: variant must be an integer 1-999';
if (msg.url !== undefined) {
if (typeof msg.url !== 'string') return 'variant_mounted: url must be string';
if (msg.url.length > MOUNT_URL_MAX_LENGTH) return 'variant_mounted: url too long';
}
return null;
}
function validateMountFailure(msg) {
if (!isValidId(msg.id)) return 'variant_mount_failed: missing or malformed id';
if (!isValidMountVariant(msg.variant)) return 'variant_mount_failed: variant must be an integer 1-999';
if (typeof msg.url !== 'string' || !msg.url.trim()) return 'variant_mount_failed: url required';
if (msg.url.length > MOUNT_URL_MAX_LENGTH) return 'variant_mount_failed: url too long';
if (typeof msg.error !== 'string' || !msg.error.trim()) return 'variant_mount_failed: error required';
if (msg.error.length > MOUNT_ERROR_MAX_LENGTH) return 'variant_mount_failed: error too long';
return null;
}
export function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
@@ -120,13 +159,21 @@ export function validateEvent(msg) {
return null;
case 'agent_phase':
if (!isValidId(msg.id)) return 'agent_phase: missing or malformed id';
if (typeof msg.phase !== 'string' || !/^[a-z][a-z0-9_]{1,63}$/.test(msg.phase)) {
return 'agent_phase: missing or malformed phase';
if (typeof msg.phase !== 'string' || !msg.phase) return 'agent_phase: missing phase';
// The enum, not a shape pattern. A phase the browser cannot rank is a
// phase the progress bar cannot show, so accepting an arbitrary
// lowercase word only defers the failure to the UI.
if (!AGENT_PHASE_SET.has(msg.phase)) {
return 'agent_phase: unknown phase ' + msg.phase + ' (expected one of ' + AGENT_PHASES.join(', ') + ')';
}
if (msg.durationMs !== undefined && (!Number.isFinite(msg.durationMs) || msg.durationMs < 0)) {
return 'agent_phase: durationMs must be a non-negative number';
}
return null;
case 'variant_mounted':
return validateMountAck(msg);
case 'variant_mount_failed':
return validateMountFailure(msg);
case 'exit':
return null;
case 'prefetch':
+47
View File
@@ -0,0 +1,47 @@
/**
* Astro registry entry.
*
* Astro takes the generic tag strategy, with two Astro-specific values that
* used to sit as inline `endsWith('.astro')` branches in live-inject.mjs and
* live-wrap.mjs:
*
* injectScriptAttrs Astro processes <script> tags by default and rewrites
* src to its own bundled URL; is:inline opts out.
* styleMode Astro scopes component styles, which strips preview CSS
* off the generated variant wrappers, so preview rules are
* authored global and prefixed instead of @scope'd.
*/
import { findConfigFile, hasAnyDependency, literalConfigFiles } from './detect-utils.mjs';
const ASTRO_CONFIG_RE = /^astro\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectAstroProject(cwd = process.cwd(), config = null) {
const configFile = findConfigFile(cwd, ASTRO_CONFIG_RE);
if (configFile) return { configFile, via: 'config' };
if (hasAnyDependency(cwd, ['astro'])) return { configFile: null, via: 'package' };
// A tree of .astro entry templates with no astro.config still belongs to
// Astro; the configured injection target names it.
const entry = literalConfigFiles(cwd, config).find((rel) => rel.endsWith('.astro'));
if (entry) return { configFile: null, via: 'config-files', entry };
return null;
}
export const astro = {
name: 'astro',
detect(cwd, config) {
return detectAstroProject(cwd, config);
},
inject: { kind: 'tag' },
source: {
extensions: ['.astro'],
preview: 'source',
styleMode: 'astro-global-prefixed',
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
commentSyntax: 'html',
injectScriptAttrs: 'is:inline ',
},
};
@@ -0,0 +1,73 @@
/**
* Small read-only probes the framework entries share.
*
* Every helper here is cheap and failure-tolerant: detection runs on every
* inject, against project trees that may be half-installed, so a missing or
* malformed file means "not this framework", never a throw.
*/
import fs from 'node:fs';
import path from 'node:path';
/** Merged dependency names from package.json, or an empty object. */
export function readPackageDeps(cwd) {
const file = path.join(cwd, 'package.json');
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
return {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
} catch {
return {};
}
}
export function hasAnyDependency(cwd, names) {
const deps = readPackageDeps(cwd);
return names.some((name) => Boolean(deps[name]));
}
/** First top-level file name matching `re`, or null. */
export function findConfigFile(cwd, re) {
try {
return fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && re.test(entry.name))
?.name ?? null;
} catch {
return null;
}
}
export function fileExists(cwd, rel) {
try {
return fs.existsSync(path.join(cwd, rel));
} catch {
return false;
}
}
export function firstExistingFile(cwd, candidates) {
for (const rel of candidates) {
if (fileExists(cwd, rel)) return rel;
}
return null;
}
/**
* Literal (non-glob) entries of `config.files` that exist on disk. Several
* detectors read the configured injection target as a signal, which is how the
* bare fixtures — a tree of `.astro` files with no astro.config — still resolve
* to the framework that authored them.
*/
export function literalConfigFiles(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : [];
const out = [];
for (const rel of files) {
if (typeof rel !== 'string' || rel.includes('*') || rel.includes('?')) continue;
const normalized = rel.split(path.sep).join('/');
if (fileExists(cwd, normalized)) out.push(normalized);
}
return out;
}
+143
View File
@@ -0,0 +1,143 @@
/**
* The live-mode framework registry.
*
* Before this existed, framework knowledge was smeared across live-inject.mjs
* (detection order, the Nuxt adapter, the Astro `is:inline` branch), the two
* adapter modules, and live-wrap.mjs (which extension gets component preview,
* which gets Astro's global-prefixed CSS, which gets JSX comments). Adding or
* fixing a framework meant reading all of them.
*
* One entry per framework now declares everything the live scripts need:
*
* name stable identifier; also the `adapter` value in inject JSON.
* detect (cwd, config) → falsy when this is not the project, otherwise
* a truthy project descriptor that apply/remove/artifacts read.
* Order in FRAMEWORKS is priority order; first truthy wins.
* inject { kind: 'adapter', apply, remove, ignorePatterns, artifacts,
* unpatch } for frameworks that server-render their document
* shell, or { kind: 'tag' } for the generic marker-wrapped
* <script src> block.
* source how live-wrap treats files this framework authors:
* extensions, preview ('source' | 'component'), styleMode,
* styleTag, commentSyntax, injectScriptAttrs. Anything omitted
* falls back to SOURCE_TRAIT_DEFAULTS.
*
* Two rules hold the thing together:
*
* 1. **Detection order is injection priority.** SvelteKit → Nuxt → TanStack
* Start → Astro → Next → Vite → static HTML, exactly the order
* live-inject.mjs used to hard-code. static-html always matches, so
* resolveFramework never returns null.
* 2. **Source traits resolve by file extension, not by project.** A SvelteKit
* project's injection target is `src/app.html`; a Vite app can contain
* `.astro` partials. live-wrap has always keyed these off the target file,
* and resolveSourceTraits keeps it that way. Several entries may claim the
* same extension (`.tsx` belongs to three); when they do, the values must
* agree, which tests/live-frameworks.test.mjs asserts.
*/
import path from 'node:path';
import { sveltekit } from './sveltekit.mjs';
import { nuxt } from './nuxt.mjs';
import { tanstackStart } from './tanstack-start.mjs';
import { astro } from './astro.mjs';
import { nextjs } from './nextjs.mjs';
import { viteGeneric } from './vite-generic.mjs';
import { staticHtml } from './static-html.mjs';
import { TAG_PATCH_MARKERS, unpatchTagFile } from './tag-strategy.mjs';
/** Priority order. Do not reorder without re-reading rule 1 above. */
export const FRAMEWORKS = Object.freeze([
sveltekit,
nuxt,
tanstackStart,
astro,
nextjs,
viteGeneric,
staticHtml,
]);
export const PREVIEW_MODES = Object.freeze(['source', 'component']);
export const STYLE_MODES = Object.freeze(['scoped', 'astro-global-prefixed']);
export const COMMENT_SYNTAXES = Object.freeze(['html', 'jsx']);
export const INJECT_KINDS = Object.freeze(['adapter', 'tag']);
export const SOURCE_TRAIT_DEFAULTS = Object.freeze({
preview: 'source',
styleMode: 'scoped',
styleTag: '<style data-impeccable-css="SESSION_ID">',
commentSyntax: 'html',
injectScriptAttrs: '',
});
/** The patch kind the generic tag strategy records in the journal. */
export const TAG_PATCH_KIND = 'live-tag';
/**
* Undo functions keyed by the `patch` value an artifact carries. Built from
* the entries so a new adapter registers its own undo alongside its apply.
*/
export const PATCH_UNDOERS = Object.freeze(Object.assign(
{ [TAG_PATCH_KIND]: unpatchTagFile },
...FRAMEWORKS.map((framework) => framework.inject.unpatch || {}),
));
/**
* First entry whose detect() matches. Returns { framework, project } where
* project is the detector's descriptor (adapters read it; tag frameworks
* mostly ignore it).
*/
export function resolveFramework(cwd = process.cwd(), config = null) {
for (const framework of FRAMEWORKS) {
const project = framework.detect(cwd, config);
if (project) return { framework, project };
}
// Unreachable while static-html stays terminal, but a caller that reorders
// the array should get a diagnosable null rather than a silent tag inject.
return null;
}
/**
* Source-authoring traits for one file, merged over SOURCE_TRAIT_DEFAULTS.
* `framework` names the entry that claimed the extension, or null.
*/
export function resolveSourceTraits(filePath) {
const ext = path.extname(String(filePath || '')).toLowerCase();
for (const framework of FRAMEWORKS) {
const source = framework.source;
if (!source || !source.extensions.includes(ext)) continue;
const { extensions, ...traits } = source;
return { framework: framework.name, ...SOURCE_TRAIT_DEFAULTS, ...traits };
}
return { framework: null, ...SOURCE_TRAIT_DEFAULTS };
}
/**
* Extra gitignore patterns the resolved framework needs beyond the static
* LIVE_IGNORE_PATTERNS list (paths that depend on a detected srcDir or file
* extension and so cannot be written down ahead of time).
*/
export function frameworkIgnorePatterns(resolved) {
const fn = resolved?.framework?.inject?.ignorePatterns;
return typeof fn === 'function' ? (fn(resolved.project) || []) : [];
}
/**
* The files this injection will create or patch, in journal-artifact form.
* Adapters declare their own; the tag strategy patches exactly the resolved
* config files.
*/
export function describeInjectArtifacts(resolved, { cwd = process.cwd(), files = [] } = {}) {
if (!resolved) return [];
const { framework, project } = resolved;
if (framework.inject.kind === 'adapter') {
return (framework.inject.artifacts?.({ cwd, project }) || []).filter((a) => a && a.path);
}
return files.map((file) => ({
kind: 'patched',
path: file,
patch: TAG_PATCH_KIND,
markers: [...TAG_PATCH_MARKERS],
}));
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Crash-safe injection journal.
*
* Injection writes into the user's source tree: generated components, a Nuxt
* client plugin, marker blocks inside a layout, a patched CSP meta tag. The
* clean path removes all of it on stop. The unclean paths do not:
*
* - the dev server is SIGKILLed, so `--remove` never runs;
* - the project changes shape between start and stop (a nuxt.config appears,
* a package.json is edited), so detection resolves a different framework
* and the old framework's artifacts are nobody's business;
* - stop runs from a different directory than start did.
*
* So every inject records what it wrote to `.impeccable/live/inject-journal.json`
* before the next one runs, and both inject and `--remove` reconcile that
* record against the tree.
*
* **The journal is a claim of ownership, not a to-do list.** Healing an
* artifact only ever removes what still carries our marker; a generated file
* the user has since replaced, or a layout they have since un-patched by hand,
* is dropped from the journal untouched.
*
* **Path resolution is appRoot-relative.** Live entry scripts chdir onto the
* roots manifest (`enterLiveRoot`) before doing anything, so a journal written
* by a session started in the app root is found by a stop issued from any
* directory inside the repo.
*/
import fs from 'node:fs';
import path from 'node:path';
import { PATCH_UNDOERS } from './index.mjs';
export const INJECT_JOURNAL_VERSION = 1;
export const INJECT_JOURNAL_RELPATH = '.impeccable/live/inject-journal.json';
export function injectJournalPath(cwd = process.cwd()) {
return path.join(cwd, ...INJECT_JOURNAL_RELPATH.split('/'));
}
export function readInjectJournal(cwd = process.cwd()) {
const file = injectJournalPath(cwd);
let raw;
try {
raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return null;
}
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.artifacts)) return null;
return raw;
}
export function clearInjectJournal(cwd = process.cwd()) {
try { fs.unlinkSync(injectJournalPath(cwd)); } catch { /* already gone */ }
}
function writeInjectJournal(cwd, journal) {
const file = injectJournalPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(journal, null, 2) + '\n', 'utf-8');
return file;
}
/**
* Record the artifacts an injection just wrote. Replaces any previous record:
* callers heal first (see healInjectJournal), so nothing survivable is lost.
*/
export function recordInjection(cwd = process.cwd(), { framework, port, artifacts = [] } = {}) {
if (!artifacts.length) {
clearInjectJournal(cwd);
return null;
}
return writeInjectJournal(cwd, {
version: INJECT_JOURNAL_VERSION,
appRoot: path.resolve(cwd),
framework: framework || null,
port: Number.isFinite(Number(port)) ? Number(port) : null,
pid: process.pid,
recordedAt: new Date().toISOString(),
artifacts,
});
}
function normalizeRel(cwd, rel) {
return path.resolve(cwd, String(rel || '')).split(path.sep).join('/');
}
function readIfPresent(abs) {
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function pruneEmptyDirs(dir, stopDir) {
let current = path.resolve(dir);
const stop = path.resolve(stopDir);
while (current !== stop && current.startsWith(stop + path.sep)) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
} catch {
return;
}
current = path.dirname(current);
}
}
function insideProject(cwd, abs) {
const rel = path.relative(path.resolve(cwd), path.resolve(abs));
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function healArtifact(cwd, artifact, undoers) {
const abs = path.resolve(cwd, artifact.path);
// The journal is a project-local file, i.e. attacker-writable input in a
// cloned repo. Never touch anything outside the project tree, whatever the
// journal claims to own.
if (!insideProject(cwd, abs)) return { path: artifact.path, action: 'refused_outside_project' };
const content = readIfPresent(abs);
if (content === null) return { path: artifact.path, action: 'absent' };
if (artifact.kind === 'created') {
// Only reclaim a generated file that still carries our marker; a created
// artifact with no marker at all is unverifiable and stays untouched.
if (!artifact.marker || !content.includes(artifact.marker)) {
return { path: artifact.path, action: 'disowned' };
}
try { fs.rmSync(abs, { force: true }); } catch { return null; }
if (artifact.pruneTo !== undefined) {
const pruneRoot = path.resolve(cwd, artifact.pruneTo || '.');
if (insideProject(cwd, pruneRoot) || pruneRoot === path.resolve(cwd)) {
pruneEmptyDirs(path.dirname(abs), pruneRoot);
}
}
return { path: artifact.path, action: 'removed' };
}
if (artifact.kind === 'patched') {
const markers = Array.isArray(artifact.markers) ? artifact.markers : [];
// No marker left means the patch is already gone; never run an undo over
// a file we no longer recognize (the undoers normalize whitespace).
if (markers.length && !markers.some((marker) => content.includes(marker))) {
return { path: artifact.path, action: 'disowned' };
}
const undo = undoers[artifact.patch];
if (typeof undo !== 'function') return null;
const next = undo(content);
if (next === content) return { path: artifact.path, action: 'disowned' };
try { fs.writeFileSync(abs, next, 'utf-8'); } catch { return null; }
return { path: artifact.path, action: 'unpatched' };
}
return null;
}
/**
* Reconcile the journal against the tree.
*
* `keep` is the set of paths the current operation legitimately owns — the
* artifacts an inject is about to (re)write. Everything else in the journal is
* an orphan of a session that is gone, and gets healed. This keeps a repeat
* inject byte-idempotent: the artifacts it is about to rewrite are kept, not
* torn down and rebuilt.
*
* Returns `{ healed, kept }`. `healed` lists only artifacts whose file was
* actually changed or removed, so callers can stay silent when nothing was
* orphaned. Idempotent: a second call finds an empty journal.
*/
export function healInjectJournal(cwd = process.cwd(), { keep = [], undoers = PATCH_UNDOERS } = {}) {
const journal = readInjectJournal(cwd);
if (!journal) return { healed: [], kept: [] };
const keepSet = new Set(keep.map((rel) => normalizeRel(cwd, rel)));
const healed = [];
const kept = [];
for (const artifact of journal.artifacts) {
if (!artifact || typeof artifact.path !== 'string') continue;
if (keepSet.has(normalizeRel(cwd, artifact.path))) {
kept.push(artifact);
continue;
}
const outcome = healArtifact(cwd, artifact, undoers);
if (outcome && (outcome.action === 'removed' || outcome.action === 'unpatched')) {
healed.push(outcome);
}
}
if (kept.length) {
writeInjectJournal(cwd, { ...journal, artifacts: kept });
} else {
clearInjectJournal(cwd);
}
return { healed, kept };
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Next.js registry entry.
*
* Next takes the generic tag strategy: the App Router's root layout renders
* `<html>…<body>` in JSX, so the marker-wrapped script block goes in there
* verbatim. Nothing about injection differs from a plain Vite app, which is
* why live-inject.mjs never had a Next branch. The entry exists so the
* registry can name what it is looking at.
*/
import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
const NEXT_CONFIG_RE = /^next\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
const ROUTER_ENTRY_CANDIDATES = [
'app/layout.tsx', 'app/layout.jsx', 'app/layout.ts', 'app/layout.js',
'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.ts', 'src/app/layout.js',
'pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.ts', 'pages/_app.js',
'pages/_document.tsx', 'pages/_document.jsx',
'src/pages/_app.tsx', 'src/pages/_app.jsx',
];
export function detectNextProject(cwd = process.cwd()) {
const configFile = findConfigFile(cwd, NEXT_CONFIG_RE);
if (configFile) return { configFile, via: 'config' };
if (hasAnyDependency(cwd, ['next'])) return { configFile: null, via: 'package' };
// Next's file conventions are distinctive enough to stand alone: a root
// `app/layout.*` or `pages/_app.*` is not a shape other bundlers produce.
const entry = ROUTER_ENTRY_CANDIDATES.find((rel) => fileExists(cwd, rel));
if (entry) return { configFile: null, via: 'router-entry', entry };
return null;
}
export const nextjs = {
name: 'nextjs',
detect(cwd) {
return detectNextProject(cwd);
},
inject: { kind: 'tag' },
source: {
extensions: ['.tsx', '.jsx'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'jsx',
},
};
+161
View File
@@ -0,0 +1,161 @@
/**
* Nuxt registry entry, and the Nuxt adapter itself.
*
* 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.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from './script-src.mjs';
import { findConfigFile } from './detect-utils.mjs';
export const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
export const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectNuxtProject(cwd = process.cwd()) {
const configFile = findConfigFile(cwd, NUXT_CONFIG_RE);
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, token) {
return `/* ${NUXT_PLUGIN_MARKER} */
const liveSrc = '${buildLiveScriptSrc(port, token)}';
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, token, 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, token);
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 };
}
export const nuxt = {
name: 'nuxt',
detect(cwd) {
return detectNuxtProject(cwd);
},
inject: {
kind: 'adapter',
apply({ cwd, port, token, project }) {
return applyNuxtLiveAdapter({ cwd, port, token, project });
},
remove({ cwd, project }) {
return removeNuxtLiveAdapter({ cwd, project });
},
// The plugin path depends on the resolved srcDir, so it cannot live in the
// static ignore list the way the SvelteKit paths do.
ignorePatterns(project) {
return project?.pluginFile ? [project.pluginFile] : [];
},
artifacts({ project }) {
if (!project?.pluginFile) return [];
return [{
kind: 'created',
path: project.pluginFile,
marker: NUXT_PLUGIN_MARKER,
// Mirrors removeNuxtLiveAdapter: the generated `plugins/` directory
// goes when it empties, its parent stays.
pruneTo: path.posix.dirname(path.posix.dirname(project.pluginFile)),
}];
},
},
source: {
extensions: ['.vue'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'html',
},
};
@@ -0,0 +1,17 @@
/**
* The one place that builds the `/live.js` URL the browser loads.
*
* Every injection path needs it (the generic script tag, the Nuxt client
* plugin, the SvelteKit root component, the TanStack mount component), and a
* separate module keeps that shared leaf free of import cycles: the framework
* entries import it, and nothing here imports a framework entry.
*/
/**
* When a token is supplied it rides as a `?token=...` query param so the
* server's token-gated /live.js handler authorizes the fetch.
*/
export function buildLiveScriptSrc(port, token) {
const base = 'http://localhost:' + port + '/live.js';
return token ? base + '?token=' + encodeURIComponent(token) : base;
}
@@ -0,0 +1,26 @@
/**
* Static HTML registry entry: the terminal fallback.
*
* Hand-written pages, a multi-page site emitted by a generator, anything with
* no bundler config at the app root. `detect` always matches, so this entry
* must stay last in FRAMEWORKS. Its behavior is the plain tag strategy, which
* is what live-inject.mjs did for every unrecognized project before the
* registry existed.
*/
export const staticHtml = {
name: 'static-html',
detect() {
return { via: 'fallback' };
},
inject: { kind: 'tag' },
source: {
extensions: ['.html', '.htm'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'html',
},
};
@@ -0,0 +1,71 @@
/**
* SvelteKit registry entry.
*
* Detection and the apply/remove pair are the existing adapter's
* (`../sveltekit-adapter.mjs`); this file only declares them to the registry
* and names the artifacts the journal has to be able to heal.
*/
import {
SVELTE_LAYOUT_MARKER_OPEN,
SVELTE_LIVE_ROOT_COMPONENT,
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
unpatchSvelteLayout,
} from '../sveltekit-adapter.mjs';
export const sveltekit = {
name: 'sveltekit',
detect(cwd, config) {
return detectSvelteKitProject(cwd, config);
},
inject: {
kind: 'adapter',
apply({ cwd, port, token, config }) {
return applySvelteKitLiveAdapter({ cwd, port, token, config });
},
remove({ cwd, config }) {
return removeSvelteKitLiveAdapter({ cwd, config });
},
// The generated root component and the `src/lib/impeccable/` runtime paths
// are already in the static LIVE_IGNORE_PATTERNS list, so nothing extra.
ignorePatterns() {
return [];
},
artifacts({ project }) {
return [
{
kind: 'created',
path: SVELTE_LIVE_ROOT_COMPONENT,
marker: 'impeccable-live-root',
pruneTo: 'src',
},
{
kind: 'patched',
path: project?.layoutFile || 'src/routes/+layout.svelte',
patch: 'sveltekit-layout',
markers: [SVELTE_LAYOUT_MARKER_OPEN],
},
];
},
unpatch: {
'sveltekit-layout': unpatchSvelteLayout,
},
},
source: {
extensions: ['.svelte'],
// Svelte resets component-local state on markup HMR updates, so variants
// are mounted from generated components rather than written into the route.
preview: 'component',
commentSyntax: 'html',
},
};
@@ -0,0 +1,247 @@
/**
* The generic `tag` injection strategy.
*
* Frameworks without a dedicated adapter get a literal marker-wrapped
* `<script src>` block written into the entry template named by
* `.impeccable/live/config.json`. This module owns that block: building it,
* inserting it at the configured anchor, removing it again, and the
* Content-Security-Policy meta patch that keeps the cross-origin load allowed.
*
* It is deliberately framework-agnostic. Per-framework knowledge (Astro's
* `is:inline`, for instance) arrives as the `scriptAttrs` argument, resolved
* from the registry by the caller, so nothing here has to branch on a file
* extension or a project shape.
*/
import { buildLiveScriptSrc } from './script-src.mjs';
export const MARKER_OPEN_TEXT = 'impeccable-live-start';
export const MARKER_CLOSE_TEXT = 'impeccable-live-end';
/** Markers that identify a file as still carrying our tag-strategy patch. */
export const TAG_PATCH_MARKERS = Object.freeze([MARKER_OPEN_TEXT, 'data-impeccable-csp-original']);
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
/**
* `scriptAttrs` is a pre-rendered attribute string (trailing space included)
* that the registry supplies for the target file. Astro is the only framework
* that uses it today: Astro processes `<script>` tags by default and rewrites
* src to its own bundled URL, so `is:inline ` opts out and the literal external
* src survives.
*/
export function buildTagBlock(syntax, port, token, scriptAttrs = '') {
const open = commentOpen(syntax);
const close = commentClose(syntax);
return (
open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
'<script ' + scriptAttrs + 'src="' + buildLiveScriptSrc(port, token) + '"></script>\n' +
open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
);
}
function detectLineEnding(content) {
if (content.includes('\r\n')) return '\r\n';
if (content.includes('\r')) return '\r';
return '\n';
}
function normalizeLineEndings(content, lineEnding) {
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
}
function readLineEndingAt(content, index) {
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
if (content[index] === '\n') return '\n';
if (content[index] === '\r') return '\r';
return '';
}
export function insertTag(content, config, port, token, scriptAttrs = '') {
const lineEnding = detectLineEnding(content);
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, token, scriptAttrs), lineEnding);
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
// belong at the end, and the same literal can appear earlier in code blocks
// within rendered documentation pages.
if (config.insertBefore) {
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
// Preserve an existing trailing newline if the anchor already has one.
// Slice the remainder from the original anchor offset, not prefix.length:
// in the no-newline case prefix is one char longer than the anchor (the
// appended '\n'), so slicing by prefix.length would drop the first real
// character after the anchor (#227).
const existingNewline = readLineEndingAt(content, after);
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
const rest = content.slice(after + existingNewline.length);
return prefix + block + rest;
}
/**
* Remove the live script block. Matches either HTML or JSX comment markers
* regardless of config (so stale tags from a wrong config can still be cleaned).
*
* Indent-preserving: captures any whitespace immediately preceding the opener
* marker and re-emits it in place of the removed block. `insertTag` inserted
* the block *after* the original line's indent and *before* the anchor (e.g.
* `</body>`), which moved the indent onto the opener line and left the anchor
* unindented. Replacing the whole block (plus its trailing newline) with just
* the captured indent hands the indent back to the anchor that follows.
*/
export function removeTag(content, _syntax) {
const patterns = [
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
];
for (const pat of patterns) {
let changed = false;
let next = content;
do {
content = next;
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
if (/[\r\n]/.test(trailing)) return leadingIndent;
return leadingIndent || trailing || '';
});
if (next !== content) changed = true;
} while (next !== content);
if (changed) return next;
}
return content;
}
// ---------------------------------------------------------------------------
// Content-Security-Policy meta-tag patcher
//
// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
// the cross-origin load of /live.js (and the SSE/POST connection back to
// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
//
// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
// and stash the original `content` value in a `data-impeccable-csp-original`
// attribute (base64) so revert is exact.
//
// On remove: detect the marker attribute, decode it, restore the original
// content value verbatim, drop the marker.
//
// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
// shared helpers) is NOT patched here — those need framework-specific config
// edits and are handled via the existing detect-csp.mjs reference output.
// Only the in-source meta-tag form gets the auto-patch.
// ---------------------------------------------------------------------------
const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
function findCspMetaTags(content) {
const out = [];
const tagRe = /<meta\s+([^>]*?)\/?>/gis;
let m;
while ((m = tagRe.exec(content)) !== null) {
const attrs = m[1];
if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
}
return out;
}
function getAttr(attrs, name) {
const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
const m = attrs.match(re);
return m ? { quote: m[1], value: m[2], full: m[0] } : null;
}
function appendOriginToDirective(csp, directive, origin) {
const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
const m = csp.match(re);
if (m) {
const tokens = m[4].trim().split(/\s+/);
if (tokens.includes(origin)) return csp;
return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
}
// Directive missing — add it. Use 'self' + origin so we don't inadvertently
// narrow the policy compared to the default-src fallback (most users with
// an explicit CSP have 'self' there).
return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
}
export function patchCspMeta(content, port) {
const tags = findCspMetaTags(content);
if (tags.length === 0) return content;
const origin = `http://localhost:${port}`;
// Walk last-to-first so prior splices don't invalidate later indices.
let result = content;
for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
const attrs = tag.attrs;
if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
const contentAttr = getAttr(attrs, 'content');
if (!contentAttr) continue;
const original = contentAttr.value;
let patched = original;
patched = appendOriginToDirective(patched, 'script-src', origin);
patched = appendOriginToDirective(patched, 'connect-src', origin);
// The shader overlay during 'generating' creates a screenshot via
// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
// those. Add `blob:` so the overlay doesn't throw a CSP violation.
patched = appendOriginToDirective(patched, 'img-src', 'blob:');
if (patched === original) continue;
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
}
return result;
}
export function revertCspMeta(content) {
const tags = findCspMetaTags(content);
if (tags.length === 0) return content;
let result = content;
for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
if (!origAttr) continue;
const contentAttr = getAttr(tag.attrs, 'content');
if (!contentAttr) continue;
let originalValue;
try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
catch { continue; }
const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
// Drop the marker attribute and any single space immediately preceding it.
newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
const newTag = tag.full.replace(tag.attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
}
return result;
}
/** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */
export function unpatchTagFile(content) {
return revertCspMeta(removeTag(content));
}
@@ -0,0 +1,70 @@
/**
* TanStack Start registry entry.
*
* Detection and the apply/remove pair are the existing adapter's
* (`../tanstack-adapter.mjs`); this file only declares them to the registry
* and names the artifacts the journal has to be able to heal.
*/
import {
TANSTACK_MARKER_OPEN,
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
unpatchTanStackRoot,
} from '../tanstack-adapter.mjs';
export const tanstackStart = {
name: 'tanstack-start',
detect(cwd) {
return detectTanStackStartProject(cwd);
},
inject: {
kind: 'adapter',
apply({ cwd, port, token, project }) {
return applyTanStackLiveAdapter({ cwd, port, token, project });
},
remove({ cwd, project }) {
return removeTanStackLiveAdapter({ cwd, project });
},
// The mount component's extension follows the root route's, so the path
// cannot live in the static ignore list.
ignorePatterns(project) {
return project?.componentFile ? [project.componentFile] : [];
},
artifacts({ project }) {
if (!project) return [];
return [
{
kind: 'created',
path: project.componentFile,
marker: 'impeccable-live-tanstack',
pruneTo: 'src',
},
{
kind: 'patched',
path: project.rootRoute,
patch: 'tanstack-root',
markers: [TANSTACK_MARKER_OPEN],
},
];
},
unpatch: {
'tanstack-root': unpatchTanStackRoot,
},
},
source: {
extensions: ['.tsx', '.jsx'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'jsx',
},
};
@@ -0,0 +1,42 @@
/**
* Generic Vite registry entry: a bundled app with a real `index.html` entry
* and no framework-specific document ownership. React, Vue, Solid, Preact and
* a plain TanStack Router SPA all land here — the marker-wrapped script block
* goes straight into the HTML entry.
*
* This is the entry that catches everything with a bundler config; only
* static-html sits below it.
*/
import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
const VITE_CONFIG_RE = /^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectViteProject(cwd = process.cwd()) {
const configFile = findConfigFile(cwd, VITE_CONFIG_RE);
if (configFile) return { configFile, via: 'config' };
if (hasAnyDependency(cwd, ['vite'])) return { configFile: null, via: 'package' };
// A zero-config Vite app is index.html + package.json, the same pair
// roots.mjs treats as an app root.
if (fileExists(cwd, 'index.html') && fileExists(cwd, 'package.json')) {
return { configFile: null, via: 'zero-config' };
}
return null;
}
export const viteGeneric = {
name: 'vite-generic',
detect(cwd) {
return detectViteProject(cwd);
},
inject: { kind: 'tag' },
source: {
extensions: ['.tsx', '.jsx'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'jsx',
},
};
+329
View File
@@ -0,0 +1,329 @@
/**
* Live root resolution: the single place that decides which directories a live
* session operates on. Every live entry script resolves this once at startup
* (see enterLiveRoot) instead of trusting its ambient cwd, which is how a
* `cd` used to silently fork the whole system into a second, empty project.
*
* Four distinct roots travel together as one manifest:
*
* appRoot what the dev server serves; where live session state,
* injected adapters, and preview modules live.
* repoRoot the git boundary (falls back to appRoot outside git).
* contextRoot the nearest directory from appRoot up to repoRoot carrying
* PRODUCT.md / DESIGN.md (canonical spot or a fallback dir).
* sessionRoot <appRoot>/.impeccable/live — durable live state.
*
* appRoot detection keys on dev-server config presence (vite/svelte/next/
* astro/nuxt/... config files), not on monorepo brand markers. A nested
* website/ with vite.config.js wins over a repo root that merely has a
* package.json. Workspace declarations are one input, not the gatekeeper.
*
* The resolved manifest is persisted at <appRoot>/.impeccable/live/roots.json
* plus a pointer at <repoRoot>/.impeccable/live/app-root.json when the two
* differ, so a helper invoked from anywhere inside the repo finds the same
* roots the boot decided on. When several apps in one repo run live, the
* pointer follows the most recent boot; per-app roots.json files stay put.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveProjectRoot } from '../context.mjs';
const ROOTS_MANIFEST_VERSION = 1;
const ROOTS_FILE = 'roots.json';
const POINTER_FILE = 'app-root.json';
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const CONTEXT_FALLBACK_DIRS = ['.agents/context', 'docs'];
// Presence of any of these marks a directory as a dev-served app root.
const DEV_CONFIG_MARKERS = [
'vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.mts', 'vite.config.cjs',
'svelte.config.js', 'svelte.config.mjs', 'svelte.config.ts',
'next.config.js', 'next.config.mjs', 'next.config.ts',
'astro.config.mjs', 'astro.config.js', 'astro.config.ts', 'astro.config.cjs',
'nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs',
'remix.config.js', 'react-router.config.ts',
'angular.json',
'webpack.config.js', 'webpack.config.ts',
];
const CANDIDATE_SCAN_IGNORED = new Set([
'node_modules', '.git', 'dist', 'build', 'coverage', 'vendor', 'vendors',
'.next', '.nuxt', '.svelte-kit', '.astro', '.turbo', '.cache', '.vercel',
]);
const CANDIDATE_SCAN_DEPTH = 2;
function exists(p) {
try { fs.statSync(p); return true; } catch { return false; }
}
function isDir(p) {
try { return fs.statSync(p).isDirectory(); } catch { return false; }
}
function firstExisting(dir, names) {
for (const name of names) {
const abs = path.join(dir, name);
if (exists(abs)) return abs;
}
return null;
}
function hasDevConfig(dir) {
if (DEV_CONFIG_MARKERS.some((name) => exists(path.join(dir, name)))) return true;
// A plain Vite app can run with zero config: index.html + package.json.
return exists(path.join(dir, 'index.html')) && exists(path.join(dir, 'package.json'));
}
function isAppRoot(dir) {
// A directory already configured for live IS an app root, dev config or not
// (plain static multi-page projects have no bundler config).
return hasDevConfig(dir) || exists(path.join(dir, '.impeccable', 'live', 'config.json'));
}
function findContextFile(dir, names) {
const direct = firstExisting(dir, names);
if (direct) return direct;
for (const rel of CONTEXT_FALLBACK_DIRS) {
const nested = firstExisting(path.join(dir, rel), names);
if (nested) return nested;
}
return null;
}
export function findGitRoot(startDir) {
let dir = path.resolve(startDir);
const home = path.resolve(os.homedir());
while (true) {
if (dir === home) return null;
if (exists(path.join(dir, '.git'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function walkUp(startDir, upperBound, visit) {
let dir = path.resolve(startDir);
const stop = path.resolve(upperBound);
const home = path.resolve(os.homedir());
while (true) {
if (dir === home) return null;
const hit = visit(dir);
if (hit) return hit;
if (dir === stop) return null;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function insideOrEqual(candidate, root) {
const rel = path.relative(path.resolve(root), path.resolve(candidate));
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
/**
* Scan downward (bounded depth) for directories carrying a dev-server config.
* Used when live boots from a directory that is not itself an app root and no
* --target narrows the choice: one candidate is auto-picked, several become a
* selection prompt.
*/
export function discoverAppCandidates(rootDir, depth = CANDIDATE_SCAN_DEPTH) {
const found = [];
const scan = (dir, remaining) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || CANDIDATE_SCAN_IGNORED.has(entry.name)) continue;
const abs = path.join(dir, entry.name);
if (hasDevConfig(abs)) {
found.push(abs);
continue; // nested apps below an app root are that app's business
}
if (remaining > 1) scan(abs, remaining - 1);
}
};
scan(path.resolve(rootDir), depth);
return found.sort();
}
/**
* Fresh root resolution. Never reads a persisted manifest.
*
* Returns { manifest } on success or { selection } when several candidate
* apps exist and nothing disambiguates.
*/
export function resolveRoots({ cwd = process.cwd(), targetPath = null } = {}) {
const absCwd = path.resolve(cwd);
const absTarget = targetPath
? (path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath))
: null;
const targetDir = absTarget
? (isDir(absTarget) ? absTarget : path.dirname(absTarget))
: absCwd;
// The walk bound must be an ancestor of the target: a git root found from
// the CWD is only usable when the target actually lives inside it,
// otherwise the walk would climb out of both trees.
const targetGitRoot = findGitRoot(targetDir);
const cwdGitRoot = targetGitRoot ? null : findGitRoot(absCwd);
const repoRoot = targetGitRoot
|| (cwdGitRoot && insideOrEqual(targetDir, cwdGitRoot) ? cwdGitRoot : null);
// Without a git boundary, never ascend above the starting directory: the
// filesystem above an unversioned project is not ours to interpret.
const upperBound = repoRoot || targetDir;
// The workspace-aware legacy resolution (context.mjs) still decides two
// things: the fallback when no app marker exists, and how far the marker
// walk may ascend when an explicit target selected a workspace child. A
// root-level live config must never shadow a child the target picked.
const legacyRoot = resolveProjectRoot(absCwd, absTarget ? { targetPath: absTarget } : {});
const markerBound = absTarget && insideOrEqual(targetDir, legacyRoot) && insideOrEqual(legacyRoot, upperBound)
? legacyRoot
: upperBound;
let appRoot = walkUp(targetDir, markerBound, (dir) => (isAppRoot(dir) ? dir : null));
let resolvedFrom = appRoot
? (absTarget ? `target:${path.relative(absCwd, absTarget) || '.'}` : 'cwd')
: null;
if (!appRoot && !absTarget) {
const candidates = discoverAppCandidates(absCwd);
if (candidates.length === 1) {
appRoot = candidates[0];
resolvedFrom = `candidate:${path.relative(absCwd, appRoot)}`;
} else if (candidates.length > 1) {
return {
selection: {
candidates: candidates.map((abs) => ({
name: path.basename(abs),
path: path.relative(absCwd, abs).split(path.sep).join('/'),
})),
},
};
}
}
if (!appRoot) {
// No app marker anywhere: defer to the workspace-aware legacy resolution
// (workspace child for a targeted monorepo path, cwd otherwise). Never
// adopt an arbitrary ancestor just because it has a package.json, and
// never adopt a root that does not even contain the target.
appRoot = insideOrEqual(targetDir, legacyRoot) ? legacyRoot : targetDir;
resolvedFrom = 'fallback';
}
const effectiveRepoRoot = repoRoot && insideOrEqual(appRoot, repoRoot) ? repoRoot : appRoot;
// Each context file resolves independently: a child app may carry its own
// PRODUCT.md while inheriting DESIGN.md from the repo root (or vice versa).
const productPath = walkUp(appRoot, effectiveRepoRoot, (dir) => findContextFile(dir, PRODUCT_NAMES));
const designPath = walkUp(appRoot, effectiveRepoRoot, (dir) => findContextFile(dir, DESIGN_NAMES));
const contextRoot = productPath
? path.dirname(productPath)
: designPath
? path.dirname(designPath)
: null;
return {
manifest: {
version: ROOTS_MANIFEST_VERSION,
appRoot,
repoRoot: effectiveRepoRoot,
contextRoot,
sessionRoot: path.join(appRoot, '.impeccable', 'live'),
productPath,
designPath,
resolvedFrom,
},
};
}
function rootsFilePath(appRoot) {
return path.join(appRoot, '.impeccable', 'live', ROOTS_FILE);
}
function pointerFilePath(repoRoot) {
return path.join(repoRoot, '.impeccable', 'live', POINTER_FILE);
}
export function writeRootsManifest(manifest) {
const file = rootsFilePath(manifest.appRoot);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(manifest, null, 2));
if (path.resolve(manifest.repoRoot) !== path.resolve(manifest.appRoot)) {
const pointer = pointerFilePath(manifest.repoRoot);
fs.mkdirSync(path.dirname(pointer), { recursive: true });
fs.writeFileSync(pointer, JSON.stringify({ appRoot: manifest.appRoot }));
}
return file;
}
function readManifestAt(appRoot) {
try {
const raw = JSON.parse(fs.readFileSync(rootsFilePath(appRoot), 'utf-8'));
if (!raw || typeof raw.appRoot !== 'string') return null;
// A manifest is only trusted where it claims to live; anything else is a
// copied or stale file.
if (path.resolve(raw.appRoot) !== path.resolve(appRoot)) return null;
return raw;
} catch {
return null;
}
}
/**
* Resolve the roots for the live session governing `cwd`, preferring a
* persisted manifest (written by the boot) over fresh detection:
*
* 1. Walk up from cwd looking for .impeccable/live/roots.json.
* 2. At the git root, follow .impeccable/live/app-root.json to the app.
* 3. Fresh resolveRoots().
*
* Fresh results are NOT persisted here; only the boot (live.mjs / server
* startup) writes manifests, so ad-hoc helper invocations cannot mint
* conflicting truth.
*/
export function resolveLiveRoots(cwd = process.cwd(), { targetPath = null } = {}) {
const absCwd = path.resolve(cwd);
if (!targetPath) {
const persisted = walkUp(absCwd, findGitRoot(absCwd) || absCwd, (dir) => readManifestAt(dir));
if (persisted) return { manifest: persisted, source: 'persisted' };
const gitRoot = findGitRoot(absCwd);
if (gitRoot) {
try {
const pointer = JSON.parse(fs.readFileSync(pointerFilePath(gitRoot), 'utf-8'));
if (pointer && typeof pointer.appRoot === 'string') {
const viaPointer = readManifestAt(pointer.appRoot);
if (viaPointer) return { manifest: viaPointer, source: 'pointer' };
}
} catch { /* no pointer */ }
}
}
const fresh = resolveRoots({ cwd: absCwd, targetPath });
if (fresh.selection) return { selection: fresh.selection, source: 'fresh' };
return { manifest: fresh.manifest, source: 'fresh' };
}
/**
* Entry-point guard for live CLI scripts: resolve the governing roots and
* make appRoot the process cwd so every downstream path derivation agrees
* with the boot. Returns the manifest. Never throws; on selection ambiguity
* it stays in the current directory (the boot flow handles prompting).
*/
export function enterLiveRoot(cwd = process.cwd()) {
const resolved = resolveLiveRoots(cwd);
if (!resolved.manifest) return null;
const appRoot = resolved.manifest.appRoot;
if (path.resolve(cwd) !== path.resolve(appRoot) && isDir(appRoot)) {
try { process.chdir(appRoot); } catch { /* keep current cwd */ }
}
return resolved.manifest;
}
+213 -35
View File
@@ -1,26 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir, safeSessionId } from '../lib/impeccable-paths.mjs';
import { COMPLETED_SESSION_PHASES, GENERATION_FENCED_SESSION_PHASES } from './vocabulary.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
export const GENERATION_FENCED_PHASES = new Set([
'accept_requested',
'discard_requested',
'carbonize_required',
'completed',
'discarded',
]);
const COMPLETED_PHASES = new Set(COMPLETED_SESSION_PHASES);
export const GENERATION_FENCED_PHASES = new Set(GENERATION_FENCED_SESSION_PHASES);
// The snapshot file carries two bookkeeping fields the snapshot itself does not
// own: how large the journal was when the snapshot was written, and the next
// sequence number. Both are stripped before a snapshot is handed to a caller.
// The byte count is what makes a cached snapshot verifiable — the journal is
// append-only, so a matching size means no event has landed since.
const META_JOURNAL_BYTES = '__journalBytes';
const META_NEXT_SEQ = '__nextSeq';
// TODO(revision-unification): `checkpointRevision`, `browserCheckpointRevision`,
// and `publicationCheckpointRevision` are three counters for two domains.
// `checkpointRevision` is a compatibility mirror of the browser counter kept for
// older readers. Collapsing them means changing what a resumed browser compares
// its local revision against, so it belongs in a pass that owns resume ordering,
// not in a caching change.
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
const rootDir = getLiveSessionsDir(cwd);
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
fs.mkdirSync(rootDir, { recursive: true });
// No snapshot cache on purpose: appendEvent and getSnapshot both rebuild from
// the journal so sequence numbers and phase fences never come from a stale
// in-memory copy when the publisher/complete helpers append from another
// process. A cache written but never read would grow per session for the
// lifetime of the server without ever saving a rebuild.
// Derived state per session, keyed by what the journal looked like when it was
// derived. Publisher/complete helpers append from other processes, so the key
// is the journal's own (path, size, mtime) rather than a trusted local write
// count: an append this process did not make invalidates the entry and the
// next read replays. Without the cache every append and every read replayed
// the whole journal, which made a long session quadratic in its own length.
/** @type {Map<string, { snapshot: object, nextSeq: number, journalPath: string, size: number, mtimeMs: number }>} */
const derived = new Map();
function getReadableJournalPath(id) {
const primary = getJournalPath(rootDir, id);
if (fs.existsSync(primary)) return primary;
@@ -29,42 +43,104 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
return primary;
}
/**
* The current derived state for a session, from the in-memory cache when the
* journal has not moved, from the snapshot file when that file is provably
* current, and from a full replay otherwise.
*/
function readState(id, { allowSnapshotFile = true } = {}) {
const journalPath = getReadableJournalPath(id);
const stat = statOrNull(journalPath);
const size = stat ? stat.size : -1;
const mtimeMs = stat ? stat.mtimeMs : -1;
const cached = derived.get(id);
if (cached && cached.journalPath === journalPath && cached.size === size && cached.mtimeMs === mtimeMs) {
return cached;
}
if (allowSnapshotFile && stat) {
const hydrated = readSnapshotFile(getSnapshotPath(rootDir, id), id, size);
if (hydrated) {
const entry = { ...hydrated, journalPath, size, mtimeMs };
derived.set(id, entry);
return entry;
}
}
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
const entry = { snapshot: rebuilt.snapshot, nextSeq: rebuilt.nextSeq, journalPath, size, mtimeMs };
derived.set(id, entry);
return entry;
}
function persist(id, snapshot, nextSeq) {
const snapshotPath = getSnapshotPath(rootDir, id);
const journalPath = getReadableJournalPath(id);
const stat = statOrNull(journalPath);
writeSnapshot(snapshotPath, snapshot, { journalBytes: stat ? stat.size : -1, nextSeq });
derived.set(id, {
snapshot,
nextSeq,
journalPath,
size: stat ? stat.size : -1,
mtimeMs: stat ? stat.mtimeMs : -1,
});
}
return {
rootDir,
legacyRootDir,
appendEvent(event) {
const normalized = normalizeEvent(event, sessionId);
const journalPath = getJournalPath(rootDir, normalized.id);
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
fs.copyFileSync(legacyJournalPath, journalPath);
// The readable path just moved from legacy to primary; anything derived
// against the old path describes a file this session no longer reads.
derived.delete(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;
// Reuse the derived state when the journal has not changed under us, and
// apply the new event on top of it. Correctness still comes from the
// journal: any append from another process invalidates the entry above
// and this replays before writing, so sequence numbers and phase fences
// are never taken from a stale copy.
const prior = readState(normalized.id);
const entry = {
seq,
seq: prior.nextSeq,
id: normalized.id,
type: normalized.type,
ts: new Date().toISOString(),
event: normalized,
};
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
writeSnapshot(snapshotPath, next);
const next = applyEvent(prior.snapshot, entry);
persist(normalized.id, next, prior.nextSeq + 1);
return next;
},
/**
* Read-only. `live-status` and `live-resume` call this against a session a
* running server owns; writing the snapshot file here made every read a
* write and let a reader's replay of a half-written journal land on disk.
* Snapshot files are written by appendEvent and by flush().
*/
getSnapshot(id = sessionId, opts = {}) {
if (!id) throw new Error('session id required');
const journalPath = getReadableJournalPath(id);
const snapshotPath = getSnapshotPath(rootDir, id);
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
writeSnapshot(snapshotPath, rebuilt.snapshot);
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
return rebuilt.snapshot;
const { snapshot } = readState(id);
if (!opts.includeCompleted && COMPLETED_PHASES.has(snapshot.phase)) return null;
return snapshot;
},
/**
* Write the snapshot file for a session without appending an event. The
* durable truth is the journal, so this only refreshes the read cache other
* processes use; callers that need the state itself should use getSnapshot.
*/
flush(id = sessionId) {
if (!id) throw new Error('session id required');
const state = readState(id, { allowSnapshotFile: false });
persist(id, state.snapshot, state.nextSeq);
return state.snapshot;
},
listActiveSessions() {
const ids = new Set();
@@ -74,6 +150,9 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
}
}
// Each id goes through readState, so a session whose journal has not moved
// since it was last derived costs a stat and nothing more. The server calls
// this on every /status and on every SSE connect.
return [...ids]
.sort()
.map((id) => this.getSnapshot(id))
@@ -82,6 +161,39 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
};
}
function statOrNull(filePath) {
try {
return fs.statSync(filePath);
} catch {
return null;
}
}
/**
* Hydrate derived state from a snapshot file, but only when it provably
* describes the journal as it stands right now. Anything short of an exact byte
* match on an append-only file means events landed after the snapshot was
* written, and the caller replays instead.
*/
function readSnapshotFile(snapshotPath, id, journalBytes) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
if (parsed[META_JOURNAL_BYTES] !== journalBytes) return null;
if (!Number.isInteger(parsed[META_NEXT_SEQ])) return null;
const nextSeq = parsed[META_NEXT_SEQ];
delete parsed[META_JOURNAL_BYTES];
delete parsed[META_NEXT_SEQ];
// The journal owns identity; a snapshot file copied between session ids is
// not a reason to answer with the wrong id.
if (parsed.id !== id) return null;
return { snapshot: { ...baseSnapshot(id), ...parsed }, nextSeq };
}
function normalizeEvent(event, fallbackId) {
if (!event || typeof event !== 'object') throw new Error('event object required');
const id = event.id || fallbackId;
@@ -127,11 +239,37 @@ function baseSnapshot(id) {
generationCanceledAt: null,
cancelReason: null,
annotationArtifacts: [],
// Render truth. `arrivedVariants` says what the agent published; these say
// what the browser actually got on screen. They are kept alongside the
// published counters rather than replacing them so older readers keep
// working, but they are the only fields that answer "did the user ever see
// a variant".
mountedVariants: [],
mountFailures: [],
renderState: null,
diagnostics: [],
updatedAt: null,
};
}
// How many mount failures a session keeps. The card in the browser shows the
// newest one; the agent needs enough history to spot a variant that fails
// every republish, not the whole retry storm.
const MOUNT_FAILURE_HISTORY = 5;
/**
* `pending` = the agent published and nothing has acked yet, `mounted` = at
* least one variant reached the DOM, `failed` = the browser reported failures
* and nothing ever mounted. A single success outranks any number of failures:
* the user is looking at something.
*/
function deriveRenderState(snapshot) {
if (snapshot.mountedVariants.length > 0) return 'mounted';
if (snapshot.mountFailures.length > 0) return 'failed';
if (snapshot.generationCompletedAt) return 'pending';
return null;
}
function rebuildSnapshotFromJournal(journalPath, id) {
let snapshot = baseSnapshot(id);
const diagnostics = [];
@@ -159,7 +297,7 @@ function rebuildSnapshotFromJournal(journalPath, id) {
return { snapshot, diagnostics, nextSeq };
}
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
function applyEvent(snapshot, entry) {
const event = entry.event || entry;
const next = {
...snapshot,
@@ -168,14 +306,13 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
generationTimings: { ...(snapshot.generationTimings || {}) },
variantPlan: snapshot.variantPlan || null,
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
mountedVariants: [...(snapshot.mountedVariants || [])],
mountFailures: [...(snapshot.mountFailures || [])],
renderState: snapshot.renderState ?? null,
diagnostics: [...(snapshot.diagnostics || [])],
updatedAt: entry.ts || new Date().toISOString(),
};
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
next.diagnostics = [...inheritedDiagnostics];
}
switch (event.type) {
case 'generate':
next.phase = 'generate_requested';
@@ -184,6 +321,11 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
next.variantPlan = null;
// A new cycle publishes new files: everything the browser told us about
// the previous batch is now about modules that no longer exist.
next.mountedVariants = [];
next.mountFailures = [];
next.renderState = null;
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
break;
case 'variant_plan':
@@ -238,7 +380,38 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
});
}
next.renderState = deriveRenderState(next);
break;
case 'variant_mounted': {
const variant = Number(event.variant);
if (!Number.isInteger(variant) || variant < 1) {
next.diagnostics.push({ error: 'malformed_mount_ack', type: event.type, variant: event.variant ?? null });
break;
}
if (!next.mountedVariants.includes(variant)) {
next.mountedVariants = [...next.mountedVariants, variant].sort((a, b) => a - b);
}
next.renderState = deriveRenderState(next);
break;
}
case 'variant_mount_failed': {
const variant = Number(event.variant);
if (!Number.isInteger(variant) || variant < 1) {
next.diagnostics.push({ error: 'malformed_mount_ack', type: event.type, variant: event.variant ?? null });
break;
}
next.mountFailures = [
...next.mountFailures,
{
variant,
url: typeof event.url === 'string' ? event.url : null,
error: typeof event.error === 'string' ? event.error : null,
at: event.at ?? (Date.parse(entry.ts || '') || Date.now()),
},
].slice(-MOUNT_FAILURE_HISTORY);
next.renderState = deriveRenderState(next);
break;
}
case 'checkpoint':
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 });
@@ -361,6 +534,11 @@ function upsertArtifact(artifacts, artifact) {
}
}
function writeSnapshot(snapshotPath, snapshot) {
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
function writeSnapshot(snapshotPath, snapshot, meta) {
const payload = {
...snapshot,
[META_JOURNAL_BYTES]: meta?.journalBytes ?? -1,
[META_NEXT_SEQ]: meta?.nextSeq ?? 1,
};
fs.writeFileSync(snapshotPath, JSON.stringify(payload, null, 2) + '\n');
}
+764
View File
@@ -0,0 +1,764 @@
/**
* AST-based Svelte scaffolding for live component previews.
*
* The scaffolder turns the selected block of a route's markup into a detached
* preview component whose dynamic values arrive as props. The old
* implementation matched `{...}` with a regex, which flattened control-flow
* blocks ({#each}, {#if}) into scalar text props and shipped structurally
* wrong previews. This module uses the app's own svelte compiler
* (parse with modern: true) and replaces only expressions that are FREE,
* i.e. reference identifiers not bound by an enclosing template scope:
*
* {#each stages as stage, i} stages -> collection prop (array)
* <span>{stage.label}</span> bound -> left verbatim
* {/each}
* <p>{footerNote}</p> free -> text prop (string)
*
* Constructs that cannot work in a detached component (component tags whose
* imports live in the route file, bind:/use: directives, await blocks,
* render tags) mark the analysis unsupported; the caller falls back to
* source-preview mode, which keeps the markup inside the route file where
* those references still resolve. A wrong preview is worse than a plain one.
*
* The compiler is resolved from the APP's node_modules, never bundled: the
* preview must be parsed by the same svelte version that will compile it.
*/
import { createRequire } from 'node:module';
import path from 'node:path';
const HANDLER_ATTR_RE = /^on[a-z]/;
/**
* Resolve the app's svelte compiler synchronously (svelte 5 ships a CJS
* compiler build, so createRequire works and the accept/scaffold pipeline
* stays synchronous). Returns { parse, compile, VERSION } or null.
*/
export function loadSvelteCompiler(appRoot) {
try {
const req = createRequire(path.join(appRoot, 'package.json'));
const mod = req('svelte/compiler');
if (typeof mod.parse !== 'function') return null;
const major = parseInt(String(mod.VERSION || '0'), 10);
if (major < 5) return null; // detached mount() previews are svelte 5 only
return { parse: mod.parse, compile: mod.compile, VERSION: mod.VERSION };
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// ESTree helpers
// ---------------------------------------------------------------------------
/**
* Collect the root identifiers an ESTree expression reads. Walks generically;
* skips non-computed member properties and non-computed/non-shorthand object
* keys, which are names, not references.
*/
export function collectRootIdentifiers(node, out = new Set()) {
if (!node || typeof node !== 'object') return out;
if (Array.isArray(node)) {
for (const item of node) collectRootIdentifiers(item, out);
return out;
}
switch (node.type) {
case 'Identifier':
out.add(node.name);
return out;
case 'MemberExpression':
collectRootIdentifiers(node.object, out);
if (node.computed) collectRootIdentifiers(node.property, out);
return out;
case 'Property':
if (node.computed) collectRootIdentifiers(node.key, out);
collectRootIdentifiers(node.value, out);
return out;
case 'ArrowFunctionExpression':
case 'FunctionExpression': {
// Params shadow outer names inside the body.
const bound = new Set();
for (const param of node.params || []) collectPatternNames(param, bound);
const inner = collectRootIdentifiers(node.body, new Set());
for (const name of inner) if (!bound.has(name)) out.add(name);
return out;
}
default: {
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
collectRootIdentifiers(node[key], out);
}
return out;
}
}
}
/** Collect names bound by a destructuring pattern (each contexts, const tags). */
export function collectPatternNames(pattern, out = new Set()) {
if (!pattern || typeof pattern !== 'object') return out;
switch (pattern.type) {
case 'Identifier':
out.add(pattern.name);
return out;
case 'ObjectPattern':
for (const prop of pattern.properties || []) {
if (prop.type === 'RestElement') collectPatternNames(prop.argument, out);
else collectPatternNames(prop.value, out);
}
return out;
case 'ArrayPattern':
for (const el of pattern.elements || []) if (el) collectPatternNames(el, out);
return out;
case 'AssignmentPattern':
collectPatternNames(pattern.left, out);
return out;
case 'RestElement':
collectPatternNames(pattern.argument, out);
return out;
default:
return out;
}
}
// ---------------------------------------------------------------------------
// Template analysis
// ---------------------------------------------------------------------------
class Analysis {
constructor(source) {
this.source = source;
this.replacements = []; // { start, end, prop } source ranges to swap
this.contract = []; // [{ prop, expr, kind, ... }]
this.byExpr = new Map(); // expr text -> contract entry
this.usedNames = new Set();
this.unsupported = null;
}
fail(reason) {
if (!this.unsupported) this.unsupported = reason;
}
propFor(exprText, kind, extra = {}) {
const existing = this.byExpr.get(exprText);
if (existing) return existing;
const base = derivePropName(exprText);
let name = base;
let n = 2;
while (this.usedNames.has(name)) name = `${base}${n++}`;
this.usedNames.add(name);
const entry = { prop: name, expr: exprText, kind, ...extra };
this.byExpr.set(exprText, entry);
this.contract.push(entry);
return entry;
}
}
// A derived prop name lands in `let { <name> } = $props()`; a reserved word
// there is a syntax error the session only hits at import time.
const RESERVED_PROP_NAMES = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false',
'finally', 'for', 'function', 'if', 'implements', 'import', 'in',
'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private',
'protected', 'public', 'return', 'static', 'super', 'switch', 'this',
'throw', 'true', 'try', 'typeof', 'undefined', 'var', 'void', 'while',
'with', 'yield',
]);
export function derivePropName(expr) {
const tail = String(expr).match(/(?:\.|\[["']?)([A-Za-z_$][\w$]*)["']?\]?\s*$/);
const candidate = (tail && tail[1])
|| (String(expr).match(/^([A-Za-z_$][\w$]*)$/) || [])[1]
|| 'value';
return RESERVED_PROP_NAMES.has(candidate) ? `${candidate}Value` : candidate;
}
function exprText(source, node) {
return source.slice(node.start, node.end);
}
function isFree(node, scopes) {
const roots = collectRootIdentifiers(node);
if (roots.size === 0) return false; // literal-only: nothing to hydrate
for (const name of roots) {
for (const scope of scopes) {
if (scope.has(name)) return false;
}
}
return true;
}
/**
* Analyze a parsed template fragment. `scopes` is a stack of Sets of bound
* names; the outermost call passes an empty stack.
*/
function analyzeFragment(fragment, analysis, scopes) {
if (!fragment || !Array.isArray(fragment.nodes)) return;
// ConstTag declarations bind for the whole fragment.
const fragmentScope = new Set();
const nextScopes = [...scopes, fragmentScope];
for (const node of fragment.nodes) {
if (node.type === 'ConstTag' && node.declaration) {
for (const decl of node.declaration.declarations || []) {
collectPatternNames(decl.id, fragmentScope);
}
}
}
for (const node of fragment.nodes) analyzeNode(node, analysis, nextScopes);
}
function analyzeNode(node, analysis, scopes) {
if (!node || analysis.unsupported) return;
switch (node.type) {
case 'Text':
case 'Comment':
return;
case 'ExpressionTag': {
if (isFree(node.expression, scopes)) {
const text = exprText(analysis.source, node.expression);
const entry = analysis.propFor(text, 'text');
// node.start/end include the braces; keep them, swap the inside.
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
}
return;
}
case 'HtmlTag': {
if (isFree(node.expression, scopes)) {
const text = exprText(analysis.source, node.expression);
const entry = analysis.propFor(text, 'raw');
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
}
return;
}
case 'ConstTag': {
// Its expression may read free names; leave them: the declaration
// travels with the markup and stays valid only if its inputs do.
if (node.declaration) {
for (const decl of node.declaration.declarations || []) {
if (decl.init && isFree(decl.init, scopes)) {
const text = exprText(analysis.source, decl.init);
const entry = analysis.propFor(text, 'text');
analysis.replacements.push({ start: decl.init.start, end: decl.init.end, prop: entry.prop });
}
}
}
return;
}
case 'EachBlock': {
if (isFree(node.expression, scopes)) {
const text = exprText(analysis.source, node.expression);
const item = describeEachItem(node, analysis.source);
// Keyed each: the key must evaluate to a distinct value per hydrated
// item or Svelte throws each_key_duplicate at mount. A key that is a
// plain member of the item (the common `(item.id)` shape) gets a
// synthetic per-index value injected by the browser (keyField).
// Anything else cannot be hydrated safely; source-preview mode keeps
// it correct.
if (node.key) {
const keyInfo = classifyEachKey(node);
if (keyInfo.unsupported) {
analysis.fail(keyInfo.unsupported);
return;
}
if (keyInfo.keyField) {
if (item.textSlots.some((slot) => slot.key === keyInfo.keyField)) {
// The key doubles as a displayed slot; a synthetic value would
// change visible text, and the displayed text may not be
// unique. Not previewable in a detached component.
analysis.fail('each key that is also a displayed field requires source-preview mode');
return;
}
item.keyField = keyInfo.keyField;
}
}
const entry = analysis.propFor(text, 'collection', { item });
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
}
const bound = new Set();
if (node.context) collectPatternNames(node.context, bound);
if (node.index) bound.add(node.index);
analyzeFragment(node.body, analysis, [...scopes, bound]);
if (node.fallback) analyzeFragment(node.fallback, analysis, scopes);
return;
}
case 'IfBlock': {
if (isFree(node.test, scopes)) {
const text = exprText(analysis.source, node.test);
// The browser hydrates a free condition from what the live page
// currently shows: when the consequent's root element is present
// under the picked element, the condition is on.
const entry = analysis.propFor(text, 'condition', {
probe: describeElementProbe(node.consequent),
});
analysis.replacements.push({ start: node.test.start, end: node.test.end, prop: entry.prop });
}
analyzeFragment(node.consequent, analysis, scopes);
if (node.alternate) analyzeFragment(node.alternate, analysis, scopes);
return;
}
case 'KeyBlock': {
if (isFree(node.expression, scopes)) {
const text = exprText(analysis.source, node.expression);
const entry = analysis.propFor(text, 'text');
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
}
analyzeFragment(node.fragment, analysis, scopes);
return;
}
case 'SnippetBlock': {
const bound = new Set();
for (const param of node.parameters || []) collectPatternNames(param, bound);
// The snippet's own name becomes available to render tags in this file.
analyzeFragment(node.body, analysis, [...scopes, bound]);
return;
}
case 'RegularElement':
case 'SlotElement':
case 'TitleElement': {
if (node.name === 'script') {
// An inline script inside the selected block carries route-scoped
// code; running it a second time from a detached preview is wrong.
analysis.fail('inline script element requires source-preview mode');
return;
}
analyzeAttributes(node, analysis, scopes);
if (!analysis.unsupported) analyzeFragment(node.fragment, analysis, scopes);
return;
}
case 'SvelteElement':
case 'SvelteFragment':
case 'SvelteBoundary': {
analyzeAttributes(node, analysis, scopes);
if (!analysis.unsupported) analyzeFragment(node.fragment, analysis, scopes);
return;
}
case 'Component':
case 'SvelteComponent':
case 'SvelteSelf':
// The component's import lives in the route file; a detached preview
// cannot resolve it. Source-preview mode keeps it working.
analysis.fail(`component tag <${node.name || 'Component'}> requires source-preview mode`);
return;
case 'RenderTag':
analysis.fail('render tag requires source-preview mode');
return;
case 'AwaitBlock':
analysis.fail('await block requires source-preview mode');
return;
case 'SvelteHead':
case 'SvelteWindow':
case 'SvelteDocument':
case 'SvelteBody':
analysis.fail(`${node.type} requires source-preview mode`);
return;
default: {
if (node.fragment) analyzeFragment(node.fragment, analysis, scopes);
return;
}
}
}
function analyzeAttributes(node, analysis, scopes) {
for (const attr of node.attributes || []) {
switch (attr.type) {
case 'Attribute': {
if (attr.value === true) break;
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
for (const part of parts) {
if (!part || part.type !== 'ExpressionTag') continue;
if (!isFree(part.expression, scopes)) continue;
const text = exprText(analysis.source, part.expression);
const kind = HANDLER_ATTR_RE.test(attr.name) ? 'handler' : 'text';
const entry = analysis.propFor(text, kind);
analysis.replacements.push({ start: part.expression.start, end: part.expression.end, prop: entry.prop });
}
break;
}
case 'ClassDirective':
case 'StyleDirective': {
const expr = attr.expression;
if (expr && isFree(expr, scopes)) {
const text = exprText(analysis.source, expr);
const entry = analysis.propFor(text, 'condition');
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
}
break;
}
case 'BindDirective':
analysis.fail(`bind:${attr.name} requires source-preview mode`);
return;
case 'UseDirective':
analysis.fail(`use:${attr.name} requires source-preview mode`);
return;
case 'AnimateDirective':
case 'TransitionDirective':
// Motion directives reference route-scoped or svelte/transition
// imports; a detached preview cannot resolve them.
analysis.fail(`${attr.type} requires source-preview mode`);
return;
case 'OnDirective': {
// Legacy on:click syntax; treat like handler attributes.
const expr = attr.expression;
if (expr && isFree(expr, scopes)) {
const text = exprText(analysis.source, expr);
const entry = analysis.propFor(text, 'handler');
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
}
break;
}
case 'SpreadAttribute':
analysis.fail('spread attribute requires source-preview mode');
return;
default:
break;
}
}
}
/**
* Describe the repeating item of an each block for browser-side hydration:
* the item's root element (tag + static classes, used to count live
* iterations) and the ordered text slots that reference loop bindings.
*/
function describeEachItem(node, source) {
const body = node.body;
const rootEl = (body?.nodes || []).find((n) => n.type === 'RegularElement');
const bound = new Set();
if (node.context) collectPatternNames(node.context, bound);
if (node.index) bound.add(node.index);
const textSlots = [];
const staticTexts = [];
let nestedUnsupported = false;
const collectStatics = (fragment) => {
for (const child of fragment?.nodes || []) {
if (child.type === 'Text') {
const trimmed = String(child.data || '').trim();
if (trimmed) staticTexts.push(trimmed);
} else if (child.type === 'IfBlock') {
collectStatics(child.consequent);
if (child.alternate) collectStatics(child.alternate);
} else if (child.type === 'EachBlock') {
collectStatics(child.body);
} else if (child.fragment) {
collectStatics(child.fragment);
}
}
};
collectStatics(body);
const walkForSlots = (fragment, scopes) => {
for (const child of fragment?.nodes || []) {
if (child.type === 'ExpressionTag') {
const roots = collectRootIdentifiers(child.expression);
const referencesItem = [...roots].some((name) => scopes.some((s) => s.has(name)));
if (referencesItem) {
textSlots.push({
key: derivePropName(exprText(source, child.expression)),
expr: exprText(source, child.expression),
});
}
} else if (child.type === 'EachBlock') {
const roots = collectRootIdentifiers(child.expression);
const boundNested = [...roots].some((name) => scopes.some((s) => s.has(name)));
if (boundNested) nestedUnsupported = true; // nested per-item arrays: no hydration plan yet
const innerBound = new Set();
if (child.context) collectPatternNames(child.context, innerBound);
if (child.index) innerBound.add(child.index);
walkForSlots(child.body, [...scopes, innerBound]);
} else if (child.type === 'IfBlock') {
walkForSlots(child.consequent, scopes);
if (child.alternate) walkForSlots(child.alternate, scopes);
} else if (child.fragment) {
walkForSlots(child.fragment, scopes);
}
}
};
walkForSlots(body, [bound]);
const staticClasses = [];
for (const attr of rootEl?.attributes || []) {
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
for (const part of attr.value) {
if (part.type === 'Text') staticClasses.push(...part.data.split(/\s+/).filter(Boolean));
}
}
}
return {
rootTag: rootEl?.name || null,
rootClasses: staticClasses,
textSlots,
staticTexts,
nestedUnsupported,
};
}
/**
* Classify a keyed each block's key expression:
* { keyField } member of the loop item (e.g. `(expense.id)` when the
* context binds `expense`): browser injects a unique
* per-index value under that field.
* {} key is the whole loop item or the index: already
* distinct per iteration, nothing to inject.
* { unsupported } free or complex keys: cannot hydrate distinct values.
*/
function classifyEachKey(node) {
const bound = new Set();
if (node.context) collectPatternNames(node.context, bound);
if (node.index) bound.add(node.index);
const key = node.key;
const roots = collectRootIdentifiers(key);
const usesLoopBinding = [...roots].some((name) => bound.has(name));
if (!usesLoopBinding) {
// A key that ignores the loop item is constant across iterations:
// guaranteed duplicate keys at mount.
return { unsupported: 'each key not derived from the loop item requires source-preview mode' };
}
if (key.type === 'Identifier' && bound.has(key.name)) return {};
if (
key.type === 'MemberExpression'
&& !key.computed
&& key.object?.type === 'Identifier'
&& bound.has(key.object.name)
&& key.property?.type === 'Identifier'
) {
return { keyField: key.property.name };
}
return { unsupported: 'complex each key requires source-preview mode' };
}
/**
* Describe a fragment's root element for browser presence probing:
* { tag, classes } of the first RegularElement, or null for text-only
* fragments (which cannot be probed reliably).
*/
function describeElementProbe(fragment) {
const rootEl = (fragment?.nodes || []).find((n) => n.type === 'RegularElement');
if (!rootEl) return null;
const classes = [];
for (const attr of rootEl.attributes || []) {
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
for (const part of attr.value) {
if (part.type === 'Text') classes.push(...part.data.split(/\s+/).filter(Boolean));
}
}
}
return { tag: rootEl.name, classes };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Analyze a markup block and produce the prop-substituted scaffold markup and
* the v2 prop contract. Returns { ok: false, reason } when the block needs
* source-preview mode (parse failure or unsupported construct).
*/
export function analyzeSvelteMarkup(markup, parse) {
const source = String(markup || '');
let ast;
try {
ast = parse(source, { modern: true });
} catch (err) {
return { ok: false, reason: `svelte parse failed: ${err.message}` };
}
if (ast.instance || ast.module) {
return { ok: false, reason: 'selected block contains a script tag' };
}
const analysis = new Analysis(source);
analyzeFragment(ast.fragment, analysis, []);
if (analysis.unsupported) {
return { ok: false, reason: analysis.unsupported };
}
for (const entry of analysis.contract) {
if (entry.kind === 'collection' && entry.item?.nestedUnsupported) {
return { ok: false, reason: 'nested per-item each blocks require source-preview mode' };
}
}
const markupWithProps = applyReplacements(source, analysis.replacements);
return {
ok: true,
markupWithProps,
contract: analysis.contract.map((entry) => ({
prop: entry.prop,
expr: entry.expr,
kind: entry.kind,
// Kept for backward compatibility with v1 consumers (fake e2e agent,
// text-only restore paths).
placeholder: `{${entry.expr}}`,
...(entry.item ? { item: entry.item } : {}),
...(entry.probe ? { probe: entry.probe } : {}),
})),
};
}
function applyReplacements(source, replacements) {
const sorted = [...replacements].sort((a, b) => b.start - a.start);
let out = source;
for (const { start, end, prop } of sorted) {
out = out.slice(0, start) + prop + out.slice(end);
}
return out;
}
/**
* Restore a variant's markup back to route-source form: every free
* identifier that matches a contract prop is replaced by its original
* expression. AST-based so `{#each stages as stage}` restores to
* `{#each data.stages as stage}` even though the prop appears without braces.
*/
export function restoreSvelteMarkup(markup, contract, parse) {
const source = String(markup || '');
const byProp = new Map();
for (const entry of contract || []) byProp.set(entry.prop, entry.expr);
if (byProp.size === 0) return { ok: true, markup: source };
let ast;
try {
ast = parse(source, { modern: true });
} catch (err) {
return { ok: false, reason: `variant parse failed: ${err.message}` };
}
const replacements = [];
const visitExpr = (expression, scopes) => {
if (!expression) return;
collectFreeIdentifierRanges(expression, scopes, (name, start, end) => {
const original = byProp.get(name);
if (original != null && original !== name) replacements.push({ start, end, prop: original });
});
};
const walk = (fragment, scopes) => {
const fragmentScope = new Set();
const nextScopes = [...scopes, fragmentScope];
for (const node of fragment?.nodes || []) {
if (node.type === 'ConstTag' && node.declaration) {
for (const decl of node.declaration.declarations || []) collectPatternNames(decl.id, fragmentScope);
}
}
for (const node of fragment?.nodes || []) {
switch (node?.type) {
case 'ExpressionTag':
case 'HtmlTag':
visitExpr(node.expression, nextScopes);
break;
case 'ConstTag':
for (const decl of node.declaration?.declarations || []) visitExpr(decl.init, nextScopes);
break;
case 'EachBlock': {
visitExpr(node.expression, nextScopes);
if (node.key) visitExpr(node.key, nextScopes);
const bound = new Set();
if (node.context) collectPatternNames(node.context, bound);
if (node.index) bound.add(node.index);
walk(node.body, [...nextScopes, bound]);
if (node.fallback) walk(node.fallback, nextScopes);
break;
}
case 'IfBlock':
visitExpr(node.test, nextScopes);
walk(node.consequent, nextScopes);
if (node.alternate) walk(node.alternate, nextScopes);
break;
case 'KeyBlock':
visitExpr(node.expression, nextScopes);
walk(node.fragment, nextScopes);
break;
case 'SnippetBlock': {
const bound = new Set();
for (const param of node.parameters || []) collectPatternNames(param, bound);
walk(node.body, [...nextScopes, bound]);
break;
}
default: {
for (const attr of node?.attributes || []) {
if (attr.type === 'Attribute' && Array.isArray(attr.value)) {
for (const part of attr.value) {
if (part?.type === 'ExpressionTag') visitExpr(part.expression, nextScopes);
}
} else if (attr.expression) {
visitExpr(attr.expression, nextScopes);
}
}
if (node?.fragment) walk(node.fragment, nextScopes);
}
}
}
};
walk(ast.fragment, []);
return { ok: true, markup: applyReplacements(source, replacements) };
}
/**
* Report [name, start, end] for every free root identifier READ in an
* expression (skips member properties, object keys, shadowed names).
*/
function collectFreeIdentifierRanges(node, scopes, emit) {
const visit = (n, localBound) => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) { for (const item of n) visit(item, localBound); return; }
switch (n.type) {
case 'Identifier': {
const bound = localBound.has(n.name) || scopes.some((s) => s.has(n.name));
if (!bound) emit(n.name, n.start, n.end);
return;
}
case 'MemberExpression':
visit(n.object, localBound);
if (n.computed) visit(n.property, localBound);
return;
case 'Property':
if (n.computed) visit(n.key, localBound);
visit(n.value, localBound);
return;
case 'ArrowFunctionExpression':
case 'FunctionExpression': {
const inner = new Set(localBound);
for (const param of n.params || []) collectPatternNames(param, inner);
visit(n.body, inner);
return;
}
default:
for (const key of Object.keys(n)) {
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
visit(n[key], localBound);
}
}
};
visit(node, new Set());
}
/**
* Build the preview component's script block from a v2 contract, with
* defaults that keep an unhydrated mount rendering instead of crashing.
*/
export function buildPropsScriptV2(contract) {
if (!contract || contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const defaults = {
text: "''",
raw: "''",
condition: 'false',
collection: '[]',
handler: '() => {}',
};
const types = {
text: 'string',
raw: 'string',
condition: 'boolean',
collection: 'Array<Record<string, unknown>>',
handler: '() => void',
};
const names = contract
.map((c) => `${c.prop} = ${defaults[c.kind] ?? "''"}`)
.join(', ');
const typeFields = contract
.map((c) => ` ${c.prop}?: ${types[c.kind] ?? 'string'};`)
.join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
+415 -71
View File
@@ -10,9 +10,38 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
import {
analyzeSvelteMarkup,
buildPropsScriptV2,
loadSvelteCompiler,
restoreSvelteMarkup,
} from './svelte-ast.mjs';
import {
bakeParamValues,
collectAllSelectors,
collectUnusedSelectors,
normalizeSelector,
parseStylesheet,
pruneUnusedSelectors,
reconcileCss,
serializeNodes,
splitSelectorList,
} from './accept-css.mjs';
import { verifyAcceptedSource } from './accept-verify.mjs';
// Preview modules stay under node_modules on purpose: SvelteKit restricts
// vite's server.fs.allow to src/lib, src/routes, .svelte-kit, and
// node_modules, so an .impeccable/ tree under the app root 403s (verified
// against a real SvelteKit dev server). Staleness from node_modules being
// unwatched is solved by REVISIONED module paths instead: every publish
// snapshots the variant files into a fresh r<N>/ directory and the browser
// imports from there, so a republished fix can never be pinned by a
// transform cache keyed on the old path.
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
// A short-lived interim location; swept so no project keeps a stray tree.
export const LEGACY_SVELTE_COMPONENT_ROOT = '.impeccable/live/previews';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const SVELTE_PROBE_FILE = `${SVELTE_COMPONENT_ROOT}/__probe.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
@@ -32,9 +61,18 @@ export function manifestPathForSession(id, cwd = process.cwd()) {
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
if (!fs.existsSync(file)) {
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
}
// Attach-time probe: the browser imports this through the dev server before
// the first mount. A 404 here means the resolved app root and the dev
// server's root disagree, and the session fails with a named error instead
// of a silent fall-back to the picker at first variant.
const probe = path.join(cwd, SVELTE_PROBE_FILE);
if (!fs.existsSync(probe)) {
fs.writeFileSync(probe, `export const impeccableLivePreviewProbe = true;\n`, 'utf-8');
}
return file;
}
@@ -136,6 +174,14 @@ function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
/**
* Scaffold a component-preview session. The scaffold is AST-based: the app's
* own svelte compiler parses the selected markup, control-flow blocks are
* preserved (an each collection crosses the prop contract as ONE structured
* prop, its loop body verbatim), and constructs a detached preview cannot
* support return `{ fallback: 'source-preview', reason }` so the caller keeps
* the markup inside the route file instead of shipping a wrong preview.
*/
export function scaffoldSvelteComponentSession({
id,
count,
@@ -145,17 +191,31 @@ export function scaffoldSvelteComponentSession({
originalLines,
cwd = process.cwd(),
}) {
const originalMarkup = originalLines.join('\n');
const compiler = loadSvelteCompiler(cwd);
if (!compiler) {
return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' };
}
const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse);
if (!analysis.ok) {
return { fallback: 'source-preview', reason: analysis.reason };
}
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const contract = analysis.contract;
const seededCss = extractMatchingSourceCss(
safeReadSource(path.resolve(cwd, sourceFile)),
originalMarkup,
);
const manifest = {
id,
previewMode: 'svelte-component',
contractVersion: 2,
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
@@ -163,7 +223,14 @@ export function scaffoldSvelteComponentSession({
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
// Absolute paths let the browser fall back to /@fs/ imports when the dev
// server's base or root makes root-relative URLs miss, and probe whether
// the preview tree is reachable at all before blaming a variant.
componentDirAbs: dir.split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
probeModule: `/${SVELTE_PROBE_FILE}`,
probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
@@ -171,7 +238,7 @@ export function scaffoldSvelteComponentSession({
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8');
}
}
@@ -183,6 +250,59 @@ export function scaffoldSvelteComponentSession({
};
}
function safeReadSource(filePath) {
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; }
}
/**
* Seed variant stubs with the source component's rules that already style the
* selected markup, so variants start from the real cascade (a detached
* preview inherits none of the route's compile-scoped CSS) instead of
* reimplementing it blind.
*/
export function extractMatchingSourceCss(routeSource, originalMarkup) {
const styleMatch = String(routeSource || '').match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i);
if (!styleMatch) return '';
const classNames = new Set();
const classRe = /class\s*=\s*(["'])(.*?)\1/g;
let m;
while ((m = classRe.exec(originalMarkup))) {
for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls);
}
const tagRe = /<([a-z][a-z0-9-]*)/gi;
const tags = new Set();
while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase());
if (classNames.size === 0 && tags.size === 0) return '';
const selectorMatches = (prelude) => splitSelectorList(prelude).some((selector) => {
for (const cls of classNames) if (selector.includes(`.${cls}`)) return true;
return false;
});
const pick = (nodes) => {
const kept = [];
for (const node of nodes) {
if (node.type === 'rule' && selectorMatches(node.prelude)) kept.push(node);
else if (node.type === 'at' && node.children) {
const children = pick(node.children);
if (children.length) kept.push({ ...node, children });
}
}
return kept;
};
return serializeNodes(pick(parseStylesheet(styleMatch[1])));
}
function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} (${c.kind}) <- {${c.expr}}`).join(', ')} -->\n`
: '';
const css = seededCss
? `\n<style>\n /* Variant ${variantNum}: seeded from the route's current rules; restyle freely */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n</style>\n`
: `\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`;
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
@@ -213,7 +333,11 @@ export function scaffoldSvelteComponentInsertSession({
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
componentDirAbs: dir.split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
probeModule: `/${SVELTE_PROBE_FILE}`,
probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
@@ -238,16 +362,24 @@ export function findSvelteComponentManifest(id, cwd = process.cwd()) {
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
// Legacy location: a session scaffolded by an older version can still be
// accepted after an upgrade.
const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json');
if (fs.existsSync(legacyDirect)) {
return readManifest(legacyDirect);
}
for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
const root = path.join(cwd, rootRel);
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
}
return null;
}
@@ -451,35 +583,6 @@ function rewriteParamSelectors(selector, paramValues) {
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
@@ -527,10 +630,24 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const compiler = loadSvelteCompiler(cwd);
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
// Restore props back to route expressions. Contract v2 restores through the
// AST so a prop used without braces (each headers, attribute positions)
// still maps back to its original expression; v1 falls back to the textual
// placeholder swap.
let restoredText;
if (Number(manifest.contractVersion) === 2 && compiler) {
const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse);
if (!restored.ok) {
return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase };
}
restoredText = restored.markup;
} else {
restoredText = substitutePropsWithExprs(mergedMarkup, contract);
}
const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
@@ -541,10 +658,7 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent);
let newLines = [
...sourceLines.slice(0, start),
@@ -552,25 +666,145 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
// Selectors that were already unused before this accept are the user's
// pre-existing code; the pruning pass must not touch them.
const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set();
// Bake params (declared kinds from params.json drive branch pruning), then
// MERGE into the component's existing style block: matching selectors are
// replaced, new ones appended. Appending alone is how superseded rules used
// to survive their own replacement.
const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
let variantCss = cssLines.join('\n');
if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
// Defensive: strip preview-wrapper selectors that authoring rules forbid
// on this path but an off-spec agent may still emit.
variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
}
const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
const cssStats = { replaced: 0, appended: 0, pruned: [] };
if (bakedCss.trim()) {
const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
newLines = merged.text.split('\n');
cssStats.replaced = merged.replaced;
cssStats.appended = merged.appended;
}
let finalText = newLines.join('\n');
if (compiler) {
const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused });
finalText = pruned.source;
cssStats.pruned = pruned.removed;
}
// Postcondition: no selector from the user's pre-accept CSS may vanish
// unless the compiler-driven prune deliberately removed it. This turns any
// parser or reconciler defect into a loud refusal instead of silent damage
// to a hand-written style block.
const lostSelectors = findLostSelectors(sourceContent, finalText, cssStats.pruned);
if (lostSelectors.length > 0) {
return {
handled: false,
error: 'CSS reconciliation would lose selectors from the existing style block: '
+ lostSelectors.join(', ')
+ '. Source not modified; accept the variant manually.',
mode: 'error',
...resultBase,
};
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
fs.writeFileSync(sourceFile, finalText, 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
const verify = verifyAcceptedSource(finalText);
return {
handled: true,
css: cssStats,
verify,
...resultBase,
};
}
/** Re-indent a block onto `indent` while preserving its internal structure. */
export function reindentPreservingStructure(lines, indent) {
const nonEmpty = lines.filter((line) => line.trim() !== '');
if (nonEmpty.length === 0) return lines.map(() => '');
const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length));
return lines.map((line) => {
if (line.trim() === '') return '';
const current = (line.match(/^\s*/) || [''])[0].length;
return indent + line.slice(Math.min(minIndent, current));
});
}
function styleBlockText(sourceText) {
const match = String(sourceText || '').match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i);
return match ? match[1] : '';
}
export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) {
const before = collectAllSelectors(styleBlockText(beforeSource));
const after = collectAllSelectors(styleBlockText(afterSource));
const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s)));
const lost = [];
for (const selector of before) {
if (!after.has(selector) && !pruned.has(selector)) lost.push(selector);
}
return lost;
}
function readDeclaredParams(manifest, variantNum, cwd) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8'));
const list = raw?.[String(variantNum)];
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
/**
* Merge CSS into a svelte component's top-level style block (created when
* absent), replacing rules whose selectors match and appending the rest.
*/
export function mergeCssIntoSvelteSource(sourceText, incomingCss) {
const text = String(sourceText || '');
const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
let lastMatch = null;
let m;
while ((m = styleRe.exec(text))) lastMatch = m;
if (!lastMatch) {
const { css, replaced, appended } = reconcileCss('', incomingCss);
return {
text: `${text.replace(/\s*$/, '')}\n\n<style>\n${indentCssBlock(css)}\n</style>\n`,
replaced,
appended,
};
}
const inner = lastMatch[1];
const { css, replaced, appended } = reconcileCss(inner, incomingCss);
const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1);
const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n</style>`;
return {
text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length),
replaced,
appended,
};
}
function indentCssBlock(css) {
return String(css || '')
.split('\n')
.map((line) => (line.trim() === '' ? '' : ' ' + line))
.join('\n');
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
@@ -601,10 +835,7 @@ function inlineSvelteComponentInsertAccept({
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent);
let newLines = [
...sourceLines.slice(0, insertIndex),
@@ -612,10 +843,15 @@ function inlineSvelteComponentInsertAccept({
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
let variantCss = cssLines.join('\n');
if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
}
const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
if (bakedCss.trim()) {
const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
newLines = merged.text.split('\n');
}
try {
@@ -625,8 +861,10 @@ function inlineSvelteComponentInsertAccept({
}
removeSvelteComponentSession(manifest.id, cwd);
const verify = verifyAcceptedSource(newLines.join('\n'));
return {
handled: true,
verify,
...resultBase,
};
}
@@ -729,18 +967,124 @@ export function removeSvelteComponentSession(id, cwd = process.cwd()) {
} catch { /* non-fatal */ }
}
/**
* Snapshot the agent-authored variant files into a fresh revision directory
* and stamp the manifest. Called by the server on every publish (`done`
* reply) for a component session; the browser imports from the revision dir,
* so the dev server can never serve a stale compile of a republished file.
*/
export function bumpSvelteComponentPreviewRevision(id, cwd = process.cwd()) {
const manifest = findSvelteComponentManifest(id, cwd);
if (!manifest || !manifest.manifestPath) return null;
const sessionDir = path.dirname(manifest.manifestPath);
const revision = Number(manifest.revision || 0) + 1;
const revDirName = `r${revision}`;
const revDir = path.join(sessionDir, revDirName);
try {
fs.mkdirSync(revDir, { recursive: true });
let entries = [];
try { entries = fs.readdirSync(sessionDir, { withFileTypes: true }); } catch { /* empty */ }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === 'manifest.json') continue;
fs.copyFileSync(path.join(sessionDir, entry.name), path.join(revDir, entry.name));
}
// Previous revision dirs are dead the moment a new one exists.
for (const entry of entries) {
if (entry.isDirectory() && /^r\d+$/.test(entry.name) && entry.name !== revDirName) {
try { fs.rmSync(path.join(sessionDir, entry.name), { recursive: true, force: true }); } catch { /* non-fatal */ }
}
}
const relSessionDir = path.relative(cwd, sessionDir).split(path.sep).join('/');
const updated = {
...manifest,
revision,
revisionDir: `${relSessionDir}/${revDirName}`,
revisionDirAbs: revDir.split(path.sep).join('/'),
};
delete updated.manifestPath;
fs.writeFileSync(manifest.manifestPath, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
return { revision, revisionDir: updated.revisionDir };
} catch {
return null;
}
}
/**
* Stop-path sweep. The whole `node_modules/.impeccable-live` tree is
* impeccable-owned and gitignored, so once no session should survive there is
* nothing left worth keeping: the per-session dirs, the generated
* `__runtime.js`, and the parent directory all go. The old per-entry loop
* skipped `__*` entries and the parent, which left the runtime shim and an
* empty directory in every project that ever ran live mode once.
*/
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
const root = path.join(cwd, rootRel);
if (!fs.existsSync(root)) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
fs.rmSync(root, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
/**
* Boot-path sweep. A restart must not delete the tree wholesale: sessions
* recorded in the session store may still be mid-generation. Remove only the
* session dirs whose id has no active snapshot, then drop `__runtime.js` and
* the parent directory when nothing is left to serve.
*
* @param {Iterable<string>} activeIds session ids that must be preserved
* @returns {{ removed: string[], removedRoot: boolean, kept: string[] }}
*/
export function sweepInactiveSvelteComponentSessions(activeIds = [], cwd = process.cwd()) {
const result = { removed: [], removedRoot: false, kept: [] };
const active = new Set();
for (const id of activeIds || []) {
if (typeof id === 'string' && id) active.add(id);
}
for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
const root = path.join(cwd, rootRel);
if (!fs.existsSync(root)) continue;
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
let keptHere = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
if (active.has(entry.name)) {
result.kept.push(entry.name);
keptHere++;
continue;
}
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
result.removed.push(entry.name);
} catch {
// Could not remove it, so it still occupies the tree; treat it as kept
// so the parent directory is not torn out from under it.
result.kept.push(entry.name);
keptHere++;
}
}
if (keptHere === 0) {
try {
fs.rmSync(root, { recursive: true, force: true });
result.removedRoot = true;
} catch { /* non-fatal */ }
}
}
return result;
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
+1 -1
View File
@@ -19,7 +19,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
+135
View File
@@ -34,3 +34,138 @@ export const LIVE_COMMANDS = [
// Action values accepted by the live event protocol, in palette order.
export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
/*
* ---------------------------------------------------------------------------
* Protocol vocabulary
* ---------------------------------------------------------------------------
* The enums below are the wire contract between the browser overlay, the live
* helper server, and the durable session journal. They live here rather than in
* the modules that use them so a value cannot be added to the validator without
* the store and the server seeing it too.
*
* live-browser.js still cannot import this file (it is served raw and injected
* as an IIFE), so its local phase table repeats the agent-phase names. Anything
* the server can broadcast must appear in AGENT_PHASES here first.
*/
/**
* Phases the live server broadcasts as `agent_phase`, in lifecycle order.
* Every one of these is emitted by `recordAgentPhase()` in live-server.mjs;
* the validator rejects anything else, so a typo in a phase name fails loudly
* instead of quietly ranking as an unknown phase in the browser's progress bar.
*/
export const AGENT_PHASES = Object.freeze([
'picked_up',
'scaffolding',
'source_ready',
'scaffold_fallback',
'generation_ready',
'first_reviewable',
'second_reviewable',
'all_variants_ready',
]);
/** Event types the helper server accepts from the browser over POST /events. */
export const CLIENT_EVENT_TYPES = Object.freeze([
'generate',
'accept',
'discard',
'checkpoint',
'agent_phase',
'variant_mounted',
'variant_mount_failed',
'exit',
'prefetch',
'manual_edits',
'steer',
'carbonize_cleanup',
]);
/**
* Event types the durable journal applies. A superset of CLIENT_EVENT_TYPES:
* the agent-side helpers (live-poll, live-complete) and the server itself
* append the rest. An event type missing here lands as `unknown_event_type`
* in the snapshot diagnostics.
*/
export const JOURNAL_EVENT_TYPES = Object.freeze([
'generate',
'variant_plan',
'detector_waivers',
'agent_phase',
'variants_ready',
'agent_done',
'variant_mounted',
'variant_mount_failed',
'checkpoint',
'accept',
'accept_intent',
'manual_edit_apply',
'steer',
'steer_done',
'carbonize_cleanup',
'discard',
'discarded',
'complete',
'agent_error',
]);
/** Phases the session store assigns to a snapshot. */
export const SESSION_PHASES = Object.freeze([
'new',
'generate_requested',
'variants_ready',
'carbonize_required',
'carbonize_cleanup_requested',
'manual_edit_apply_requested',
'steer_requested',
'steer_done',
'accept_requested',
'discard_requested',
'discarded',
'completed',
'agent_error',
]);
/** Phases that retire a session from the active list. */
export const COMPLETED_SESSION_PHASES = Object.freeze(['completed', 'discarded']);
/**
* Phases after which a late generation write is a ghost from a canceled cycle.
* The store journals such an event as a diagnostic instead of applying it.
*/
export const GENERATION_FENCED_SESSION_PHASES = Object.freeze([
'accept_requested',
'discard_requested',
'carbonize_required',
'completed',
'discarded',
]);
/**
* `reason` values carried on checkpoint events. Not validated (an unknown
* reason is journaled, never rejected) because the reason is diagnostic
* breadcrumb, not control flow. Two exceptions drive behavior and are split
* out below.
*/
export const CHECKPOINT_REASONS = Object.freeze([
'generate_started',
'variants_progress',
'variants_ready',
'browser_resumed',
'browser_resumed_svelte_component',
'param_changed',
'variant_anchor_missing',
'component_preview_anchor_missing',
'steer_input_focused',
'steer_submitted',
'steer_send_failed',
'steer_done',
'steer_error',
]);
/** Checkpoint reasons the server reads as variant-publication progress. */
export const VARIANT_PROGRESS_CHECKPOINT_REASONS = Object.freeze([
'variants_progress',
'variants_ready',
]);