返回 AiToEarn
live-svelte-component.mjs
根目录 / project / aitoearn-web / .agents / skills / impeccable / scripts / live-svelte-component.mjs
1 /**
2 * Svelte live-mode component injection helpers.
3 *
4 * Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
5 * The browser mounts them via Svelte 5 mount(); accept inlines the chosen
6 * variant back into the route source with props mapped to original bindings.
7 */
8
9 import fs from 'node:fs';
10 import path from 'node:path';
11 import os from 'node:os';
12 import { createHash } from 'node:crypto';
13
14 export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
15 export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
16 export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
17
18 const MUSTACHE_RE = /\{([^{}]+)\}/g;
19
20 export function shouldUseSvelteComponentInjection(filePath) {
21 if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
22 return path.extname(filePath).toLowerCase() === '.svelte';
23 }
24
25 export function componentSessionDir(id, cwd = process.cwd()) {
26 return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
27 }
28
29 export function manifestPathForSession(id, cwd = process.cwd()) {
30 return path.join(componentSessionDir(id, cwd), 'manifest.json');
31 }
32
33 export function ensureRuntimeHelper(cwd = process.cwd()) {
34 const file = path.join(cwd, SVELTE_RUNTIME_FILE);
35 if (fs.existsSync(file)) return file;
36 fs.mkdirSync(path.dirname(file), { recursive: true });
37 fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
38 return file;
39 }
40
41 /**
42 * Extract ordered unique mustache expressions from markup (not inside <!-- -->).
43 */
44 export function extractMustacheExpressions(text) {
45 const expressions = [];
46 const seen = new Set();
47 const lines = String(text || '').split('\n');
48 for (const line of lines) {
49 const trimmed = line.trim();
50 if (trimmed.startsWith('<!--')) continue;
51 let match;
52 MUSTACHE_RE.lastIndex = 0;
53 while ((match = MUSTACHE_RE.exec(line)) !== null) {
54 const expr = match[1].trim();
55 if (!expr || seen.has(expr)) continue;
56 seen.add(expr);
57 expressions.push(expr);
58 }
59 }
60 return expressions;
61 }
62
63 export function buildPropContract(expressions) {
64 return expressions.map((expr, index) => {
65 const derived = derivePropName(expr, index);
66 return {
67 prop: derived,
68 expr,
69 placeholder: `{${expr}}`,
70 };
71 });
72 }
73
74 function derivePropName(expr, index) {
75 const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
76 if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
77 return tail[1];
78 }
79 return `prop${index}`;
80 }
81
82 export function substituteExprsWithProps(markup, contract) {
83 let out = String(markup || '');
84 for (const entry of contract) {
85 out = out.split(entry.placeholder).join(`{${entry.prop}}`);
86 }
87 return out;
88 }
89
90 export function substitutePropsWithExprs(markup, contract) {
91 let out = String(markup || '');
92 for (const entry of contract) {
93 out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
94 }
95 return out;
96 }
97
98 export function parseSvelteComponentFile(content) {
99 const text = String(content || '');
100 const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
101 const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
102 const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
103 const styleBlock = styleMatch ? styleMatch[0] : '';
104 const markup = styleMatch
105 ? withoutScript.slice(0, styleMatch.index).trim()
106 : withoutScript.trim();
107 const cssLines = styleBlock
108 ? styleBlock
109 .replace(/^<style\b[^>]*>/i, '')
110 .replace(/<\/style\s*>$/i, '')
111 .split('\n')
112 .map((line) => line.trimEnd())
113 : [];
114 while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
115 while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
116 return { markup, cssLines, styleBlock };
117 }
118
119 function buildPropsScript(contract) {
120 if (contract.length === 0) {
121 return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
122 }
123 const names = contract.map((c) => c.prop).join(', ');
124 const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
125 return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
126 }
127
128 function buildVariantStub(variantNum, originalWithProps, contract) {
129 const propsComment = contract.length > 0
130 ? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
131 : '';
132 return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
133 }
134
135 function buildInsertVariantStub(variantNum) {
136 return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
137 }
138
139 export function scaffoldSvelteComponentSession({
140 id,
141 count,
142 sourceFile,
143 sourceStartLine,
144 sourceEndLine,
145 originalLines,
146 cwd = process.cwd(),
147 }) {
148 ensureRuntimeHelper(cwd);
149 const dir = componentSessionDir(id, cwd);
150 fs.mkdirSync(dir, { recursive: true });
151
152 const originalMarkup = originalLines.join('\n');
153 const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
154 const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
155
156 const manifest = {
157 id,
158 previewMode: 'svelte-component',
159 sourceFile: sourceFile.split(path.sep).join('/'),
160 sourceStartLine,
161 sourceEndLine,
162 count,
163 propContract: contract,
164 originalMarkup,
165 componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
166 runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
167 };
168
169 fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
170
171 for (let n = 1; n <= count; n++) {
172 const variantFile = path.join(dir, `v${n}.svelte`);
173 if (!fs.existsSync(variantFile)) {
174 fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
175 }
176 }
177
178 return {
179 manifest,
180 manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
181 componentDir: manifest.componentDir,
182 propContract: contract,
183 };
184 }
185
186 export function scaffoldSvelteComponentInsertSession({
187 id,
188 count,
189 sourceFile,
190 insertLine,
191 position,
192 anchorStartLine,
193 anchorEndLine,
194 anchorLines,
195 cwd = process.cwd(),
196 }) {
197 ensureRuntimeHelper(cwd);
198 const dir = componentSessionDir(id, cwd);
199 fs.mkdirSync(dir, { recursive: true });
200
201 const anchorMarkup = (anchorLines || []).join('\n');
202 const manifest = {
203 id,
204 mode: 'insert',
205 previewMode: 'svelte-component',
206 sourceFile: sourceFile.split(path.sep).join('/'),
207 insertLine,
208 position,
209 anchorStartLine,
210 anchorEndLine,
211 originalMarkup: anchorMarkup,
212 anchorMarkup,
213 count,
214 propContract: [],
215 componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
216 runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
217 };
218
219 fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
220
221 for (let n = 1; n <= count; n++) {
222 const variantFile = path.join(dir, `v${n}.svelte`);
223 if (!fs.existsSync(variantFile)) {
224 fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
225 }
226 }
227
228 return {
229 manifest,
230 manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
231 componentDir: manifest.componentDir,
232 propContract: [],
233 };
234 }
235
236 export function findSvelteComponentManifest(id, cwd = process.cwd()) {
237 const direct = manifestPathForSession(id, cwd);
238 if (fs.existsSync(direct)) {
239 return readManifest(direct);
240 }
241 const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
242 if (!fs.existsSync(root)) return null;
243 for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
244 if (!entry.isDirectory()) continue;
245 const candidate = path.join(root, entry.name, 'manifest.json');
246 if (!fs.existsSync(candidate)) continue;
247 try {
248 const manifest = readManifest(candidate);
249 if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
250 } catch { /* skip */ }
251 }
252 return null;
253 }
254
255 export function readManifest(manifestPath) {
256 const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
257 return {
258 ...data,
259 manifestPath,
260 };
261 }
262
263 export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
264 if (!sourceFile || path.isAbsolute(sourceFile)) {
265 throw new Error('Invalid svelte-component source file');
266 }
267 const full = path.resolve(cwd, sourceFile);
268 const rel = path.relative(cwd, full);
269 if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
270 throw new Error('Svelte-component source file escapes project root');
271 }
272 if (!fs.existsSync(full)) {
273 throw new Error('Svelte-component source file not found: ' + sourceFile);
274 }
275 return full;
276 }
277
278 function appendCssToSvelteStyle(lines, cssLines) {
279 const closeIdx = findLastStyleCloseLine(lines);
280 const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
281 if (closeIdx === -1) {
282 return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
283 }
284 return [
285 ...lines.slice(0, closeIdx),
286 ...prepared,
287 ...lines.slice(closeIdx),
288 ];
289 }
290
291 function findLastStyleCloseLine(lines) {
292 for (let i = lines.length - 1; i >= 0; i--) {
293 if (/<\/style\s*>/.test(lines[i])) return i;
294 }
295 return -1;
296 }
297
298 function bakeParamValuesInCss(cssLines, paramValues) {
299 if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
300 return cssLines.map((line) => {
301 let out = line;
302 for (const [key, value] of Object.entries(paramValues)) {
303 const varName = `--p-${key}`;
304 out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
305 }
306 return out;
307 });
308 }
309
310 function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
311 const css = String((cssLines || []).join('\n'));
312 if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
313
314 const rules = parseCssRules(css);
315 const output = [];
316 for (const rule of rules) {
317 appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
318 }
319 return output.join('\n')
320 .split('\n')
321 .map((line) => line.trimEnd())
322 .filter((line) => line.trim() !== '');
323 }
324
325 function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
326 const prelude = rule.prelude.trim();
327 const body = rule.body.trim();
328 if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
329
330 if (/^@scope\b/i.test(prelude)) {
331 if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
332 const inner = parseCssRules(body);
333 for (const innerRule of inner) {
334 const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
335 if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
336 output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
337 }
338 return;
339 }
340
341 const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
342 if (!rewrittenPrelude) return;
343 output.push(formatCssRule(rewrittenPrelude, body));
344 }
345
346 function parseCssRules(css) {
347 const rules = [];
348 const text = String(css || '');
349 let i = 0;
350 while (i < text.length) {
351 while (i < text.length && /\s/.test(text[i])) i++;
352 const preludeStart = i;
353 while (i < text.length && text[i] !== '{') i++;
354 if (i >= text.length) break;
355 const prelude = text.slice(preludeStart, i).trim();
356 i++;
357 const bodyStart = i;
358 let depth = 1;
359 let quote = null;
360 let comment = false;
361 while (i < text.length && depth > 0) {
362 const ch = text[i];
363 const next = text[i + 1];
364 if (comment) {
365 if (ch === '*' && next === '/') {
366 comment = false;
367 i += 2;
368 continue;
369 }
370 i++;
371 continue;
372 }
373 if (quote) {
374 if (ch === '\\') {
375 i += 2;
376 continue;
377 }
378 if (ch === quote) quote = null;
379 i++;
380 continue;
381 }
382 if (ch === '/' && next === '*') {
383 comment = true;
384 i += 2;
385 continue;
386 }
387 if (ch === '"' || ch === "'") {
388 quote = ch;
389 i++;
390 continue;
391 }
392 if (ch === '{') depth++;
393 else if (ch === '}') depth--;
394 i++;
395 }
396 const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
397 if (prelude) rules.push({ prelude, body });
398 }
399 return rules;
400 }
401
402 function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
403 const selectors = splitSelectorList(prelude);
404 const rewritten = [];
405 for (const selector of selectors) {
406 const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
407 if (next) rewritten.push(next);
408 }
409 return rewritten.join(', ');
410 }
411
412 function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
413 let out = selector.trim();
414 const hasVariant = /data-impeccable-variant/.test(out);
415 if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
416 if (hasVariant) {
417 out = out.replace(variantSelectorRegex(variantNum), '');
418 out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
419 }
420
421 const paramResult = rewriteParamSelectors(out, paramValues);
422 if (!paramResult.keep) return '';
423 out = paramResult.selector;
424
425 out = out
426 .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
427 .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
428 .replace(/\s+/g, ' ')
429 .trim();
430
431 out = out.replace(/^[>+~]\s*/, '').trim();
432 if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
433 return out;
434 }
435
436 function rewriteParamSelectors(selector, paramValues) {
437 let keep = true;
438 const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
439 if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
440 const actual = paramValues[key];
441 if (expected != null && String(actual) !== String(expected)) {
442 keep = false;
443 return '';
444 }
445 if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
446 keep = false;
447 return '';
448 }
449 return '';
450 });
451 return { keep, selector: next };
452 }
453
454 function splitSelectorList(prelude) {
455 const selectors = [];
456 let start = 0;
457 let bracket = 0;
458 let paren = 0;
459 let quote = null;
460 for (let i = 0; i < prelude.length; i++) {
461 const ch = prelude[i];
462 if (quote) {
463 if (ch === '\\') i++;
464 else if (ch === quote) quote = null;
465 continue;
466 }
467 if (ch === '"' || ch === "'") {
468 quote = ch;
469 continue;
470 }
471 if (ch === '[') bracket++;
472 else if (ch === ']') bracket = Math.max(0, bracket - 1);
473 else if (ch === '(') paren++;
474 else if (ch === ')') paren = Math.max(0, paren - 1);
475 else if (ch === ',' && bracket === 0 && paren === 0) {
476 selectors.push(prelude.slice(start, i));
477 start = i + 1;
478 }
479 }
480 selectors.push(prelude.slice(start));
481 return selectors;
482 }
483
484 function selectorHasVariant(selector, variantNum) {
485 return variantSelectorRegex(variantNum).test(selector);
486 }
487
488 function variantSelectorRegex(variantNum) {
489 return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
490 }
491
492 function formatCssRule(selector, body) {
493 return `${selector} { ${body.trim()} }`;
494 }
495
496 function escapeRegExp(value) {
497 return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
498 }
499
500 export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
501 const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
502 const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
503 const resultBase = {
504 file: manifest.sourceFile,
505 sourceFile: manifest.sourceFile,
506 previewMode: 'svelte-component',
507 componentDir: manifest.componentDir,
508 carbonize: false,
509 };
510 if (!fs.existsSync(variantPath)) {
511 return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
512 }
513
514 const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
515 if (manifest.mode === 'insert') {
516 return inlineSvelteComponentInsertAccept({
517 manifest,
518 markup,
519 cssLines,
520 variantNum,
521 paramValues,
522 sourceFile,
523 resultBase,
524 cwd,
525 });
526 }
527
528 const rootTag = matchOpeningTag(markup)?.tag || 'div';
529 const contract = manifest.propContract || [];
530 const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
531 const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
532 .split('\n')
533 .map((line) => line.trimEnd());
534
535 const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
536 const sourceLines = sourceContent.split('\n');
537 const start = Number(manifest.sourceStartLine) - 1;
538 const end = Number(manifest.sourceEndLine) - 1;
539 if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
540 return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
541 }
542
543 const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
544 const indentedMarkup = restoredMarkup.map((line) => {
545 if (line.trim() === '') return '';
546 return indent + line.trimStart();
547 });
548
549 let newLines = [
550 ...sourceLines.slice(0, start),
551 ...indentedMarkup,
552 ...sourceLines.slice(end + 1),
553 ];
554
555 const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
556 const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
557 if (bakedCss.length > 0) {
558 newLines = appendCssToSvelteStyle(newLines, bakedCss);
559 }
560
561 try {
562 fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
563 } catch (err) {
564 return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
565 }
566 removeSvelteComponentSession(manifest.id, cwd);
567
568 return {
569 handled: true,
570 ...resultBase,
571 };
572 }
573
574 function inlineSvelteComponentInsertAccept({
575 manifest,
576 markup,
577 cssLines,
578 variantNum,
579 paramValues,
580 sourceFile,
581 resultBase,
582 cwd,
583 }) {
584 if (!svelteMarkupHasVisibleContent(markup)) {
585 return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
586 }
587 if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
588 return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
589 }
590
591 const rootTag = matchOpeningTag(markup)?.tag || 'div';
592 const restoredMarkup = String(markup || '')
593 .split('\n')
594 .map((line) => line.trimEnd());
595 const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
596 const sourceLines = sourceContent.split('\n');
597 const insertIndex = Number(manifest.insertLine) - 1;
598 if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
599 return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
600 }
601
602 const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
603 const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
604 const indentedMarkup = restoredMarkup.map((line) => {
605 if (line.trim() === '') return '';
606 return indent + line.trimStart();
607 });
608
609 let newLines = [
610 ...sourceLines.slice(0, insertIndex),
611 ...indentedMarkup,
612 ...sourceLines.slice(insertIndex),
613 ];
614
615 const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
616 const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
617 if (bakedCss.length > 0) {
618 newLines = appendCssToSvelteStyle(newLines, bakedCss);
619 }
620
621 try {
622 fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
623 } catch (err) {
624 return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
625 }
626 removeSvelteComponentSession(manifest.id, cwd);
627
628 return {
629 handled: true,
630 ...resultBase,
631 };
632 }
633
634 function svelteMarkupHasVisibleContent(markup) {
635 const text = String(markup || '')
636 .replace(/<script[\s\S]*?<\/script>/gi, '')
637 .replace(/<style[\s\S]*?<\/style>/gi, '')
638 .replace(/<!--[\s\S]*?-->/g, '')
639 .replace(/<[^>]+>/g, ' ')
640 .replace(/\s+/g, ' ')
641 .trim();
642 if (text.length > 0) return true;
643 return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
644 }
645
646 function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
647 const variantOpen = matchOpeningTag(markup);
648 const originalOpen = matchOpeningTag(originalMarkup);
649 if (!variantOpen || !originalOpen) return markup;
650 if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
651
652 const variantAttrs = parseAttrSegments(variantOpen.attrs);
653 const originalAttrs = parseAttrSegments(originalOpen.attrs);
654 const additions = [];
655 let attrs = variantOpen.attrs;
656
657 const originalClass = originalAttrs.get('class');
658 const variantClass = variantAttrs.get('class');
659 if (originalClass && variantClass) {
660 const merged = mergeStaticClassAttr(originalClass, variantClass);
661 if (merged) {
662 attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
663 variantAttrs.set('class', { ...variantClass, raw: merged });
664 }
665 } else if (originalClass && !variantClass) {
666 additions.push(originalClass.raw);
667 }
668
669 for (const [name, attr] of originalAttrs) {
670 if (name === 'class') continue;
671 if (!variantAttrs.has(name)) additions.push(attr.raw);
672 }
673
674 if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
675 const nextOpen = variantOpen.prefix
676 + variantOpen.tag
677 + attrs
678 + additions.map((attr) => ' ' + attr.trim()).join('')
679 + variantOpen.close;
680 return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
681 }
682
683 function matchOpeningTag(markup) {
684 const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
685 if (!match) return null;
686 return {
687 raw: match[0],
688 prefix: match[1],
689 tag: match[2],
690 attrs: match[3] || '',
691 close: match[4],
692 index: match.index || 0,
693 };
694 }
695
696 function parseAttrSegments(attrs) {
697 const out = new Map();
698 const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
699 let match;
700 while ((match = re.exec(attrs))) {
701 const raw = match[0];
702 const name = match[1];
703 out.set(name, {
704 name,
705 raw,
706 start: match.index,
707 end: match.index + raw.length,
708 });
709 }
710 return out;
711 }
712
713 function mergeStaticClassAttr(originalClass, variantClass) {
714 const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
715 const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
716 if (!originalValue || !variantValue) return null;
717 const quote = variantValue[1];
718 const classes = [
719 ...variantValue[2].split(/\s+/),
720 ...originalValue[2].split(/\s+/),
721 ].filter(Boolean);
722 return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
723 }
724
725 export function removeSvelteComponentSession(id, cwd = process.cwd()) {
726 const dir = componentSessionDir(id, cwd);
727 try {
728 fs.rmSync(dir, { recursive: true, force: true });
729 } catch { /* non-fatal */ }
730 }
731
732 export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
733 const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
734 if (!fs.existsSync(root)) return;
735 for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
736 if (!entry.isDirectory()) continue;
737 if (entry.name.startsWith('__')) continue;
738 try {
739 fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
740 } catch { /* non-fatal */ }
741 }
742 }
743
744 export function deferredAcceptsPath(cwd = process.cwd()) {
745 const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
746 return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
747 }
748
749 export function readDeferredAccepts(cwd = process.cwd()) {
750 const file = deferredAcceptsPath(cwd);
751 try {
752 return JSON.parse(fs.readFileSync(file, 'utf-8'));
753 } catch {
754 return { accepts: [] };
755 }
756 }
757
758 export function writeDeferredAccept(entry, cwd = process.cwd()) {
759 const file = deferredAcceptsPath(cwd);
760 fs.mkdirSync(path.dirname(file), { recursive: true });
761 const data = readDeferredAccepts(cwd);
762 data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
763 data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
764 fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
765 }
766
767 export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
768 const file = deferredAcceptsPath(cwd);
769 const data = readDeferredAccepts(cwd);
770 const pending = Array.isArray(data.accepts) ? data.accepts : [];
771 const results = [];
772 const remaining = [];
773 for (const entry of pending) {
774 try {
775 const manifest = findSvelteComponentManifest(entry.id, cwd);
776 if (!manifest) {
777 results.push({ id: entry.id, ok: false, error: 'manifest not found' });
778 remaining.push(entry);
779 continue;
780 }
781 const result = inlineSvelteComponentAccept(
782 manifest,
783 entry.variantNum,
784 entry.paramValues || null,
785 cwd,
786 );
787 results.push({ id: entry.id, ok: result.handled !== false, result });
788 if (result.handled === false) remaining.push(entry);
789 } catch (err) {
790 results.push({ id: entry.id, ok: false, error: err.message });
791 remaining.push(entry);
792 }
793 }
794 if (remaining.length > 0) {
795 fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
796 } else {
797 try { fs.rmSync(file, { force: true }); } catch {}
798 }
799 return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
800 }
801
802 export function buildSvelteComponentCssAuthoring(count) {
803 const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
804 return {
805 mode: 'svelte-component',
806 styleTag: null,
807 strategy: 'component-style-block',
808 rulePattern: '.semantic-class { ... }',
809 selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
810 requirements: [
811 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
812 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
813 'Put variant CSS in the component <style> block using semantic class selectors.',
814 'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
815 'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
816 'Do not use @scope or data-impeccable-variant selectors in component files.',
817 'Do not edit the route source file during generation; only edit files under componentDir.',
818 ],
819 forbidden: [
820 'Do not use @scope blocks in Svelte component variants.',
821 'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
822 'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
823 ],
824 paramsFile: 'params.json',
825 };
826 }
827
827 lines Plain Text