mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
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:
co-authored by
Claude Code
parent
839dd10079
commit
17dabf4b7e
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+751
-96
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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='));
|
||||
|
||||
@@ -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
@@ -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, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
}));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 */}';
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user