| 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 |