/**
* Nuxt/Vue live-mode component previews.
*
* Generation writes real Vue SFCs into a generated app-local module tree.
* Nuxt/Vite compiles those modules without touching the active route; Accept
* is the only operation that writes the user's .vue source.
*/
import fs from 'node:fs';
import path from 'node:path';
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectNuxtVueProject(cwd = process.cwd()) {
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && NUXT_CONFIG_RE.test(entry.name))?.name;
if (!configFile) return null;
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
const srcDirMatch = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
let appDir = fs.existsSync(path.join(cwd, 'app')) ? 'app' : '';
if (srcDirMatch) {
const candidate = path.posix.normalize(srcDirMatch[2].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
if (candidate !== '..' && !candidate.startsWith('../') && !path.isAbsolute(candidate)) {
appDir = candidate === '.' ? '' : candidate;
}
}
const componentRoot = [appDir, '.impeccable-live'].filter(Boolean).join('/');
return { configFile, appDir, componentRoot };
}
export function shouldUseVueComponentInjection(filePath, cwd = process.cwd()) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_VUE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.vue' && !!detectNuxtVueProject(cwd);
}
export function vueComponentSessionDir(id, cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) throw new Error('Nuxt project not found');
return path.join(cwd, project.componentRoot, id);
}
export function vueManifestPathForSession(id, cwd = process.cwd()) {
return path.join(vueComponentSessionDir(id, cwd), 'manifest.json');
}
function ensureVueRuntime(cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) throw new Error('Nuxt project not found');
const rel = `${project.componentRoot}/__runtime.js`;
const file = path.join(cwd, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
const source = `import { createApp } from 'vue';\n\nexport function mount(Component, options = {}) {\n const app = createApp(Component, options.props || {});\n app.mount(options.target);\n return app;\n}\n\nexport async function unmount(app) {\n app?.unmount?.();\n}\n`;
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) fs.writeFileSync(file, source, 'utf-8');
return nuxtViteFsModulePath(file, cwd);
}
/**
* Nuxt mounts Vite beneath its build-assets base (normally `/_nuxt/`).
* Keep the manifest path base-agnostic and let the browser prepend the
* runtime's actual buildAssetsDir. A page-route URL such as
* `/app/.impeccable-live/x.vue` is handled by Nitro and returns HTML.
*/
export function nuxtViteFsModulePath(file, cwd = process.cwd()) {
const absolute = path.resolve(cwd, file).split(path.sep).join('/');
const relative = path.relative(cwd, absolute);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Nuxt live module must stay inside the project root');
}
return '/@fs/' + absolute.replace(/^\/+/, '');
}
export function extractVueExpressions(markup) {
const out = [];
const seen = new Set();
const re = /\{\{\s*([^{}]+?)\s*\}\}/g;
let match;
while ((match = re.exec(String(markup || '')))) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
out.push({ expr, token: match[0] });
}
return out;
}
function buildVuePropContract(expressions) {
return expressions.map(({ expr, token }, index) => ({
prop: derivePropName(expr, index),
expr,
placeholder: token,
// DOMParser sees Vue interpolation `{{ user.name }}` as text containing
// the inner `{ user.name }` token; preserve its whitespace for the
// browser's source-text → rendered-text map.
previewToken: token.slice(1, -1),
}));
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:^|\.|\[)([A-Za-z_$][\w$]*)\s*\]?$/);
return tail?.[1] || `prop${index}`;
}
function substituteVueExpressions(markup, contract) {
let out = String(markup || '');
for (const entry of contract) out = out.split(entry.placeholder).join(`{{ ${entry.prop} }}`);
return out;
}
function buildVueVariantStub(variant, markup, contract) {
const props = contract.length > 0
? `\n\n`
: '';
return `${props}\n${markup.trim()}\n\n\n\n`;
}
export function scaffoldVueComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
const runtimeModule = ensureVueRuntime(cwd);
const dir = vueComponentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const propContract = buildVuePropContract(extractVueExpressions(originalMarkup));
const previewMarkup = substituteVueExpressions(originalMarkup, propContract);
const manifest = {
id,
previewMode: 'vue-component',
framework: 'vue',
componentExtension: 'vue',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
componentModuleBase: nuxtViteFsModulePath(dir, cwd),
runtimeModule,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let variant = 1; variant <= count; variant++) {
const file = path.join(dir, `v${variant}.vue`);
if (!fs.existsSync(file)) fs.writeFileSync(file, buildVueVariantStub(variant, previewMarkup, propContract), 'utf-8');
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract,
};
}
export function findVueComponentManifest(id, cwd = process.cwd()) {
let direct;
try { direct = vueManifestPathForSession(id, cwd); } catch { return null; }
if (!fs.existsSync(direct)) return null;
try {
const manifest = JSON.parse(fs.readFileSync(direct, 'utf-8'));
return manifest?.id === id && manifest?.previewMode === 'vue-component'
? { ...manifest, manifestPath: direct }
: null;
} catch {
return null;
}
}
function parseVueSfc(source) {
const text = String(source || '');
const template = text.match(/]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
const style = text.match(/'];
return [...lines.slice(0, close), ...block, ...lines.slice(close)];
}
function mergeOriginalVueAttrs(markup, originalMarkup) {
const variant = matchOpeningTag(markup);
const original = matchOpeningTag(originalMarkup);
if (!variant || !original || variant.tag.toLowerCase() !== original.tag.toLowerCase()) return markup;
const variantAttrs = parseStaticAttrs(variant.attrs);
const originalAttrs = parseStaticAttrs(original.attrs);
const additions = [];
let attrs = variant.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const classes = [
...variantClass.value.split(/\s+/),
...originalClass.value.split(/\s+/),
].filter(Boolean);
const replacement = `class=${variantClass.quote}${[...new Set(classes)].join(' ')}${variantClass.quote}`;
attrs = attrs.slice(0, variantClass.start) + replacement + attrs.slice(variantClass.end);
} else if (originalClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class' || variantAttrs.has(name)) continue;
additions.push(attr.raw);
}
const open = `<${variant.tag}${attrs}${additions.map((attr) => ' ' + attr.trim()).join('')}${variant.close}`;
return markup.slice(0, variant.index) + open + markup.slice(variant.index + variant.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
return match ? {
raw: match[0],
tag: match[1],
attrs: match[2] || '',
close: match[3],
index: match.index || 0,
} : null;
}
function parseStaticAttrs(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)\s*=\s*(["'])(.*?)\2/g;
let match;
while ((match = re.exec(attrs))) {
out.set(match[1], {
raw: match[0],
value: match[3],
quote: match[2],
start: match.index,
end: match.index + match[0].length,
});
}
return out;
}
export function removeVueComponentSession(id, cwd = process.cwd()) {
try { fs.rmSync(vueComponentSessionDir(id, cwd), { recursive: true, force: true }); } catch { /* best effort */ }
}
/**
* Make an accepted/discarded session undiscoverable immediately while keeping
* Vue modules that Vite has in its graph alive until Live shuts down. Deleting
* an imported SFC mid-session makes Nuxt's HMR client attempt to reload a
* missing module and emit a console error. The generated directory remains
* ignored and removeAllVueComponentSessions removes it on server shutdown.
*/
export function retireVueComponentSession(id, cwd = process.cwd()) {
let dir;
try { dir = vueComponentSessionDir(id, cwd); } catch { return; }
for (const name of ['manifest.json', 'params.json']) {
try { fs.rmSync(path.join(dir, name), { force: true }); } catch { /* best effort */ }
}
}
export function removeAllVueComponentSessions(cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) return;
const root = path.join(cwd, project.componentRoot);
if (!fs.existsSync(root)) return;
fs.rmSync(root, { recursive: true, force: true });
}
export function buildVueComponentCssAuthoring(count) {
return {
mode: 'vue-component',
count,
requirements: [
'Write each variant as a real Vue SFC in componentDir/vN.vue.',
'Keep one root element inside and put variant CSS in