@nextwebwg/html-next 1.0.0-alpha.1 → 1.0.0-alpha.2

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.
@@ -5,129 +5,47 @@
5
5
  * Vue refs, effects, and lifecycle; styles become `<style scoped>`.
6
6
  */
7
7
  import { fail } from "../diagnostics.js";
8
- import { isEnumeratedBoolean } from "../expression.js";
9
8
  import { componentName } from "../names.js";
10
9
  import { getDomInterface } from "../platform.js";
11
10
  import { compileComponentStylesForVue } from "../component-styles-build.js";
12
11
  import { stateAttribute } from "../component-styles.js";
13
- import { parseTypeExpression } from "../type-system.js";
12
+ import { normalizeType, parseTypeExpression } from "../type-system.js";
14
13
  import { targetComponent } from "./backend.js";
15
- import { escapeHtml, isVoidElement, propKey, propTypeSource, quote } from "./shared.js";
16
- /** Expression helpers with HTML Next semantics, emitted into each component (not imported) as used. */
17
- const HELPERS = [
18
- ["t", ` t: (v: unknown): boolean => v === true || (typeof v === "string" ? v.length > 0 : typeof v === "number" ? v !== 0 && v === v : Array.isArray(v) ? v.length > 0 : v !== null && typeof v === "object" ? Object.keys(v).length > 0 : false),`],
19
- ["n", ` n: (v: unknown): number | undefined => typeof v === "number" && v === v ? v : undefined,`],
20
- ["op", ` op: (op: string, a: unknown, b: unknown): unknown => {
21
- const x = hn.n(a), y = hn.n(b);
22
- if (x === undefined || y === undefined) return undefined;
23
- switch (op) {
24
- case "<": return x < y; case "<=": return x <= y; case ">": return x > y; case ">=": return x >= y;
25
- case "+": return x + y; case "-": return x - y; case "*": return x * y; case "/": return x / y; case "%": return x % y;
14
+ import { escapeHtml, isVoidElement, propKey, propTypeSource, quote, typeSource } from "./shared.js";
15
+ import { formatVue } from "./vue-format.js";
16
+ import { category, Lowering, present, typeOf, typeScript, UNKNOWN } from "./vue-lowering.js";
17
+ const VUE_APIS = ["computed", "onBeforeUnmount", "onMounted", "ref", "shallowRef", "useTemplateRef", "watchEffect"];
18
+ /** Names the generated script defines itself, which declared names must not take. */
19
+ const RESERVED = new Set([
20
+ "props", "emit", "root", "refs", "dispatch", "host", "hostState", "read", "write", "stops", "cleanup", "ready",
21
+ "model", "controllerModule", "event", "element", "truthy", "text", "attribute", "list", "number", "sortBy",
22
+ "String", "Boolean", "Number", "Math", "Object", "Array", "CustomEvent", "Promise", "Proxy", "TypeError",
23
+ "encodeURIComponent", "undefined", "NaN", "Infinity", ...VUE_APIS,
24
+ "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum",
25
+ "export", "extends", "false", "finally", "for", "function", "if", "import", "in", "instanceof", "new", "null",
26
+ "return", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield",
27
+ "let", "static", "implements", "interface", "package", "private", "protected", "public", "await", "arguments", "eval",
28
+ ]);
29
+ /** Allocates readable script identifiers: the declared name when it is free. */
30
+ class Identifiers {
31
+ #taken = new Set(RESERVED);
32
+ constructor(taken) {
33
+ for (const name of taken)
34
+ this.#taken.add(name);
26
35
  }
27
- return undefined;
28
- },`],
29
- ["match", ` match: (op: string, a: unknown, b: unknown): boolean | undefined => typeof a === "string" && typeof b === "string"
30
- ? op === "^=" ? a.startsWith(b) : op === "$=" ? a.endsWith(b) : a.includes(b)
31
- : undefined,`],
32
- ["text", ` text: (v: unknown): string => v === undefined || v === null ? "" : Array.isArray(v) ? v.map(hn.text).join(" ") : typeof v === "object" ? "" : String(v),`],
33
- // Vue types intrinsic attributes more narrowly than the strings the platform accepts.
34
- ["attr", ` attr: (v: unknown): any => v === undefined || v === null || v === false ? undefined : v === true ? "" : Array.isArray(v) ? v.map(hn.text).join(" ") : typeof v === "object" ? undefined : String(v),`],
35
- ["enumerated", ` enumerated: (v: unknown): any => typeof v === "boolean" ? String(v) : hn.attr(v),`],
36
- ["call", ` call: (fn: string, ...args: unknown[]): any => {
37
- if (fn === "format") {
38
- let index = 1;
39
- return typeof args[0] === "string" ? args[0].replace(/%s/g, () => index < args.length ? hn.text(args[index++]) : "%s") : undefined;
36
+ take(name, suffix) {
37
+ const base = name.replace(/[^A-Za-z0-9_$]/g, "_").replace(/^(?=\d)/, "_");
38
+ let candidate = this.#taken.has(base) ? `${base}${suffix}` : base;
39
+ for (let index = 2; this.#taken.has(candidate); index++)
40
+ candidate = `${base}${suffix}${index}`;
41
+ this.#taken.add(candidate);
42
+ return candidate;
40
43
  }
41
- const n = args.map(hn.n);
42
- if (n.some((value) => value === undefined)) return undefined;
43
- const v = n as number[];
44
- switch (fn) {
45
- case "abs": return v.length === 1 ? Math.abs(v[0]!) : undefined;
46
- case "round": return v.length === 1 ? Math.round(v[0]!) : undefined;
47
- case "min": return v.length > 0 ? Math.min(...v) : undefined;
48
- case "max": return v.length > 0 ? Math.max(...v) : undefined;
49
- case "clamp": return v.length === 3 ? Math.min(Math.max(v[0]!, v[1]!), v[2]!) : undefined;
50
- }
51
- return undefined;
52
- },`],
53
- ["shape", ` shape: (items: unknown, where: ((item: any) => unknown) | undefined, sort: readonly string[], limit: unknown): any[] => {
54
- let list = Array.isArray(items) ? items.slice() : [];
55
- if (where !== undefined) list = list.filter((item) => hn.t(where(item)));
56
- if (sort.length > 0) {
57
- const field = (item: unknown, path: string): unknown => item !== null && typeof item === "object" && !Array.isArray(item)
58
- ? path.split(".").reduce<unknown>((value, key) => (value as Record<string, unknown> | undefined)?.[key], item)
59
- : item;
60
- const compare = (a: unknown, b: unknown): number => typeof a === "number" && typeof b === "number" ? a - b : hn.text(a).localeCompare(hn.text(b));
61
- list.sort((a, b) => {
62
- for (const key of sort) {
63
- const descending = key.startsWith("-");
64
- const order = compare(field(a, descending ? key.slice(1) : key), field(b, descending ? key.slice(1) : key));
65
- if (order !== 0) return descending ? -order : order;
66
- }
67
- return 0;
68
- });
69
- }
70
- return typeof limit === "number" ? list.slice(0, Math.max(0, Math.trunc(limit))) : list;
71
- },`],
72
- ];
73
- /** The `hn` object holding the helpers `code` uses, and the helpers they use. */
74
- function helperSource(code) {
75
- const used = new Set();
76
- const visit = (source) => {
77
- for (const [, name] of source.matchAll(/\bhn\.(\w+)/g)) {
78
- if (used.has(name))
79
- continue;
80
- used.add(name);
81
- visit(HELPERS.find(([helper]) => helper === name)?.[1] ?? "");
82
- }
83
- };
84
- visit(code);
85
- const entries = HELPERS.filter(([name]) => used.has(name));
86
- return entries.length === 0 ? "" : `const hn = {\n${entries.map(([, source]) => source).join("\n")}\n};\n`;
87
44
  }
