/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '';
export const SVELTE_LAYOUT_MARKER_CLOSE = '';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
// Matches the import at ANY revision (or none). [ \t]* bounds only, never
// \s*: a greedy \s* after the statement swallowed the next line's
// indentation on removal, leaving a formatting scar in user layouts.
const SVELTE_ROOT_IMPORT_LINE_RE = /^[ \t]*import ImpeccableLiveRoot from '\$lib\/impeccable\/ImpeccableLiveRoot\.svelte(?:\?[^']*)?';[ \t]*\r?\n?/gm;
/**
* The import specifier carries a token-derived revision query. The adapter
* component embeds the helper token, and Vite (client AND SSR) can keep
* serving a stale compiled module after the file is rewritten on a helper
* restart; the browser then requests /live.js with a rotated-out token and
* gets a 401 with no picker. A changed specifier is a different module id,
* which no cache survives.
*/
export function svelteRootImportLine(rev) {
if (!rev) return SVELTE_ROOT_IMPORT;
return "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte?impeccable-live=" + rev + "';";
}
export function svelteAdapterRev(token) {
if (!token) return null;
return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 8);
}
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, token, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port), token);
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before, { rev: svelteAdapterRev(token) });
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content, { rev = null } = {}) {
let out = String(content || '');
const importLine = svelteRootImportLine(rev);
if (!out.includes(importLine)) {
// An import at an older revision is replaced in place, keeping its
// indentation; only a layout with no impeccable import gets an insert.
let replaced = false;
out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, (line) => {
if (replaced) return '';
replaced = true;
const indent = (line.match(/^[ \t]*/) || [''])[0];
return indent + importLine + '\n';
});
if (!replaced) {
const scriptMatch = out.match(/\n\n` + out;
}
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(//);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, '');
out = out.replace(/
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}