962 lines
38 KiB
JavaScript
962 lines
38 KiB
JavaScript
/**
|
|
* AST-based Svelte scaffolding for live component previews.
|
|
*
|
|
* The scaffolder turns the selected block of a route's markup into a detached
|
|
* preview component whose dynamic values arrive as props. The old
|
|
* implementation matched `{...}` with a regex, which flattened control-flow
|
|
* blocks ({#each}, {#if}) into scalar text props and shipped structurally
|
|
* wrong previews. This module uses the app's own svelte compiler
|
|
* (parse with modern: true) and replaces only expressions that are FREE,
|
|
* i.e. reference identifiers not bound by an enclosing template scope:
|
|
*
|
|
* {#each stages as stage, i} stages -> collection prop (array)
|
|
* <span>{stage.label}</span> bound -> left verbatim
|
|
* {/each}
|
|
* <p>{footerNote}</p> free -> text prop (string)
|
|
*
|
|
* Constructs that cannot work in a detached component (component tags whose
|
|
* imports live in the route file, bind:/use: directives, await blocks,
|
|
* render tags) mark the analysis unsupported; the caller falls back to
|
|
* source-preview mode, which keeps the markup inside the route file where
|
|
* those references still resolve. A wrong preview is worse than a plain one.
|
|
*
|
|
* The compiler is resolved from the APP's node_modules, never bundled: the
|
|
* preview must be parsed by the same svelte version that will compile it.
|
|
*/
|
|
|
|
import { createRequire } from 'node:module';
|
|
import path from 'node:path';
|
|
|
|
const HANDLER_ATTR_RE = /^on[a-z]/;
|
|
|
|
/**
|
|
* Resolve the app's svelte compiler synchronously (svelte 5 ships a CJS
|
|
* compiler build, so createRequire works and the accept/scaffold pipeline
|
|
* stays synchronous). Returns { parse, compile, VERSION } or null.
|
|
*/
|
|
export function loadSvelteCompiler(appRoot) {
|
|
try {
|
|
const req = createRequire(path.join(appRoot, 'package.json'));
|
|
const mod = req('svelte/compiler');
|
|
if (typeof mod.parse !== 'function') return null;
|
|
const major = parseInt(String(mod.VERSION || '0'), 10);
|
|
if (major < 5) return null; // detached mount() previews are svelte 5 only
|
|
return { parse: mod.parse, compile: mod.compile, VERSION: mod.VERSION };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ESTree helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Collect the root identifiers an ESTree expression reads. Walks generically;
|
|
* skips non-computed member properties and non-computed/non-shorthand object
|
|
* keys, which are names, not references.
|
|
*/
|
|
export function collectRootIdentifiers(node, out = new Set()) {
|
|
if (!node || typeof node !== 'object') return out;
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) collectRootIdentifiers(item, out);
|
|
return out;
|
|
}
|
|
switch (node.type) {
|
|
case 'Identifier':
|
|
out.add(node.name);
|
|
return out;
|
|
case 'MemberExpression':
|
|
collectRootIdentifiers(node.object, out);
|
|
if (node.computed) collectRootIdentifiers(node.property, out);
|
|
return out;
|
|
case 'Property':
|
|
if (node.computed) collectRootIdentifiers(node.key, out);
|
|
collectRootIdentifiers(node.value, out);
|
|
return out;
|
|
case 'ArrowFunctionExpression':
|
|
case 'FunctionExpression': {
|
|
// Params shadow outer names inside the body.
|
|
const bound = new Set();
|
|
for (const param of node.params || []) collectPatternNames(param, bound);
|
|
const inner = collectRootIdentifiers(node.body, new Set());
|
|
for (const name of inner) if (!bound.has(name)) out.add(name);
|
|
return out;
|
|
}
|
|
default: {
|
|
for (const key of Object.keys(node)) {
|
|
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
|
|
collectRootIdentifiers(node[key], out);
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Collect names bound by a destructuring pattern (each contexts, const tags). */
|
|
export function collectPatternNames(pattern, out = new Set()) {
|
|
if (!pattern || typeof pattern !== 'object') return out;
|
|
switch (pattern.type) {
|
|
case 'Identifier':
|
|
out.add(pattern.name);
|
|
return out;
|
|
case 'ObjectPattern':
|
|
for (const prop of pattern.properties || []) {
|
|
if (prop.type === 'RestElement') collectPatternNames(prop.argument, out);
|
|
else collectPatternNames(prop.value, out);
|
|
}
|
|
return out;
|
|
case 'ArrayPattern':
|
|
for (const el of pattern.elements || []) if (el) collectPatternNames(el, out);
|
|
return out;
|
|
case 'AssignmentPattern':
|
|
collectPatternNames(pattern.left, out);
|
|
return out;
|
|
case 'RestElement':
|
|
collectPatternNames(pattern.argument, out);
|
|
return out;
|
|
default:
|
|
return out;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Template analysis
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class Analysis {
|
|
constructor(source) {
|
|
this.source = source;
|
|
this.replacements = []; // { start, end, prop } source ranges to swap
|
|
this.contract = []; // [{ prop, expr, kind, ... }]
|
|
this.byExpr = new Map(); // expr text -> contract entry
|
|
this.usedNames = new Set();
|
|
this.unsupported = null;
|
|
}
|
|
|
|
fail(reason) {
|
|
if (!this.unsupported) this.unsupported = reason;
|
|
}
|
|
|
|
propFor(exprText, kind, extra = {}) {
|
|
const existing = this.byExpr.get(exprText);
|
|
if (existing) return existing;
|
|
const base = derivePropName(exprText);
|
|
let name = base;
|
|
let n = 2;
|
|
while (this.usedNames.has(name)) name = `${base}${n++}`;
|
|
this.usedNames.add(name);
|
|
const entry = { prop: name, expr: exprText, kind, ...extra };
|
|
this.byExpr.set(exprText, entry);
|
|
this.contract.push(entry);
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
// A derived prop name lands in `let { <name> } = $props()`; a reserved word
|
|
// there is a syntax error the session only hits at import time.
|
|
const RESERVED_PROP_NAMES = new Set([
|
|
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
|
|
'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false',
|
|
'finally', 'for', 'function', 'if', 'implements', 'import', 'in',
|
|
'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private',
|
|
'protected', 'public', 'return', 'static', 'super', 'switch', 'this',
|
|
'throw', 'true', 'try', 'typeof', 'undefined', 'var', 'void', 'while',
|
|
'with', 'yield',
|
|
]);
|
|
|
|
export function derivePropName(expr) {
|
|
const tail = String(expr).match(/(?:\.|\[["']?)([A-Za-z_$][\w$]*)["']?\]?\s*$/);
|
|
const candidate = (tail && tail[1])
|
|
|| (String(expr).match(/^([A-Za-z_$][\w$]*)$/) || [])[1]
|
|
|| 'value';
|
|
return RESERVED_PROP_NAMES.has(candidate) ? `${candidate}Value` : candidate;
|
|
}
|
|
|
|
function exprText(source, node) {
|
|
return source.slice(node.start, node.end);
|
|
}
|
|
|
|
// Identifiers that resolve in ANY module scope. They are neither hydratable
|
|
// props nor evidence of route coupling, so they count as neither free nor
|
|
// bound: `{Math.round(x)}` must not mint a prop named `round`, and
|
|
// `{fmt(stage.label)}` must not pass as global-only.
|
|
const GLOBAL_IDENTIFIERS = new Set([
|
|
'Math', 'JSON', 'Date', 'Intl', 'Number', 'String', 'Boolean', 'Array',
|
|
'Object', 'Map', 'Set', 'Promise', 'RegExp', 'NaN', 'Infinity', 'undefined',
|
|
'isNaN', 'isFinite', 'parseInt', 'parseFloat', 'encodeURIComponent',
|
|
'decodeURIComponent', 'console', 'window', 'document', 'navigator',
|
|
'location', 'structuredClone', 'crypto',
|
|
]);
|
|
|
|
function classifyRoots(node, scopes) {
|
|
const roots = collectRootIdentifiers(node);
|
|
let bound = 0;
|
|
let free = 0;
|
|
for (const name of roots) {
|
|
if (GLOBAL_IDENTIFIERS.has(name)) continue;
|
|
if (scopes.some((scope) => scope.has(name))) bound++;
|
|
else free++;
|
|
}
|
|
return { bound, free };
|
|
}
|
|
|
|
function isFree(node, scopes) {
|
|
const { bound, free } = classifyRoots(node, scopes);
|
|
return free > 0 && bound === 0;
|
|
}
|
|
|
|
/**
|
|
* An expression mixing loop-bound and outer free identifiers (e.g.
|
|
* `{fmt(stage.label)}` where `fmt` lives in the route script) can neither
|
|
* become a prop (the bound part varies per item) nor survive detachment
|
|
* verbatim (the free name is undeclared in the preview and throws at mount,
|
|
* past the compile gate, because globals make it legal to the compiler).
|
|
* Source-preview mode is the only correct home for it.
|
|
*/
|
|
function failOnMixedExpression(node, scopes, analysis, source) {
|
|
const { bound, free } = classifyRoots(node, scopes);
|
|
if (bound > 0 && free > 0) {
|
|
analysis.fail(`expression mixing loop and outer identifiers ({${exprText(source, node).slice(0, 60)}}) requires source-preview mode`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Analyze a parsed template fragment. `scopes` is a stack of Sets of bound
|
|
* names; the outermost call passes an empty stack.
|
|
*/
|
|
function analyzeFragment(fragment, analysis, scopes) {
|
|
if (!fragment || !Array.isArray(fragment.nodes)) return;
|
|
// ConstTag declarations bind for the whole fragment.
|
|
const fragmentScope = new Set();
|
|
const nextScopes = [...scopes, fragmentScope];
|
|
for (const node of fragment.nodes) {
|
|
if (node.type === 'ConstTag' && node.declaration) {
|
|
for (const decl of node.declaration.declarations || []) {
|
|
collectPatternNames(decl.id, fragmentScope);
|
|
}
|
|
}
|
|
}
|
|
for (const node of fragment.nodes) analyzeNode(node, analysis, nextScopes);
|
|
}
|
|
|
|
function analyzeNode(node, analysis, scopes) {
|
|
if (!node || analysis.unsupported) return;
|
|
switch (node.type) {
|
|
case 'Text':
|
|
case 'Comment':
|
|
return;
|
|
case 'ExpressionTag': {
|
|
if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
|
|
if (isFree(node.expression, scopes)) {
|
|
const text = exprText(analysis.source, node.expression);
|
|
const entry = analysis.propFor(text, 'text');
|
|
// node.start/end include the braces; keep them, swap the inside.
|
|
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
|
|
}
|
|
return;
|
|
}
|
|
case 'HtmlTag': {
|
|
if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
|
|
if (isFree(node.expression, scopes)) {
|
|
const text = exprText(analysis.source, node.expression);
|
|
const entry = analysis.propFor(text, 'raw');
|
|
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
|
|
}
|
|
return;
|
|
}
|
|
case 'ConstTag': {
|
|
// Its expression may read free names; leave them: the declaration
|
|
// travels with the markup and stays valid only if its inputs do.
|
|
if (node.declaration) {
|
|
for (const decl of node.declaration.declarations || []) {
|
|
if (decl.init && failOnMixedExpression(decl.init, scopes, analysis, analysis.source)) return;
|
|
if (decl.init && isFree(decl.init, scopes)) {
|
|
const text = exprText(analysis.source, decl.init);
|
|
const entry = analysis.propFor(text, 'text');
|
|
analysis.replacements.push({ start: decl.init.start, end: decl.init.end, prop: entry.prop });
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
case 'EachBlock': {
|
|
if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
|
|
if (isFree(node.expression, scopes)) {
|
|
const text = exprText(analysis.source, node.expression);
|
|
const item = describeEachItem(node, analysis.source);
|
|
// Keyed each: the key must evaluate to a distinct value per hydrated
|
|
// item or Svelte throws each_key_duplicate at mount. A key that is a
|
|
// plain member of the item (the common `(item.id)` shape) gets a
|
|
// synthetic per-index value injected by the browser (keyField).
|
|
// Anything else cannot be hydrated safely; source-preview mode keeps
|
|
// it correct.
|
|
if (node.key) {
|
|
const keyInfo = classifyEachKey(node);
|
|
if (keyInfo.unsupported) {
|
|
analysis.fail(keyInfo.unsupported);
|
|
return;
|
|
}
|
|
if (keyInfo.keyField) {
|
|
if (item.textSlots.some((slot) => slot.key === keyInfo.keyField)) {
|
|
// The key doubles as a displayed slot; a synthetic value would
|
|
// change visible text, and the displayed text may not be
|
|
// unique. Not previewable in a detached component.
|
|
analysis.fail('each key that is also a displayed field requires source-preview mode');
|
|
return;
|
|
}
|
|
item.keyField = keyInfo.keyField;
|
|
}
|
|
}
|
|
const entry = analysis.propFor(text, 'collection', { item });
|
|
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
|
|
}
|
|
const bound = new Set();
|
|
if (node.context) collectPatternNames(node.context, bound);
|
|
if (node.index) bound.add(node.index);
|
|
analyzeFragment(node.body, analysis, [...scopes, bound]);
|
|
if (node.fallback) analyzeFragment(node.fallback, analysis, scopes);
|
|
return;
|
|
}
|
|
case 'IfBlock': {
|
|
if (failOnMixedExpression(node.test, scopes, analysis, analysis.source)) return;
|
|
if (isFree(node.test, scopes)) {
|
|
const text = exprText(analysis.source, node.test);
|
|
// The browser hydrates a free condition from what the live page
|
|
// currently shows: when the consequent's root element is present
|
|
// under the picked element, the condition is on.
|
|
const entry = analysis.propFor(text, 'condition', {
|
|
probe: describeElementProbe(node.consequent),
|
|
});
|
|
analysis.replacements.push({ start: node.test.start, end: node.test.end, prop: entry.prop });
|
|
}
|
|
analyzeFragment(node.consequent, analysis, scopes);
|
|
if (node.alternate) analyzeFragment(node.alternate, analysis, scopes);
|
|
return;
|
|
}
|
|
case 'KeyBlock': {
|
|
if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
|
|
if (isFree(node.expression, scopes)) {
|
|
const text = exprText(analysis.source, node.expression);
|
|
const entry = analysis.propFor(text, 'text');
|
|
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
|
|
}
|
|
analyzeFragment(node.fragment, analysis, scopes);
|
|
return;
|
|
}
|
|
case 'SnippetBlock': {
|
|
const bound = new Set();
|
|
for (const param of node.parameters || []) collectPatternNames(param, bound);
|
|
// The snippet's own name becomes available to render tags in this file.
|
|
analyzeFragment(node.body, analysis, [...scopes, bound]);
|
|
return;
|
|
}
|
|
case 'RegularElement':
|
|
case 'SlotElement':
|
|
case 'TitleElement': {
|
|
if (node.name === 'script') {
|
|
// An inline script inside the selected block carries route-scoped
|
|
// code; running it a second time from a detached preview is wrong.
|
|
analysis.fail('inline script element requires source-preview mode');
|
|
return;
|
|
}
|
|
analyzeAttributes(node, analysis, scopes);
|
|
if (!analysis.unsupported) analyzeFragment(node.fragment, analysis, scopes);
|
|
return;
|
|
}
|
|
case 'SvelteElement':
|
|
case 'SvelteFragment':
|
|
case 'SvelteBoundary': {
|
|
analyzeAttributes(node, analysis, scopes);
|
|
if (!analysis.unsupported) analyzeFragment(node.fragment, analysis, scopes);
|
|
return;
|
|
}
|
|
case 'Component':
|
|
case 'SvelteComponent':
|
|
case 'SvelteSelf':
|
|
// The component's import lives in the route file; a detached preview
|
|
// cannot resolve it. Source-preview mode keeps it working.
|
|
analysis.fail(`component tag <${node.name || 'Component'}> requires source-preview mode`);
|
|
return;
|
|
case 'RenderTag':
|
|
analysis.fail('render tag requires source-preview mode');
|
|
return;
|
|
case 'AwaitBlock':
|
|
analysis.fail('await block requires source-preview mode');
|
|
return;
|
|
case 'SvelteHead':
|
|
case 'SvelteWindow':
|
|
case 'SvelteDocument':
|
|
case 'SvelteBody':
|
|
analysis.fail(`${node.type} requires source-preview mode`);
|
|
return;
|
|
default: {
|
|
if (node.fragment) analyzeFragment(node.fragment, analysis, scopes);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function analyzeAttributes(node, analysis, scopes) {
|
|
for (const attr of node.attributes || []) {
|
|
switch (attr.type) {
|
|
case 'Attribute': {
|
|
if (attr.value === true) break;
|
|
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
|
|
for (const part of parts) {
|
|
if (!part || part.type !== 'ExpressionTag') continue;
|
|
if (failOnMixedExpression(part.expression, scopes, analysis, analysis.source)) return;
|
|
if (!isFree(part.expression, scopes)) continue;
|
|
const text = exprText(analysis.source, part.expression);
|
|
const kind = HANDLER_ATTR_RE.test(attr.name) ? 'handler' : 'text';
|
|
const entry = analysis.propFor(text, kind);
|
|
analysis.replacements.push({ start: part.expression.start, end: part.expression.end, prop: entry.prop });
|
|
}
|
|
break;
|
|
}
|
|
case 'ClassDirective': {
|
|
const expr = attr.expression;
|
|
if (expr && failOnMixedExpression(expr, scopes, analysis, analysis.source)) return;
|
|
if (expr && isFree(expr, scopes)) {
|
|
const text = exprText(analysis.source, expr);
|
|
// The directive's class name is literal, so the live DOM answers
|
|
// the condition directly: the class is either present or not.
|
|
const entry = analysis.propFor(text, 'condition', {
|
|
probe: { className: attr.name },
|
|
});
|
|
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
|
|
}
|
|
break;
|
|
}
|
|
case 'StyleDirective': {
|
|
// Unlike ClassDirective, a style directive stores its value in
|
|
// attribute shape: `true` for the shorthand, else an array of parts.
|
|
const parts = attr.value === true ? [] : (Array.isArray(attr.value) ? attr.value : [attr.value]);
|
|
for (const part of parts) {
|
|
if (part?.type === 'ExpressionTag'
|
|
&& failOnMixedExpression(part.expression, scopes, analysis, analysis.source)) {
|
|
return;
|
|
}
|
|
}
|
|
const dynamic = parts.some((part) => part?.type === 'ExpressionTag' && isFree(part.expression, scopes));
|
|
const shorthandFree = attr.value === true && isFree({ type: 'Identifier', name: attr.name }, scopes);
|
|
if (dynamic || shorthandFree) {
|
|
// style:opacity={x} carries a css VALUE, not a boolean, and the
|
|
// computed value on the live element is not reliably recoverable in
|
|
// the shape the expression produced. A falsified style is worse
|
|
// than an HMR-resetting preview.
|
|
analysis.fail(`style:${attr.name} with a dynamic value requires source-preview mode`);
|
|
}
|
|
break;
|
|
}
|
|
case 'BindDirective':
|
|
analysis.fail(`bind:${attr.name} requires source-preview mode`);
|
|
return;
|
|
case 'UseDirective':
|
|
analysis.fail(`use:${attr.name} requires source-preview mode`);
|
|
return;
|
|
case 'AnimateDirective':
|
|
case 'TransitionDirective':
|
|
// Motion directives reference route-scoped or svelte/transition
|
|
// imports; a detached preview cannot resolve them.
|
|
analysis.fail(`${attr.type} requires source-preview mode`);
|
|
return;
|
|
case 'OnDirective': {
|
|
// Legacy on:click syntax; treat like handler attributes.
|
|
const expr = attr.expression;
|
|
if (expr && failOnMixedExpression(expr, scopes, analysis, analysis.source)) return;
|
|
if (expr && isFree(expr, scopes)) {
|
|
const text = exprText(analysis.source, expr);
|
|
const entry = analysis.propFor(text, 'handler');
|
|
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
|
|
}
|
|
break;
|
|
}
|
|
case 'SpreadAttribute':
|
|
analysis.fail('spread attribute requires source-preview mode');
|
|
return;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Describe the repeating item of an each block for browser-side hydration:
|
|
* the item's root element (tag + static classes, used to count live
|
|
* iterations) and the ordered text slots that reference loop bindings.
|
|
*/
|
|
function describeEachItem(node, source) {
|
|
const body = node.body;
|
|
const rootEl = (body?.nodes || []).find((n) => n.type === 'RegularElement');
|
|
|
|
const textSlots = [];
|
|
const staticTexts = [];
|
|
let nestedUnsupported = false;
|
|
const collectStatics = (fragment) => {
|
|
for (const child of fragment?.nodes || []) {
|
|
if (child.type === 'Text') {
|
|
const trimmed = String(child.data || '').trim();
|
|
if (trimmed) staticTexts.push(trimmed);
|
|
} else if (child.type === 'IfBlock') {
|
|
collectStatics(child.consequent);
|
|
if (child.alternate) collectStatics(child.alternate);
|
|
} else if (child.type === 'EachBlock') {
|
|
collectStatics(child.body);
|
|
} else if (child.fragment) {
|
|
collectStatics(child.fragment);
|
|
}
|
|
}
|
|
};
|
|
collectStatics(body);
|
|
const attrSlots = [];
|
|
// The hydration item is a SHALLOW object whose string fields are the exact
|
|
// property names the markup accesses, filled from the rendered page. That
|
|
// model supports one item access per slot, optionally wrapped in a global
|
|
// transform ({Math.round(r.score)} hydrates `score`). Shapes it cannot
|
|
// represent split two ways: CRASHY ones would throw at mount time against a
|
|
// shallow item (deep paths like r.meta.label, method calls like r.format())
|
|
// and force the source-preview fallback; LOSSY ones render wrong but safe
|
|
// (bare {r}, multi-access expressions that would double their text) and
|
|
// also fall back in text position, where the damage is visible.
|
|
const boundAs = (name, scopeInfos) => {
|
|
for (let i = scopeInfos.length - 1; i >= 0; i--) {
|
|
const info = scopeInfos[i];
|
|
if (info.indexName === name) return 'index';
|
|
if (info.itemName === name) return 'item';
|
|
if (info.names.has(name)) return 'field';
|
|
}
|
|
return null;
|
|
};
|
|
const slotKeysOf = (expression, scopeInfos) => {
|
|
const keys = new Set();
|
|
let crashy = false;
|
|
let lossy = false;
|
|
let touches = false;
|
|
const visit = (node, ctx) => {
|
|
if (!node || typeof node !== 'object' || crashy) return;
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) visit(item, {});
|
|
return;
|
|
}
|
|
switch (node.type) {
|
|
case 'Identifier': {
|
|
const kind = boundAs(node.name, scopeInfos);
|
|
if (!kind) return;
|
|
touches = true;
|
|
if (kind === 'index') return; // the runtime each provides it
|
|
if (kind === 'item') { lossy = true; return; } // bare item reference
|
|
if (ctx.callee) { crashy = true; return; } // field() on a hydrated string
|
|
keys.add(node.name); // destructured context field
|
|
return;
|
|
}
|
|
case 'MemberExpression': {
|
|
if (
|
|
!node.computed
|
|
&& node.object?.type === 'Identifier'
|
|
&& boundAs(node.object.name, scopeInfos) === 'item'
|
|
&& node.property?.type === 'Identifier'
|
|
) {
|
|
touches = true;
|
|
// item.a.b or item.method(): a shallow string field throws here.
|
|
if (ctx.memberObject || ctx.callee) { crashy = true; return; }
|
|
keys.add(node.property.name);
|
|
return;
|
|
}
|
|
visit(node.object, { memberObject: true });
|
|
if (node.computed) visit(node.property, {});
|
|
return;
|
|
}
|
|
case 'CallExpression':
|
|
visit(node.callee, { callee: true });
|
|
for (const arg of node.arguments || []) visit(arg, {});
|
|
return;
|
|
case 'ArrowFunctionExpression':
|
|
case 'FunctionExpression': {
|
|
// Closures cannot hydrate; only lossy when they capture the item.
|
|
const roots = collectRootIdentifiers(node);
|
|
if ([...roots].some((name) => boundAs(name, scopeInfos))) { touches = true; lossy = true; }
|
|
return;
|
|
}
|
|
case 'Property':
|
|
if (node.computed) visit(node.key, {});
|
|
visit(node.value, {});
|
|
return;
|
|
default: {
|
|
for (const key of Object.keys(node)) {
|
|
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
|
|
visit(node[key], {});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
visit(expression, {});
|
|
if (crashy) return { crashy: true };
|
|
if (lossy || keys.size > 1) return { lossy: true };
|
|
if (!touches || keys.size === 0) return { skip: true };
|
|
return { key: [...keys][0] };
|
|
};
|
|
const staticClassesOf = (el) => {
|
|
const classes = [];
|
|
for (const attr of el?.attributes || []) {
|
|
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
|
|
for (const part of attr.value) {
|
|
if (part.type === 'Text') classes.push(...part.data.split(/\s+/).filter(Boolean));
|
|
}
|
|
}
|
|
}
|
|
return classes;
|
|
};
|
|
const scopeInfoOf = (eachNode) => {
|
|
const names = new Set();
|
|
if (eachNode.context) collectPatternNames(eachNode.context, names);
|
|
return {
|
|
names,
|
|
itemName: eachNode.context?.type === 'Identifier' ? eachNode.context.name : null,
|
|
indexName: eachNode.index || null,
|
|
};
|
|
};
|
|
const walkForSlots = (fragment, scopeInfos) => {
|
|
for (const child of fragment?.nodes || []) {
|
|
if (child.type === 'ExpressionTag') {
|
|
const slot = slotKeysOf(child.expression, scopeInfos);
|
|
if (slot.crashy || slot.lossy) { nestedUnsupported = true; continue; }
|
|
if (slot.skip) continue;
|
|
textSlots.push({ key: slot.key, expr: exprText(source, child.expression) });
|
|
} else if (child.type === 'RegularElement' || child.type === 'SvelteElement') {
|
|
// Bound values in ATTRIBUTES (href={link.href}, src={item.img}) are
|
|
// part of the item too: the browser reads the rendered attribute off
|
|
// the live element, so the preview does not mount with empty links.
|
|
// Only a single-expression attribute hydrates exactly; a mixed value
|
|
// ("card {r.status}") stays unhydrated because the rendered attribute
|
|
// is not separable into its parts, which was the prior behavior.
|
|
for (const attr of child.attributes || []) {
|
|
if (attr.type !== 'Attribute' || attr.value === true) continue;
|
|
if (HANDLER_ATTR_RE.test(attr.name)) continue; // functions cannot hydrate
|
|
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
|
|
const exprParts = parts.filter((part) => part?.type === 'ExpressionTag');
|
|
for (const part of exprParts) {
|
|
const slot = slotKeysOf(part.expression, scopeInfos);
|
|
if (slot.crashy) { nestedUnsupported = true; continue; }
|
|
if (slot.skip || slot.lossy) continue;
|
|
if (parts.length !== 1) continue; // mixed static+dynamic value
|
|
attrSlots.push({
|
|
key: slot.key,
|
|
expr: exprText(source, part.expression),
|
|
attr: attr.name,
|
|
tag: child.name || null,
|
|
classes: staticClassesOf(child),
|
|
});
|
|
}
|
|
}
|
|
walkForSlots(child.fragment, scopeInfos);
|
|
continue;
|
|
} else if (child.type === 'EachBlock') {
|
|
const roots = collectRootIdentifiers(child.expression);
|
|
const boundNested = [...roots].some((name) => boundAs(name, scopeInfos));
|
|
if (boundNested) nestedUnsupported = true; // nested per-item arrays: no hydration plan yet
|
|
walkForSlots(child.body, [...scopeInfos, scopeInfoOf(child)]);
|
|
} else if (child.type === 'IfBlock') {
|
|
walkForSlots(child.consequent, scopeInfos);
|
|
if (child.alternate) walkForSlots(child.alternate, scopeInfos);
|
|
} else if (child.fragment) {
|
|
walkForSlots(child.fragment, scopeInfos);
|
|
}
|
|
}
|
|
};
|
|
walkForSlots(body, [scopeInfoOf(node)]);
|
|
|
|
const staticClasses = [];
|
|
for (const attr of rootEl?.attributes || []) {
|
|
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
|
|
for (const part of attr.value) {
|
|
if (part.type === 'Text') staticClasses.push(...part.data.split(/\s+/).filter(Boolean));
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
rootTag: rootEl?.name || null,
|
|
rootClasses: staticClasses,
|
|
textSlots,
|
|
attrSlots,
|
|
staticTexts,
|
|
nestedUnsupported,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Classify a keyed each block's key expression:
|
|
* { keyField } member of the loop item (e.g. `(expense.id)` when the
|
|
* context binds `expense`): browser injects a unique
|
|
* per-index value under that field.
|
|
* {} key is the whole loop item or the index: already
|
|
* distinct per iteration, nothing to inject.
|
|
* { unsupported } free or complex keys: cannot hydrate distinct values.
|
|
*/
|
|
function classifyEachKey(node) {
|
|
const bound = new Set();
|
|
if (node.context) collectPatternNames(node.context, bound);
|
|
if (node.index) bound.add(node.index);
|
|
const key = node.key;
|
|
const roots = collectRootIdentifiers(key);
|
|
const usesLoopBinding = [...roots].some((name) => bound.has(name));
|
|
if (!usesLoopBinding) {
|
|
// A key that ignores the loop item is constant across iterations:
|
|
// guaranteed duplicate keys at mount.
|
|
return { unsupported: 'each key not derived from the loop item requires source-preview mode' };
|
|
}
|
|
if (key.type === 'Identifier' && bound.has(key.name)) return {};
|
|
if (
|
|
key.type === 'MemberExpression'
|
|
&& !key.computed
|
|
&& key.object?.type === 'Identifier'
|
|
&& bound.has(key.object.name)
|
|
&& key.property?.type === 'Identifier'
|
|
) {
|
|
return { keyField: key.property.name };
|
|
}
|
|
return { unsupported: 'complex each key requires source-preview mode' };
|
|
}
|
|
|
|
/**
|
|
* Describe a fragment's root element for browser presence probing:
|
|
* { tag, classes } of the first RegularElement, or null for text-only
|
|
* fragments (which cannot be probed reliably).
|
|
*/
|
|
function describeElementProbe(fragment) {
|
|
const rootEl = (fragment?.nodes || []).find((n) => n.type === 'RegularElement');
|
|
if (!rootEl) return null;
|
|
const classes = [];
|
|
for (const attr of rootEl.attributes || []) {
|
|
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
|
|
for (const part of attr.value) {
|
|
if (part.type === 'Text') classes.push(...part.data.split(/\s+/).filter(Boolean));
|
|
}
|
|
}
|
|
}
|
|
return { tag: rootEl.name, classes };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Analyze a markup block and produce the prop-substituted scaffold markup and
|
|
* the v2 prop contract. Returns { ok: false, reason } when the block needs
|
|
* source-preview mode (parse failure or unsupported construct).
|
|
*/
|
|
export function analyzeSvelteMarkup(markup, parse) {
|
|
const source = String(markup || '');
|
|
let ast;
|
|
try {
|
|
ast = parse(source, { modern: true });
|
|
} catch (err) {
|
|
return { ok: false, reason: `svelte parse failed: ${err.message}` };
|
|
}
|
|
if (ast.instance || ast.module) {
|
|
return { ok: false, reason: 'selected block contains a script tag' };
|
|
}
|
|
const analysis = new Analysis(source);
|
|
analyzeFragment(ast.fragment, analysis, []);
|
|
if (analysis.unsupported) {
|
|
return { ok: false, reason: analysis.unsupported };
|
|
}
|
|
for (const entry of analysis.contract) {
|
|
if (entry.kind === 'collection' && entry.item?.nestedUnsupported) {
|
|
return { ok: false, reason: 'per-item content (nested blocks or expressions) this preview cannot hydrate requires source-preview mode' };
|
|
}
|
|
}
|
|
|
|
const markupWithProps = applyReplacements(source, analysis.replacements);
|
|
return {
|
|
ok: true,
|
|
markupWithProps,
|
|
contract: analysis.contract.map((entry) => ({
|
|
prop: entry.prop,
|
|
expr: entry.expr,
|
|
kind: entry.kind,
|
|
// Kept for backward compatibility with v1 consumers (fake e2e agent,
|
|
// text-only restore paths).
|
|
placeholder: `{${entry.expr}}`,
|
|
...(entry.item ? { item: entry.item } : {}),
|
|
...(entry.probe ? { probe: entry.probe } : {}),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function applyReplacements(source, replacements) {
|
|
const sorted = [...replacements].sort((a, b) => b.start - a.start);
|
|
let out = source;
|
|
for (const { start, end, prop } of sorted) {
|
|
out = out.slice(0, start) + prop + out.slice(end);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Restore a variant's markup back to route-source form: every free
|
|
* identifier that matches a contract prop is replaced by its original
|
|
* expression. AST-based so `{#each stages as stage}` restores to
|
|
* `{#each data.stages as stage}` even though the prop appears without braces.
|
|
*/
|
|
export function restoreSvelteMarkup(markup, contract, parse) {
|
|
const source = String(markup || '');
|
|
const byProp = new Map();
|
|
for (const entry of contract || []) byProp.set(entry.prop, entry.expr);
|
|
if (byProp.size === 0) return { ok: true, markup: source };
|
|
|
|
let ast;
|
|
try {
|
|
ast = parse(source, { modern: true });
|
|
} catch (err) {
|
|
return { ok: false, reason: `variant parse failed: ${err.message}` };
|
|
}
|
|
|
|
const replacements = [];
|
|
const visitExpr = (expression, scopes) => {
|
|
if (!expression) return;
|
|
collectFreeIdentifierRanges(expression, scopes, (name, start, end) => {
|
|
const original = byProp.get(name);
|
|
if (original != null && original !== name) replacements.push({ start, end, prop: original });
|
|
});
|
|
};
|
|
|
|
const walk = (fragment, scopes) => {
|
|
const fragmentScope = new Set();
|
|
const nextScopes = [...scopes, fragmentScope];
|
|
for (const node of fragment?.nodes || []) {
|
|
if (node.type === 'ConstTag' && node.declaration) {
|
|
for (const decl of node.declaration.declarations || []) collectPatternNames(decl.id, fragmentScope);
|
|
}
|
|
}
|
|
for (const node of fragment?.nodes || []) {
|
|
switch (node?.type) {
|
|
case 'ExpressionTag':
|
|
case 'HtmlTag':
|
|
visitExpr(node.expression, nextScopes);
|
|
break;
|
|
case 'ConstTag':
|
|
for (const decl of node.declaration?.declarations || []) visitExpr(decl.init, nextScopes);
|
|
break;
|
|
case 'EachBlock': {
|
|
visitExpr(node.expression, nextScopes);
|
|
const bound = new Set();
|
|
if (node.context) collectPatternNames(node.context, bound);
|
|
if (node.index) bound.add(node.index);
|
|
// The key evaluates per item, so the loop context and index are in
|
|
// scope there. Visiting it with outer scopes only let a contract
|
|
// prop that shares a loop binding's name rewrite the key.
|
|
if (node.key) visitExpr(node.key, [...nextScopes, bound]);
|
|
walk(node.body, [...nextScopes, bound]);
|
|
if (node.fallback) walk(node.fallback, nextScopes);
|
|
break;
|
|
}
|
|
case 'IfBlock':
|
|
visitExpr(node.test, nextScopes);
|
|
walk(node.consequent, nextScopes);
|
|
if (node.alternate) walk(node.alternate, nextScopes);
|
|
break;
|
|
case 'KeyBlock':
|
|
visitExpr(node.expression, nextScopes);
|
|
walk(node.fragment, nextScopes);
|
|
break;
|
|
case 'SnippetBlock': {
|
|
const bound = new Set();
|
|
for (const param of node.parameters || []) collectPatternNames(param, bound);
|
|
walk(node.body, [...nextScopes, bound]);
|
|
break;
|
|
}
|
|
default: {
|
|
for (const attr of node?.attributes || []) {
|
|
if (attr.type === 'Attribute' && Array.isArray(attr.value)) {
|
|
for (const part of attr.value) {
|
|
if (part?.type === 'ExpressionTag') visitExpr(part.expression, nextScopes);
|
|
}
|
|
} else if (attr.expression) {
|
|
visitExpr(attr.expression, nextScopes);
|
|
}
|
|
}
|
|
if (node?.fragment) walk(node.fragment, nextScopes);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
walk(ast.fragment, []);
|
|
|
|
return { ok: true, markup: applyReplacements(source, replacements) };
|
|
}
|
|
|
|
/**
|
|
* Report [name, start, end] for every free root identifier READ in an
|
|
* expression (skips member properties, object keys, shadowed names).
|
|
*/
|
|
function collectFreeIdentifierRanges(node, scopes, emit) {
|
|
const visit = (n, localBound) => {
|
|
if (!n || typeof n !== 'object') return;
|
|
if (Array.isArray(n)) { for (const item of n) visit(item, localBound); return; }
|
|
switch (n.type) {
|
|
case 'Identifier': {
|
|
const bound = localBound.has(n.name) || scopes.some((s) => s.has(n.name));
|
|
if (!bound) emit(n.name, n.start, n.end);
|
|
return;
|
|
}
|
|
case 'MemberExpression':
|
|
visit(n.object, localBound);
|
|
if (n.computed) visit(n.property, localBound);
|
|
return;
|
|
case 'Property':
|
|
if (n.computed) visit(n.key, localBound);
|
|
visit(n.value, localBound);
|
|
return;
|
|
case 'ArrowFunctionExpression':
|
|
case 'FunctionExpression': {
|
|
const inner = new Set(localBound);
|
|
for (const param of n.params || []) collectPatternNames(param, inner);
|
|
visit(n.body, inner);
|
|
return;
|
|
}
|
|
default:
|
|
for (const key of Object.keys(n)) {
|
|
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
|
|
visit(n[key], localBound);
|
|
}
|
|
}
|
|
};
|
|
visit(node, new Set());
|
|
}
|
|
|
|
/**
|
|
* Build the preview component's script block from a v2 contract, with
|
|
* defaults that keep an unhydrated mount rendering instead of crashing.
|
|
*/
|
|
export function buildPropsScriptV2(contract) {
|
|
if (!contract || contract.length === 0) {
|
|
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
|
|
}
|
|
const defaults = {
|
|
text: "''",
|
|
raw: "''",
|
|
condition: 'false',
|
|
collection: '[]',
|
|
handler: '() => {}',
|
|
};
|
|
const types = {
|
|
text: 'string',
|
|
raw: 'string',
|
|
condition: 'boolean',
|
|
collection: 'Array<Record<string, unknown>>',
|
|
handler: '() => void',
|
|
};
|
|
const names = contract
|
|
.map((c) => `${c.prop} = ${defaults[c.kind] ?? "''"}`)
|
|
.join(', ');
|
|
const typeFields = contract
|
|
.map((c) => ` ${c.prop}?: ${types[c.kind] ?? 'string'};`)
|
|
.join('\n');
|
|
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
|
|
}
|