88
- const VUE_APIS = ["computed", "onBeforeUnmount", "onMounted", "ref", "shallowRef", "watchEffect"];
89
- /** Translates an HTML Next expression to JavaScript with the same absence and typing rules. */
90
- function expression(node, names) {
91
- switch (node.kind) {
92
- case "literal":
93
- return node.value === undefined ? "undefined" : JSON.stringify(node.value);
94
- case "id":
95
- return names.get(node.name) ?? "undefined";
96
- case "member":
97
- return /^[A-Za-z_$][\w$]*$/.test(node.key)
98
- ? `(${expression(node.object, names)})?.${node.key}`
99
- : `(${expression(node.object, names)})?.[${quote(node.key)}]`;
100
- case "index":
101
- return `(${expression(node.object, names)})?.[${expression(node.index, names)}]`;
102
- case "unary":
103
- return node.op === "not" ? `!hn.t(${expression(node.operand, names)})` : `hn.op("-", 0, ${expression(node.operand, names)})`;
104
- case "binary": {
105
- const left = expression(node.left, names);
106
- const right = expression(node.right, names);
107
- if (node.op === "and")
108
- return `(hn.t(${left}) && hn.t(${right}))`;
109
- if (node.op === "or")
110
- return `(hn.t(${left}) || hn.t(${right}))`;
111
- if (node.op === "=")
112
- return `(${left} === ${right})`;
113
- if (node.op === "!=")
114
- return `(${left} !== ${right})`;
115
- if (node.op === "^=" || node.op === "$=" || node.op === "*=")
116
- return `hn.match(${quote(node.op)}, ${left}, ${right})`;
117
- return `hn.op(${quote(node.op)}, ${left}, ${right})`;
118
- }
119
- case "call":
120
- return `hn.call(${[quote(node.fn), ...node.args.map((argument) => expression(argument, names))].join(", ")})`;
121
- case "object":
122
- return `({ ${node.pairs.map((pair) => `${quote(pair.key)}: ${expression(pair.value, names)}`).join(", ")} })`;
123
- case "array":
124
- return `[${node.items.map((item) => expression(item, names)).join(", ")}]`;
125
- }
126
- }
127
- function compiled(plan, names, source) {
45
+ function ast(plan, source) {
128
46
  if (plan === undefined)
129
47
  fail("HT030", `Expression \`${source}\` could not be converted.`);
130
- return expression(plan.ast, names);
48
+ return plan.ast;
131
49
  }
132
50
  /**
133
51
  * A double-quoted HTML attribute value. Only `"` and an `&` that could begin a character reference
@@ -144,84 +62,113 @@ function attributeValue(value) {
144
62
  function bound(code) {
145
63
  return attributeValue(code.replace(/"(?:\\.|[^"\\])*"/g, (literal) => `'${literal.slice(1, -1).replace(/\\"/g, "\"").replace(/'/g, "\\'")}'`));
146
64
  }
147
- function writableTarget(path, names) {
65
+ function writableTarget(path, scope, lowering) {
148
66
  const [root, ...rest] = path;
149
- const base = names.get(String(root));
67
+ const base = scope.code.get(String(root));
150
68
  if (base === undefined)
151
69
  fail("HT031", `\`${String(root)}\` is not a writable state path.`);
152
- return `${base}${rest.map((segment) => `[${JSON.stringify(segment)}]`).join("")}`;
70
+ return base + rest.map((segment) => typeof segment === "object" ? `[${lowering.value(segment.expression, scope)}]`
71
+ : typeof segment === "string" && /^[A-Za-z_$][\w$]*$/.test(segment) ? `.${segment}`
72
+ : `[${JSON.stringify(segment)}]`).join("");
153
73
  }
154
74
  function isComponentTag(name) {
155
75
  return name.includes("-") && getDomInterface(name) === undefined;
156
76
  }
77
+ /**
78
+ * Adjacent elements go on separate lines, for the formatter to lay out; Vue drops whitespace that
79
+ * holds a newline between elements, so the rendering is unchanged. Text keeps its own whitespace.
80
+ */
157
81
  function renderChildren(nodes, names, context) {
158
- return nodes.map((child) => renderNode(child, names, context)).join("");
82
+ return nodes.map((child, index) => {
83
+ const markup = renderNode(child, names, context);
84
+ return index > 0 && child.kind !== "text" && nodes[index - 1].kind !== "text" ? `\n${markup}` : markup;
85
+ }).join("");
159
86
  }
87
+ /** Names bound by `$each`, `$with`, and `$match`, read the same way in template and script. */
160
88
  function withLocal(names, entries) {
161
- const template = new Map(names.template);
162
- const script = new Map(names.script);
163
- for (const [name, code] of entries) {
164
- template.set(name, code);
165
- script.set(name, code);
166
- }
167
- return { template, script };
89
+ const scope = (base) => {
90
+ const code = new Map(base.code);
91
+ const types = new Map(base.types);
92
+ for (const [name, type] of entries) {
93
+ code.set(name, name);
94
+ types.set(name, type);
95
+ }
96
+ return { code, types };
97
+ };
98
+ return { template: scope(names.template), script: scope(names.script) };
168
99
  }
169
100
  function renderNode(node, names, context) {
101
+ const { lowering } = context;
170
102
  if (node.kind === "text")
171
103
  return escapeHtml(node.value).replace(/\{\{/g, "{{ '{{' }}");
172
104
  if (node.kind === "slot") {
173
105
  const name = node.nameExpression !== undefined
174
- ? ` :name=${bound(compiled(node.nameExpression, names.template, "slot name"))}`
106
+ ? ` :name=${bound(lowering.value(ast(node.nameExpression, "slot name"), names.template))}`
175
107
  : node.name === undefined ? "" : ` name=${quote(node.name)}`;
176
- return `<slot${name}>${renderChildren(node.fallback ?? [], names, context)}</slot>`;
108
+ const fallback = renderChildren(node.fallback ?? [], names, context);
109
+ return fallback === "" ? `<slot${name} />` : `<slot${name}>${fallback}</slot>`;
177
110
  }
178
111
  const flow = node.flow;
179
112
  if (flow?.kind === "each") {
180
- const list = compiled(flow.listPlan, names.template, flow.list);
113
+ const listNode = ast(flow.listPlan, flow.list);
114
+ const listType = typeOf(listNode, names.template);
115
+ const itemType = listType.type.kind === "list" ? { type: present(listType.type.item).type, nullable: false } : { ...UNKNOWN, nullable: false };
181
116
  const item = flow.item;
182
117
  const index = flow.index ?? "index";
183
- const local = withLocal(names, [[item, item], [index, index]]);
184
- const where = flow.wherePlan === undefined ? "undefined" : `(${item}) => ${expression(flow.wherePlan.ast, local.template)}`;
185
- const sort = JSON.stringify((flow.sort ?? "").split(",").map((key) => key.trim()).filter(Boolean));
186
- const limit = flow.limitPlan === undefined ? "undefined" : expression(flow.limitPlan.ast, names.template);
187
- const key = flow.keyPlan === undefined ? index : expression(flow.keyPlan.ast, local.template);
118
+ const local = withLocal(names, [[item, itemType], [index, { type: { kind: "terminal", name: "number" }, nullable: false }]]);
119
+ const list = lowering.list(listNode, names.template, item, {
120
+ ...(flow.wherePlan === undefined ? {} : { where: flow.wherePlan.ast }),
121
+ itemScope: local.template,
122
+ sort: (flow.sort ?? "").split(",").map((key) => key.trim()).filter(Boolean),
123
+ ...(flow.limitPlan === undefined ? {} : { limit: flow.limitPlan.ast }),
124
+ });
125
+ const key = flow.keyPlan === undefined ? index : lowering.value(flow.keyPlan.ast, local.template);
188
126
  const { flow: _flow, ...body } = node;
189
- return `<template v-for=${bound(`(${item}, ${index}) in hn.shape(${list}, ${where}, ${sort}, ${limit})`)} :key=${bound(key)}>${renderNode(body, local, context)}</template>`;
127
+ const loop = flow.index === undefined && flow.keyPlan !== undefined ? item : `(${item}, ${index})`;
128
+ return wrap(body, [`v-for=${bound(`${loop} in ${list}`)}`, `:key=${bound(key)}`], local, context);
190
129
  }
191
130
  if (flow?.kind === "with") {
192
- const value = compiled(flow.expressionPlan, names.template, flow.expr);
131
+ const value = ast(flow.expressionPlan, flow.expr);
193
132
  const { flow: _flow, ...body } = node;
194
- return `<template v-for=${bound(`${flow.alias} in [${value}]`)}>${renderNode(body, withLocal(names, [[flow.alias, flow.alias]]), context)}</template>`;
133
+ return `<template v-for=${bound(`${flow.alias} in [${lowering.value(value, names.template)}]`)}>${renderNode(body, withLocal(names, [[flow.alias, typeOf(value, names.template)]]), context)}</template>`;
195
134
  }
196
135
  if (flow?.kind === "match") {
197
- const local = flow.alias === undefined ? names : withLocal(names, [[flow.alias, flow.alias]]);
136
+ const value = flow.expr === undefined ? undefined : ast(flow.expressionPlan, flow.expr);
137
+ const local = flow.alias === undefined ? names : withLocal(names, [[flow.alias, value === undefined ? UNKNOWN : typeOf(value, names.template)]]);
198
138
  const arms = node.children
199
139
  .filter((child) => child.kind === "element" && (child.flow?.kind === "when" || child.flow?.kind === "else"))
200
140
  .map((arm, index) => {
201
141
  const { flow: armFlow, ...armBody } = arm;
202
- const test = armFlow?.kind === "when" ? compiled(armFlow.testPlan, local.template, armFlow.test) : undefined;
203
- const directive = test === undefined ? "v-else" : `${index === 0 ? "v-if" : "v-else-if"}=${bound(`hn.t(${test})`)}`;
204
- return `<template ${directive}>${renderNode(armBody, local, context)}</template>`;
205
- }).join("");
142
+ const test = armFlow?.kind === "when" ? lowering.condition(ast(armFlow.testPlan, armFlow.test), local.template) : undefined;
143
+ const directive = test === undefined ? "v-else" : `${index === 0 ? "v-if" : "v-else-if"}=${bound(test)}`;
144
+ return wrap(armBody, [directive], local, context);
145
+ }).join("\n");
206
146
  const inner = node.name === "template" ? arms : `<${node.name}${literalAttributes(node)}>${arms}</${node.name}>`;
207
- if (flow.expr === undefined)
147
+ if (value === undefined)
208
148
  return inner;
209
- const value = compiled(flow.expressionPlan, names.template, flow.expr);
210
- return `<template v-for=${bound(`${flow.alias} in [${value}]`)}>${inner}</template>`;
149
+ return `<template v-for=${bound(`${flow.alias} in [${lowering.value(value, names.template)}]`)}>${inner}</template>`;
211
150
  }
212
151
  if (flow?.kind === "if") {
213
152
  const { flow: _flow, ...body } = node;
214
- return `<template v-if=${bound(`hn.t(${compiled(flow.testPlan, names.template, flow.test)})`)}>${renderNode(body, names, context)}</template>`;
153
+ return wrap(body, [`v-if=${bound(lowering.condition(ast(flow.testPlan, flow.test), names.template))}`], names, context);
215
154
  }
216
155
  return renderElement(node, names, context, false);
217
156
  }
157
+ /** A structural directive on its element, or on a `<template>` when the element is one itself. */
158
+ function wrap(node, directives, names, context) {
159
+ if (node.name === "template" || node.flow !== undefined) {
160
+ return `<template ${directives.join(" ")}>${renderNode(node, names, context)}</template>`;
161
+ }
162
+ return renderElement(node, names, context, false, directives);
163
+ }
218
164
  function literalAttributes(node) {
219
165
  return node.attributes
220
166
  .filter((attribute) => attribute.kind === "literal")
221
167
  .map((attribute) => ` ${attribute.name}=${attributeValue(attribute.value)}`)
222
168
  .join("");
223
169
  }
224
- function renderElement(node, names, context, isRoot) {
170
+ function renderElement(node, names, context, isRoot, directives = []) {
171
+ const { lowering } = context;
225
172
  const component = isComponentTag(node.name);
226
173
  const name = component ? componentName(node.name) : node.name;
227
174
  if (component)
@@ -238,25 +185,30 @@ function renderElement(node, names, context, isRoot) {
238
185
  else if (attribute.kind === "directive") {
239
186
  if (attribute.name === "html")
240
187
  fail("HT032", "`$html` is not supported in Vue conversion yet.");
241
- content = `{{ hn.text(${compiled(attribute.expressionPlan, names.template, attribute.expression)}) }}`;
188
+ content = `{{ ${lowering.text(ast(attribute.expressionPlan, attribute.expression), names.template)} }}`;
242
189
  }
243
190
  else if (attribute.kind === "property") {
244
- // DOM property types are narrower than HTML Next values (a prop may be null).
245
- attributes.push(`:${attribute.name}.prop=${bound(`(${compiled(attribute.expressionPlan, names.template, attribute.expression)}) as any`)}`);
191
+ const value = ast(attribute.expressionPlan, attribute.expression);
192
+ const type = typeOf(value, names.template);
193
+ // DOM property types are narrower than an absent or untyped HTML Next value.
194
+ const code = lowering.value(value, names.template);
195
+ attributes.push(`:${attribute.name}.prop=${bound(type.nullable || category(type.type) === "unknown" ? `${code} as any` : code)}`);
246
196
  }
247
197
  else if (attribute.target === "class") {
248
- classes.push(`${quote(attribute.name)}: hn.t(${compiled(attribute.expressionPlan, names.template, attribute.expression)})`);
198
+ classes.push(`${quote(attribute.name)}: ${lowering.condition(ast(attribute.expressionPlan, attribute.expression), names.template)}`);
249
199
  }
250
200
  else if (attribute.target === "style") {
251
- styles.push(`${quote(attribute.name)}: hn.text(${compiled(attribute.expressionPlan, names.template, attribute.expression)})`);
201
+ styles.push(`${quote(attribute.name)}: ${lowering.text(ast(attribute.expressionPlan, attribute.expression), names.template)}`);
252
202
  }
253
203
  else if (attribute.twoWay === true && attribute.writablePath !== undefined) {
254
- attributes.push(`v-model=${bound(writableTarget(attribute.writablePath, names.template))}`);
204
+ attributes.push(`v-model=${bound(writableTarget(attribute.writablePath, names.template, lowering))}`);
205
+ }
206
+ else if (isRoot && context.model && attribute.name === "value") {
207
+ // The model supplies the root's value (below).
255
208
  }
256
209
  else {
257
- const value = compiled(attribute.expressionPlan, names.template, attribute.expression);
258
- const helper = isEnumeratedBoolean(attribute.name) ? "hn.enumerated" : "hn.attr";
259
- attributes.push(component ? `:${attribute.name}=${bound(value)}` : `:${attribute.name}=${bound(`${helper}(${value})`)}`);
210
+ const value = ast(attribute.expressionPlan, attribute.expression);
211
+ attributes.push(`:${attribute.name}=${bound(component ? lowering.value(value, names.template) : lowering.attribute(value, names.template, attribute.name))}`);
260
212
  }
261
213
  }
262
214
  if (classes.length > 0)
@@ -264,14 +216,15 @@ function renderElement(node, names, context, isRoot) {
264
216
  if (styles.length > 0)
265
217
  attributes.push(`:style=${bound(`{ ${styles.join(", ")} }`)}`);
266
218
  for (const event of node.events ?? []) {
267
- const handler = (context.definition.declarations ?? []).find((declaration) => declaration.kind === "handler" && declaration.name === event.handler);
219
+ const handler = context.handlers.get(event.handler);
268
220
  if (handler === undefined)
269
221
  fail("HT033", `Handler \`${event.handler}\` is not declared.`);
270
- attributes.push(`@${event.name}${event.modifiers.map((modifier) => `.${modifier}`).join("")}=${attributeValue(`handler_${safe(event.handler)}`)}`);
222
+ attributes.push(`@${event.name}${event.modifiers.map((modifier) => `.${modifier}`).join("")}=${attributeValue(handler)}`);
271
223
  }
272
224
  if (node.ref !== undefined) {
273
- context.usesRefs.value = true;
274
- attributes.push(`:ref=${bound(`(element) => { refs[${quote(node.ref)}] = element as Element }`)}`);
225
+ if (!context.refs.has(node.ref))
226
+ context.refs.set(node.ref, context.identifiers.take(`${node.ref}Element`, ""));
227
+ attributes.push(`ref=${quote(node.ref)}`);
275
228
  }
276
229
  if (isRoot) {
277
230
  // The consumer's attributes win over the template's literals and lose to its bindings, as in
@@ -279,21 +232,25 @@ function renderElement(node, names, context, isRoot) {
279
232
  const tag = context.definition.contract.tag;
280
233
  literals.unshift(`data-component=${attributeValue(tag)}`);
281
234
  literals.push("v-bind=\"$attrs\"");
235
+ // Vue's own v-model keeps a native control's value, a select's included, in step with the model.
236
+ if (context.model)
237
+ attributes.push('v-model="model"');
282
238
  if (context.hostState)
283
239
  attributes.push(`:${stateAttribute(tag)}="hostState || undefined"`);
284
- if (context.model)
285
- attributes.push(`@${context.model}="updateModel"`);
286
240
  if (context.root)
287
241
  attributes.push("ref=\"root\"");
288
242
  }
289
243
  // A <template> without structural flow produces its content with no wrapper element.
290
244
  if (node.name === "template" && !isRoot)
291
245
  return content ?? renderChildren(node.children, names, context);
292
- attributes.unshift(...literals);
246
+ attributes.unshift(...directives, ...literals);
293
247
  const open = `<${name}${attributes.length === 0 ? "" : ` ${attributes.join(" ")}`}>`;
294
248
  if (!component && isVoidElement(node.name))
295
249
  return open;
296
250
  const children = content ?? renderChildren(node.children, names, context);
251
+ // An empty component closes itself, as Vue's style guide has it.
252
+ if (component && children === "")
253
+ return `${open.slice(0, -1)} />`;
297
254
  return `${open}${children}</${name}>`;
298
255
  }
299
256
  /** A JavaScript predicate for a declared type, so event details are checked as the runtime checks them. */
@@ -329,33 +286,61 @@ function typeCheck(type, value) {
329
286
  }
330
287
  }
331
288
  }
332
- function safe(name) {
333
- return name.replace(/[^A-Za-z0-9_$]/g, "_");
334
- }
335
- function handlerSource(handler, names, events) {
336
- const lines = [`const handler_${safe(handler.name)} = (event?: Event): void => {`];
337
- const local = withLocal(names, [["event", "event"]]);
289
+ function handlerSource(handler, name, names, events, context) {
290
+ const { lowering } = context;
291
+ const lines = [];
292
+ const local = withLocal(names, [["event", UNKNOWN]]);
293
+ const element = (ref) => {
294
+ if (!context.refs.has(ref))
295
+ context.refs.set(ref, context.identifiers.take(`${ref}Element`, ""));
296
+ return context.refs.get(ref);
297
+ };
338
298
  for (const step of handler.steps) {
339
- const guard = step.guard === undefined ? "" : `if (hn.t(${expression(step.guard.ast, local.script)})) `;
299
+ const guard = step.guard === undefined ? "" : `if (${lowering.condition(step.guard.ast, local.script)}) `;
340
300
  if (step.kind === "set") {
341
- lines.push(` ${guard}${writableTarget(step.writablePath, local.script)} = ${expression(step.value.ast, local.script)};`);
301
+ lines.push(` ${guard}${writableTarget(step.writablePath, local.script, lowering)} = ${lowering.value(step.value.ast, local.script)};`);
342
302
  }
343
303
  else if (step.kind === "dispatch") {
344
- const detail = step.value === undefined ? "undefined" : expression(step.value.ast, local.script);
304
+ const detail = step.value === undefined ? "" : `, ${lowering.value(step.value.ast, local.script)}`;
345
305
  const declaration = events.find((event) => event.name === step.event);
346
306
  if (declaration === undefined)
347
307
  fail("HT034", `Handler \`${handler.name}\` dispatches undeclared event \`${step.event}\`.`);
348
- lines.push(` ${guard}dispatch(${quote(step.event)}, ${detail});`);
308
+ lines.push(` ${guard}dispatch(${quote(step.event)}${detail});`);
349
309
  }
350
310
  else if (step.kind === "focus") {
351
- lines.push(` ${guard}(refs[${quote(step.target)}] as HTMLElement | undefined)?.focus();`);
311
+ lines.push(` ${guard}${element(step.target)}.value?.focus();`);
352
312
  }
353
313
  else {
354
- lines.push(` ${guard}(refs[${quote(step.target)}] as HTMLInputElement | undefined)?.reportValidity?.();`);
314
+ lines.push(` ${guard}(${element(step.target)}.value as HTMLInputElement | null)?.reportValidity?.();`);
355
315
  }
356
316
  }
357
- lines.push("};");
358
- return lines.join("\n");
317
+ const parameter = lines.some((line) => /\bevent\b/.test(line)) ? "event?: Event" : "";
318
+ return [`function ${name}(${parameter}): void {`, ...lines, "}"].join("\n");
319
+ }
320
+ /** One `data-<tag>-state` token source per styled name: the bare name when truthy, and name=value. */
321
+ function stateTokens(name, scope, lowering) {
322
+ const node = { kind: "id", name };
323
+ const type = typeOf(node, scope);
324
+ const code = lowering.value(node, scope);
325
+ const kind = category(type.type);
326
+ const keywords = type.type.kind === "keyword" ? [type.type.value]
327
+ : type.type.kind === "union" && type.type.members.every((member) => member.kind === "keyword")
328
+ ? type.type.members.map((member) => member.value)
329
+ : undefined;
330
+ if (kind === "boolean")
331
+ return [`${code} && ${quote(name)}`];
332
+ if (keywords !== undefined && keywords.every((keyword) => keyword !== "" && encodeURIComponent(keyword) === keyword)) {
333
+ return [`${code} && \`${name} ${name}=\${${code}}\``];
334
+ }
335
+ if (kind === "string" || kind === "number") {
336
+ const value = kind === "string" ? `encodeURIComponent(${code})` : code;
337
+ return [`${code} && ${quote(name)}`, type.nullable ? `${code} != null && \`${name}=\${${value}}\`` : `\`${name}=\${${value}}\``];
338
+ }
339
+ const value = lowering.value(node, scope);
340
+ return [
341
+ `${lowering.condition(node, scope)} && ${quote(name)}`,
342
+ `(typeof ${value} === "string" || typeof ${value} === "number") && \`${name}=\${encodeURIComponent(String(${value}))}\``,
343
+ ];
359
344
  }
360
345
  export function generateVue(definition, version) {
361
346
  const { contract, template } = definition;
@@ -368,120 +353,157 @@ export function generateVue(definition, version) {
368
353
  const computedValues = declarations.filter((declaration) => declaration.kind === "computed");
369
354
  const handlers = declarations.filter((declaration) => declaration.kind === "handler");
370
355
  const events = declarations.filter((declaration) => declaration.kind === "event");
356
+ if (template.flow !== undefined) {
357
+ fail("HT036", `<${contract.tag}> has a structural directive on its root, which Vue conversion does not support yet.`);
358
+ }
371
359
  // A native form-control root with a `value` prop takes Vue's v-model: `modelValue` sets the value
372
360
  // and the control's input (change, for a select) reports it.
373
361
  const modelProp = ["input", "textarea", "select"].includes(template.name)
374
362
  ? target.props.find((prop) => prop.name === "value")
375
363
  : undefined;
376
- const names = { template: new Map(), script: new Map() };
364
+ const identifiers = new Identifiers(target.props.map((prop) => prop.name));
365
+ const lowering = new Lowering();
366
+ const templateScope = { code: new Map(), types: new Map() };
367
+ const script = { code: new Map(), types: new Map() };
368
+ const names = { template: templateScope, script };
369
+ const define = (name, templateCode, scriptCode, type) => {
370
+ templateScope.code.set(name, templateCode);
371
+ script.code.set(name, scriptCode);
372
+ templateScope.types.set(name, type);
373
+ script.types.set(name, type);
374
+ };
375
+ // A prop with a default or marked required is present; others may be absent.
376
+ const optional = (prop) => !prop.contract.required && !("default" in prop.contract);
377
377
  for (const prop of target.props) {
378
+ const identifier = /^[A-Za-z_$][\w$]*$/.test(prop.name);
378
379
  const read = prop === modelProp
379
380
  ? "(props.modelValue ?? props.value)"
380
- : /^[A-Za-z_$][\w$]*$/.test(prop.name) ? `props.${prop.name}` : `props[${quote(prop.name)}]`;
381
- names.template.set(prop.name, read);
382
- names.script.set(prop.name, read);
381
+ : identifier ? `props.${prop.name}` : `props[${quote(prop.name)}]`;
382
+ // A template reads a prop by its name, as Vue exposes it.
383
+ const templateRead = prop === modelProp ? "(modelValue ?? value)" : identifier && !RESERVED.has(prop.name) ? prop.name : read;
384
+ const type = present(normalizeType(prop.contract.type));
385
+ define(prop.name, templateRead, read, { type: type.type, nullable: type.nullable || optional(prop) || prop === modelProp });
383
386
  }
387
+ const stateNames = new Map();
384
388
  for (const state of states) {
385
- names.template.set(state.name, `state_${safe(state.name)}`);
386
- names.script.set(state.name, `state_${safe(state.name)}.value`);
389
+ const name = identifiers.take(state.name, "State");
390
+ stateNames.set(state, name);
391
+ const inferred = state.expression === undefined ? UNKNOWN : typeOf(state.expression.ast, script);
392
+ const declared = state.type === undefined ? undefined : present(parseTypeExpression(state.type));
393
+ // A declared type wins; an absent initial value keeps the state nullable.
394
+ const initial = declared === undefined ? inferred : { type: declared.type, nullable: declared.nullable || inferred === UNKNOWN, null: (declared.null ?? false) || inferred === UNKNOWN };
395
+ define(state.name, name, `${name}.value`, initial);
387
396
  }
388
397
  for (const value of computedValues) {
389
- names.template.set(value.name, `computed_${safe(value.name)}`);
390
- names.script.set(value.name, `computed_${safe(value.name)}.value`);
391
- }
392
- if (template.flow !== undefined) {
393
- fail("HT036", `<${contract.tag}> has a structural directive on its root, which Vue conversion does not support yet.`);
398
+ const name = identifiers.take(value.name, "Computed");
399
+ stateNames.set(value, name);
400
+ define(value.name, name, `${name}.value`, value.expression === undefined ? UNKNOWN : typeOf(value.expression.ast, script));
394
401
  }
402
+ const handlerNames = new Map(handlers.map((handler) => [handler.name, identifiers.take(handler.name, "Handler")]));
395
403
  const styles = compileComponentStylesForVue(definition.css, definition);
396
404
  const controlled = definition.controller !== undefined;
397
405
  const dispatches = events.length > 0 || controlled;
398
- const reads = styles.stateNames.length > 0 || controlled;
399
406
  const context = {
400
407
  definition,
408
+ lowering,
401
409
  imports: new Set(),
402
- usesRefs: { value: false },
410
+ refs: new Map(),
411
+ identifiers,
412
+ handlers: handlerNames,
403
413
  root: dispatches,
404
414
  hostState: styles.stateNames.length > 0,
405
- ...(modelProp === undefined ? {} : { model: template.name === "select" ? "change" : "input" }),
415
+ model: modelProp !== undefined,
406
416
  };
407
417
  const rootMarkup = renderElement(template, names, context, true);
408
418
  const defaults = target.props.filter((prop) => "default" in prop.contract);
419
+ // An optional prop is declared as Vue authors declare one, `name?: T`; absent is undefined.
420
+ const propType = (prop) => typeSource(prop.contract.type);
409
421
  const propsType = [
410
422
  "{",
411
- ...target.props.map((prop) => ` ${propKey(prop.name)}?: ${propTypeSource(prop.contract)};`),
423
+ ...target.props.map((prop) => ` ${propKey(prop.name)}?: ${propType(prop)};`),
412
424
  ...(modelProp === undefined ? [] : [` modelValue?: ${propTypeSource(modelProp.contract)};`]),
413
425
  "}",
414
426
  ].join("\n");
427
+ // An event whose detail reports a prop's new value (query-change's { query }, open and close's
428
+ // { open }) also updates that prop, so Vue consumers can write v-model:query and v-model:open.
429
+ const modeled = target.props.filter((prop) => prop !== modelProp && events.some((event) => {
430
+ const detail = parseTypeExpression(event.type);
431
+ return detail.kind === "object" && detail.fields.some((field) => field.name === prop.name);
432
+ }));
415
433
  const emits = [
434
+ ...modeled.map((prop) => ` ${quote(`update:${prop.name}`)}: [value: ${typeSource(prop.contract.type)}];`),
416
435
  ...events.map((event) => {
417
436
  const typed = target.events.find((candidate) => candidate.name === event.name);
418
437
  return ` ${quote(event.name)}: [detail: ${typed?.detailType ?? "unknown"}];`;
419
438
  }),
420
439
  ...(modelProp === undefined ? [] : [' "update:modelValue": [value: string];']),
421
440
  ];
422
- const body = [
423
- ...(target.props.length === 0 ? [] : defaults.length === 0 ? [`const props = defineProps<${propsType}>();`] : [
424
- `const props = withDefaults(defineProps<${propsType}>(), {`,
425
- ...defaults.map((prop) => ` ${propKey(prop.name)}: ${defaultSource(prop.contract.default)},`),
426
- "});",
427
- ]),
428
- ...(emits.length === 0 ? [] : ["const emit = defineEmits<{", ...emits, "}>();"]),
429
- ...(modelProp === undefined ? [] : [
430
- 'const updateModel = (event: Event): void => emit("update:modelValue", (event.target as HTMLInputElement).value);',
431
- ]),
432
- ...(dispatches ? ["const root = ref<HTMLElement | null>(null);"] : []),
433
- ...(context.usesRefs.value || controlled ? ["const refs: Record<string, Element | undefined> = {};"] : []),
434
- ...states.map((state) => `const state_${safe(state.name)} = ref<any>(${state.expression === undefined ? "undefined" : expression(state.expression.ast, names.script)});`),
435
- ...computedValues.map((value) => `const computed_${safe(value.name)} = computed(() => ${value.expression === undefined ? "undefined" : expression(value.expression.ast, names.script)});`),
436
- ...(!dispatches ? [] : [
437
- "",
438
- "/** Dispatches a component event to Vue listeners and, for controllers and page code, on the root. */",
439
- "const dispatch = (name: string, detail?: unknown): boolean => {",
440
- ...(events.length === 0 ? [] : [
441
- ` const declared = ${JSON.stringify(Object.fromEntries(events.map((event) => [event.name, { bubbles: event.bubbles, composed: event.composed, cancelable: event.cancelable }])))} as Record<string, EventInit | undefined>;`,
442
- " const checks: Record<string, (detail: unknown) => boolean> = {",
443
- ...events.map((event) => ` ${quote(event.name)}: (detail: unknown) => ${typeCheck(parseTypeExpression(event.type), "detail")},`),
444
- " };",
445
- " if (detail !== undefined && checks[name] !== undefined && !checks[name]!(detail)) {",
446
- " throw new TypeError(`HR002: Event \\`${name}\\` detail does not satisfy its declared type.`);",
447
- " }",
448
- " (emit as (name: string, detail: unknown) => void)(name, detail);",
449
- ]),
450
- ` return root.value?.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, ${events.length === 0 ? "" : "...declared[name], "}detail })) ?? true;`,
451
- "};",
452
- ]),
453
- ...handlers.map((handler) => handlerSource(handler, names, events)),
454
- ...(styles.stateNames.length === 0 ? [] : [
455
- "",
456
- "/** The resolved values the styles' :host-state() rules test. */",
457
- `const hostState = computed(() => (${JSON.stringify(styles.stateNames)} as const).flatMap((name) => {`,
458
- " const value = read(name);",
459
- " const tokens: string[] = hn.t(value) ? [name] : [];",
460
- " if (typeof value === \"string\" || typeof value === \"number\") tokens.push(`${name}=${encodeURIComponent(String(value))}`);",
461
- " return tokens;",
462
- "}).join(\" \"));",
463
- ]),
464
- ...(!reads ? [] : [
465
- "",
466
- "function read(name: string): unknown {",
467
- ...states.map((state) => ` if (name === ${quote(state.name)}) return state_${safe(state.name)}.value;`),
468
- ...computedValues.map((value) => ` if (name === ${quote(value.name)}) return computed_${safe(value.name)}.value;`),
469
- target.props.length === 0 ? " return undefined;" : " return (props as Record<string, unknown>)[name];",
470
- "}",
471
- ]),
472
- ...(!controlled ? [] : [
473
- "",
474
- "function write(name: string, value: unknown): boolean {",
475
- ...states.map((state) => ` if (name === ${quote(state.name)}) { state_${safe(state.name)}.value = value; return true; }`),
476
- " throw new TypeError(`Only declared state is writable; \\`${name}\\` is not.`);",
477
- "}",
441
+ const handlerSources = handlers.map((handler) => handlerSource(handler, handlerNames.get(handler.name), names, events, context));
442
+ const stateSource = (declaration) => {
443
+ const name = stateNames.get(declaration);
444
+ const initial = declaration.expression === undefined ? "undefined" : lowering.value(declaration.expression.ast, script);
445
+ if (declaration.kind === "computed")
446
+ return `const ${name} = computed(() => ${initial});`;
447
+ const type = script.types.get(declaration.name);
448
+ // Scalars infer their own type; structured and unknown initial values declare what they hold.
449
+ const plain = ["boolean", "string", "number"].includes(category(type.type)) && !type.nullable;
450
+ return `const ${name} = ref${plain ? "" : `<${typeScript(type)}>`}(${initial});`;
451
+ };
452
+ const body = [];
453
+ body.push(...(target.props.length === 0 ? [] : defaults.length === 0 ? [`const props = defineProps<${propsType}>();`] : [
454
+ `const props = withDefaults(defineProps<${propsType}>(), {`,
455
+ ...defaults.map((prop) => ` ${propKey(prop.name)}: ${defaultSource(prop.contract.default)},`),
456
+ "});",
457
+ ]), ...(emits.length === 0 ? [] : ["const emit = defineEmits<{", ...emits, "}>();"]), ...(modelProp === undefined ? [] : [
458
+ "const model = computed({",
459
+ " get: () => props.modelValue ?? props.value ?? undefined,",
460
+ ' set: (value) => emit("update:modelValue", value as string),',
461
+ "});",
462
+ ]), "", ...(dispatches ? ["const root = ref<HTMLElement | null>(null);"] : []), ...[...context.refs].map(([ref, name]) => `const ${name} = useTemplateRef<HTMLElement>(${quote(ref)});`), ...states.map(stateSource), ...computedValues.map(stateSource), ...(styles.stateNames.length === 0 ? [] : [
463
+ "",
464
+ "/** The values the styles' :host-state() rules test. */",
465
+ "const hostState = computed(() => [",
466
+ ...styles.stateNames.flatMap((name) => stateTokens(name, script, lowering)).map((token) => ` ${token},`),
467
+ "].filter(Boolean).join(\" \"));",
468
+ ]), ...(!dispatches ? [] : [
469
+ "",
470
+ "/** Dispatches a component event to Vue listeners and, for controllers and page code, on the root. */",
471
+ "function dispatch(name: string, detail?: unknown): boolean {",
472
+ ...(events.length === 0 ? [] : [
473
+ ` const declared: Record<string, EventInit | undefined> = ${JSON.stringify(Object.fromEntries(events.map((event) => [event.name, { bubbles: event.bubbles, composed: event.composed, cancelable: event.cancelable }])))};`,
474
+ " const checks: Record<string, (detail: unknown) => boolean> = {",
475
+ ...events.map((event) => ` ${quote(event.name)}: (detail) => ${typeCheck(parseTypeExpression(event.type), "detail").replace(/^\((.*)\)$/s, "$1")},`),
476
+ " };",
477
+ " if (detail !== undefined && checks[name] !== undefined && !checks[name]!(detail)) {",
478
+ " throw new TypeError(`HR002: Event \\`${name}\\` detail does not satisfy its declared type.`);",
479
+ " }",
480
+ " const emitEvent = emit as (name: string, detail: unknown) => void;",
481
+ " emitEvent(name, detail);",
482
+ ...modeled.map((prop) => ` if (detail !== null && typeof detail === "object" && ${quote(prop.name)} in detail) emitEvent(${quote(`update:${prop.name}`)}, (detail as Record<string, unknown>)[${quote(prop.name)}]);`),
478
483
  ]),
484
+ ` return root.value?.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, ${events.length === 0 ? "" : "...declared[name], "}detail })) ?? true;`,
485
+ "}",
486
+ ]), ...handlerSources.flatMap((source) => ["", source]), ...(!controlled ? [] : [
479
487
  "",
480
- ...hostSource(definition, target.methods),
481
- ];
488
+ "function read(name: string): unknown {",
489
+ ...[...states, ...computedValues].map((declaration) => ` if (name === ${quote(declaration.name)}) return ${stateNames.get(declaration)}.value;`),
490
+ target.props.length === 0 ? " return undefined;" : " return (props as Record<string, unknown>)[name];",
491
+ "}",
492
+ "",
493
+ "function write(name: string, value: unknown): boolean {",
494
+ ...states.map((state) => ` if (name === ${quote(state.name)}) { ${stateNames.get(state)}.value = value as never; return true; }`),
495
+ " throw new TypeError(`Only declared state is writable; \\`${name}\\` is not.`);",
496
+ "}",
497
+ ]), "", ...hostSource(definition, target.methods, context.refs), ...lowering.fallbacks().flatMap((source) => ["", source]));
498
+ // `props` is named only when the script reads it; the template reads props by name.
499
+ if (!body.some((line) => /\bprops\b/.test(line) && !line.startsWith("const props = "))) {
500
+ const index = body.findIndex((line) => line.startsWith("const props = "));
501
+ if (index !== -1)
502
+ body[index] = body[index].replace("const props = ", "");
503
+ }
482
504
  const code = `${body.join("\n")}\n${rootMarkup}`;
483
505
  const apis = VUE_APIS.filter((api) => new RegExp(`\\b${api}[<(]`).test(code));
484
- const script = [
506
+ const lines = [
485
507
  `<!-- Generated by HTML Next ${version} for Vue 3.5. Do not edit. -->`,
486
508
  '<script setup lang="ts">',
487
509
  ...(apis.length === 0 ? [] : [`import { ${apis.join(", ")} } from "vue";`]),
@@ -490,24 +512,24 @@ export function generateVue(definition, version) {
490
512
  "",
491
513
  "defineOptions({ inheritAttrs: false });",
492
514
  "",
493
- helperSource(code),
494
515
  ...body,
495
516
  "</script>",
496
517
  "",
497
518
  "<template>",
498
- ` ${rootMarkup}`,
519
+ rootMarkup,
499
520
  "</template>",
500
521
  ];
501
522
  if (styles.css !== "")
502
- script.push("", "<style scoped>", styles.css, "</style>");
503
- return `${script.join("\n").replace(/\n{3,}/g, "\n\n")}\n`;
523
+ lines.push("", "<style scoped>", styles.css, "</style>");
524
+ const source = `${lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/(?<=<script setup lang="ts">\n)\n/, "").replace(/\n\n(?=<\/script>)/, "\n")}\n`;
525
+ return formatVue(source, `${componentName(contract.tag)}.vue`);
504
526
  }
505
527
  function defaultSource(value) {
506
528
  // Vue requires factories for object and array defaults.
507
529
  return value !== null && typeof value === "object" ? `() => (${JSON.stringify(value)})` : JSON.stringify(value);
508
530
  }
509
531
  /** The controller host, built from Vue refs, effects, and lifecycle. */
510
- function hostSource(definition, methods) {
532
+ function hostSource(definition, methods, refs) {
511
533
  if (definition.controller === undefined)
512
534
  return methods.length === 0 ? [] : [
513
535
  `defineExpose({ ${methods.map((method) => `${propKey(method.name)}: () => Promise.reject(new TypeError(${quote(`<${definition.contract.tag}> has no controller.`)}))`).join(", ")} });`,
@@ -520,7 +542,9 @@ function hostSource(definition, methods) {
520
542
  " get: (_target, name) => typeof name === \"string\" ? read(name) : undefined,",
521
543
  " set: (_target, name, value) => typeof name === \"string\" && write(name, value),",
522
544
  " }),",
523
- " refs: refs as Readonly<Record<string, Element>>,",
545
+ " refs: {",
546
+ ...[...refs].map(([ref, name]) => ` get ${propKey(ref)}(): Element { return ${name}.value as Element; },`),
547
+ " } as Readonly<Record<string, Element>>,",
524
548
  " elements: new Proxy({} as Record<string, Element | RadioNodeList | undefined>, {",
525
549
  " get: (_target, name) => {",
526
550
  " if (typeof name !== \"string\" || root.value === null) return undefined;",