vue-html-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1067 @@
1
+ import { createHash } from "node:crypto";
2
+ import { performance } from "node:perf_hooks";
3
+ import { parse as parseTemplate } from "@vue/compiler-dom";
4
+ import { ElementTypes, NodeTypes, } from "@vue/compiler-core";
5
+ import { parse as parseSfc } from "@vue/compiler-sfc";
6
+ import { evaluateExpression, isSideEffectFreeExpression, lengthComparisonSafety, normalizeExpression, referencedPaths, } from "./expressions.js";
7
+ import { analyzeBindings, createTypeAnalysisContext, } from "./type-analysis.js";
8
+ export { createTypeAnalysisContext };
9
+ function rootIdentifier(path) {
10
+ const dot = path.indexOf(".");
11
+ return dot === -1 ? path : path.slice(0, dot);
12
+ }
13
+ function isShadowed(path, scope) {
14
+ const root = rootIdentifier(path);
15
+ return scope.some((frame) => frame.alias === root);
16
+ }
17
+ function enclosingScopeId(paths, scope) {
18
+ for (let index = scope.length - 1; index >= 0; index -= 1) {
19
+ const frame = scope[index];
20
+ if (paths.some((path) => rootIdentifier(path) === frame.alias)) {
21
+ return frame.scopeId;
22
+ }
23
+ }
24
+ return undefined;
25
+ }
26
+ function cardinalityIdentity(bindings, source, scope) {
27
+ if (!isShadowed(source, scope)) {
28
+ const binding = bindings.get(source);
29
+ if (binding)
30
+ return `${binding.identity}#cardinality`;
31
+ }
32
+ const scopeId = enclosingScopeId([source], scope);
33
+ return scopeId
34
+ ? `for:${scopeId}:${normalizeExpression(source)}#cardinality`
35
+ : `for:${normalizeExpression(source)}#cardinality`;
36
+ }
37
+ function predicateIdentity(expression, scope) {
38
+ const normalized = normalizePredicate(expression);
39
+ const scopeId = enclosingScopeId(referencedPaths(normalized), scope);
40
+ return scopeId
41
+ ? `predicate:${scopeId}:${normalized}`
42
+ : `predicate:${normalized}`;
43
+ }
44
+ const VOID_ELEMENTS = new Set([
45
+ "area",
46
+ "base",
47
+ "br",
48
+ "col",
49
+ "embed",
50
+ "hr",
51
+ "img",
52
+ "input",
53
+ "link",
54
+ "meta",
55
+ "param",
56
+ "source",
57
+ "track",
58
+ "wbr",
59
+ ]);
60
+ const BOOLEAN_ATTRIBUTES = new Set([
61
+ "allowfullscreen",
62
+ "async",
63
+ "autofocus",
64
+ "autoplay",
65
+ "checked",
66
+ "controls",
67
+ "default",
68
+ "defer",
69
+ "disabled",
70
+ "formnovalidate",
71
+ "hidden",
72
+ "inert",
73
+ "ismap",
74
+ "itemscope",
75
+ "loop",
76
+ "multiple",
77
+ "muted",
78
+ "nomodule",
79
+ "novalidate",
80
+ "open",
81
+ "playsinline",
82
+ "readonly",
83
+ "required",
84
+ "reversed",
85
+ "selected",
86
+ ]);
87
+ const ATTRIBUTE_BLOCKLIST = new Set([
88
+ "key",
89
+ "ref",
90
+ "true-value",
91
+ "false-value",
92
+ ]);
93
+ export async function generateVariants(request) {
94
+ const started = performance.now();
95
+ const signal = request.signal ?? new AbortController().signal;
96
+ signal.throwIfAborted();
97
+ const diagnostics = [];
98
+ const parsed = parseSfc(request.source, { filename: request.filename });
99
+ for (const error of parsed.errors) {
100
+ diagnostics.push(diagnosticFromCompilerError(request.filename, error, "sfc-parse-error"));
101
+ }
102
+ const template = parsed.descriptor.template;
103
+ if (!template) {
104
+ diagnostics.push({
105
+ code: "missing-template",
106
+ severity: "error",
107
+ message: "The SFC has no <template> block.",
108
+ sourceRange: range(request.filename, 0, 0),
109
+ });
110
+ return emptyResult(started, diagnostics);
111
+ }
112
+ const templateRange = range(request.filename, template.loc.start.offset, template.loc.end.offset);
113
+ if (template.lang || template.src) {
114
+ diagnostics.push({
115
+ code: "unsupported-template-source",
116
+ severity: "error",
117
+ message: template.src
118
+ ? "<template src> is not supported."
119
+ : `Template language ${template.lang ?? "unknown"} is not supported.`,
120
+ sourceRange: templateRange,
121
+ });
122
+ return emptyResult(started, diagnostics, templateRange);
123
+ }
124
+ await yieldToEventLoop(signal);
125
+ let root;
126
+ const templateErrors = [];
127
+ try {
128
+ root = parseTemplate(template.content, {
129
+ comments: true,
130
+ onError(error) {
131
+ templateErrors.push(error);
132
+ },
133
+ });
134
+ }
135
+ catch (error) {
136
+ templateErrors.push(error);
137
+ }
138
+ for (const error of templateErrors) {
139
+ diagnostics.push(diagnosticFromCompilerError(request.filename, error, "template-parse-error", template.loc.start.offset));
140
+ }
141
+ if (!root)
142
+ return emptyResult(started, diagnostics, templateRange);
143
+ const scriptBlock = parsed.descriptor.scriptSetup;
144
+ if (parsed.descriptor.script?.src || parsed.descriptor.scriptSetup?.src) {
145
+ diagnostics.push({
146
+ code: "script-type-analysis-unavailable",
147
+ severity: "warning",
148
+ message: "Script src is not resolved; template expressions use conservative values.",
149
+ sourceRange: templateRange,
150
+ });
151
+ }
152
+ else if (parsed.descriptor.script && !scriptBlock) {
153
+ diagnostics.push({
154
+ code: "script-type-analysis-unavailable",
155
+ severity: "info",
156
+ message: "Options API and non-setup script bindings are not type-resolved; template expressions use conservative values.",
157
+ sourceRange: templateRange,
158
+ });
159
+ }
160
+ const bindings = analyzeBindings(request.filename, scriptBlock?.content, request.typeContext);
161
+ signal.throwIfAborted();
162
+ const collector = new DecisionCollector(request.filename, template.loc.start.offset, bindings);
163
+ collector.walk(root);
164
+ diagnostics.push(...collector.diagnostics);
165
+ const decisions = collector.decisions;
166
+ const environments = enumerate(decisions);
167
+ const warningThreshold = request.options?.warnVariantCount ?? 256;
168
+ const warningThresholdExceeded = environments.length > warningThreshold;
169
+ if (warningThresholdExceeded) {
170
+ diagnostics.push({
171
+ code: "large-variant-space",
172
+ severity: "warning",
173
+ message: `This template produces ${environments.length} variants, exceeding the warning threshold of ${warningThreshold}.`,
174
+ sourceRange: templateRange,
175
+ });
176
+ }
177
+ const variants = [];
178
+ let lastYield = performance.now();
179
+ for (const [ordinal, environment] of environments.entries()) {
180
+ signal.throwIfAborted();
181
+ if (performance.now() - lastYield >= 8) {
182
+ await yieldToEventLoop(signal);
183
+ lastYield = performance.now();
184
+ }
185
+ const renderer = new Renderer({
186
+ filename: request.filename,
187
+ templateOffset: template.loc.start.offset,
188
+ bindings,
189
+ decisions,
190
+ environment,
191
+ diagnostics,
192
+ customElements: request.options?.customElements ?? [],
193
+ });
194
+ const fragments = renderer.renderChildren(root.children);
195
+ const serialized = serialize(fragments);
196
+ variants.push({
197
+ id: variantId(environment.assignments),
198
+ ordinal,
199
+ html: serialized.html,
200
+ decisions: environment.assignments,
201
+ map: serialized.map,
202
+ });
203
+ }
204
+ const uniqueDiagnostics = deduplicateDiagnostics(diagnostics);
205
+ return {
206
+ variants,
207
+ diagnostics: uniqueDiagnostics,
208
+ templateRange,
209
+ stats: {
210
+ decisionCount: decisions.length,
211
+ candidateCount: environments.length,
212
+ emittedCount: variants.length,
213
+ uniqueHtmlCount: new Set(variants.map((variant) => variant.html)).size,
214
+ durationMs: performance.now() - started,
215
+ warningThresholdExceeded,
216
+ },
217
+ };
218
+ }
219
+ class DecisionCollector {
220
+ filename;
221
+ templateOffset;
222
+ bindings;
223
+ decisions = [];
224
+ diagnostics = [];
225
+ byIdentity = new Map();
226
+ constructor(filename, templateOffset, bindings) {
227
+ this.filename = filename;
228
+ this.templateOffset = templateOffset;
229
+ this.bindings = bindings;
230
+ }
231
+ walk(node, scope = []) {
232
+ let ownScope = scope;
233
+ if (node.type === NodeTypes.ELEMENT) {
234
+ const forDirective = directive(node, "for");
235
+ const forExpression = expressionContent(forDirective?.exp);
236
+ const parsedFor = forExpression ? parseFor(forExpression) : undefined;
237
+ if (parsedFor) {
238
+ this.addCardinality(parsedFor.source, scope);
239
+ ownScope = [
240
+ ...scope,
241
+ { alias: parsedFor.alias, scopeId: `for:${node.loc.start.offset}` },
242
+ ];
243
+ }
244
+ for (const prop of node.props) {
245
+ if (prop.type !== NodeTypes.DIRECTIVE || prop.name === "for")
246
+ continue;
247
+ const expression = expressionContent(prop.exp);
248
+ // Vue 3: when v-if sits on the same element as v-for, v-if is
249
+ // evaluated OUTSIDE the loop and cannot see its alias — only the
250
+ // node's other directives and its children can (core.md §5.3).
251
+ const isIfLike = prop.name === "if" || prop.name === "else-if";
252
+ const propScope = isIfLike ? scope : ownScope;
253
+ if (expression && isIfLike) {
254
+ this.addExpression(expression, true, prop.exp, propScope);
255
+ }
256
+ else if (expression && ["bind", "model"].includes(prop.name)) {
257
+ this.addExpression(expression, false, prop.exp, propScope);
258
+ }
259
+ if (prop.name === "bind" && prop.arg) {
260
+ const arg = asSimpleExpression(prop.arg);
261
+ if (arg && !arg.isStatic) {
262
+ this.addExpression(arg.content, false, prop.arg, propScope);
263
+ }
264
+ }
265
+ }
266
+ if (node.tag === "Suspense") {
267
+ this.addDecision(`suspense:${node.loc.start.offset}`, `Suspense@${node.loc.start.offset}`, ["default", "fallback"]);
268
+ }
269
+ }
270
+ for (const child of childrenOf(node))
271
+ this.walk(child, ownScope);
272
+ }
273
+ addExpression(expression, booleanSite, loc, scope) {
274
+ let added = false;
275
+ for (const path of referencedPaths(expression)) {
276
+ if (isShadowed(path, scope))
277
+ continue;
278
+ if (path.endsWith(".length")) {
279
+ const base = path.slice(0, -".length".length);
280
+ const baseBinding = isShadowed(base, scope)
281
+ ? undefined
282
+ : this.bindings.get(base);
283
+ if (baseBinding?.domain.kind === "array" &&
284
+ lengthComparisonSafety(expression, path) === "safe") {
285
+ this.addCardinality(base, scope);
286
+ added = true;
287
+ }
288
+ continue;
289
+ }
290
+ const binding = this.bindings.get(path);
291
+ if (!binding)
292
+ continue;
293
+ if (binding.domain.kind === "finite") {
294
+ this.addDecision(binding.identity, binding.displayName, binding.domain.values);
295
+ added = true;
296
+ }
297
+ }
298
+ if (booleanSite && !added && isSideEffectFreeExpression(expression)) {
299
+ const identity = predicateIdentity(expression, scope);
300
+ this.addDecision(identity, expression, [true, false]);
301
+ }
302
+ else if (booleanSite && !added) {
303
+ this.diagnostics.push({
304
+ code: "expression-not-symbolically-evaluable",
305
+ severity: "warning",
306
+ message: `Expression cannot be evaluated without running JavaScript: ${expression}`,
307
+ sourceRange: this.sourceRange(loc),
308
+ });
309
+ this.addDecision(`local-predicate:${loc.loc.start.offset}`, expression, [
310
+ true,
311
+ false,
312
+ ]);
313
+ }
314
+ }
315
+ addCardinality(path, scope) {
316
+ const identity = cardinalityIdentity(this.bindings, path, scope);
317
+ this.addDecision(identity, `${path}.length`, [0, 1, 2]);
318
+ }
319
+ addDecision(identity, displayName, values) {
320
+ const existing = this.byIdentity.get(identity);
321
+ if (existing)
322
+ return existing;
323
+ const decision = {
324
+ id: `d-${hash(identity).slice(0, 12)}`,
325
+ identity,
326
+ displayName,
327
+ values,
328
+ };
329
+ this.byIdentity.set(identity, decision);
330
+ this.decisions.push(decision);
331
+ return decision;
332
+ }
333
+ sourceRange(node) {
334
+ return range(this.filename, this.templateOffset + node.loc.start.offset, this.templateOffset + node.loc.end.offset);
335
+ }
336
+ }
337
+ class Renderer {
338
+ options;
339
+ decisionsByIdentity;
340
+ constructor(options) {
341
+ this.options = options;
342
+ this.decisionsByIdentity = new Map(options.decisions.map((decision) => [decision.identity, decision]));
343
+ }
344
+ renderChildren(nodes, scope = []) {
345
+ const result = [];
346
+ for (let index = 0; index < nodes.length; index += 1) {
347
+ const node = nodes[index];
348
+ if (node?.type === NodeTypes.COMMENT)
349
+ continue;
350
+ if (node?.type === NodeTypes.ELEMENT && directive(node, "if")) {
351
+ const chain = [node];
352
+ let cursor = index + 1;
353
+ while (cursor < nodes.length) {
354
+ const candidate = nodes[cursor];
355
+ if (isIgnorableWhitespace(candidate) ||
356
+ candidate?.type === NodeTypes.COMMENT) {
357
+ cursor += 1;
358
+ continue;
359
+ }
360
+ if (candidate?.type === NodeTypes.ELEMENT &&
361
+ (directive(candidate, "else-if") || directive(candidate, "else"))) {
362
+ chain.push(candidate);
363
+ cursor += 1;
364
+ continue;
365
+ }
366
+ break;
367
+ }
368
+ const selected = chain.find((branch) => {
369
+ const expNode = directive(branch, "if")?.exp ?? directive(branch, "else-if")?.exp;
370
+ const condition = expressionContent(expNode);
371
+ return (condition === undefined ||
372
+ this.truthy(condition, scope, expNode.loc.start.offset));
373
+ });
374
+ if (selected)
375
+ result.push(...this.renderNode(selected, { skipIf: true }, scope));
376
+ index = cursor - 1;
377
+ continue;
378
+ }
379
+ if (node?.type === NodeTypes.ELEMENT &&
380
+ (directive(node, "else-if") || directive(node, "else"))) {
381
+ continue;
382
+ }
383
+ result.push(...this.renderNode(node, {}, scope));
384
+ }
385
+ return result;
386
+ }
387
+ renderNode(node, state = {}, scope = []) {
388
+ if (!node)
389
+ return [];
390
+ if (node.type === NodeTypes.TEXT) {
391
+ const value = normalizeText(node.content);
392
+ if (!value)
393
+ return [];
394
+ const sourceRange = this.sourceRange(node.loc);
395
+ return [
396
+ {
397
+ kind: "text",
398
+ value,
399
+ sourceRange,
400
+ provenance: { kind: "source-literal", sourceRange },
401
+ },
402
+ ];
403
+ }
404
+ if (node.type === NodeTypes.INTERPOLATION) {
405
+ const sourceRange = this.sourceRange(node.content.loc ?? node.loc);
406
+ return [
407
+ {
408
+ kind: "text",
409
+ value: "dummy-string",
410
+ sourceRange,
411
+ provenance: {
412
+ kind: "synthetic",
413
+ sourceRange,
414
+ transformation: "text-placeholder",
415
+ },
416
+ },
417
+ ];
418
+ }
419
+ if (node.type !== NodeTypes.ELEMENT)
420
+ return [];
421
+ if (!state.skipFor) {
422
+ const forDirective = directive(node, "for");
423
+ if (forDirective) {
424
+ const forExpression = expressionContent(forDirective.exp);
425
+ if (forExpression) {
426
+ const parsed = parseFor(forExpression);
427
+ if (!parsed) {
428
+ this.addDiagnostic("unsupported-v-for", "Could not parse this v-for expression.", forDirective.loc);
429
+ return [];
430
+ }
431
+ const count = this.cardinality(parsed.source, scope);
432
+ const ownScope = [
433
+ ...scope,
434
+ { alias: parsed.alias, scopeId: `for:${node.loc.start.offset}` },
435
+ ];
436
+ const output = [];
437
+ for (let index = 0; index < count; index += 1) {
438
+ output.push(...this.renderNode(node, { ...state, skipFor: true }, ownScope));
439
+ }
440
+ return output;
441
+ }
442
+ }
443
+ }
444
+ if (!state.skipIf) {
445
+ const ifDirective = directive(node, "if");
446
+ const condition = expressionContent(ifDirective?.exp);
447
+ if (condition &&
448
+ !this.truthy(condition, scope, ifDirective.exp.loc.start.offset)) {
449
+ return [];
450
+ }
451
+ }
452
+ const tag = node.tag;
453
+ if (tag === "slot")
454
+ return [];
455
+ if (tag === "Suspense")
456
+ return this.renderSuspense(node, scope);
457
+ if (["Transition", "Teleport"].includes(tag)) {
458
+ return this.renderChildren(unwrapDefaultSlot(node.children), scope);
459
+ }
460
+ if (tag === "TransitionGroup") {
461
+ const wrapper = textContent(staticAttribute(node, "tag")?.value);
462
+ if (!wrapper)
463
+ return this.renderChildren(unwrapDefaultSlot(node.children), scope);
464
+ return [
465
+ this.renderElement(node, wrapper, unwrapDefaultSlot(node.children), scope),
466
+ ];
467
+ }
468
+ if (tag === "template")
469
+ return this.renderChildren(node.children, scope);
470
+ const isVueIs = textContent(staticAttribute(node, "is")?.value)?.startsWith("vue:");
471
+ const custom = matchesCustomElement(tag, this.options.customElements);
472
+ const component = isVueIs ||
473
+ (node.tagType === ElementTypes.COMPONENT && !custom) ||
474
+ /^[A-Z]/.test(tag);
475
+ if (component)
476
+ return [];
477
+ return [this.renderElement(node, tag, node.children, scope)];
478
+ }
479
+ renderElement(node, tagName, children, scope) {
480
+ const tagStart = node.loc.start.offset + 1;
481
+ const tagRange = this.sourceRangeOffsets(tagStart, tagStart + node.tag.length);
482
+ const attributes = this.renderAttributes(node, scope);
483
+ let renderedChildren = this.renderChildren(children, scope);
484
+ const htmlDirective = directive(node, "html");
485
+ const textDirective = directive(node, "text");
486
+ if (htmlDirective) {
487
+ renderedChildren = [];
488
+ this.addDiagnostic("v-html-content-not-analyzed", "Content injected with v-html cannot be statically validated.", htmlDirective.loc);
489
+ }
490
+ else if (textDirective) {
491
+ const sourceRange = this.sourceRange(textDirective.exp?.loc ?? textDirective.loc);
492
+ renderedChildren = [
493
+ {
494
+ kind: "text",
495
+ value: "dummy-string",
496
+ sourceRange,
497
+ provenance: {
498
+ kind: "synthetic",
499
+ sourceRange,
500
+ transformation: "text-placeholder",
501
+ },
502
+ },
503
+ ];
504
+ }
505
+ const endSource = node.loc.source;
506
+ const close = endSource.lastIndexOf(`</${node.tag}`);
507
+ const endTagRange = close >= 0
508
+ ? this.sourceRangeOffsets(node.loc.start.offset + close + 2, node.loc.start.offset + close + 2 + node.tag.length)
509
+ : undefined;
510
+ return {
511
+ kind: "element",
512
+ tagName,
513
+ tagRange,
514
+ endTagRange,
515
+ attributes,
516
+ children: renderedChildren,
517
+ };
518
+ }
519
+ renderAttributes(node, scope) {
520
+ const output = [];
521
+ const model = directive(node, "model");
522
+ for (const prop of node.props) {
523
+ if (prop.type === NodeTypes.ATTRIBUTE) {
524
+ if (ATTRIBUTE_BLOCKLIST.has(prop.name) || prop.name === "tag")
525
+ continue;
526
+ if (model && ["value", "checked"].includes(prop.name)) {
527
+ this.addDiagnostic("v-model-static-attribute-conflict", `v-model overrides the static ${prop.name} attribute.`, prop.loc);
528
+ continue;
529
+ }
530
+ const nameRange = this.sourceRangeOffsets(prop.loc.start.offset, prop.loc.start.offset + prop.name.length);
531
+ const valueRange = prop.value
532
+ ? sourceSubRange(this.options.filename, this.options.templateOffset, prop.loc, prop.value.content)
533
+ : undefined;
534
+ output.push({
535
+ name: prop.name,
536
+ value: prop.value?.content,
537
+ nameRange,
538
+ valueRange,
539
+ provenance: {
540
+ kind: "source-literal",
541
+ sourceRange: valueRange ?? nameRange,
542
+ },
543
+ });
544
+ continue;
545
+ }
546
+ if (prop.type !== NodeTypes.DIRECTIVE)
547
+ continue;
548
+ if ([
549
+ "if",
550
+ "else-if",
551
+ "else",
552
+ "for",
553
+ "show",
554
+ "once",
555
+ "memo",
556
+ "cloak",
557
+ ].includes(prop.name)) {
558
+ continue;
559
+ }
560
+ if (prop.name === "bind") {
561
+ output.push(...this.renderBind(prop, scope));
562
+ }
563
+ else if (prop.name === "on") {
564
+ output.push(this.renderEvent(prop));
565
+ }
566
+ else if (prop.name === "model") {
567
+ const modelAttribute = this.renderModel(node, prop);
568
+ if (modelAttribute)
569
+ output.push(modelAttribute);
570
+ }
571
+ else if (!["text", "html", "slot", "pre"].includes(prop.name)) {
572
+ this.addDiagnostic("custom-directive-not-modeled", `The DOM effects of v-${prop.name} are not modeled.`, prop.loc);
573
+ }
574
+ }
575
+ return output.sort((left, right) => left.name.localeCompare(right.name) ||
576
+ left.nameRange.start - right.nameRange.start);
577
+ }
578
+ renderBind(prop, scope) {
579
+ if (prop.modifiers.some((modifier) => modifier.content === "prop")) {
580
+ return [];
581
+ }
582
+ const exp = asSimpleExpression(prop.exp);
583
+ if (!exp?.content)
584
+ return [];
585
+ const evaluated = this.evaluate(exp.content, scope, exp.loc.start.offset);
586
+ if (!prop.arg) {
587
+ if (evaluated.kind !== "known" || !isJsonObject(evaluated.value)) {
588
+ this.addDiagnostic("object-v-bind-not-finite", "v-bind object keys and values could not be resolved to a finite object.", prop.loc);
589
+ return [];
590
+ }
591
+ return Object.entries(evaluated.value)
592
+ .sort(([left], [right]) => left.localeCompare(right))
593
+ .flatMap(([name, value]) => this.attributeFromValue(name, value, prop.loc, exp, scope));
594
+ }
595
+ let name;
596
+ const argExp = asSimpleExpression(prop.arg);
597
+ if (argExp?.isStatic)
598
+ name = argExp.content;
599
+ else if (argExp) {
600
+ const arg = this.evaluate(argExp.content, scope, argExp.loc.start.offset);
601
+ if (arg.kind === "known" && typeof arg.value === "string")
602
+ name = arg.value;
603
+ }
604
+ if (!name) {
605
+ this.addDiagnostic("dynamic-argument-not-finite", "The dynamic attribute name could not be narrowed to a finite value.", prop.arg.loc);
606
+ return [];
607
+ }
608
+ if (prop.modifiers.some((modifier) => modifier.content === "camel")) {
609
+ name = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
610
+ }
611
+ if (ATTRIBUTE_BLOCKLIST.has(name))
612
+ return [];
613
+ if (evaluated.kind === "known") {
614
+ return this.attributeFromValue(name, evaluated.value, prop.arg.loc, exp, scope);
615
+ }
616
+ const sourceRange = this.sourceRange(exp.loc);
617
+ return [
618
+ {
619
+ name,
620
+ value: dummyValue("", name),
621
+ nameRange: this.sourceRange(prop.arg.loc),
622
+ valueRange: sourceRange,
623
+ provenance: this.sentinelProvenance(exp.content, sourceRange, scope),
624
+ },
625
+ ];
626
+ }
627
+ attributeFromValue(name, value, fallbackLoc, valueNode, scope) {
628
+ if (value === null ||
629
+ value === undefined ||
630
+ (value === false && BOOLEAN_ATTRIBUTES.has(name.toLowerCase()))) {
631
+ return [];
632
+ }
633
+ const valueRange = this.sourceRange(valueNode.loc ?? fallbackLoc);
634
+ const provenance = this.provenanceForExpression(valueNode.content, valueRange, scope);
635
+ const stringValue = value === true && BOOLEAN_ATTRIBUTES.has(name.toLowerCase())
636
+ ? undefined
637
+ : formatAttributeValue(name, value);
638
+ return [
639
+ {
640
+ name,
641
+ value: stringValue,
642
+ nameRange: this.sourceRange(fallbackLoc),
643
+ valueRange,
644
+ provenance,
645
+ },
646
+ ];
647
+ }
648
+ renderEvent(prop) {
649
+ const argExp = asSimpleExpression(prop.arg);
650
+ const event = eventNameForModifiers(argExp?.isStatic ? argExp.content : "event", prop.modifiers.map((modifier) => modifier.content));
651
+ const sourceRange = this.sourceRange(prop.loc);
652
+ return {
653
+ name: `on${event.toLowerCase()}`,
654
+ value: "dummy-fn",
655
+ nameRange: prop.arg ? this.sourceRange(prop.arg.loc) : sourceRange,
656
+ valueRange: prop.exp ? this.sourceRange(prop.exp.loc) : sourceRange,
657
+ provenance: {
658
+ kind: "synthetic",
659
+ sourceRange,
660
+ transformation: "vue-event",
661
+ },
662
+ };
663
+ }
664
+ renderModel(node, prop) {
665
+ const type = textContent(staticAttribute(node, "type")?.value);
666
+ if (node.tag === "select")
667
+ return undefined;
668
+ const name = type && ["checkbox", "radio"].includes(type) ? "checked" : "value";
669
+ const sourceRange = this.sourceRange(prop.exp?.loc ?? prop.loc);
670
+ return {
671
+ name,
672
+ value: name === "checked" ? undefined : dummyValue(node.tag, name, type),
673
+ nameRange: this.sourceRange(prop.loc),
674
+ valueRange: sourceRange,
675
+ provenance: {
676
+ kind: "synthetic",
677
+ sourceRange,
678
+ transformation: "v-model",
679
+ },
680
+ };
681
+ }
682
+ renderSuspense(node, scope) {
683
+ const decision = this.decisionsByIdentity.get(`suspense:${node.loc.start.offset}`);
684
+ const selected = decision
685
+ ? this.options.environment.values.get(decision.id)
686
+ : "default";
687
+ const template = node.children.find((child) => {
688
+ if (child.type !== NodeTypes.ELEMENT)
689
+ return false;
690
+ const slot = directive(child, "slot");
691
+ return expressionContent(slot?.arg) === selected;
692
+ });
693
+ return template && template.type === NodeTypes.ELEMENT
694
+ ? this.renderChildren(template.children, scope)
695
+ : [];
696
+ }
697
+ evaluate(expression, scope, offset) {
698
+ return evaluateExpression(expression, this.expressionEnvironment(scope, offset, expression));
699
+ }
700
+ truthy(expression, scope, offset) {
701
+ const result = this.evaluate(expression, scope, offset);
702
+ return result.kind === "known" ? Boolean(result.value) : false;
703
+ }
704
+ cardinality(source, scope) {
705
+ const identity = cardinalityIdentity(this.options.bindings, source, scope);
706
+ const decision = this.decisionsByIdentity.get(identity);
707
+ return Number(decision ? this.options.environment.values.get(decision.id) : 1);
708
+ }
709
+ expressionEnvironment(scope, offset, contextExpression) {
710
+ return {
711
+ resolve: (path) => {
712
+ if (isShadowed(path, scope))
713
+ return { found: false };
714
+ if (path.endsWith(".length")) {
715
+ const base = path.slice(0, -".length".length);
716
+ if (!isShadowed(base, scope) &&
717
+ lengthComparisonSafety(contextExpression, path) === "safe") {
718
+ const identity = cardinalityIdentity(this.options.bindings, base, scope);
719
+ const decision = this.decisionsByIdentity.get(identity);
720
+ if (decision) {
721
+ return {
722
+ found: true,
723
+ value: this.options.environment.values.get(decision.id) ?? 0,
724
+ };
725
+ }
726
+ }
727
+ }
728
+ const binding = this.options.bindings.get(path);
729
+ if (!binding)
730
+ return { found: false };
731
+ const decision = this.decisionsByIdentity.get(binding.identity);
732
+ if (decision) {
733
+ return {
734
+ found: true,
735
+ value: this.options.environment.values.get(decision.id) ?? null,
736
+ };
737
+ }
738
+ return { found: false };
739
+ },
740
+ resolvePredicate: (source) => {
741
+ const negated = source.startsWith("!");
742
+ const identity = predicateIdentity(source, scope);
743
+ const decision = this.decisionsByIdentity.get(identity);
744
+ if (!decision) {
745
+ const local = this.decisionsByIdentity.get(`local-predicate:${offset}`);
746
+ if (!local)
747
+ return undefined;
748
+ const value = Boolean(this.options.environment.values.get(local.id));
749
+ return negated ? !value : value;
750
+ }
751
+ const value = Boolean(this.options.environment.values.get(decision.id));
752
+ return negated ? !value : value;
753
+ },
754
+ };
755
+ }
756
+ provenanceForExpression(expression, sourceRange, scope) {
757
+ for (const path of referencedPaths(expression)) {
758
+ if (isShadowed(path, scope))
759
+ continue;
760
+ const binding = this.options.bindings.get(path);
761
+ if (!binding)
762
+ continue;
763
+ const decision = this.decisionsByIdentity.get(binding.identity);
764
+ if (decision) {
765
+ return {
766
+ kind: "finite-domain",
767
+ sourceRange,
768
+ decisionId: decision.id,
769
+ };
770
+ }
771
+ if (["string", "number", "unknown"].includes(binding.domain.kind)) {
772
+ return {
773
+ kind: "sentinel",
774
+ sourceRange,
775
+ reason: "non-finite-type",
776
+ originalType: binding.domain.typeName,
777
+ };
778
+ }
779
+ }
780
+ return { kind: "source-literal", sourceRange };
781
+ }
782
+ /**
783
+ * Provenance for a value we could not evaluate to a known JS value at all
784
+ * (core.md §5.4). Distinguishes "this is a single bound symbol whose type
785
+ * is just too broad to be finite" (non-finite-type, e.g. `pressed: string`)
786
+ * from "this could not be resolved at all" (unresolved-expression, e.g. an
787
+ * undeclared identifier or a compound expression) — never from the
788
+ * expression's syntactic shape alone, so a symbol combined with anything
789
+ * else (an operator, a second identifier) is conservatively the latter.
790
+ */
791
+ sentinelProvenance(expression, sourceRange, scope) {
792
+ const paths = referencedPaths(expression);
793
+ const [only] = paths;
794
+ if (only &&
795
+ paths.length === 1 &&
796
+ !isShadowed(only, scope) &&
797
+ normalizeExpression(expression) === normalizeExpression(only)) {
798
+ const binding = this.options.bindings.get(only);
799
+ if (binding &&
800
+ ["string", "number", "unknown"].includes(binding.domain.kind)) {
801
+ return {
802
+ kind: "sentinel",
803
+ sourceRange,
804
+ reason: "non-finite-type",
805
+ originalType: binding.domain.typeName,
806
+ };
807
+ }
808
+ }
809
+ return { kind: "sentinel", sourceRange, reason: "unresolved-expression" };
810
+ }
811
+ addDiagnostic(code, message, loc) {
812
+ this.options.diagnostics.push({
813
+ code,
814
+ severity: "warning",
815
+ message,
816
+ sourceRange: this.sourceRange(loc),
817
+ });
818
+ }
819
+ sourceRange(loc) {
820
+ return this.sourceRangeOffsets(loc.start.offset, loc.end.offset);
821
+ }
822
+ sourceRangeOffsets(start, end) {
823
+ return range(this.options.filename, this.options.templateOffset + start, this.options.templateOffset + end);
824
+ }
825
+ }
826
+ function serialize(fragments) {
827
+ let html = "";
828
+ const map = [];
829
+ const appendMapped = (value, source, kind, provenance) => {
830
+ const start = html.length;
831
+ html += value;
832
+ map.push({
833
+ generated: { start, end: html.length },
834
+ source,
835
+ kind,
836
+ provenance,
837
+ });
838
+ };
839
+ const write = (fragment) => {
840
+ if (fragment.kind === "text") {
841
+ appendMapped(escapeText(fragment.value), fragment.sourceRange, "text", fragment.provenance);
842
+ return;
843
+ }
844
+ html += "<";
845
+ appendMapped(fragment.tagName, fragment.tagRange, "element-name", {
846
+ kind: "source-literal",
847
+ sourceRange: fragment.tagRange,
848
+ });
849
+ for (const attribute of fragment.attributes) {
850
+ html += " ";
851
+ appendMapped(attribute.name, attribute.nameRange, "attribute-name", attribute.provenance);
852
+ if (attribute.value !== undefined) {
853
+ html += '="';
854
+ appendMapped(escapeAttribute(attribute.value), attribute.valueRange ?? attribute.nameRange, "attribute-value", attribute.provenance);
855
+ html += '"';
856
+ }
857
+ }
858
+ html += ">";
859
+ if (VOID_ELEMENTS.has(fragment.tagName.toLowerCase()))
860
+ return;
861
+ for (const child of fragment.children)
862
+ write(child);
863
+ html += "</";
864
+ appendMapped(fragment.tagName, fragment.endTagRange ?? fragment.tagRange, "element-name", {
865
+ kind: "source-literal",
866
+ sourceRange: fragment.endTagRange ?? fragment.tagRange,
867
+ });
868
+ html += ">";
869
+ };
870
+ for (const fragment of fragments)
871
+ write(fragment);
872
+ map.sort((left, right) => left.generated.start - right.generated.start ||
873
+ left.generated.end -
874
+ left.generated.start -
875
+ (right.generated.end - right.generated.start) ||
876
+ left.source.start - right.source.start);
877
+ return { html, map };
878
+ }
879
+ function enumerate(decisions) {
880
+ let environments = [{ values: new Map(), assignments: [] }];
881
+ for (const decision of decisions) {
882
+ environments = environments.flatMap((environment) => decision.values.map((value) => ({
883
+ values: new Map(environment.values).set(decision.id, value),
884
+ assignments: [
885
+ ...environment.assignments,
886
+ {
887
+ decisionId: decision.id,
888
+ displayName: decision.displayName,
889
+ value,
890
+ },
891
+ ],
892
+ })));
893
+ }
894
+ return environments;
895
+ }
896
+ function childrenOf(node) {
897
+ return node.type === NodeTypes.ROOT || node.type === NodeTypes.ELEMENT
898
+ ? node.children
899
+ : [];
900
+ }
901
+ function asSimpleExpression(node) {
902
+ return node?.type === NodeTypes.SIMPLE_EXPRESSION ? node : undefined;
903
+ }
904
+ function expressionContent(node) {
905
+ return asSimpleExpression(node)?.content;
906
+ }
907
+ function textContent(node) {
908
+ return node?.content;
909
+ }
910
+ function directive(node, name) {
911
+ return node?.props.find((prop) => prop.type === NodeTypes.DIRECTIVE && prop.name === name);
912
+ }
913
+ function staticAttribute(node, name) {
914
+ return node?.props.find((prop) => prop.type === NodeTypes.ATTRIBUTE && prop.name === name);
915
+ }
916
+ function parseFor(expression) {
917
+ const match = expression.match(/^\s*(?:\(([^,)]+)(?:,[^)]+)?\)|([^\s]+))\s+(?:in|of)\s+(.+)$/);
918
+ if (!match)
919
+ return undefined;
920
+ return {
921
+ alias: (match[1] ?? match[2] ?? "item").trim(),
922
+ source: (match[3] ?? "").trim(),
923
+ };
924
+ }
925
+ // core.md §5.3: ".right"/".middle" are the only modifiers that change the
926
+ // HTML event name the compiler listens for, and only for "click".
927
+ function eventNameForModifiers(event, modifiers) {
928
+ if (event !== "click")
929
+ return event;
930
+ if (modifiers.includes("right"))
931
+ return "contextmenu";
932
+ if (modifiers.includes("middle"))
933
+ return "mouseup";
934
+ return event;
935
+ }
936
+ function normalizePredicate(expression) {
937
+ return normalizeExpression(expression).replace(/^!/, "");
938
+ }
939
+ function normalizeText(value) {
940
+ return value.replace(/\r\n?|\n/g, " ");
941
+ }
942
+ function escapeText(value) {
943
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;");
944
+ }
945
+ function escapeAttribute(value) {
946
+ return value
947
+ .replace(/&/g, "&amp;")
948
+ .replace(/"/g, "&quot;")
949
+ .replace(/\r\n?|\n/g, "&#10;");
950
+ }
951
+ function formatAttributeValue(name, value) {
952
+ if (Array.isArray(value))
953
+ return value.map(String).join(" ");
954
+ if (isJsonObject(value)) {
955
+ if (name === "style") {
956
+ return Object.entries(value)
957
+ .sort(([left], [right]) => left.localeCompare(right))
958
+ .map(([key, item]) => `${key}:${String(item)}`)
959
+ .join(";");
960
+ }
961
+ return Object.entries(value)
962
+ .filter(([, item]) => Boolean(item))
963
+ .map(([key]) => key)
964
+ .sort()
965
+ .join(" ");
966
+ }
967
+ return String(value);
968
+ }
969
+ function dummyValue(tag, attribute, inputType) {
970
+ if (tag === "input" && attribute === "value") {
971
+ if (inputType === "email")
972
+ return "dummy@example.com";
973
+ if (["number", "range"].includes(inputType ?? ""))
974
+ return "1";
975
+ if (inputType === "url")
976
+ return "https://example.invalid/";
977
+ }
978
+ if (attribute === "id")
979
+ return "dummy-id";
980
+ return "dummy-string";
981
+ }
982
+ function sourceSubRange(filename, templateOffset, loc, substring) {
983
+ const relative = loc.source.lastIndexOf(substring);
984
+ const start = loc.start.offset + Math.max(0, relative);
985
+ return range(filename, templateOffset + start, templateOffset + start + substring.length);
986
+ }
987
+ function unwrapDefaultSlot(children) {
988
+ const direct = children.find((child) => child.type === NodeTypes.ELEMENT &&
989
+ expressionContent(directive(child, "slot")?.arg) === "default");
990
+ return direct && direct.type === NodeTypes.ELEMENT
991
+ ? direct.children
992
+ : children;
993
+ }
994
+ function isIgnorableWhitespace(node) {
995
+ return node?.type === NodeTypes.TEXT && /^\s*$/.test(node.content);
996
+ }
997
+ function matchesCustomElement(tag, patterns) {
998
+ return patterns.some((pattern) => {
999
+ const escaped = pattern
1000
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
1001
+ .replace(/\*/g, ".*");
1002
+ return new RegExp(`^${escaped}$`, "i").test(tag);
1003
+ });
1004
+ }
1005
+ function isJsonObject(value) {
1006
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1007
+ }
1008
+ function range(filename, start, end) {
1009
+ return { filename, start, end };
1010
+ }
1011
+ function hash(value) {
1012
+ return createHash("sha256").update(value).digest("hex");
1013
+ }
1014
+ function variantId(assignments) {
1015
+ return `v-${hash(JSON.stringify(assignments)).slice(0, 16)}`;
1016
+ }
1017
+ function deduplicateDiagnostics(diagnostics) {
1018
+ const byKey = new Map();
1019
+ for (const diagnostic of diagnostics) {
1020
+ const key = `${diagnostic.code}:${diagnostic.sourceRange.filename}:${diagnostic.sourceRange.start}:${diagnostic.sourceRange.end}`;
1021
+ if (!byKey.has(key))
1022
+ byKey.set(key, diagnostic);
1023
+ }
1024
+ return [...byKey.values()].sort((left, right) => left.sourceRange.start - right.sourceRange.start ||
1025
+ left.sourceRange.end - right.sourceRange.end ||
1026
+ left.code.localeCompare(right.code));
1027
+ }
1028
+ function diagnosticFromCompilerError(filename, error, code, baseOffset = 0) {
1029
+ const candidate = error;
1030
+ const start = baseOffset + (candidate.loc?.start?.offset ?? 0);
1031
+ const end = baseOffset + (candidate.loc?.end?.offset ?? start);
1032
+ return {
1033
+ code,
1034
+ severity: "error",
1035
+ message: candidate.message ?? String(error),
1036
+ sourceRange: range(filename, start, end),
1037
+ };
1038
+ }
1039
+ function emptyResult(started, diagnostics, templateRange) {
1040
+ return {
1041
+ variants: [],
1042
+ diagnostics,
1043
+ templateRange,
1044
+ stats: {
1045
+ decisionCount: 0,
1046
+ candidateCount: 0,
1047
+ emittedCount: 0,
1048
+ uniqueHtmlCount: 0,
1049
+ durationMs: performance.now() - started,
1050
+ warningThresholdExceeded: false,
1051
+ },
1052
+ };
1053
+ }
1054
+ function yieldToEventLoop(signal) {
1055
+ return new Promise((resolve, reject) => {
1056
+ setImmediate(() => {
1057
+ try {
1058
+ signal.throwIfAborted();
1059
+ resolve();
1060
+ }
1061
+ catch (error) {
1062
+ reject(error);
1063
+ }
1064
+ });
1065
+ });
1066
+ }
1067
+ //# sourceMappingURL=generate.js.map