@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.
- package/dist/browser-loader.bundle.js +4 -4
- package/dist/browser.js +4 -4
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +6 -0
- package/dist/parser.js.map +1 -1
- package/dist/targets/vue-format.d.ts +2 -0
- package/dist/targets/vue-format.d.ts.map +1 -0
- package/dist/targets/vue-format.js +21 -0
- package/dist/targets/vue-format.js.map +1 -0
- package/dist/targets/vue-lowering.d.ts +54 -0
- package/dist/targets/vue-lowering.d.ts.map +1 -0
- package/dist/targets/vue-lowering.js +394 -0
- package/dist/targets/vue-lowering.js.map +1 -0
- package/dist/targets/vue.d.ts.map +1 -1
- package/dist/targets/vue.js +279 -255
- package/dist/targets/vue.js.map +1 -1
- package/dist/template.d.ts +2 -0
- package/dist/template.d.ts.map +1 -1
- package/package.json +6 -2
package/dist/targets/vue.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
65
|
+
function writableTarget(path, scope, lowering) {
|
|
148
66
|
const [root, ...rest] = path;
|
|
149
|
-
const base =
|
|
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
|
|
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) =>
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
106
|
+
? ` :name=${bound(lowering.value(ast(node.nameExpression, "slot name"), names.template))}`
|
|
175
107
|
: node.name === undefined ? "" : ` name=${quote(node.name)}`;
|
|
176
|
-
|
|
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
|
|
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,
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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" ?
|
|
203
|
-
const directive = test === undefined ? "v-else" : `${index === 0 ? "v-if" : "v-else-if"}=${bound(
|
|
204
|
-
return
|
|
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 (
|
|
147
|
+
if (value === undefined)
|
|
208
148
|
return inner;
|
|
209
|
-
|
|
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
|
|
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 = `{{
|
|
188
|
+
content = `{{ ${lowering.text(ast(attribute.expressionPlan, attribute.expression), names.template)} }}`;
|
|
242
189
|
}
|
|
243
190
|
else if (attribute.kind === "property") {
|
|
244
|
-
|
|
245
|
-
|
|
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)}:
|
|
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)}:
|
|
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 =
|
|
258
|
-
|
|
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 =
|
|
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(
|
|
222
|
+
attributes.push(`@${event.name}${event.modifiers.map((modifier) => `.${modifier}`).join("")}=${attributeValue(handler)}`);
|
|
271
223
|
}
|
|
272
224
|
if (node.ref !== undefined) {
|
|
273
|
-
context.
|
|
274
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
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 (
|
|
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)} = ${
|
|
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 ? "
|
|
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)}
|
|
308
|
+
lines.push(` ${guard}dispatch(${quote(step.event)}${detail});`);
|
|
349
309
|
}
|
|
350
310
|
else if (step.kind === "focus") {
|
|
351
|
-
lines.push(` ${guard}
|
|
311
|
+
lines.push(` ${guard}${element(step.target)}.value?.focus();`);
|
|
352
312
|
}
|
|
353
313
|
else {
|
|
354
|
-
lines.push(` ${guard}(
|
|
314
|
+
lines.push(` ${guard}(${element(step.target)}.value as HTMLInputElement | null)?.reportValidity?.();`);
|
|
355
315
|
}
|
|
356
316
|
}
|
|
357
|
-
lines.
|
|
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
|
|
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
|
-
:
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
410
|
+
refs: new Map(),
|
|
411
|
+
identifiers,
|
|
412
|
+
handlers: handlerNames,
|
|
403
413
|
root: dispatches,
|
|
404
414
|
hostState: styles.stateNames.length > 0,
|
|
405
|
-
|
|
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)}?: ${
|
|
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
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
...(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
...
|
|
454
|
-
|
|
455
|
-
"",
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
"
|
|
459
|
-
"
|
|
460
|
-
"
|
|
461
|
-
"
|
|
462
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
519
|
+
rootMarkup,
|
|
499
520
|
"</template>",
|
|
500
521
|
];
|
|
501
522
|
if (styles.css !== "")
|
|
502
|
-
|
|
503
|
-
|
|
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:
|
|
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;",
|