@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.
- package/dist/browser-loader.bundle.js +4 -4
- package/dist/browser.js +4 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -1
- package/dist/cli.js.map +1 -1
- package/dist/generate.d.ts +4 -1
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +7 -1
- package/dist/generate.js.map +1 -1
- package/dist/package.d.ts.map +1 -1
- package/dist/package.js +7 -1
- package/dist/package.js.map +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-host.d.ts +8 -0
- package/dist/targets/vue-host.d.ts.map +1 -0
- package/dist/targets/vue-host.js +169 -0
- package/dist/targets/vue-host.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 +309 -313
- 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,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
|
-
|
|
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 { 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
|
-
|
|
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;
|
|
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
|
|
48
|
+
function ast(plan, source) {
|
|
128
49
|
if (plan === undefined)
|
|
129
50
|
fail("HT030", `Expression \`${source}\` could not be converted.`);
|
|
130
|
-
return
|
|
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,
|
|
68
|
+
function writableTarget(path, scope, lowering) {
|
|
148
69
|
const [root, ...rest] = path;
|
|
149
|
-
const base =
|
|
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
|
|
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) =>
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
109
|
+
? ` :name=${bound(lowering.value(ast(node.nameExpression, "slot name"), names.template))}`
|
|
175
110
|
: node.name === undefined ? "" : ` name=${quote(node.name)}`;
|
|
176
|
-
|
|
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
|
|
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,
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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" ?
|
|
203
|
-
const directive = test === undefined ? "v-else" : `${index === 0 ? "v-if" : "v-else-if"}=${bound(
|
|
204
|
-
return
|
|
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 (
|
|
150
|
+
if (value === undefined)
|
|
208
151
|
return inner;
|
|
209
|
-
|
|
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
|
|
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 = `{{
|
|
191
|
+
content = `{{ ${lowering.text(ast(attribute.expressionPlan, attribute.expression), names.template)} }}`;
|
|
242
192
|
}
|
|
243
193
|
else if (attribute.kind === "property") {
|
|
244
|
-
|
|
245
|
-
|
|
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)}:
|
|
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)}:
|
|
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 =
|
|
258
|
-
|
|
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 =
|
|
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(
|
|
225
|
+
attributes.push(`@${event.name}${event.modifiers.map((modifier) => `.${modifier}`).join("")}=${attributeValue(handler)}`);
|
|
271
226
|
}
|
|
272
227
|
if (node.ref !== undefined) {
|
|
273
|
-
context.
|
|
274
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
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 (
|
|
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)} = ${
|
|
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 ? "
|
|
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)}
|
|
311
|
+
lines.push(` ${guard}dispatch(${quote(step.event)}${detail});`);
|
|
349
312
|
}
|
|
350
313
|
else if (step.kind === "focus") {
|
|
351
|
-
lines.push(` ${guard}
|
|
314
|
+
lines.push(` ${guard}${element(step.target)}.value?.focus();`);
|
|
352
315
|
}
|
|
353
316
|
else {
|
|
354
|
-
lines.push(` ${guard}(
|
|
317
|
+
lines.push(` ${guard}(${element(step.target)}.value as HTMLInputElement | null)?.reportValidity?.();`);
|
|
355
318
|
}
|
|
356
319
|
}
|
|
357
|
-
lines.
|
|
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
|
|
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
|
-
:
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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.`);
|
|
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
|
-
|
|
413
|
+
refs: new Map(),
|
|
414
|
+
identifiers,
|
|
415
|
+
handlers: handlerNames,
|
|
403
416
|
root: dispatches,
|
|
404
417
|
hostState: styles.stateNames.length > 0,
|
|
405
|
-
|
|
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)}?: ${
|
|
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
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
529
|
+
rootMarkup,
|
|
499
530
|
"</template>",
|
|
500
531
|
];
|
|
501
532
|
if (styles.css !== "")
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
"
|
|
519
|
-
"
|
|
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
|
-
"
|
|
562
|
-
|
|
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
|