返回 AiToEarn
live-sveltekit-adapter.mjs
根目录 / project / aitoearn-web / .agents / skills / impeccable / scripts / live-sveltekit-adapter.mjs
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
275 lines Plain Text