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

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