mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
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>
162 lines
5.1 KiB
JavaScript
162 lines
5.1 KiB
JavaScript
/**
|
|
* 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',
|
|
},
|
|
};
|