| 1 | /** |
| 2 | * SvelteKit live-mode adapter. |
| 3 | * |
| 4 | * SvelteKit must not be patched through src/app.html. That file is a document |
| 5 | * template, not framework-owned component chrome. The adapter keeps SvelteKit |
| 6 | * work limited to mounting a dev-only shadow host from +layout.svelte; the |
| 7 | * actual live UI remains the shared plain-DOM browser chrome. |
| 8 | */ |
| 9 | |
| 10 | import fs from 'node:fs'; |
| 11 | import path from 'node:path'; |
| 12 | |
| 13 | export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte'; |
| 14 | export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->'; |
| 15 | export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->'; |
| 16 | export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';"; |
| 17 | |
| 18 | export function detectSvelteKitProject(cwd = process.cwd(), config = null) { |
| 19 | const appHtml = findSvelteKitAppHtml(cwd, config); |
| 20 | if (!appHtml) return null; |
| 21 | const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%') |
| 22 | && fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%'); |
| 23 | if (!hasTemplateMarkers) return null; |
| 24 | |
| 25 | const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js')) |
| 26 | || fs.existsSync(path.join(cwd, 'svelte.config.mjs')) |
| 27 | || fs.existsSync(path.join(cwd, 'svelte.config.cjs')) |
| 28 | || fs.existsSync(path.join(cwd, 'svelte.config.ts')); |
| 29 | const hasKitPackage = packageHasSvelteKit(cwd); |
| 30 | if (!hasSvelteConfig && !hasKitPackage) return null; |
| 31 | |
| 32 | return { |
| 33 | appHtml, |
| 34 | layoutFile: findSvelteKitLayout(cwd), |
| 35 | rootComponent: SVELTE_LIVE_ROOT_COMPONENT, |
| 36 | }; |
| 37 | } |
| 38 | |
| 39 | export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) { |
| 40 | if (!Number.isFinite(Number(port))) { |
| 41 | throw new Error('SvelteKit live adapter requires a numeric port'); |
| 42 | } |
| 43 | const detected = detectSvelteKitProject(cwd, config); |
| 44 | if (!detected) return null; |
| 45 | |
| 46 | ensureSvelteLiveRootComponent(cwd, Number(port)); |
| 47 | |
| 48 | const layoutRel = detected.layoutFile; |
| 49 | const layoutAbs = path.join(cwd, layoutRel); |
| 50 | fs.mkdirSync(path.dirname(layoutAbs), { recursive: true }); |
| 51 | const layoutExisted = fs.existsSync(layoutAbs); |
| 52 | const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout(); |
| 53 | const after = patchSvelteLayout(before); |
| 54 | fs.writeFileSync(layoutAbs, after, 'utf-8'); |
| 55 | |
| 56 | return { |
| 57 | file: layoutRel, |
| 58 | adapter: 'sveltekit', |
| 59 | inserted: after !== before || !layoutExisted, |
| 60 | appHtmlUntouched: true, |
| 61 | rootComponent: SVELTE_LIVE_ROOT_COMPONENT, |
| 62 | }; |
| 63 | } |
| 64 | |
| 65 | export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) { |
| 66 | const detected = detectSvelteKitProject(cwd, config); |
| 67 | if (!detected) return null; |
| 68 | |
| 69 | const layoutAbs = path.join(cwd, detected.layoutFile); |
| 70 | let removed = false; |
| 71 | if (fs.existsSync(layoutAbs)) { |
| 72 | const before = fs.readFileSync(layoutAbs, 'utf-8'); |
| 73 | const after = unpatchSvelteLayout(before); |
| 74 | if (after !== before) { |
| 75 | fs.writeFileSync(layoutAbs, after, 'utf-8'); |
| 76 | removed = true; |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT); |
| 81 | if (fs.existsSync(rootAbs)) { |
| 82 | fs.rmSync(rootAbs, { force: true }); |
| 83 | removed = true; |
| 84 | } |
| 85 | |
| 86 | pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src')); |
| 87 | |
| 88 | return { |
| 89 | file: detected.layoutFile, |
| 90 | adapter: 'sveltekit', |
| 91 | removed, |
| 92 | appHtmlUntouched: true, |
| 93 | rootComponent: SVELTE_LIVE_ROOT_COMPONENT, |
| 94 | }; |
| 95 | } |
| 96 | |
| 97 | export function patchSvelteLayout(content) { |
| 98 | let out = String(content || ''); |
| 99 | if (!out.includes(SVELTE_ROOT_IMPORT)) { |
| 100 | const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i); |
| 101 | if (scriptMatch) { |
| 102 | const insertAt = scriptMatch.index + scriptMatch[0].length; |
| 103 | out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt); |
| 104 | } else { |
| 105 | out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out; |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) { |
| 110 | const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`; |
| 111 | const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/); |
| 112 | const slotMatch = out.match(/<slot\s*\/?>/); |
| 113 | const match = renderMatch || slotMatch; |
| 114 | if (match) { |
| 115 | out = out.slice(0, match.index) + block + out.slice(match.index); |
| 116 | } else { |
| 117 | out = out.replace(/\s*$/, '\n\n' + block); |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | return out; |
| 122 | } |
| 123 | |
| 124 | export function unpatchSvelteLayout(content) { |
| 125 | let out = String(content || ''); |
| 126 | const blockRe = new RegExp( |
| 127 | '([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN) |
| 128 | + '\\n<ImpeccableLiveRoot\\s*/>\\n' |
| 129 | + escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE) |
| 130 | + '\\n?', |
| 131 | 'g', |
| 132 | ); |
| 133 | out = out.replace(blockRe, '$1'); |
| 134 | out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), ''); |
| 135 | out = out.replace(/<script>\s*<\/script>\s*\n?/g, ''); |
| 136 | return out.replace(/\n{3,}/g, '\n\n'); |
| 137 | } |
| 138 | |
| 139 | export function ensureSvelteLiveRootComponent(cwd, port) { |
| 140 | const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT); |
| 141 | fs.mkdirSync(path.dirname(file), { recursive: true }); |
| 142 | fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8'); |
| 143 | return file; |
| 144 | } |
| 145 | |
| 146 | export function buildSvelteLiveRootComponent(port) { |
| 147 | return `<script> |
| 148 | import { onMount } from 'svelte'; |
| 149 | |
| 150 | const LIVE_URL = 'http://localhost:${Number(port)}/live.js'; |
| 151 | const HOST_ID = 'impeccable-live-root'; |
| 152 | |
| 153 | onMount(() => { |
| 154 | let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID); |
| 155 | if (!host) { |
| 156 | host = document.createElement('impeccable-live-root'); |
| 157 | host.id = HOST_ID; |
| 158 | document.body.appendChild(host); |
| 159 | } |
| 160 | |
| 161 | host.dataset.impeccableLiveAdapter = 'sveltekit'; |
| 162 | host.style.setProperty('all', 'initial', 'important'); |
| 163 | host.style.setProperty('display', 'block', 'important'); |
| 164 | host.style.setProperty('position', 'fixed', 'important'); |
| 165 | host.style.setProperty('top', '0', 'important'); |
| 166 | host.style.setProperty('left', '0', 'important'); |
| 167 | host.style.setProperty('width', '0', 'important'); |
| 168 | host.style.setProperty('height', '0', 'important'); |
| 169 | host.style.setProperty('overflow', 'visible', 'important'); |
| 170 | host.style.setProperty('z-index', '2147483000', 'important'); |
| 171 | host.style.setProperty('pointer-events', 'none', 'important'); |
| 172 | |
| 173 | const root = host.shadowRoot || host.attachShadow({ mode: 'open' }); |
| 174 | if (!root.querySelector('style[data-impeccable-live-reset]')) { |
| 175 | const reset = document.createElement('style'); |
| 176 | reset.dataset.impeccableLiveReset = 'true'; |
| 177 | reset.textContent = ':host, :host *, * { box-sizing: border-box; }'; |
| 178 | root.appendChild(reset); |
| 179 | } |
| 180 | |
| 181 | window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit'; |
| 182 | window.__IMPECCABLE_LIVE_UI_ROOT__ = root; |
| 183 | window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = { |
| 184 | adapter: 'sveltekit', |
| 185 | version: 1, |
| 186 | host, |
| 187 | root, |
| 188 | }; |
| 189 | |
| 190 | const script = document.createElement('script'); |
| 191 | script.src = LIVE_URL; |
| 192 | script.async = true; |
| 193 | script.dataset.impeccableLiveScript = 'true'; |
| 194 | document.head.appendChild(script); |
| 195 | |
| 196 | return () => { |
| 197 | script.remove(); |
| 198 | if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__; |
| 199 | if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__; |
| 200 | if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__; |
| 201 | }; |
| 202 | }); |
| 203 | </script> |
| 204 | `; |
| 205 | } |
| 206 | |
| 207 | function findSvelteKitAppHtml(cwd, config) { |
| 208 | const files = Array.isArray(config?.files) ? config.files : ['src/app.html']; |
| 209 | for (const rel of files) { |
| 210 | if (rel.includes('*')) continue; |
| 211 | const normalized = rel.split(path.sep).join('/'); |
| 212 | if (!normalized.endsWith('app.html')) continue; |
| 213 | const abs = path.join(cwd, normalized); |
| 214 | if (fs.existsSync(abs)) return normalized; |
| 215 | } |
| 216 | const fallback = 'src/app.html'; |
| 217 | return fs.existsSync(path.join(cwd, fallback)) ? fallback : null; |
| 218 | } |
| 219 | |
| 220 | function findSvelteKitLayout(cwd) { |
| 221 | const candidates = [ |
| 222 | 'src/routes/+layout.svelte', |
| 223 | 'src/routes/(app)/+layout.svelte', |
| 224 | ]; |
| 225 | for (const rel of candidates) { |
| 226 | if (fs.existsSync(path.join(cwd, rel))) return rel; |
| 227 | } |
| 228 | return 'src/routes/+layout.svelte'; |
| 229 | } |
| 230 | |
| 231 | function defaultSvelteLayout() { |
| 232 | return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`; |
| 233 | } |
| 234 | |
| 235 | function packageHasSvelteKit(cwd) { |
| 236 | const file = path.join(cwd, 'package.json'); |
| 237 | if (!fs.existsSync(file)) return false; |
| 238 | try { |
| 239 | const pkg = JSON.parse(fs.readFileSync(file, 'utf-8')); |
| 240 | const deps = { |
| 241 | ...(pkg.dependencies || {}), |
| 242 | ...(pkg.devDependencies || {}), |
| 243 | ...(pkg.peerDependencies || {}), |
| 244 | }; |
| 245 | return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte); |
| 246 | } catch { |
| 247 | return false; |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | function fileIncludes(file, text) { |
| 252 | try { |
| 253 | return fs.readFileSync(file, 'utf-8').includes(text); |
| 254 | } catch { |
| 255 | return false; |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | function pruneEmptyDir(dir, stopDir) { |
| 260 | let current = dir; |
| 261 | while (current.startsWith(stopDir) && current !== stopDir) { |
| 262 | try { |
| 263 | if (fs.readdirSync(current).length > 0) return; |
| 264 | fs.rmdirSync(current); |
| 265 | current = path.dirname(current); |
| 266 | } catch { |
| 267 | return; |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | function escapeRegExp(value) { |
| 273 | return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 274 | } |
| 275 |