ferrovue 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/compiler.js CHANGED
@@ -13,3193 +13,62 @@
13
13
  *
14
14
  * A project describes itself in `ferrovue.config.json` at its root — see `Config` — and runs the
15
15
  * `ferrovue` command there. `generate()` is also what a drift test calls, so the committed files can
16
- * be held to exactly what this produces. */
17
- import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
18
- import { parse as parseJs } from "@babel/parser";
19
- import { escapeHtml, hyphenate, isBooleanAttr, isSSRSafeAttrName, parseStringStyle, propsToAttrMap } from "@vue/shared";
20
- import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, statSync } from "node:fs";
21
- import { basename, dirname, join, relative, resolve } from "node:path";
22
- const STR = { k: "str" };
23
- const INT = { k: "int" };
24
- const BOOL = { k: "bool" };
25
- const UNDEF = { k: "undef" };
26
- const opt = (of) => (of.k === "opt" ? of : { k: "opt", of });
27
- export const CONFIG_FILE = "ferrovue.config.json";
28
- /** Read the project's configuration. */
29
- export function loadConfig(root) {
30
- const raw = JSON.parse(readFileSync(join(root, CONFIG_FILE), "utf8"));
31
- if (typeof raw.components !== "string" || typeof raw.out !== "string") {
32
- throw new GenError(`${CONFIG_FILE} needs \`components\` and \`out\` directories`);
33
- }
34
- return raw;
35
- }
36
- function tyOfName(name) {
37
- switch (name) {
38
- case "string":
39
- return STR;
40
- case "string?":
41
- return opt(STR);
42
- case "int":
43
- return INT;
44
- case "int?":
45
- return opt(INT);
46
- case "bool":
47
- return BOOL;
48
- default:
49
- throw new GenError(`unknown helper type \`${String(name)}\``);
50
- }
51
- }
52
- /** The helpers of the configuration being compiled: name → Rust twin, typed. */
53
- let HELPERS = {};
54
- let HELPER_MODULE = null;
55
- let TRUSTED_HTML = null;
56
- /** Custom directives declared to render nothing on the server. */
57
- let CLIENT_DIRECTIVES = new Set();
58
- /** The configured routes, when there are any. */
59
- let ROUTES = null;
60
- /** The history's base, and the class names active links take, from the configured router. */
61
- let ROUTER_BASE = "";
62
- let LINK_ACTIVE = "router-link-active";
63
- let LINK_EXACT_ACTIVE = "router-link-exact-active";
64
- /** The configured stores, by hook name, and every interface their files declare. */
65
- let STORES = new Map();
66
- let STORE_STRUCTS = new Map();
67
- /** The store file each of those interfaces is declared in. */
68
- let STORE_FILES = new Map();
69
- /** The project root, for resolving a component's imports. */
70
- let ROOT_DIR = "";
71
- /** Interfaces and object types declared in shared `.ts` files that components import, and type
72
- * aliases there; the file each comes from. They are written once, to `types.rs`. */
73
- let TYPE_STRUCTS = new Map();
74
- let TYPE_ALIASES = new Map();
75
- let TYPE_FILES = new Map();
76
- /** The shared files read so far. */
77
- let TYPE_READ = new Set();
78
- /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
79
- export const TYPES_MODULE = "ferrovue/types";
80
- const RUST_KEYWORDS = new Set("as break const continue crate else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while async await dyn abstract become box do final macro override priv typeof unsized virtual yield try".split(" "));
81
- /** Types the generated code names, which an interface of the same name would shadow. */
82
- const RUST_PRELUDE = new Set(["Option", "Some", "None", "Vec", "String", "Box", "Result", "Ok", "Err", "Cow", "Slots", "Default"]);
83
- function snake(js) {
84
- const s = js.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase()).replace(/^_/, "");
85
- return RUST_KEYWORDS.has(s) ? `r#${s}` : s;
86
- }
87
- function rustStr(s) {
88
- let out = '"';
89
- for (const ch of s) {
90
- const c = ch.codePointAt(0);
91
- if (ch === '"')
92
- out += '\\"';
93
- else if (ch === "\\")
94
- out += "\\\\";
95
- else if (ch === "\n")
96
- out += "\\n";
97
- else if (ch === "\r")
98
- out += "\\r";
99
- else if (ch === "\t")
100
- out += "\\t";
101
- else if (c < 0x20 || c === 0x7f)
102
- out += `\\u{${c.toString(16)}}`;
103
- else
104
- out += ch;
105
- }
106
- return out + '"';
107
- }
108
- export class GenError extends Error {
109
- }
110
- function fail(comp, what, node) {
111
- const at = node?.loc ? ` (line ${node.loc.start.line})` : "";
112
- throw new GenError(`${comp.file}${at}: ${what}`);
113
- }
114
- function tyOfTs(comp, t, structs, seen = new Set()) {
115
- switch (t.type) {
116
- case "TSStringKeyword":
117
- return STR;
118
- case "TSNumberKeyword":
119
- return INT;
120
- case "TSBooleanKeyword":
121
- return BOOL;
122
- case "TSArrayType":
123
- return { k: "list", of: tyOfTs(comp, t.elementType, structs, seen) };
124
- case "TSParenthesizedType":
125
- return tyOfTs(comp, t.typeAnnotation, structs, seen);
126
- case "TSTypeOperator":
127
- // `readonly string[]`: the same list, which the server never writes to anyway.
128
- if (t.operator === "readonly")
129
- return tyOfTs(comp, t.typeAnnotation, structs, seen);
130
- break;
131
- case "TSLiteralType":
132
- // A literal type is a value of its kind: `"sm"` a string, `3` a number.
133
- if (t.literal.type === "StringLiteral" || t.literal.type === "TemplateLiteral")
134
- return STR;
135
- if (t.literal.type === "NumericLiteral" && Number.isInteger(t.literal.value))
136
- return INT;
137
- if (t.literal.type === "BooleanLiteral")
138
- return BOOL;
139
- break;
140
- case "TSUnionType": {
141
- // `"sm" | "md"` is a string; `T | undefined` is an optional `T`. `null` is a different value.
142
- const parts = t.types.filter((u) => u.type !== "TSUndefinedKeyword");
143
- if (t.types.some((u) => u.type === "TSNullKeyword" || (u.type === "TSLiteralType" && u.literal.type === "NullLiteral"))) {
144
- return fail(comp, "`null` in a type: use `undefined`, which is what an absent value is", t);
145
- }
146
- const tys = parts.map((u) => tyOfTs(comp, u, structs, seen));
147
- const first = tys[0];
148
- if (!first || tys.some((x) => !sameTy(x, first)))
149
- return fail(comp, "a union of different types has no Rust type", t);
150
- return parts.length < t.types.length ? opt(first) : first;
151
- }
152
- case "TSTypeReference": {
153
- const name = t.typeName.type === "Identifier" ? t.typeName.name : undefined;
154
- if (name === undefined)
155
- break;
156
- // `Array<T>` and `ReadonlyArray<T>`, as `T[]`.
157
- if ((name === "Array" || name === "ReadonlyArray") && t.typeParameters?.params?.length === 1) {
158
- return { k: "list", of: tyOfTs(comp, t.typeParameters.params[0], structs, seen) };
159
- }
160
- if (structs.has(name))
161
- return { k: "struct", name };
162
- if (comp.childProps.has(name))
163
- return { k: "child", name: comp.childProps.get(name) };
164
- if (comp.trustedName !== null && name === comp.trustedName) {
165
- if (TRUSTED_HTML === null) {
166
- fail(comp, `a \`TrustedHtml\` prop needs \`trustedHtml\` in ${CONFIG_FILE}: the Rust type it is`, t);
167
- }
168
- return { k: "html" };
169
- }
170
- const imported = comp.importedTypes.get(name);
171
- if (imported)
172
- return imported;
173
- const alias = comp.aliases.get(name);
174
- if (alias) {
175
- if (seen.has(name))
176
- fail(comp, `type \`${name}\` refers to itself through an alias`, t);
177
- return tyOfTs(comp, alias, structs, new Set(seen).add(name));
178
- }
179
- return fail(comp, `unsupported prop type \`${name}\`: declare it as an interface in the component, or import it from a \`.ts\` file`, t);
180
- }
181
- }
182
- return fail(comp, `unsupported prop type \`${t.type}\``, t);
183
- }
184
- /** Every local name an interface or object type alias declares a struct for, and every other
185
- * alias: what a type in that block may name. */
186
- function declareTypes(comp, body, structs, aliases) {
187
- const decls = [];
188
- for (const st of body) {
189
- const d = st.type === "ExportNamedDeclaration" ? st.declaration : st;
190
- if (d?.type === "TSInterfaceDeclaration")
191
- decls.push({ name: d.id.name, members: d.body.body, node: d });
192
- else if (d?.type === "TSTypeAliasDeclaration") {
193
- if (d.typeAnnotation.type === "TSTypeLiteral")
194
- decls.push({ name: d.id.name, members: d.typeAnnotation.members, node: d });
195
- else
196
- aliases.set(d.id.name, d.typeAnnotation);
197
- }
198
- }
199
- for (const d of decls) {
200
- if (RUST_PRELUDE.has(d.name)) {
201
- fail(comp, `an interface called \`${d.name}\` would hide Rust's own \`${d.name}\` in the generated code; rename it`, d.node);
202
- }
203
- structs.set(d.name, { name: d.name, fields: [] });
204
- }
205
- return decls;
206
- }
207
- /** A relative import's file, resolved as a bundler resolves a TypeScript import. */
208
- function resolveImport(fromFile, spec) {
209
- if (!spec.startsWith("."))
210
- return null;
211
- const base = resolve(ROOT_DIR, dirname(fromFile), spec);
212
- for (const candidate of [base, `${base}.ts`, join(base, "index.ts"), base.replace(/\.js$/, ".ts")]) {
213
- try {
214
- if (statSync(candidate).isFile() && candidate.endsWith(".ts"))
215
- return candidate;
216
- }
217
- catch {
218
- // not this one
219
- }
220
- }
221
- return null;
222
- }
223
- /** A shared `.ts` file of types, read once: its interfaces and object types become structs in
224
- * `types.rs`, its other aliases are resolved where they are used. */
225
- function readTypeFile(file) {
226
- if (TYPE_READ.has(file))
227
- return;
228
- TYPE_READ.add(file);
229
- const rel = relative(ROOT_DIR, file);
230
- const home = storeHome(rel);
231
- home.structs = TYPE_STRUCTS;
232
- home.aliases = TYPE_ALIASES;
233
- const body = parseJs(readFileSync(file, "utf8"), { sourceType: "module", plugins: ["typescript"] }).program.body;
234
- const before = new Set(TYPE_STRUCTS.keys());
235
- const local = new Map();
236
- const decls = declareTypes(home, body, local, TYPE_ALIASES);
237
- for (const d of decls) {
238
- if (before.has(d.name))
239
- fail(home, `\`${d.name}\` is declared by ${TYPE_FILES.get(d.name)} too`, d.node);
240
- TYPE_STRUCTS.set(d.name, local.get(d.name));
241
- TYPE_FILES.set(d.name, rel);
242
- }
243
- for (const d of decls) {
244
- const st = structOf(home, d.name, d.members, TYPE_STRUCTS);
245
- for (const f of st.fields)
246
- f.ty = markHome(f.ty, "types");
247
- TYPE_STRUCTS.set(d.name, st);
248
- }
249
- }
250
- /** A type read in the file that declares it, marked with where it lives for readers elsewhere. */
251
- function markHome(ty, home) {
252
- if (ty.k === "struct" && !ty.store && !ty.home && ty.name !== "Props")
253
- return { ...ty, home };
254
- if (ty.k === "opt" || ty.k === "list")
255
- return { ...ty, of: markHome(ty.of, home) };
256
- return ty;
257
- }
258
- /** Where a struct type is declared: its fields, the component whose types its fields name, and
259
- * the path the generated code names it by. */
260
- function lookupStruct(comp, ty) {
261
- // Named from the module being written: plainly within its own, by path from any other.
262
- if (ty.store)
263
- return { st: STORE_STRUCTS.get(ty.name), owner: comp, path: comp.module === "stores" ? "" : "super::stores::" };
264
- if (ty.home === "types")
265
- return { st: TYPE_STRUCTS.get(ty.name), owner: comp, path: comp.module === "types" ? "" : "super::types::" };
266
- if (ty.home !== undefined && ty.home !== comp.name) {
267
- const owner = childOf(ty.home);
268
- return { st: owner.structs.get(ty.name), owner, path: `super::${owner.module}::` };
269
- }
270
- return { st: ty.name === "Props" ? comp.props : comp.structs.get(ty.name), owner: comp, path: "" };
271
- }
272
- function structOf(comp, name, members, structs) {
273
- const fields = [];
274
- for (const m of members) {
275
- if (m.type !== "TSPropertySignature" || m.key.type !== "Identifier") {
276
- fail(comp, `\`${name}\` may only hold plain named fields`, m);
277
- }
278
- const base = tyOfTs(comp, m.typeAnnotation.typeAnnotation, structs);
279
- if (m.optional && base.k === "opt") {
280
- fields.push({ js: m.key.name, rust: snake(m.key.name), ty: base });
281
- continue;
282
- }
283
- fields.push({ js: m.key.name, rust: snake(m.key.name), ty: m.optional ? opt(base) : base });
284
- }
285
- return { name, fields };
286
- }
287
- /** The type literal (or interface) `defineProps<...>()` was given, seen through `withDefaults`. */
288
- function definePropsType(call) {
289
- if (call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "withDefaults") {
290
- return definePropsType(call.arguments[0]);
291
- }
292
- if (call?.type === "CallExpression" &&
293
- call.callee.type === "Identifier" &&
294
- call.callee.name === "defineProps") {
295
- return call.typeParameters?.params?.[0] ?? null;
296
- }
297
- return null;
298
- }
299
- /** The `default` of each prop in the runtime declaration `compileScript` wrote, by prop name. */
300
- function runtimeDefaults(comp, content) {
301
- const out = new Map();
302
- let program;
303
- try {
304
- program = parseJs(content, { sourceType: "module", plugins: ["typescript"] }).program.body;
305
- }
306
- catch {
307
- return out;
308
- }
309
- /** An object literal's properties, with `...{ … }` spreads opened, as `defineModel` writes them. */
310
- const props = (o) => o?.type !== "ObjectExpression"
311
- ? []
312
- : o.properties.flatMap((p) => (p.type === "SpreadElement" ? props(p.argument) : [p]));
313
- const visit = (n) => {
314
- if (!n || typeof n !== "object")
315
- return;
316
- if (n.type === "ObjectProperty" && !n.computed && (n.key.name ?? n.key.value) === "props") {
317
- // `props: { … }`, or `_mergeModels({ … }, { … })` when there is a `defineModel`.
318
- const objects = n.value.type === "CallExpression" ? n.value.arguments : [n.value];
319
- for (const o of objects) {
320
- for (const p of props(o)) {
321
- if (p.type !== "ObjectProperty")
322
- continue;
323
- const d = props(p.value).find((q) => q.type === "ObjectProperty" && (q.key.name ?? q.key.value) === "default");
324
- if (d)
325
- out.set(p.key.name ?? p.key.value, d.value);
326
- }
327
- }
328
- return;
329
- }
330
- for (const k of Object.keys(n)) {
331
- if (k === "loc" || k === "start" || k === "end")
332
- continue;
333
- const v = n[k];
334
- if (Array.isArray(v))
335
- v.forEach(visit);
336
- else if (v && typeof v === "object" && typeof v.type === "string")
337
- visit(v);
338
- }
339
- };
340
- program.forEach(visit);
341
- void comp;
342
- return out;
343
- }
344
- /** A prop's default as a Rust value of its type: a literal, or an empty list from `() => []`. */
345
- function defaultValue(comp, f, node) {
346
- const of = f.ty.k === "opt" ? f.ty.of : f.ty;
347
- if (of.k === "str" && node.type === "StringLiteral")
348
- return rustStr(node.value);
349
- if (of.k === "int" && node.type === "NumericLiteral" && Number.isInteger(node.value))
350
- return `${node.value}i64`;
351
- if (of.k === "int" && node.type === "UnaryExpression" && node.operator === "-" && node.argument.type === "NumericLiteral" && Number.isInteger(node.argument.value)) {
352
- return `-${node.argument.value}i64`;
353
- }
354
- if (of.k === "bool" && node.type === "BooleanLiteral")
355
- return String(node.value);
356
- if (of.k === "list" && node.type === "ArrowFunctionExpression" && node.body.type === "ArrayExpression" && node.body.elements.length === 0) {
357
- return "&[]";
358
- }
359
- return fail(comp, `the default of \`${f.js}\` must be a literal of its type, or \`() => []\` for a list`, node);
360
- }
361
- function readComponent(file, root) {
362
- const name = basename(file, ".vue");
363
- const source = readFileSync(file, "utf8");
364
- const { descriptor, errors } = parseSfc(source, { filename: file });
365
- const rel = relative(root, file);
366
- const comp = {
367
- name,
368
- module: snake(name),
369
- file: rel,
370
- props: { name: "Props", fields: [] },
371
- structs: new Map(),
372
- trustedName: null,
373
- childProps: new Map(),
374
- imports: new Set(),
375
- slotNames: [],
376
- routerView: false,
377
- routerLink: false,
378
- readsRoute: false,
379
- usesRoute: false,
380
- readsStores: false,
381
- usesStores: false,
382
- models: new Map(),
383
- aliases: new Map(),
384
- importedTypes: new Map(),
385
- slotShapes: new Map(),
386
- };
387
- if (errors.length)
388
- fail(comp, String(errors[0]));
389
- if (!descriptor.scriptSetup || !descriptor.template) {
390
- fail(comp, "an island needs `<script setup lang=\"ts\">` and a `<template>`");
391
- }
392
- // A global `<style>` block changes no markup. A scoped one adds `data-v-…` attributes whose hash
393
- // the bundler chooses, a CSS module renames classes, and `v-bind()` in CSS writes variables onto
394
- // the root: none of which the server can know.
395
- for (const st of descriptor.styles) {
396
- if (st.scoped)
397
- fail(comp, "`<style scoped>` adds `data-v-` attributes whose id the bundler chooses; use a global `<style>` or a stylesheet");
398
- if (st.module)
399
- fail(comp, "`<style module>` renames classes in the bundler; use a global `<style>` or a stylesheet");
400
- }
401
- if (descriptor.cssVars.length)
402
- fail(comp, "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead");
403
- const script = compileScript(descriptor, { id: name });
404
- const ast = script.scriptSetupAst ?? [];
405
- // A plain `<script>` beside the setup one may declare the types the setup block uses.
406
- const plainAst = script.scriptAst ?? [];
407
- for (const s of [...plainAst, ...ast]) {
408
- if (s.type !== "ImportDeclaration" || !s.source.value.endsWith(".vue"))
409
- continue;
410
- const from = basename(s.source.value, ".vue");
411
- if (s.specifiers.some((sp) => sp.type === "ImportDefaultSpecifier"))
412
- comp.imports.add(from);
413
- for (const sp of s.specifiers) {
414
- if (sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === "Props") {
415
- comp.childProps.set(sp.local.name, from);
416
- }
417
- }
418
- }
419
- for (const s of [...plainAst, ...ast]) {
420
- if (s.type !== "ImportDeclaration" || s.source.value !== TYPES_MODULE)
421
- continue;
422
- for (const sp of s.specifiers) {
423
- if (sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === "TrustedHtml") {
424
- comp.trustedName = sp.local.name;
425
- }
426
- }
427
- }
428
- // Types imported from elsewhere: a store's file, a shared `.ts` file, or another component.
429
- for (const st of [...plainAst, ...ast]) {
430
- if (st.type !== "ImportDeclaration")
431
- continue;
432
- const from = st.source.value;
433
- for (const sp of st.specifiers) {
434
- if (sp.type !== "ImportSpecifier")
435
- continue;
436
- const name = sp.imported.name ?? sp.imported.value;
437
- if (from.endsWith(".vue")) {
438
- if (name !== "Props")
439
- comp.importedTypes.set(sp.local.name, { k: "struct", name, home: basename(from, ".vue") });
440
- continue;
441
- }
442
- const isType = st.importKind === "type" || sp.importKind === "type";
443
- const file = resolveImport(comp.file, from);
444
- if (!file)
445
- continue;
446
- const module = file.replace(/\.ts$/, "");
447
- if ([...STORES.values()].some((x) => x.module === module)) {
448
- if (STORE_STRUCTS.has(name))
449
- comp.importedTypes.set(sp.local.name, { k: "struct", name, store: true });
450
- continue;
451
- }
452
- if (HELPER_MODULE !== null && from === HELPER_MODULE && !isType)
453
- continue;
454
- readTypeFile(file);
455
- if (TYPE_STRUCTS.has(name))
456
- comp.importedTypes.set(sp.local.name, { k: "struct", name, home: "types" });
457
- else if (TYPE_ALIASES.has(name))
458
- comp.aliases.set(sp.local.name, TYPE_ALIASES.get(name));
459
- }
460
- }
461
- // Interfaces in two passes: every name first, so one may name another declared after it, or
462
- // itself — a tree node's children are tree nodes.
463
- const decls = declareTypes(comp, [...plainAst, ...ast], comp.structs, comp.aliases);
464
- for (const d of decls)
465
- comp.structs.set(d.name, structOf(comp, d.name, d.members, comp.structs));
466
- let propsTy = null;
467
- for (const s of ast) {
468
- const call = s.type === "ExpressionStatement" ? s.expression : s.type === "VariableDeclaration" ? s.declarations[0]?.init : null;
469
- const inner = call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "withDefaults" ? call.arguments[0] : call;
470
- if (inner?.type === "CallExpression" && inner.callee.type === "Identifier" && inner.callee.name === "defineProps" && !inner.typeParameters) {
471
- fail(comp, "props are declared with a type, `defineProps<{ ... }>()`: a runtime declaration has no Rust type", inner);
472
- }
473
- const found = definePropsType(call);
474
- if (found)
475
- propsTy = found;
476
- }
477
- // A component without `defineProps` takes no props.
478
- if (!propsTy) {
479
- // nothing to read
480
- }
481
- else if (propsTy.type === "TSTypeLiteral") {
482
- comp.props = structOf(comp, "Props", propsTy.members, comp.structs);
483
- }
484
- else if (propsTy.type === "TSTypeReference" && comp.structs.has(propsTy.typeName.name)) {
485
- comp.props = { name: "Props", fields: comp.structs.get(propsTy.typeName.name).fields };
486
- }
487
- else {
488
- fail(comp, "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
489
- }
490
- // `defineModel`: a prop of its own (`modelValue` unless named), which the binding reads.
491
- for (const st of ast) {
492
- if (st.type !== "VariableDeclaration")
493
- continue;
494
- for (const d of st.declarations) {
495
- const call = d.init;
496
- if (call?.type !== "CallExpression" || call.callee.type !== "Identifier" || call.callee.name !== "defineModel")
497
- continue;
498
- const t = call.typeParameters?.params?.[0];
499
- if (!t)
500
- fail(comp, "`defineModel` needs its type: `defineModel<string>()`", call);
501
- const named = call.arguments[0]?.type === "StringLiteral" ? call.arguments[0].value : "modelValue";
502
- const options = call.arguments.find((a) => a.type === "ObjectExpression");
503
- const required = options?.properties.some((p) => p.type === "ObjectProperty" && (p.key.name ?? p.key.value) === "required" && p.value.type === "BooleanLiteral" && p.value.value);
504
- const base = tyOfTs(comp, t, comp.structs);
505
- comp.props.fields.push({ js: named, rust: snake(named), ty: required ? base : opt(base) });
506
- comp.models.set(d.id.name, named);
507
- }
508
- }
509
- // What Vue uses for an absent optional prop: the default `compileScript` resolved — from
510
- // `withDefaults`, a destructured default, or `defineModel`'s options — or `false` for a boolean.
511
- const defaults = runtimeDefaults(comp, script.content);
512
- for (const f of comp.props.fields) {
513
- if (f.ty.k !== "opt")
514
- continue;
515
- const node = defaults.get(f.js);
516
- if (node)
517
- f.dflt = defaultValue(comp, f, node);
518
- else if (f.ty.of.k === "bool")
519
- f.dflt = "false";
520
- }
521
- const compiled = compileTemplate({
522
- source: descriptor.template.content,
523
- filename: file,
524
- id: name,
525
- ssr: true,
526
- ssrCssVars: [],
527
- compilerOptions: { bindingMetadata: script.bindings },
528
- });
529
- if (compiled.errors.length)
530
- fail(comp, String(compiled.errors[0]));
531
- for (const m of compiled.code.matchAll(/_ssrRenderSlot\(_ctx\.\$slots, "([^"]+)"/g)) {
532
- if (!comp.slotNames.includes(m[1]))
533
- comp.slotNames.push(m[1]);
534
- }
535
- // Resolved by name, as globally registered components are, or imported from `vue-router`.
536
- const imported = (name) => [...plainAst, ...ast].some((st) => st.type === "ImportDeclaration" && st.source.value === "vue-router" &&
537
- st.specifiers.some((sp) => sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === name));
538
- comp.routerLink = compiled.code.includes('_resolveComponent("RouterLink")') || imported("RouterLink");
539
- comp.routerView = compiled.code.includes('_resolveComponent("RouterView")') || imported("RouterView");
540
- comp.readsRoute = compiled.code.includes("_ctx.$route");
541
- return { comp, ast, ssr: compiled.code };
542
- }
543
- /* ── Expressions ─────────────────────────────────────────────────────────────────────────── */
544
- function fieldVal(comp, base, ty, js, node) {
545
- if (ty.k !== "struct")
546
- fail(comp, `\`.${js}\` on a value that is not an object`, node);
547
- const { st, owner } = lookupStruct(comp, ty);
548
- const found = st?.fields.find((x) => x.js === js);
549
- if (!found)
550
- fail(comp, `\`${ty.name}\` has no field \`${js}\``, node);
551
- // A field of a type another component declares names that component's types.
552
- const f = owner === comp ? found : { ...found, ty: markHome(found.ty, owner.name) };
553
- // A scoped slot's props already hold borrows and copies: each field is read as it is.
554
- if (st.slot)
555
- return { code: `${base}.${f.rust}`, ty: f.ty };
556
- const place = `${base}.${f.rust}`;
557
- if (f.dflt !== undefined && f.ty.k === "opt") {
558
- const of = f.ty.of;
559
- if (of.k === "str")
560
- return { code: `${place}.as_deref().unwrap_or(${f.dflt})`, ty: STR };
561
- if (of.k === "list")
562
- return { code: `${place}.as_deref().unwrap_or(${f.dflt})`, ty: of };
563
- return { code: `${place}.unwrap_or(${f.dflt})`, ty: of };
564
- }
565
- switch (f.ty.k) {
566
- case "str":
567
- return { code: `&*${place}`, ty: STR };
568
- case "html":
569
- case "child":
570
- return { code: `&${place}`, ty: f.ty };
571
- case "opt":
572
- if (f.ty.of.k === "html")
573
- return { code: `${place}.as_ref()`, ty: f.ty };
574
- if (f.ty.of.k === "str")
575
- return { code: `${place}.as_deref()`, ty: f.ty };
576
- if (f.ty.of.k === "int" || f.ty.of.k === "bool")
577
- return { code: place, ty: f.ty };
578
- // An object or a list, borrowed: `Option<&T>`, which a `v-if` narrows to the `&T`.
579
- return { code: `${place}.as_ref()`, ty: f.ty };
580
- default:
581
- return { code: place, ty: f.ty };
582
- }
583
- }
584
- function truthy(v) {
585
- switch (v.ty.k) {
586
- case "str":
587
- return `!(${v.code}).is_empty()`;
588
- case "int":
589
- return `(${v.code}) != 0`;
590
- case "bool":
591
- return `(${v.code})`;
592
- case "undef":
593
- return "false";
594
- case "opt":
595
- return `(${v.code}).is_some_and(|v| ${truthy({ code: "v", ty: v.ty.of })})`;
596
- default:
597
- return "true";
598
- }
599
- }
600
- function sameTy(a, b) {
601
- return JSON.stringify(a) === JSON.stringify(b);
602
- }
603
- /** A value passed where `want` is expected: a present value into an optional slot is wrapped. */
604
- function coerce(comp, v, want, node) {
605
- if (sameTy(v.ty, want))
606
- return v.code;
607
- if (want.k === "opt" && v.ty.k === "undef")
608
- return "None";
609
- if (want.k === "opt" && sameTy(v.ty, want.of))
610
- return `Some(${v.code})`;
611
- return fail(comp, `a ${JSON.stringify(v.ty)} where ${JSON.stringify(want)} is expected`, node);
612
- }
613
- /** The JavaScript path an expression names — `$props.user`, `item.author.name` — or `null` when it
614
- * is not a plain chain of names. */
615
- function pathOf(n) {
616
- if (n.type === "Identifier")
617
- return n.name;
618
- if (n.type !== "MemberExpression")
619
- return null;
620
- const base = pathOf(n.object);
621
- if (base === null)
622
- return null;
623
- if (!n.computed && n.property.type === "Identifier")
624
- return `${base}.${n.property.name}`;
625
- if (n.computed && n.property.type === "StringLiteral")
626
- return `${base}.${n.property.value}`;
627
- return null;
628
- }
629
- let narrowCount = 0;
630
- /* A test, as a Rust boolean. Only its truthiness is used, so `||`, `&&` and `!` combine the truthiness
631
- * of their operands whatever their types — where as a VALUE `a || b` is `a` or `b`, and stays held to
632
- * the stricter rules `expr` applies. */
633
- function cond(s, n) {
634
- if (n.type === "LogicalExpression" && (n.operator === "||" || n.operator === "&&")) {
635
- return `(${cond(s, n.left)} ${n.operator} ${cond(s, n.right)})`;
636
- }
637
- // Parenthesised: `truthy` of a number is `(x) != 0`, and a bare `!` would bind to `(x)` — which
638
- // in Rust is a bitwise NOT, not a negation of the test.
639
- if (n.type === "UnaryExpression" && n.operator === "!")
640
- return `!(${cond(s, n.argument)})`;
641
- const v = expr(s, n);
642
- return v.konst !== undefined ? String(v.konst) : truthy(v);
643
- }
644
- /** Every component being compiled, by name, for a type that names another one's props. */
645
- let ALL = new Map();
646
- function childOf(name) {
647
- const c = ALL.get(name);
648
- if (!c)
649
- throw new GenError(`\`Props\` is imported from ${name}.vue, which is not among the components compiled`);
650
- return c;
651
- }
652
- /** The reader's route, which the component then takes. */
653
- function theRoute(s, n) {
654
- if (!ROUTES)
655
- fail(s.comp, `reading the route needs \`routes\` in ${CONFIG_FILE}`, n);
656
- s.comp.readsRoute = true;
657
- return { code: "fv_route", ty: { k: "route" } };
658
- }
659
- /** A field of the route: what `useRoute()` gives that the server knows as vue-router does. */
660
- function routeField(s, base, prop, n) {
661
- if (base.ty.k === "params") {
662
- // Every parameter is a string; absent when the route has none of that name.
663
- return { code: `fv_route.param(${rustStr(prop)})`, ty: opt(STR) };
664
- }
665
- switch (prop) {
666
- case "path":
667
- return { code: "fv_route.path()", ty: STR };
668
- case "hash":
669
- return { code: "fv_route.hash()", ty: STR };
670
- case "name":
671
- return { code: "fv_route.name()", ty: opt(STR) };
672
- case "params":
673
- return { code: "fv_route", ty: { k: "params" } };
674
- }
675
- return fail(s.comp, `\`route.${prop}\` is not available on the server: \`path\`, \`hash\`, \`name\` and \`params\` are`, n);
676
- }
677
- /** A Pinia getter read from a store's state, `prefs.doubled`: its expression, translated with the
678
- * state it takes bound to that state. `null` when `name` is not a getter of that store. */
679
- function storeGetter(s, base, name, node) {
680
- if (base.ty.k !== "struct" || !base.ty.store)
681
- return null;
682
- const stateName = base.ty.name;
683
- const store = [...STORES.values()].find((st) => st.state === stateName);
684
- const g = store?.getters.get(name);
685
- if (!g)
686
- return null;
687
- const where = `getter \`${name}\` in ${g.file}`;
688
- if (!g.body)
689
- fail(s.comp, `${where} returns a single expression`, node);
690
- const uses = (n, type) => !!n && typeof n === "object" && (n.type === type || Object.entries(n).some(([k, v]) => k !== "loc" && (Array.isArray(v) ? v.some((x) => uses(x, type)) : typeof v === "object" && uses(v, type))));
691
- if (uses(g.body, "ThisExpression"))
692
- fail(s.comp, `${where} reads \`this\`; read the state through the getter's parameter`, node);
693
- if (g.body.type === "ArrowFunctionExpression" || g.body.type === "FunctionExpression") {
694
- fail(s.comp, `${where} returns a function, which takes arguments only the client passes`, node);
695
- }
696
- const locals = new Map();
697
- if (g.param)
698
- locals.set(g.param, { code: base.code, ty: base.ty });
699
- try {
700
- return expr({ ...s, locals, narrowed: new Map(), setup: new Map(), propsIdent: null }, g.body);
701
- }
702
- catch (e) {
703
- if (e instanceof GenError)
704
- throw new GenError(`${s.comp.file}: ${where}: ${e.message.replace(/^[^:]*: /, "")}`);
705
- throw e;
706
- }
707
- }
708
- function expr(s, n) {
709
- const comp = s.comp;
710
- const path = pathOf(n);
711
- if (path !== null) {
712
- const narrowed = s.narrowed.get(path);
713
- if (narrowed)
714
- return narrowed;
715
- }
716
- if (n.type === "MemberExpression" && !n.computed && n.property.type === "Identifier" && n.property.name === "length") {
717
- const base = expr(s, n.object);
718
- if (base.ty.k === "list")
719
- return { code: `((${base.code}).len() as i64)`, ty: INT };
720
- // A JavaScript string's length counts UTF-16 code units, not the bytes Rust's `len` counts.
721
- if (base.ty.k === "str")
722
- return { code: `fv::js_length(${base.code})`, ty: INT };
723
- }
724
- if (n.type === "BinaryExpression" && n.operator === "+") {
725
- const a = expr(s, n.left);
726
- const b = expr(s, n.right);
727
- // JavaScript's `+` concatenates as soon as one side is a string, writing a number in decimal —
728
- // which is what `{}` does with an `i64`. Two numbers would be arithmetic, which is not here.
729
- const joinable = (t) => t.k === "str" || t.k === "int";
730
- if ((a.ty.k === "str" || b.ty.k === "str") && joinable(a.ty) && joinable(b.ty)) {
731
- return { code: `&*format!("{}{}", ${a.code}, ${b.code})`, ty: STR };
732
- }
733
- // Two numbers: arithmetic, as `n + 1` in a template means.
734
- if (a.ty.k === "int" && b.ty.k === "int")
735
- return { code: `(${a.code} + ${b.code})`, ty: INT };
736
- return fail(comp, "`+` joins a string to a string or a number, or adds two numbers", n);
737
- }
738
- if (n.type === "BinaryExpression" && ["-", "*", "%", "/", "<", ">", "<=", ">="].includes(n.operator)) {
739
- const a = expr(s, n.left);
740
- const b = expr(s, n.right);
741
- if (a.ty.k !== "int" || b.ty.k !== "int") {
742
- return fail(comp, `\`${n.operator}\` is supported between two numbers that are present: ${a.ty.k === "opt" || b.ty.k === "opt" ? "narrow an optional one with `v-if` first" : "strings compare by UTF-16 code unit in JavaScript"}`, n);
743
- }
744
- if (n.operator === "/")
745
- return fail(comp, "`/` gives a fraction, and only integers are supported", n);
746
- // JavaScript's `%` keeps the dividend's sign, as Rust's does; only a zero divisor differs (NaN
747
- // against a panic), so the divisor must be a literal that is not zero.
748
- if (n.operator === "%" && !(n.right.type === "NumericLiteral" && n.right.value !== 0)) {
749
- return fail(comp, "`%` takes a literal divisor that is not zero", n);
750
- }
751
- const ty = ["<", ">", "<=", ">="].includes(n.operator) ? BOOL : INT;
752
- return { code: `(${a.code} ${n.operator} ${b.code})`, ty };
753
- }
754
- switch (n.type) {
755
- case "TemplateLiteral": {
756
- // `${a}-${b}`: each part written as JavaScript writes it into a string.
757
- let fmt = "";
758
- const args = [];
759
- n.quasis.forEach((q, i) => {
760
- fmt += q.value.cooked.replace(/[{}]/g, (c) => c + c);
761
- if (i >= n.expressions.length)
762
- return;
763
- const v = expr(s, n.expressions[i]);
764
- if (v.ty.k === "str" || v.ty.k === "int")
765
- args.push(v.code);
766
- else if (v.ty.k === "bool")
767
- args.push(`if ${v.code} { "true" } else { "false" }`);
768
- else
769
- fail(comp, "a template literal interpolates strings, numbers and booleans that are present", n.expressions[i]);
770
- fmt += "{}";
771
- });
772
- return { code: args.length ? `&*format!(${rustStr(fmt)}, ${args.join(", ")})` : rustStr(fmt.replace(/\{\{|\}\}/g, (c) => c[0])), ty: STR };
773
- }
774
- case "ArrayExpression": {
775
- // A list of literals or values of one scalar type, as a Rust array.
776
- if (n.elements.length === 0)
777
- return { code: "[]", ty: { k: "list", of: UNDEF } };
778
- const items = n.elements.map((el) => {
779
- if (!el || el.type === "SpreadElement")
780
- fail(comp, "an array literal holds plain values", n);
781
- return expr(s, el);
782
- });
783
- const of = items[0].ty;
784
- if (!(of.k === "str" || of.k === "int" || of.k === "bool") || items.some((v) => !sameTy(v.ty, of))) {
785
- fail(comp, "an array literal holds strings, numbers or booleans, all of one type", n);
786
- }
787
- return { code: `[${items.map((v) => v.code).join(", ")}]`, ty: { k: "list", of } };
788
- }
789
- case "OptionalMemberExpression": {
790
- // `a?.b`: the field of an optional object when it is present.
791
- if (n.computed)
792
- return fail(comp, "computed member access", n);
793
- const base = expr(s, n.object);
794
- if (base.ty.k !== "opt")
795
- return fieldVal(comp, base.code, base.ty, n.property.name, n);
796
- const f = fieldVal(comp, "v", base.ty.of, n.property.name, n);
797
- if (f.ty.k === "opt")
798
- return { code: `(${base.code}).and_then(|v| ${f.code})`, ty: f.ty };
799
- return { code: `(${base.code}).map(|v| ${f.code})`, ty: opt(f.ty) };
800
- }
801
- case "StringLiteral":
802
- return { code: rustStr(n.value), ty: STR };
803
- case "NumericLiteral":
804
- if (!Number.isInteger(n.value))
805
- fail(comp, "only integer literals", n);
806
- return { code: `${n.value}i64`, ty: INT };
807
- case "BooleanLiteral":
808
- return { code: String(n.value), ty: BOOL, konst: n.value };
809
- case "NullLiteral":
810
- // An absent prop is `undefined`, and `null` is a different value: `x === null` is false for
811
- // it in Vue and would be `is_none()` here.
812
- return fail(comp, "`null`: compare with `undefined`, which is what an absent prop is", n);
813
- case "Identifier": {
814
- if (n.name === "undefined")
815
- return { code: "None", ty: UNDEF };
816
- const local = s.locals.get(n.name) ?? s.setup.get(n.name);
817
- if (local)
818
- return local;
819
- if (s.clientOnly.has(n.name)) {
820
- return fail(comp, `\`${n.name}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(n.name)}`, n);
821
- }
822
- if (n.name === s.propsIdent)
823
- return { code: "props", ty: { k: "struct", name: "Props" } };
824
- return fail(comp, `\`${n.name}\` is not available when rendering on the server`, n);
825
- }
826
- case "MemberExpression": {
827
- // `count.value` in the script: the ref's value, which is what the binding already reads.
828
- if (!n.computed && n.property.name === "value" && n.object.type === "Identifier" && s.refs.has(n.object.name) && !s.locals.has(n.object.name)) {
829
- return expr(s, n.object);
830
- }
831
- if (n.computed) {
832
- if (n.object.type === "Identifier" && n.object.name === "$setup" && n.property.type === "StringLiteral") {
833
- return expr(s, { ...n.property, type: "Identifier", name: n.property.value });
834
- }
835
- if (n.property.type === "StringLiteral") {
836
- const base = expr(s, n.object);
837
- if (base.ty.k === "params")
838
- return routeField(s, base, n.property.value, n);
839
- }
840
- return fail(comp, "computed member access", n);
841
- }
842
- const prop = n.property.name;
843
- // `$slots.side`: whether the parent gave that slot any content.
844
- const slotsObject = (n.object.type === "Identifier" && n.object.name === "$slots") ||
845
- (n.object.type === "MemberExpression" && !n.object.computed && n.object.object.type === "Identifier" &&
846
- n.object.object.name === "_ctx" && n.object.property.name === "$slots");
847
- if (slotsObject) {
848
- if (!comp.slotNames.includes(prop))
849
- fail(comp, `\`$slots.${prop}\` names a slot this template does not render`, n);
850
- return { code: `fv_slots.${snake(prop)}.is_some()`, ty: BOOL };
851
- }
852
- if (n.object.type === "Identifier") {
853
- switch (n.object.name) {
854
- case "$props":
855
- return fieldVal(comp, "props", { k: "struct", name: "Props" }, prop, n);
856
- case "$setup":
857
- case "_ctx": {
858
- if (n.object.name === "_ctx" && prop === "$route")
859
- return theRoute(s, n);
860
- const v = s.setup.get(prop);
861
- if (v)
862
- return v;
863
- if (s.clientOnly.has(prop)) {
864
- return fail(comp, `\`${prop}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(prop)}`, n);
865
- }
866
- if (n.object.name === "_ctx" && comp.props.fields.some((f) => f.js === prop)) {
867
- return fieldVal(comp, "props", { k: "struct", name: "Props" }, prop, n);
868
- }
869
- return fail(comp, `\`${prop}\` is not available when rendering on the server`, n);
870
- }
871
- }
872
- }
873
- const base = expr(s, n.object);
874
- if (base.ty.k === "route" || base.ty.k === "params")
875
- return routeField(s, base, prop, n);
876
- return storeGetter(s, base, prop, n) ?? fieldVal(comp, base.code, base.ty, prop, n);
877
- }
878
- case "CallExpression":
879
- return call(s, n);
880
- case "LogicalExpression": {
881
- const a = expr(s, n.left);
882
- const b = expr(s, n.right);
883
- if (n.operator === "??") {
884
- if (a.ty.k !== "opt")
885
- return a;
886
- if (b.ty.k === "undef")
887
- return a;
888
- if (sameTy(a.ty.of, b.ty))
889
- return { code: `(${a.code}).unwrap_or(${b.code})`, ty: b.ty };
890
- if (sameTy(a.ty, b.ty))
891
- return { code: `(${a.code}).or(${b.code})`, ty: a.ty };
892
- return fail(comp, "`??` between different types", n);
893
- }
894
- if (n.operator === "||") {
895
- if (a.ty.k === "bool" && b.ty.k === "bool")
896
- return { code: `(${a.code} || ${b.code})`, ty: BOOL };
897
- if (b.ty.k === "undef") {
898
- // `x || undefined`: the value when it is truthy, nothing otherwise.
899
- const inner = a.ty.k === "opt" ? a.ty.of : a.ty;
900
- const test = truthy({ code: "v", ty: inner });
901
- const src = a.ty.k === "opt" ? a.code : `Some(${a.code})`;
902
- return { code: `(${src}).filter(|v| ${test.replace(/\bv\b/g, "*v")})`, ty: opt(inner) };
903
- }
904
- if (sameTy(a.ty, b.ty) && (a.ty.k === "str" || a.ty.k === "int")) {
905
- return { code: `{ let a = ${a.code}; if ${truthy({ code: "a", ty: a.ty })} { a } else { ${b.code} } }`, ty: a.ty };
906
- }
907
- return fail(comp, "`||` between these types", n);
908
- }
909
- if (n.operator === "&&" && a.ty.k === "bool" && b.ty.k === "bool") {
910
- return { code: `(${a.code} && ${b.code})`, ty: BOOL };
911
- }
912
- return fail(comp, `\`${n.operator}\` is supported between booleans only`, n);
913
- }
914
- case "UnaryExpression":
915
- if (n.operator === "!") {
916
- const a = expr(s, n.argument);
917
- return a.konst !== undefined
918
- ? { code: String(!a.konst), ty: BOOL, konst: !a.konst }
919
- : { code: `!(${truthy(a)})`, ty: BOOL };
920
- }
921
- if (n.operator === "-") {
922
- const a = expr(s, n.argument);
923
- if (a.ty.k === "int")
924
- return { code: `(-${a.code})`, ty: INT };
925
- }
926
- return fail(comp, `unary \`${n.operator}\``, n);
927
- case "BinaryExpression": {
928
- if (n.operator !== "===" && n.operator !== "!==")
929
- fail(comp, `\`${n.operator}\``, n);
930
- const a = expr(s, n.left);
931
- const b = expr(s, n.right);
932
- let eq;
933
- const scalar = (t) => t.k === "str" || t.k === "int" || t.k === "bool";
934
- if (b.ty.k === "undef" && a.ty.k === "opt")
935
- eq = `(${a.code}).is_none()`;
936
- else if (a.ty.k === "undef" && b.ty.k === "opt")
937
- eq = `(${b.code}).is_none()`;
938
- else if (sameTy(a.ty, b.ty) && (scalar(a.ty) || (a.ty.k === "opt" && scalar(a.ty.of)))) {
939
- eq = `(${a.code}) == (${b.code})`;
940
- }
941
- else if (a.ty.k === "opt" && sameTy(a.ty.of, b.ty) && scalar(b.ty))
942
- eq = `(${a.code}) == Some(${b.code})`;
943
- else if (b.ty.k === "opt" && sameTy(b.ty.of, a.ty) && scalar(a.ty))
944
- eq = `Some(${a.code}) == (${b.code})`;
945
- else
946
- return fail(comp, "`===` between these types", n);
947
- return { code: n.operator === "===" ? eq : `!(${eq})`, ty: BOOL };
948
- }
949
- case "ConditionalExpression": {
950
- const t = expr(s, n.test);
951
- if (t.konst !== undefined)
952
- return expr(s, t.konst ? n.consequent : n.alternate);
953
- const a = expr(s, n.consequent);
954
- const b = expr(s, n.alternate);
955
- if (sameTy(a.ty, b.ty))
956
- return { code: `if ${truthy(t)} { ${a.code} } else { ${b.code} }`, ty: a.ty };
957
- if (a.ty.k === "undef" || b.ty.k === "undef" || (a.ty.k === "opt" && sameTy(a.ty.of, b.ty)) || (b.ty.k === "opt" && sameTy(b.ty.of, a.ty))) {
958
- const ty = opt(a.ty.k === "undef" || b.ty.k === "opt" ? b.ty : a.ty);
959
- return {
960
- code: `if ${truthy(t)} { ${coerce(comp, a, ty, n)} } else { ${coerce(comp, b, ty, n)} }`,
961
- ty,
962
- };
963
- }
964
- return fail(comp, "the two branches of `?:` differ in type", n);
965
- }
966
- default:
967
- return fail(comp, `\`${n.type}\` is not supported in an island template`, n);
968
- }
969
- }
970
- function call(s, n) {
971
- const comp = s.comp;
972
- const callee = n.callee;
973
- const args = n.arguments;
974
- if (callee.type === "Identifier") {
975
- switch (callee.name) {
976
- case "_ssrLooseEqual": {
977
- const a = expr(s, args[0]);
978
- const b = expr(s, args[1]);
979
- if (a.ty.k === "str" && b.ty.k === "str")
980
- return { code: `(${a.code}) == (${b.code})`, ty: BOOL };
981
- return fail(comp, "`v-model` comparison between these types", n);
982
- }
983
- case "_ssrIncludeBooleanAttr": {
984
- // `!!value || value === ""`: every string is included, empty or not.
985
- const a = expr(s, args[0]);
986
- if (a.konst !== undefined)
987
- return a;
988
- if (a.ty.k === "str")
989
- return { code: "true", ty: BOOL, konst: true };
990
- if (a.ty.k === "opt" && a.ty.of.k === "str")
991
- return { code: `(${a.code}).is_some()`, ty: BOOL };
992
- return { code: truthy(a), ty: BOOL };
993
- }
994
- case "_ssrLooseContain":
995
- return fail(comp, "`v-model` over an array", n);
996
- }
997
- const helper = s.helpers.get(callee.name);
998
- if (helper)
999
- return helperCall(s, helper, args, n);
1000
- if (callee.name === "String" && args.length === 1) {
1001
- const a = expr(s, args[0]);
1002
- if (a.ty.k === "str")
1003
- return a;
1004
- if (a.ty.k === "int")
1005
- return { code: `&*(${a.code}).to_string()`, ty: STR };
1006
- if (a.ty.k === "bool")
1007
- return { code: `if ${a.code} { "true" } else { "false" }`, ty: STR };
1008
- }
1009
- return fail(comp, `\`${callee.name}()\` is not available when rendering on the server`, n);
1010
- }
1011
- if (callee.type === "MemberExpression" && !callee.computed) {
1012
- const method = callee.property.name;
1013
- if (callee.object.type === "Identifier" && callee.object.name === "Array" && method === "isArray") {
1014
- const a = expr(s, args[0]);
1015
- const is = a.ty.k === "list";
1016
- return { code: String(is), ty: BOOL, konst: is };
1017
- }
1018
- if (callee.object.type === "Identifier" && (callee.object.name === "$setup" || callee.object.name === "_ctx")) {
1019
- const helper = s.helpers.get(method);
1020
- if (helper)
1021
- return helperCall(s, helper, args, n);
1022
- }
1023
- // `Math.max(a, b, …)`, `Math.min`, `Math.abs`, over integers.
1024
- if (callee.object.type === "Identifier" && callee.object.name === "Math") {
1025
- const vals = args.map((a) => expr(s, a));
1026
- if (vals.length && vals.every((v) => v.ty.k === "int")) {
1027
- if ((method === "max" || method === "min") && vals.length >= 2) {
1028
- return { code: vals.slice(1).reduce((acc, v) => `(${acc}).${method}(${v.code})`, vals[0].code), ty: INT };
1029
- }
1030
- if (method === "abs" && vals.length === 1)
1031
- return { code: `(${vals[0].code}).abs()`, ty: INT };
1032
- }
1033
- return fail(comp, `\`Math.${method}()\` is supported on integers as \`max\`, \`min\` and \`abs\``, n);
1034
- }
1035
- const target = expr(s, callee.object);
1036
- const strArg = (i) => {
1037
- const v = expr(s, args[i]);
1038
- if (v.ty.k !== "str")
1039
- fail(comp, `\`.${method}()\` takes a string`, args[i]);
1040
- return v.code;
1041
- };
1042
- if (target.ty.k === "str") {
1043
- switch (args.length === 0 ? method : "") {
1044
- case "trim":
1045
- return { code: `fv::js_trim(${target.code})`, ty: STR };
1046
- case "trimStart":
1047
- return { code: `fv::js_trim_start(${target.code})`, ty: STR };
1048
- case "trimEnd":
1049
- return { code: `fv::js_trim_end(${target.code})`, ty: STR };
1050
- // Unicode's default case mappings, which JavaScript and Rust both apply, final sigma included.
1051
- case "toUpperCase":
1052
- return { code: `&*(${target.code}).to_uppercase()`, ty: STR };
1053
- case "toLowerCase":
1054
- return { code: `&*(${target.code}).to_lowercase()`, ty: STR };
1055
- case "toString":
1056
- return target;
1057
- }
1058
- if (args.length === 1 && method === "includes")
1059
- return { code: `(${target.code}).contains(${strArg(0)})`, ty: BOOL };
1060
- if (args.length === 1 && method === "startsWith")
1061
- return { code: `(${target.code}).starts_with(${strArg(0)})`, ty: BOOL };
1062
- if (args.length === 1 && method === "endsWith")
1063
- return { code: `(${target.code}).ends_with(${strArg(0)})`, ty: BOOL };
1064
- }
1065
- if (target.ty.k === "int" && method === "toString" && args.length === 0) {
1066
- return { code: `&*(${target.code}).to_string()`, ty: STR };
1067
- }
1068
- if (target.ty.k === "list" && (target.ty.of.k === "str" || target.ty.of.k === "int")) {
1069
- const of = target.ty.of;
1070
- if (method === "includes" && args.length === 1) {
1071
- const v = expr(s, args[0]);
1072
- if (!sameTy(v.ty, of))
1073
- fail(comp, "`.includes()` looks for a value of the list's own type", args[0]);
1074
- const item = of.k === "str" ? "&**v" : "*v";
1075
- return { code: `(${target.code}).iter().any(|v| ${item} == ${v.code})`, ty: BOOL };
1076
- }
1077
- if (method === "join" && args.length <= 1) {
1078
- // JavaScript joins with a comma when given no separator.
1079
- const sep = args.length ? strArg(0) : '","';
1080
- const items = of.k === "str" ? `(${target.code}).iter().map(|v| &**v)` : `(${target.code}).iter().map(|v| v.to_string())`;
1081
- return { code: `&*${items}.collect::<Vec<_>>().join(${sep})`, ty: STR };
1082
- }
1083
- }
1084
- return fail(comp, `\`.${method}()\` is not supported`, n);
1085
- }
1086
- return fail(comp, "this call is not supported", n);
1087
- }
1088
- function helperCall(s, name, args, n) {
1089
- const h = HELPERS[name];
1090
- if (args.length !== h.params.length)
1091
- fail(s.comp, `\`${name}\` takes ${h.params.length} argument(s)`, n);
1092
- const code = args.map((a, i) => coerce(s.comp, expr(s, a), h.params[i], a)).join(", ");
1093
- s.helperBytes.n += h.maxLen;
1094
- return { code: `${h.rust}(${code})`, ty: h.ret };
1095
- }
1096
- /* ── Output ──────────────────────────────────────────────────────────────────────────────── */
1097
- /** Statements, with adjacent literal pushes merged into one `push_str`. */
1098
- class Emitter {
1099
- lines = [];
1100
- /** Bytes of literal markup written once per render, part of what `render` reserves up front. */
1101
- literalBytes = 0;
1102
- /** Rust expressions for the rest of the reservation: a loop's markup once per item. */
1103
- perItem = [];
1104
- pending = "";
1105
- depth = 1;
1106
- lit(s) {
1107
- this.pending += s;
1108
- this.literalBytes += Buffer.byteLength(s);
1109
- }
1110
- stmt(code) {
1111
- this.flush();
1112
- this.lines.push(" ".repeat(this.depth) + code);
1113
- }
1114
- open(code) {
1115
- this.stmt(code ? code + " {" : "{");
1116
- this.depth++;
1117
- }
1118
- close(tail = "") {
1119
- this.flush();
1120
- this.depth--;
1121
- this.lines.push(" ".repeat(this.depth) + "}" + tail);
1122
- // `} else {` closes one block and opens the next.
1123
- if (tail.endsWith("{"))
1124
- this.depth++;
1125
- }
1126
- flush() {
1127
- if (!this.pending)
1128
- return;
1129
- const text = this.pending;
1130
- this.pending = "";
1131
- this.lines.push(" ".repeat(this.depth) + `out.push_str(${rustStr(text)});`);
1132
- }
1133
- }
1134
- /** `toDisplayString`, escaped. */
1135
- function interpolate(e, v) {
1136
- switch (v.ty.k) {
1137
- case "str":
1138
- e.stmt(`fv::escape_into(out, ${v.code});`);
1139
- return;
1140
- case "int":
1141
- e.stmt(`fv::push_int(out, ${v.code});`);
1142
- return;
1143
- case "bool":
1144
- e.stmt(`out.push_str(if ${v.code} { "true" } else { "false" });`);
1145
- return;
1146
- case "undef":
1147
- return;
1148
- case "opt":
1149
- e.open(`if let Some(v) = ${v.code}`);
1150
- interpolate(e, { code: "v", ty: v.ty.of });
1151
- e.close();
1152
- return;
1153
- default:
1154
- throw new GenError("only strings, integers and booleans can be interpolated");
1155
- }
1156
- }
1157
- /** The value half of `key="value"`, escaped. */
1158
- function attrValue(e, v) {
1159
- switch (v.ty.k) {
1160
- case "str":
1161
- e.stmt(`fv::escape_into(out, ${v.code});`);
1162
- return;
1163
- case "int":
1164
- e.stmt(`fv::push_int(out, ${v.code});`);
1165
- return;
1166
- case "bool":
1167
- e.stmt(`out.push_str(if ${v.code} { "true" } else { "false" });`);
1168
- return;
1169
- default:
1170
- throw new GenError("an attribute value must be a string, integer or boolean");
1171
- }
1172
- }
1173
- /** `ssrRenderAttr(key, value)`: absent for null/undefined, `key="value"` for anything else. */
1174
- function renderAttr(e, key, v) {
1175
- if (v.ty.k === "undef")
1176
- return;
1177
- if (v.ty.k === "opt") {
1178
- e.open(`if let Some(v) = ${v.code}`);
1179
- renderAttr(e, key, { code: "v", ty: v.ty.of });
1180
- e.close();
1181
- return;
1182
- }
1183
- e.lit(` ${key}="`);
1184
- attrValue(e, v);
1185
- e.lit(`"`);
1186
- }
1187
- /** `ssrRenderDynamicAttr(key, value)`: as `renderAttr`, but a boolean attribute is present or
1188
- * absent, and an empty string renders the name alone. */
1189
- function renderDynamicAttr(s, e, key, v, n) {
1190
- const name = propsToAttrMap[key] ?? key.toLowerCase();
1191
- if (!isSSRSafeAttrName(name))
1192
- fail(s.comp, `unsafe attribute name \`${name}\``, n);
1193
- if (v.ty.k === "undef")
1194
- return;
1195
- if (v.ty.k === "opt") {
1196
- e.open(`if let Some(v) = ${v.code}`);
1197
- renderDynamicAttr(s, e, key, { code: "v", ty: v.ty.of }, n);
1198
- e.close();
1199
- return;
1200
- }
1201
- if (isBooleanAttr(name) || (name === "hidden" && (v.ty.k === "bool" || v.ty.k === "int"))) {
1202
- if (v.ty.k === "str")
1203
- e.lit(` ${name}`);
1204
- else {
1205
- e.open(`if ${truthy(v)}`);
1206
- e.lit(` ${name}`);
1207
- e.close();
1208
- }
1209
- return;
1210
- }
1211
- if (v.ty.k === "str") {
1212
- e.open(`if (${v.code}).is_empty()`);
1213
- e.lit(` ${name}`);
1214
- e.close(" else {");
1215
- e.lit(` ${name}="`);
1216
- // Re-evaluated rather than bound: every string expression here is a borrow or a pure call.
1217
- attrValue(e, v);
1218
- e.lit(`"`);
1219
- e.close();
1220
- return;
1221
- }
1222
- e.lit(` ${name}="`);
1223
- attrValue(e, v);
1224
- e.lit(`"`);
1225
- }
1226
- /** `normalizeClass`'s view of a class binding, flattened: a string, an array of items, an object
1227
- * of `name: condition`, `cond && "name"`, `cond ? "a" : null`. Every item is trimmed and the empty
1228
- * ones dropped before they are joined with one space, which is what `class_into` does too, so an
1229
- * object's keys become items of their own. */
1230
- function classItems(s, n) {
1231
- switch (n.type) {
1232
- case "StringLiteral": {
1233
- const t = n.value.trim();
1234
- return t ? [{ lit: t }] : [];
1235
- }
1236
- case "ArrayExpression":
1237
- return n.elements.flatMap((el) => (el ? classItems(s, el) : []));
1238
- case "ObjectExpression":
1239
- return n.properties.flatMap((p) => {
1240
- if (p.type !== "ObjectProperty")
1241
- fail(s.comp, "a class object holds `name: condition` pairs", p);
1242
- // A literal name is written as it is: an object's names are joined, not trimmed one by one.
1243
- let name;
1244
- if (!p.computed) {
1245
- const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
1246
- if (key !== key.trim() || !key)
1247
- fail(s.comp, `class name \`${key}\` has spaces around it`, p);
1248
- name = { lit: key };
1249
- }
1250
- else {
1251
- const k = expr(s, p.key);
1252
- if (k.ty.k !== "str")
1253
- fail(s.comp, "a computed class name is a string", p.key);
1254
- name = { code: `fv::js_trim(${k.code})` };
1255
- }
1256
- const v = expr(s, p.value);
1257
- if (v.konst === false)
1258
- return [];
1259
- if (v.konst === true)
1260
- return [name];
1261
- const text = "lit" in name ? rustStr(name.lit) : name.code;
1262
- return [{ code: `if ${truthy(v)} { ${text} } else { "" }` }];
1263
- });
1264
- case "LogicalExpression":
1265
- if (n.operator === "&&") {
1266
- const right = classItems(s, n.right);
1267
- const test = cond(s, n.left);
1268
- return right.map((it) => ({ code: `if ${test} { ${"lit" in it ? rustStr(it.lit) : it.code} } else { "" }` }));
1269
- }
1270
- break;
1271
- case "ConditionalExpression":
1272
- if (n.consequent.type === "NullLiteral" || n.alternate.type === "NullLiteral") {
1273
- const branch = n.consequent.type === "NullLiteral" ? n.alternate : n.consequent;
1274
- const test = cond(s, n.test);
1275
- const items = classItems(s, branch);
1276
- const when = n.consequent.type === "NullLiteral" ? `!(${test})` : test;
1277
- return items.map((it) => ({ code: `if ${when} { ${"lit" in it ? rustStr(it.lit) : it.code} } else { "" }` }));
1278
- }
1279
- break;
1280
- }
1281
- const v = expr(s, n);
1282
- if (v.ty.k === "str")
1283
- return [{ code: v.code }];
1284
- if (v.ty.k === "opt" && v.ty.of.k === "str")
1285
- return [{ code: `(${v.code}).unwrap_or("")` }];
1286
- if (v.ty.k === "undef")
1287
- return [];
1288
- return fail(s.comp, "a class is a string, an array, or an object of conditions", n);
1289
- }
1290
- /** `ssrRenderClass(value)`: normalised, then escaped. `after` says a class was already written. */
1291
- function renderClass(s, e, n, after = false) {
1292
- const items = classItems(s, n);
1293
- // Literal items before the first run-time one are written now; the rest go to `class_into`,
1294
- // which decides at run time whether each needs a separator.
1295
- let wrote = after;
1296
- let i = 0;
1297
- for (; i < items.length; i++) {
1298
- const it = items[i];
1299
- if (!("lit" in it))
1300
- break;
1301
- e.lit((wrote ? " " : "") + escapeHtml(it.lit));
1302
- wrote = true;
1303
- }
1304
- if (i < items.length) {
1305
- const rest = items.slice(i).map((it) => ("lit" in it ? rustStr(it.lit) : it.code));
1306
- e.stmt(`fv::class_into(out, ${wrote}, &[${rest.join(", ")}]);`);
1307
- }
1308
- }
1309
- const IGNORED_PROPS = new Set(["", "key", "ref", "innerHTML", "textContent", "ref_key", "ref_for"]);
1310
- /** `ssrRenderAttrs(obj)`, key by key in the object's order. `_attrs` is always empty: an island
1311
- * passes a child only the props it declares, so nothing falls through. */
1312
- /** `ssrGetDirectiveProps(_ctx, dir)`: a custom directive's server props, which are none for a
1313
- * directive the configuration declares client-only. `false` when `n` is not such a call. */
1314
- function directiveProps(s, n) {
1315
- if (n?.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "_ssrGetDirectiveProps")
1316
- return false;
1317
- const dir = n.arguments[1];
1318
- // `$setup["vFocus"]` for an imported directive, `_directive_focus` for a registered one.
1319
- let name = null;
1320
- if (dir?.type === "MemberExpression") {
1321
- const local = dir.computed ? dir.property.value : dir.property.name;
1322
- name = hyphenate(local.replace(/^v(?=[A-Z])/, "")).replace(/^-/, "");
1323
- }
1324
- else if (dir?.type === "Identifier")
1325
- name = s.directives.get(dir.name) ?? null;
1326
- if (name === null || !CLIENT_DIRECTIVES.has(name)) {
1327
- fail(s.comp, `custom directive \`v-${name ?? "?"}\` may add attributes on the server; if it has no \`getSSRProps\`, list \`${name ?? "?"}\` in \`clientDirectives\` in ${CONFIG_FILE}`, n);
1328
- }
1329
- return true;
1330
- }
1331
- function renderAttrs(s, e, n) {
1332
- if (n.type === "Identifier" && n.name === "_attrs")
1333
- return;
1334
- if (directiveProps(s, n))
1335
- return;
1336
- if (n.type === "CallExpression" && n.callee.type === "Identifier" && n.callee.name === "_mergeProps") {
1337
- renderAttrs(s, e, mergeProps(s, n));
1338
- return;
1339
- }
1340
- if (n.type !== "ObjectExpression")
1341
- fail(s.comp, "attributes must be an object literal", n);
1342
- for (const p of n.properties) {
1343
- if (p.type !== "ObjectProperty" || p.computed)
1344
- fail(s.comp, "attribute objects hold plain keys", p);
1345
- const raw = p.key.type === "Identifier" ? p.key.name : p.key.value;
1346
- if (IGNORED_PROPS.has(raw) || /^on[^a-z]/.test(raw) || raw.startsWith("."))
1347
- continue;
1348
- // Vue strips the `^` attribute-binding prefix before it looks at the name.
1349
- const key = raw.startsWith("^") ? raw.slice(1) : raw;
1350
- if (key === "className") {
1351
- fail(s.comp, "`className` renders by its own rule in Vue; bind `class`", p);
1352
- }
1353
- else if (key === "class") {
1354
- e.lit(` class="`);
1355
- renderClass(s, e, p.value);
1356
- e.lit(`"`);
1357
- }
1358
- else if (key === "style") {
1359
- e.lit(` style="`);
1360
- renderStyle(s, e, p.value);
1361
- e.lit(`"`);
1362
- }
1363
- else {
1364
- renderDynamicAttr(s, e, key, expr(s, p.value), p);
1365
- }
1366
- }
1367
- }
1368
- /** `mergeProps(a, _attrs, b, …)` as one object literal. `_attrs` is empty, an island passing a child
1369
- * only the props it declares; of the rest, `class` and `style` values are merged as an array, and
1370
- * any other key keeps the place it first had with the last value given. */
1371
- function mergeProps(s, n) {
1372
- const merged = new Map();
1373
- for (const a of n.arguments) {
1374
- if (a.type === "Identifier" && a.name === "_attrs")
1375
- continue;
1376
- if (directiveProps(s, a))
1377
- continue;
1378
- if (a.type !== "ObjectExpression")
1379
- fail(s.comp, "`_mergeProps` of object literals is supported", a);
1380
- for (const p of a.properties) {
1381
- if (p.type !== "ObjectProperty" || p.computed)
1382
- fail(s.comp, "attribute objects hold plain keys", p);
1383
- const key = p.key.type === "Identifier" ? p.key.name : p.key.value;
1384
- const had = merged.get(key);
1385
- if (had && (key === "class" || key === "style")) {
1386
- merged.set(key, { ...p, value: { type: "ArrayExpression", elements: [had.value, p.value] } });
1387
- }
1388
- else if (had) {
1389
- // `Map` keeps a key where it was first set, as a JavaScript object does.
1390
- merged.set(key, { ...had, value: p.value });
1391
- }
1392
- else
1393
- merged.set(key, p);
1394
- }
1395
- }
1396
- return { type: "ObjectExpression", properties: [...merged.values()] };
1397
- }
1398
- /** The objects a style binding merges, in order: an object literal, a static `style` the compiler
1399
- * parsed, `v-show`'s `cond ? null : { display: "none" }`, and arrays of those. */
1400
- function styleItems(s, n, when) {
1401
- const both = (a, b) => (a === null ? b : `(${a} && ${b})`);
1402
- switch (n.type) {
1403
- case "NullLiteral":
1404
- return [];
1405
- case "ArrayExpression":
1406
- return n.elements.flatMap((el) => (el ? styleItems(s, el, when) : []));
1407
- case "StringLiteral":
1408
- // Parsed as `normalizeStyle` parses a string inside an array, by Vue's own function.
1409
- return [{ cond: when, entries: Object.entries(parseStringStyle(n.value)).map(([key, v]) => ({ key, css: key, value: { type: "StringLiteral", value: v } })) }];
1410
- case "ConditionalExpression": {
1411
- const t = `(${cond(s, n.test)})`;
1412
- return [...styleItems(s, n.consequent, both(when, t)), ...styleItems(s, n.alternate, both(when, `!${t}`))];
1413
- }
1414
- case "LogicalExpression":
1415
- if (n.operator === "&&")
1416
- return styleItems(s, n.right, both(when, `(${cond(s, n.left)})`));
1417
- break;
1418
- case "ObjectExpression":
1419
- return [{
1420
- cond: when,
1421
- entries: n.properties.map((p) => {
1422
- if (p.type !== "ObjectProperty" || p.computed)
1423
- fail(s.comp, "a style object holds plain `property: value` pairs", p);
1424
- const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
1425
- // An integer-like key comes first in a JavaScript object whatever the order written.
1426
- if (/^\d+$/.test(key) || key.startsWith(":"))
1427
- fail(s.comp, `style property \`${key}\``, p);
1428
- return { key, css: key.startsWith("--") ? key : hyphenate(key), value: p.value };
1429
- }),
1430
- }];
1431
- }
1432
- return fail(s.comp, "a style binding is an object, an array of objects, or a string on its own", n);
1433
- }
1434
- /** `ssrRenderStyle(value)`: a string as it is, or the objects merged — each property where it first
1435
- * appears, with the last value given — and written `name:value;`, all escaped. */
1436
- function renderStyle(s, e, n) {
1437
- // A string on its own is written as it is, with no normalising.
1438
- if (n.type === "StringLiteral") {
1439
- e.lit(escapeHtml(n.value));
1440
- return;
1441
- }
1442
- if (n.type !== "ObjectExpression" && n.type !== "ArrayExpression" && n.type !== "ConditionalExpression" && n.type !== "LogicalExpression" && n.type !== "NullLiteral") {
1443
- const v = expr(s, n);
1444
- if (v.ty.k === "str")
1445
- e.stmt(`fv::escape_into(out, ${v.code});`);
1446
- else if (v.ty.k === "opt" && v.ty.of.k === "str") {
1447
- e.open(`if let Some(v) = ${v.code}`);
1448
- e.stmt("fv::escape_into(out, v);");
1449
- e.close();
1450
- }
1451
- else if (v.ty.k !== "undef")
1452
- fail(s.comp, "a style binding is a string, an object or an array", n);
1453
- return;
1454
- }
1455
- const items = styleItems(s, n, null);
1456
- // Each property, where it first appears, with every place that sets it.
1457
- const order = [];
1458
- const sets = new Map();
1459
- for (const it of items) {
1460
- for (const en of it.entries) {
1461
- if (!sets.has(en.key)) {
1462
- order.push(en.key);
1463
- sets.set(en.key, []);
1464
- }
1465
- sets.get(en.key).push({ cond: it.cond, css: en.css, value: en.value });
1466
- }
1467
- }
1468
- const write = (css, value) => {
1469
- // A literal is stringified now, by JavaScript itself: `0.5`, `"1px"`.
1470
- if (value.type === "StringLiteral" || value.type === "NumericLiteral") {
1471
- e.lit(escapeHtml(`${css}:${String(value.value)};`));
1472
- return;
1473
- }
1474
- const v = expr(s, value);
1475
- const one = (w) => {
1476
- if (w.ty.k === "str" || w.ty.k === "int") {
1477
- e.lit(`${escapeHtml(css)}:`);
1478
- e.stmt(w.ty.k === "str" ? `fv::escape_into(out, ${w.code});` : `fv::push_int(out, ${w.code});`);
1479
- e.lit(";");
1480
- }
1481
- else if (w.ty.k === "opt") {
1482
- e.open(`if let Some(v) = ${w.code}`);
1483
- one({ code: "v", ty: w.ty.of });
1484
- e.close();
1485
- }
1486
- // A boolean or `undefined` writes nothing — and, set later, removes what was set before.
1487
- };
1488
- one(v);
1489
- };
1490
- // A property's place is where an object that is present first sets it. When that object is
1491
- // conditional and the property is set again after another one first appears, the order would be
1492
- // decided at run time.
1493
- const flat = items.flatMap((it, i) => it.entries.map((en) => ({ key: en.key, item: i, cond: it.cond })));
1494
- for (const key of order) {
1495
- const at = flat.filter((f) => f.key === key);
1496
- if (at.length < 2 || at[0].cond === null)
1497
- continue;
1498
- const first = flat.indexOf(at[0]);
1499
- const again = flat.indexOf(at[at.length - 1]);
1500
- const between = flat.slice(first + 1, again).some((f) => order.indexOf(f.key) > order.indexOf(key));
1501
- if (between)
1502
- fail(s.comp, `the place of style property \`${key}\` would depend on a condition; set it unconditionally first`, n);
1503
- }
1504
- for (const key of order) {
1505
- // The last place that sets it wins: walked back to the first that always applies.
1506
- const chain = [];
1507
- for (const set of [...sets.get(key)].reverse()) {
1508
- chain.push(set);
1509
- if (set.cond === null)
1510
- break;
1511
- }
1512
- if (chain.length === 1 && chain[0].cond === null) {
1513
- write(chain[0].css, chain[0].value);
1514
- continue;
1515
- }
1516
- chain.forEach((set, i) => {
1517
- if (set.cond === null) {
1518
- e.close(" else {");
1519
- }
1520
- else if (i === 0) {
1521
- e.open(`if ${set.cond}`);
1522
- }
1523
- else {
1524
- e.close(` else if ${set.cond} {`);
1525
- }
1526
- write(set.css, set.value);
1527
- });
1528
- e.close();
1529
- }
1530
- }
1531
- /** One `${...}` inside a pushed template literal. */
1532
- function slot(s, e, n) {
1533
- // A slot's scope id, for scoped styles, which an island never has.
1534
- if (n.type === "Identifier" && n.name === "_scopeId")
1535
- return;
1536
- if (n.type === "CallExpression" && n.callee.type === "Identifier") {
1537
- const a = n.arguments;
1538
- switch (n.callee.name) {
1539
- case "_ssrInterpolate":
1540
- interpolate(e, expr(s, a[0]));
1541
- return;
1542
- case "_ssrRenderAttr":
1543
- if (a[0].type !== "StringLiteral")
1544
- fail(s.comp, "attribute names are literal", n);
1545
- renderAttr(e, a[0].value, expr(s, a[1]));
1546
- return;
1547
- // `:hidden`, whose rendering depends on the value's type: Vue cannot decide it at compile time.
1548
- case "_ssrRenderDynamicAttr":
1549
- if (a[0].type !== "StringLiteral")
1550
- fail(s.comp, "attribute names are literal", n);
1551
- renderDynamicAttr(s, e, a[0].value, expr(s, a[1]), n);
1552
- return;
1553
- case "_ssrRenderAttrs":
1554
- if (a.length > 1)
1555
- fail(s.comp, "`ssrRenderAttrs` with a tag argument", n);
1556
- renderAttrs(s, e, a[0]);
1557
- return;
1558
- case "_ssrRenderClass":
1559
- renderClass(s, e, a[0]);
1560
- return;
1561
- case "_ssrRenderStyle":
1562
- renderStyle(s, e, a[0]);
1563
- return;
1564
- }
1565
- }
1566
- /* `v-html`, which Vue compiles to the bare value with an empty-string fallback and no escaping.
1567
- * Only a `TrustedHtml` prop may arrive here; anything else would be a string written raw. */
1568
- if (n.type === "LogicalExpression" && n.operator === "??" && n.right.type === "StringLiteral" && n.right.value === "") {
1569
- const v = expr(s, n.left);
1570
- if (v.ty.k === "html") {
1571
- e.stmt(`fv::trusted_into(out, ${v.code});`);
1572
- return;
1573
- }
1574
- if (v.ty.k === "opt" && v.ty.of.k === "html") {
1575
- e.open(`if let Some(html) = ${v.code}`);
1576
- e.stmt("fv::trusted_into(out, html);");
1577
- e.close();
1578
- return;
1579
- }
1580
- fail(s.comp, "`v-html` renders only a `TrustedHtml` prop (from `ferrovue/types`)", n);
1581
- }
1582
- if (n.type === "ConditionalExpression" && n.consequent.type === "StringLiteral" && n.alternate.type === "StringLiteral") {
1583
- const t = expr(s, n.test);
1584
- if (t.konst !== undefined) {
1585
- e.lit(t.konst ? n.consequent.value : n.alternate.value);
1586
- return;
1587
- }
1588
- e.open(`if ${truthy(t)}`);
1589
- e.lit(n.consequent.value);
1590
- if (n.alternate.value) {
1591
- e.close(" else {");
1592
- e.lit(n.alternate.value);
1593
- }
1594
- e.close();
1595
- return;
1596
- }
1597
- fail(s.comp, "this expression cannot be rendered on the server", n);
1598
- }
1599
- /** `isComment` in `@vue/server-renderer`: a chunk that is only comments and whitespace. */
1600
- function isComment(text) {
1601
- if (!/^<!--[\s\S]*-->$/.test(text))
1602
- return false;
1603
- return text.length <= 8 || !text.replace(/<!--[^]*?-->/gm, "").trim();
1604
- }
1605
- /** Whether a `_push` argument is content to `ssrRenderSlot`, rather than only comments. */
1606
- function pushesContent(s, n) {
1607
- if (n.type === "StringLiteral")
1608
- return !isComment(n.value);
1609
- if (n.type === "TemplateLiteral") {
1610
- if (n.expressions.length === 0)
1611
- return !isComment(n.quasis[0].value.cooked);
1612
- if (n.quasis[0].value.cooked.startsWith("<!--")) {
1613
- fail(s.comp, "slot content that starts with a comment holding an interpolation", n);
1614
- }
1615
- return true;
1616
- }
1617
- // A component's render is a buffer, never a comment.
1618
- return true;
1619
- }
1620
- function push(s, e, n) {
1621
- if (s.fill && pushesContent(s, n))
1622
- e.stmt("filled = true;");
1623
- const text = n.type === "StringLiteral" ? n.value : n.type === "TemplateLiteral" && !n.expressions.length ? n.quasis[0].value.cooked : null;
1624
- if (s.vnode && text === "<!---->") {
1625
- e.lit("<!--v-if-->");
1626
- return;
1627
- }
1628
- if (n.type === "StringLiteral") {
1629
- e.lit(n.value);
1630
- return;
1631
- }
1632
- if (n.type === "TemplateLiteral") {
1633
- n.quasis.forEach((q, i) => {
1634
- e.lit(q.value.cooked);
1635
- if (i < n.expressions.length)
1636
- slot(s, e, n.expressions[i]);
1637
- });
1638
- return;
1639
- }
1640
- if (n.type === "CallExpression" && n.callee.type === "Identifier" && n.callee.name === "_ssrRenderComponent") {
1641
- const target = n.arguments[0];
1642
- const routed = target.type === "Identifier"
1643
- ? s.router.get(target.name)
1644
- : target.type === "MemberExpression" && target.object.name === "$setup"
1645
- ? s.router.get(target.computed ? target.property.value : target.property.name)
1646
- : undefined;
1647
- if (routed === "RouterLink")
1648
- routerLink(s, e, n);
1649
- else if (routed === "RouterView")
1650
- e.stmt("fv_slots.router_view.render_to(out);");
1651
- else
1652
- renderChild(s, e, n);
1653
- return;
1654
- }
1655
- fail(s.comp, "this cannot be pushed", n);
1656
- }
1657
- function renderChild(s, e, n) {
1658
- const [target, rawProps, slots] = n.arguments;
1659
- let local = null;
1660
- if (target.type === "MemberExpression" && target.object.name === "$setup") {
1661
- local = target.computed ? target.property.value : target.property.name;
1662
- }
1663
- const isSelf = target.type === "Identifier" && target.name === s.selfAlias.name;
1664
- const childName = isSelf ? s.comp.name : local ? s.children.get(local) : undefined;
1665
- const child = childName ? s.components.get(childName) : undefined;
1666
- // Inside a `v-for`, Vue merges a `{ ref_for: true }` marker into the props; it renders nothing.
1667
- let props = rawProps;
1668
- if (props?.type === "CallExpression" && props.callee.type === "Identifier" && props.callee.name === "_mergeProps" &&
1669
- props.arguments.length === 2 && props.arguments[0].type === "ObjectExpression" &&
1670
- props.arguments[0].properties.every((p) => p.type === "ObjectProperty" && IGNORED_PROPS.has(p.key.name ?? p.key.value))) {
1671
- props = props.arguments[1];
1672
- }
1673
- // A component at the root of the template is handed the fallthrough attributes, which an island
1674
- // never has.
1675
- if (props?.type === "CallExpression" && props.callee.type === "Identifier" && props.callee.name === "_mergeProps" &&
1676
- props.arguments.length === 2 && props.arguments[1].type === "Identifier" && props.arguments[1].name === "_attrs") {
1677
- props = props.arguments[0];
1678
- }
1679
- if (props?.type === "Identifier" && props.name === "_attrs")
1680
- props = { type: "ObjectExpression", properties: [] };
1681
- if (!child)
1682
- fail(s.comp, "a child component must be an imported island", n);
1683
- if (child.routerView) {
1684
- fail(s.comp, `${child.name} holds \`<RouterView>\`: the server renders it at the top, never as a child`, n);
1685
- }
1686
- // `v-bind="x"`, where `x` is exactly the child's own `Props`: handed over as it is.
1687
- if (props.type !== "ObjectExpression") {
1688
- const v = expr(s, props);
1689
- // The component itself, however the template reached it: its own `Props` struct is the type.
1690
- const own = child.name === s.comp.name && v.ty.k === "struct" && v.ty.name === "Props";
1691
- if (own || (v.ty.k === "child" && v.ty.name === child.name)) {
1692
- callChild(s, e, child, v.code, slots, n);
1693
- return;
1694
- }
1695
- fail(s.comp, `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, n);
1696
- }
1697
- const given = new Map();
1698
- for (const p of props.properties) {
1699
- if (p.type !== "ObjectProperty" || p.computed)
1700
- fail(s.comp, "child props hold plain keys", p);
1701
- const key = p.key.type === "Identifier" ? p.key.name : p.key.value;
1702
- // Listeners, `v-model`'s `onUpdate:…` among them, run on the client alone; a model's modifiers
1703
- // change only what an update writes.
1704
- if (IGNORED_PROPS.has(key) || /^on[^a-z]/.test(key))
1705
- continue;
1706
- if (key.endsWith("Modifiers") && child.props.fields.some((f) => f.js === (key === "modelModifiers" ? "modelValue" : key.slice(0, -"Modifiers".length))))
1707
- continue;
1708
- if (!child.props.fields.some((f) => f.js === key)) {
1709
- // A key the child does not declare would fall through onto its root element as an
1710
- // attribute, which the generated child does not render.
1711
- fail(s.comp, `\`${key}\` is not a prop of ${child.name}`, p);
1712
- }
1713
- given.set(key, p.value);
1714
- }
1715
- const inits = child.props.fields.map((f) => {
1716
- const node = given.get(f.js);
1717
- if (!node) {
1718
- if (f.ty.k !== "opt")
1719
- fail(s.comp, `${child.name} requires \`${f.js}\``, n);
1720
- return `${f.rust}: None`;
1721
- }
1722
- // Each side's own types named by the component that declares them, so that a type the parent
1723
- // imports from the child's `.vue` file is the child's type.
1724
- const v = expr(s, node);
1725
- return `${f.rust}: ${ownInto(s.comp, { ...v, ty: markHome(v.ty, s.comp.name) }, markHome(f.ty, child.name), node)}`;
1726
- });
1727
- callChild(s, e, child, `&super::${child.module}::Props { ${inits.join(", ")} }`, slots, n);
1728
- }
1729
- /** Whether a component takes a `Slots` argument. */
1730
- function takesSlots(c) {
1731
- return c.slotNames.length > 0 || c.routerView;
1732
- }
1733
- /** The arguments after `props` that a component's `render` takes. */
1734
- function extraParams(c) {
1735
- return ((takesSlots(c) ? ", fv_slots: Slots<'_>" : "") +
1736
- (c.usesRoute ? ", fv_route: &fv::Route<'_>" : "") +
1737
- (c.usesStores ? ", fv_stores: &super::stores::Stores<'_>" : ""));
1738
- }
1739
- /** `render(out, props[, slots][, route])` for a child, with the slot content this template gives
1740
- * it as closures. */
1741
- function callChild(s, e, child, propsCode, slots, _n) {
1742
- const given = new Map();
1743
- if (slots && slots.type !== "NullLiteral") {
1744
- if (slots.type !== "ObjectExpression")
1745
- fail(s.comp, "slots must be an object literal", slots);
1746
- for (const p of slots.properties) {
1747
- const key = p.key?.type === "Identifier" ? p.key.name : p.key?.value;
1748
- if (key === "_")
1749
- continue;
1750
- if (p.type !== "ObjectProperty" || p.computed)
1751
- fail(s.comp, "slots hold plain keys", p);
1752
- if (!child.slotNames.includes(key))
1753
- fail(s.comp, `${child.name} has no slot \`${key}\``, p);
1754
- given.set(key, p.value);
1755
- }
1756
- }
1757
- const m = `super::${child.module}`;
1758
- const route = (child.usesRoute ? ", fv_route" : "") + (child.usesStores ? ", fv_stores" : "");
1759
- if (!takesSlots(child)) {
1760
- e.stmt(`${m}::render(out, ${propsCode}${route});`);
1761
- return;
1762
- }
1763
- e.open(`${m}::render(out, ${propsCode}, ${m}::Slots`);
1764
- for (const name of child.slotNames) {
1765
- const field = snake(name);
1766
- const value = given.get(name);
1767
- if (!value) {
1768
- e.stmt(`${field}: None,`);
1769
- continue;
1770
- }
1771
- const { body, param } = slotContent(s, value);
1772
- const shape = child.slotShapes.get(name);
1773
- const takesNone = param?.type === "Identifier" && param.name === "_";
1774
- if (shape) {
1775
- // A scoped slot: content given the outlet's props, bound as the parent destructured them.
1776
- const sp = `fv_sp${++narrowCount}`;
1777
- const ty = { k: "struct", name: shape.name, home: child.name };
1778
- const locals = new Map(s.locals);
1779
- if (param?.type === "ObjectPattern") {
1780
- for (const p of param.properties) {
1781
- if (p.type !== "ObjectProperty" || p.computed || p.value.type !== "Identifier") {
1782
- fail(s.comp, "slot props are destructured into plain names, without defaults", p);
1783
- }
1784
- locals.set(p.value.name, fieldVal(s.comp, sp, ty, p.key.name ?? p.key.value, p));
1785
- }
1786
- }
1787
- else if (param?.type === "Identifier" && !takesNone) {
1788
- locals.set(param.name, { code: sp, ty });
1789
- }
1790
- else if (!takesNone)
1791
- fail(s.comp, "slot props are a name or an object pattern", param);
1792
- const life = shape.fields.some((f) => slotFieldBorrows(f.ty)) ? "<'_>" : "";
1793
- e.open(`${field}: Some(&|out: &mut String, ${sp}: &${m}::${shape.name}${life}| -> bool`);
1794
- const inner = { ...s, locals };
1795
- if (staticallyFilled(inner, body)) {
1796
- statements({ ...inner, fill: false }, e, body);
1797
- e.stmt("true");
1798
- }
1799
- else {
1800
- e.stmt("let mut filled = false;");
1801
- statements({ ...inner, fill: true }, e, body);
1802
- e.stmt("filled");
1803
- }
1804
- e.close("),");
1805
- continue;
1806
- }
1807
- if (!takesNone)
1808
- fail(s.comp, `\`<slot${name === "default" ? "" : ` name="${name}"`}>\` in ${child.name} passes no props`, param);
1809
- if (staticallyFilled(s, body)) {
1810
- e.open(`${field}: Some(fv::Slot::new(&|out: &mut String|`);
1811
- statements({ ...s, fill: false }, e, body);
1812
- e.close(")),");
1813
- }
1814
- else {
1815
- e.open(`${field}: Some(fv::Slot::markup(&|out: &mut String| -> bool`);
1816
- e.stmt("let mut filled = false;");
1817
- statements({ ...s, fill: true }, e, body);
1818
- e.stmt("filled");
1819
- e.close(")),");
1820
- }
1821
- }
1822
- e.close(`${route});`);
1823
- }
1824
- /** The statements a parent's slot content pushes: the `if (_push)` half of its `_withCtx`. */
1825
- function slotBody(s, value) {
1826
- return slotContent(s, value).body;
1827
- }
1828
- /** A parent's slot content: what it pushes, and the parameter its scoped slot props are bound to
1829
- * (`_` when it takes none). */
1830
- function slotContent(s, value) {
1831
- const fn = value.type === "CallExpression" && value.callee.type === "Identifier" && value.callee.name === "_withCtx"
1832
- ? value.arguments[0]
1833
- : null;
1834
- if (fn?.type !== "ArrowFunctionExpression" || fn.body.type !== "BlockStatement") {
1835
- fail(s.comp, "unexpected slot content", value);
1836
- }
1837
- const branch = fn.body.body.length === 1 ? fn.body.body[0] : null;
1838
- if (branch?.type !== "IfStatement" || branch.test.type !== "Identifier" || branch.test.name !== "_push") {
1839
- fail(s.comp, "unexpected slot content", fn);
1840
- }
1841
- const body = branch.consequent.type === "BlockStatement" ? branch.consequent.body : [branch.consequent];
1842
- return { body, param: fn.params[0] };
1843
- }
1844
- /** Whether slot content always pushes something that is not a comment: a push of content outside
1845
- * any `if` or loop. Then nothing has to be decided at run time. */
1846
- function staticallyFilled(s, body) {
1847
- return body.some((st) => st.type === "ExpressionStatement" && st.expression.type === "CallExpression" &&
1848
- st.expression.callee.type === "Identifier" && st.expression.callee.name === "_push" &&
1849
- pushesContent(s, st.expression.arguments[0]));
1850
- }
1851
- /** `RowSlotProps`, `RowSlot`: the names of a scoped slot's props struct and content type. */
1852
- function slotTypeName(slot, suffix) {
1853
- return slot.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("") + suffix;
1854
- }
1855
- /** Whether a scoped slot prop's Rust type borrows, and so needs the props' lifetime `'v`. */
1856
- function slotFieldBorrows(ty) {
1857
- return !(ty.k === "int" || ty.k === "bool" || (ty.k === "opt" && (ty.of.k === "int" || ty.of.k === "bool")));
1858
- }
1859
- /** A scoped slot prop's Rust type: a scalar copied, anything else borrowed from the render for `'v`. */
1860
- function slotFieldTy(ty, comp) {
1861
- const owned = (t) => rustTy(t, comp).replace(/'a\b/g, "'v");
1862
- switch (ty.k) {
1863
- case "int":
1864
- return "i64";
1865
- case "bool":
1866
- return "bool";
1867
- case "str":
1868
- return "&'v str";
1869
- case "list":
1870
- return `&'v [${owned(ty.of)}]`;
1871
- case "struct":
1872
- case "child":
1873
- case "html":
1874
- return `&'v ${owned(ty)}`;
1875
- case "opt":
1876
- return `Option<${slotFieldTy(ty.of, comp)}>`;
1877
- default:
1878
- throw new GenError(`no Rust type for a slot prop of type ${JSON.stringify(ty)}`);
1879
- }
1880
- }
1881
- /** A value an outlet passes, as the scoped slot prop field holds it. */
1882
- function slotFieldValue(s, v, n) {
1883
- switch (v.ty.k) {
1884
- case "str":
1885
- case "int":
1886
- case "bool":
1887
- return v.code;
1888
- case "list":
1889
- // An array literal is a Rust array of `&str`, not the list a slot prop borrows.
1890
- if (v.code.startsWith("["))
1891
- fail(s.comp, "a slot prop is not an array literal: pass a list the component holds", n);
1892
- return `&(${v.code})`;
1893
- case "struct":
1894
- case "child":
1895
- case "html":
1896
- return `&(${v.code})`;
1897
- case "opt":
1898
- if (v.ty.of.k === "list")
1899
- return `(${v.code}).map(|v| &v[..])`;
1900
- if (v.ty.of.k === "opt" || v.ty.of.k === "undef")
1901
- break;
1902
- return v.code;
1903
- }
1904
- return fail(s.comp, "a slot prop is a string, a number, a boolean, an object or a list", n);
1905
- }
1906
- /** `<slot name="x" :prop="…">fallback</slot>`: `ssrRenderSlot`, with the props a scoped slot passes. */
1907
- function slotOutlet(s, e, c) {
1908
- const [, nameNode, slotProps, fallback] = c.arguments;
1909
- if (nameNode?.type !== "StringLiteral")
1910
- fail(s.comp, "a slot's name is literal", c);
1911
- if (slotProps?.type !== "ObjectExpression")
1912
- fail(s.comp, "a slot's props are attributes or a `v-bind` object literal", c);
1913
- const slot = nameNode.value;
1914
- const field = `fv_slots.${snake(slot)}`;
1915
- const outlet = `\`<slot${slot === "default" ? "" : ` name="${slot}"`}>\``;
1916
- let fn = "fv::slot_into";
1917
- let args = field;
1918
- if (slotProps.properties.length) {
1919
- const fields = [];
1920
- const values = [];
1921
- for (const p of slotProps.properties) {
1922
- if (p.type !== "ObjectProperty" || p.computed)
1923
- fail(s.comp, "slot props hold plain names", p);
1924
- const key = p.key.name ?? p.key.value;
1925
- if (!/^[A-Za-z_$][\w$]*$/.test(key))
1926
- fail(s.comp, `slot prop \`${key}\` is not a plain name; write it in camelCase`, p);
1927
- const v = expr(s, p.value);
1928
- fields.push({ js: key, rust: snake(key), ty: v.ty });
1929
- values.push(`${snake(key)}: ${slotFieldValue(s, v, p.value)}`);
1930
- }
1931
- const name = slotTypeName(slot, "SlotProps");
1932
- const shape = { name, fields, slot: true };
1933
- const had = s.comp.slotShapes.get(slot);
1934
- if (had && JSON.stringify(had.fields) !== JSON.stringify(fields)) {
1935
- fail(s.comp, `every ${outlet} passes the same props, of the same types`, c);
1936
- }
1937
- if (!had) {
1938
- if (s.comp.structs.has(name) && !s.comp.structs.get(name).slot)
1939
- fail(s.comp, `\`${name}\` names this slot's props; rename the interface`, c);
1940
- s.comp.slotShapes.set(slot, shape);
1941
- s.comp.structs.set(name, shape);
1942
- }
1943
- fn = "fv::scoped_slot_into";
1944
- args = `${field}, &${name} { ${values.join(", ")} }`;
1945
- }
1946
- else if (s.comp.slotShapes.has(slot)) {
1947
- fail(s.comp, `every ${outlet} passes the same props, of the same types`, c);
1948
- }
1949
- const call = (rest) => (s.fill ? `if ${fn}(out, ${args}, ${rest}) { filled = true; }` : `${fn}(out, ${args}, ${rest});`);
1950
- if (fallback?.type === "NullLiteral" || !fallback) {
1951
- e.stmt(call("None"));
1952
- return;
1953
- }
1954
- if (fallback.type !== "ArrowFunctionExpression" || fallback.body.type !== "BlockStatement") {
1955
- fail(s.comp, "unexpected slot fallback", fallback);
1956
- }
1957
- // The fallback writes to the same buffer as the outlet, so inside slot content it fills it too.
1958
- e.open(`${s.fill ? "if " : ""}${fn}(out, ${args}, Some(&mut |out: &mut String|`);
1959
- statements(s, e, fallback.body.body);
1960
- e.close(s.fill ? ")) { filled = true; }" : "));");
1961
- }
1962
- const ROUTER_LINK_PROPS = new Set(["to", "class", "activeClass", "exactActiveClass", "ariaCurrentValue"]);
1963
- /** `<RouterLink>` props that change only what a click does, which the server does not render. */
1964
- const ROUTER_LINK_INERT = new Set(["replace", "viewTransition"]);
1965
- /** The parameter names of a route path, in order. */
1966
- function routeParams(path) {
1967
- return [...path.matchAll(/:(\w+)/g)].map((m) => m[1]);
1968
- }
1969
- /** A value written into a URL — a parameter or a query value — as the `&str` vue-router stringifies. */
1970
- function urlText(s, v, n) {
1971
- if (v.ty.k === "str")
1972
- return v.code;
1973
- if (v.ty.k === "int")
1974
- return `&*(${v.code}).to_string()`;
1975
- if (v.ty.k === "bool")
1976
- return `if ${v.code} { "true" } else { "false" }`;
1977
- return fail(s.comp, "a route parameter is a string or a number that is present", n);
1978
- }
1979
- /** \`let fv_link = …;\`: a \`<RouterLink>\`'s \`to\` resolved from the reader's route. */
1980
- function resolveLink(s, e, to) {
1981
- if (to.type !== "ObjectExpression") {
1982
- const target = expr(s, to);
1983
- if (target.ty.k !== "str")
1984
- fail(s.comp, "`<RouterLink>`'s `to` is a string or an object literal", to);
1985
- e.stmt(`let fv_link = fv_route.link(${target.code});`);
1986
- return;
1987
- }
1988
- const parts = new Map();
1989
- for (const p of to.properties) {
1990
- if (p.type !== "ObjectProperty" || p.computed)
1991
- fail(s.comp, "a `to` object holds plain keys", p);
1992
- const key = p.key.name ?? p.key.value;
1993
- if (["replace", "force", "state"].includes(key))
1994
- continue;
1995
- if (!["name", "path", "params", "query", "hash"].includes(key))
1996
- fail(s.comp, `\`${key}\` in a \`to\` object`, p);
1997
- parts.set(key, p.value);
1998
- }
1999
- e.open("let fv_link =");
2000
- const query = parts.get("query");
2001
- // The query, built only when the location has one.
2002
- const search = query ? "&fv_search" : '""';
2003
- if (query) {
2004
- e.stmt("let mut fv_search = String::new();");
2005
- if (query.type !== "ObjectExpression")
2006
- fail(s.comp, "a `to`'s `query` is an object literal", query);
2007
- for (const p of query.properties) {
2008
- if (p.type !== "ObjectProperty" || p.computed)
2009
- fail(s.comp, "a query holds plain keys", p);
2010
- const key = String(p.key.name ?? p.key.value);
2011
- const v = expr(s, p.value);
2012
- // An absent value leaves its key out, as `undefined` does.
2013
- if (v.ty.k === "undef")
2014
- continue;
2015
- if (v.ty.k === "opt") {
2016
- e.open(`if let Some(v) = ${v.code}`);
2017
- e.stmt(`fv::query_into(&mut fv_search, ${rustStr(key)}, ${urlText(s, { code: "v", ty: v.ty.of }, p.value)});`);
2018
- e.close();
2019
- }
2020
- else
2021
- e.stmt(`fv::query_into(&mut fv_search, ${rustStr(key)}, ${urlText(s, v, p.value)});`);
2022
- }
2023
- }
2024
- let hash = '""';
2025
- const h = parts.get("hash");
2026
- if (h) {
2027
- const v = expr(s, h);
2028
- if (v.ty.k !== "str")
2029
- fail(s.comp, "a `to`'s `hash` is a string", h);
2030
- hash = v.code;
2031
- }
2032
- const name = parts.get("name");
2033
- const path = parts.get("path");
2034
- if (name) {
2035
- if (name.type !== "StringLiteral")
2036
- fail(s.comp, "a `to`'s `name` is a string literal, checked against the routes", name);
2037
- const route = ROUTES.find((r) => r.name === name.value);
2038
- if (!route)
2039
- fail(s.comp, `no route is called \`${name.value}\``, name);
2040
- const wanted = routeParams(route.path);
2041
- const given = new Map();
2042
- const params = parts.get("params");
2043
- if (params) {
2044
- if (params.type !== "ObjectExpression")
2045
- fail(s.comp, "a `to`'s `params` is an object literal", params);
2046
- for (const p of params.properties) {
2047
- if (p.type !== "ObjectProperty" || p.computed)
2048
- fail(s.comp, "params hold plain keys", p);
2049
- const key = String(p.key.name ?? p.key.value);
2050
- if (!wanted.includes(key))
2051
- fail(s.comp, `route \`${name.value}\` has no parameter \`${key}\``, p);
2052
- given.set(key, p.value);
2053
- }
2054
- }
2055
- const missing = wanted.filter((w) => !given.has(w));
2056
- if (missing.length)
2057
- fail(s.comp, `route \`${name.value}\` needs \`${missing.join("`, `")}\`: give every parameter`, to);
2058
- const list = wanted.map((w) => `(${rustStr(w)}, ${urlText(s, expr(s, given.get(w)), given.get(w))})`);
2059
- e.stmt(`fv_route.link_named(${rustStr(name.value)}, &[${list.join(", ")}], ${search}, ${hash})`);
2060
- }
2061
- else if (path) {
2062
- if (parts.has("params"))
2063
- fail(s.comp, "a `to` with a `path` takes no `params`, which vue-router ignores", path);
2064
- const v = expr(s, path);
2065
- if (v.ty.k !== "str")
2066
- fail(s.comp, "a `to`'s `path` is a string", path);
2067
- e.stmt(`fv_route.link_path(${v.code}, ${search}, ${hash})`);
2068
- }
2069
- else
2070
- fail(s.comp, "a `to` object has a `name` or a `path`", to);
2071
- e.close(";");
2072
- }
2073
- /** \`<RouterLink to="...">\` as vue-router renders it: \`aria-current\` and the active classes when it
2074
- * points where the reader is, then \`href\`, then the link's own class and attributes. */
2075
- function routerLink(s, e, n) {
2076
- const [, rawProps, slots] = n.arguments;
2077
- let props = rawProps;
2078
- if (props?.type === "CallExpression" && props.callee.type === "Identifier" && props.callee.name === "_mergeProps") {
2079
- props = mergeProps(s, props);
2080
- }
2081
- if (props?.type !== "ObjectExpression")
2082
- fail(s.comp, "`<RouterLink>` takes literal attributes", n);
2083
- const fields = new Map();
2084
- const attrs = [];
2085
- for (const p of props.properties) {
2086
- if (p.type !== "ObjectProperty" || p.computed)
2087
- fail(s.comp, "`<RouterLink>` attributes hold plain keys", p);
2088
- const raw = p.key.type === "Identifier" ? p.key.name : p.key.value;
2089
- const key = raw.replace(/-(\w)/g, (_, c) => c.toUpperCase());
2090
- if (ROUTER_LINK_PROPS.has(key))
2091
- fields.set(key, p.value);
2092
- else if (/^on[^a-z]/.test(raw) || IGNORED_PROPS.has(raw) || ROUTER_LINK_INERT.has(key))
2093
- continue;
2094
- else if (key === "custom")
2095
- fail(s.comp, "`custom` on `<RouterLink>` renders a scoped slot, which is not supported", p);
2096
- else if (key === "href" || key === "ariaCurrent")
2097
- fail(s.comp, `\`${raw}\` on \`<RouterLink>\` is its own`, p);
2098
- else
2099
- attrs.push({ ...p, key: { type: "StringLiteral", value: raw } });
2100
- }
2101
- const to = fields.get("to");
2102
- if (!to)
2103
- fail(s.comp, "`<RouterLink>` needs `to`", n);
2104
- if (!ROUTES)
2105
- fail(s.comp, `\`<RouterLink>\` needs \`routes\` in ${CONFIG_FILE}: the paths it resolves against`, n);
2106
- /** A literal-string prop, which the class names and `aria-current` must be. */
2107
- const literal = (key, fallback) => {
2108
- const v = fields.get(key);
2109
- if (!v)
2110
- return fallback;
2111
- if (v.type !== "StringLiteral")
2112
- fail(s.comp, `\`${key}\` on \`<RouterLink>\` is a string literal`, v);
2113
- return v.value;
2114
- };
2115
- const activeClass = literal("activeClass", LINK_ACTIVE);
2116
- const exactClass = literal("exactActiveClass", LINK_EXACT_ACTIVE);
2117
- const ariaCurrent = literal("ariaCurrentValue", "page");
2118
- e.open("");
2119
- resolveLink(s, e, to);
2120
- e.lit("<a");
2121
- e.open("if fv_link.active");
2122
- e.lit(` aria-current="${escapeHtml(ariaCurrent)}"`);
2123
- e.close();
2124
- e.lit(' href="');
2125
- e.stmt("fv::escape_into(out, &fv_link.href);");
2126
- e.lit('" class="');
2127
- // `{ [activeClass]: isActive, [exactActiveClass]: isExactActive }`: one key when the two are the
2128
- // same, and both true together, as they are for the flat routes ferrovue supports.
2129
- const active = activeClass === exactClass ? [activeClass] : [activeClass, exactClass];
2130
- const linkItems = active.map((c) => `if fv_link.active { ${rustStr(c)} } else { "" }`);
2131
- const cls = fields.get("class");
2132
- const own = cls ? classItems(s, cls).map((it) => ("lit" in it ? rustStr(it.lit) : it.code)) : [];
2133
- if (own.length === 0 && active.join(" ") === "router-link-active router-link-exact-active") {
2134
- e.open("if fv_link.active");
2135
- e.lit("router-link-active router-link-exact-active");
2136
- e.close();
2137
- }
2138
- else
2139
- e.stmt(`fv::class_into(out, false, &[${[...linkItems, ...own].join(", ")}]);`);
2140
- e.lit('"');
2141
- for (const p of attrs) {
2142
- const key = p.key.value;
2143
- if (key === "style") {
2144
- e.lit(` style="`);
2145
- renderStyle(s, e, p.value);
2146
- e.lit(`"`);
2147
- continue;
2148
- }
2149
- renderDynamicAttr(s, e, key, expr(s, p.value), p);
2150
- }
2151
- e.lit(">");
2152
- if (slots && slots.type !== "NullLiteral") {
2153
- if (slots.type !== "ObjectExpression")
2154
- fail(s.comp, "slots must be an object literal", slots);
2155
- for (const p of slots.properties) {
2156
- const key = p.key?.type === "Identifier" ? p.key.name : p.key?.value;
2157
- if (key === "_")
2158
- continue;
2159
- if (key !== "default")
2160
- fail(s.comp, "`<RouterLink>` has only its default slot", p);
2161
- statements({ ...s, fill: false, vnode: true }, e, slotBody(s, p.value));
2162
- }
2163
- }
2164
- e.lit("</a>");
2165
- e.close();
2166
- }
2167
- /** A value into a child's props field, whose strings are `Cow`s. */
2168
- function ownInto(comp, v, want, node) {
2169
- if (want.k === "str") {
2170
- if (v.ty.k !== "str")
2171
- fail(comp, "a string prop needs a string", node);
2172
- return `std::borrow::Cow::Borrowed(${v.code})`;
2173
- }
2174
- if (want.k === "opt" && want.of.k === "str") {
2175
- if (v.ty.k === "undef")
2176
- return "None";
2177
- if (v.ty.k === "str")
2178
- return `Some(std::borrow::Cow::Borrowed(${v.code}))`;
2179
- if (v.ty.k === "opt" && v.ty.of.k === "str")
2180
- return `(${v.code}).map(std::borrow::Cow::Borrowed)`;
2181
- }
2182
- if (sameTy(v.ty, want) && (want.k === "int" || want.k === "bool"))
2183
- return v.code;
2184
- if (want.k === "opt" && sameTy(v.ty, want.of) && (v.ty.k === "int" || v.ty.k === "bool"))
2185
- return `Some(${v.code})`;
2186
- // An object, a list of objects, or an optional one, of the type the child declares: cloned, which
2187
- // copies its strings only where they are owned.
2188
- const objecty = (t) => t.k === "struct" || t.k === "child" || ((t.k === "list" || t.k === "opt") && objecty(t.of));
2189
- if (objecty(want) && sameTy(v.ty, want))
2190
- return v.ty.k === "opt" ? `(${v.code}).cloned()` : `(${v.code}).to_owned()`;
2191
- if (want.k === "opt" && objecty(want.of) && sameTy(v.ty, want.of))
2192
- return `Some((${v.code}).to_owned())`;
2193
- if (objecty(want) && v.ty.k === want.k && JSON.stringify(v.ty).includes('"struct"')) {
2194
- fail(comp, `a ${JSON.stringify(v.ty)} where the child takes a ${JSON.stringify(want)}: two components share a type only when both import it from one \`.ts\` file`, node);
2195
- }
2196
- // A list of strings or numbers: each item borrowed, or copied, into the child's own list.
2197
- if (want.k === "list" && v.ty.k === "list" && v.ty.of.k === "undef")
2198
- return "Vec::new()";
2199
- if (want.k === "opt" && want.of.k === "list" && v.ty.k === "list")
2200
- return `Some(${ownInto(comp, v, want.of, node)})`;
2201
- if (want.k === "list" && v.ty.k === "list" && sameTy(v.ty.of, want.of)) {
2202
- if (want.of.k === "str")
2203
- return `(${v.code}).iter().map(|v| std::borrow::Cow::Borrowed(&**v)).collect()`;
2204
- if (want.of.k === "int" || want.of.k === "bool")
2205
- return `(${v.code}).clone()`;
2206
- }
2207
- return fail(comp, `a ${JSON.stringify(v.ty)} into a ${JSON.stringify(want)} prop`, node);
2208
- }
2209
- function statements(s, e, body) {
2210
- for (const st of body) {
2211
- if (st.type === "ExpressionStatement" && st.expression.type === "CallExpression") {
2212
- const c = st.expression;
2213
- const callee = c.callee.type === "Identifier" ? c.callee.name : null;
2214
- if (callee === "_push") {
2215
- push(s, e, c.arguments[0]);
2216
- continue;
2217
- }
2218
- if (callee === "_ssrRenderList") {
2219
- list(s, e, c);
2220
- continue;
2221
- }
2222
- if (callee === "_ssrRenderSlot") {
2223
- slotOutlet(s, e, c);
2224
- continue;
2225
- }
2226
- // `<Suspense>`: its default content, rendered in place — ferrovue renders nothing async.
2227
- if (callee === "_ssrRenderSuspense") {
2228
- const def = c.arguments[1]?.properties?.find((p) => (p.key?.name ?? p.key?.value) === "default");
2229
- if (!def)
2230
- e.lit("<!---->");
2231
- else if (def.value.type === "ArrowFunctionExpression" && def.value.body.type === "BlockStatement")
2232
- statements(s, e, def.value.body.body);
2233
- else
2234
- fail(s.comp, "unexpected `<Suspense>` content", c);
2235
- continue;
2236
- }
2237
- if (callee === "_ssrRenderTeleport") {
2238
- fail(s.comp, "`<Teleport>` renders its content into a separate buffer the page must place; render it on the client (`<ClientOnly>`, `defer`, or `disabled`) or place the content in the page", st);
2239
- }
2240
- if (callee === "_ssrRenderVNode") {
2241
- fail(s.comp, "`<component :is>` chooses its component at run time; write the choices out with `v-if`", st);
2242
- }
2243
- }
2244
- /* `const _component_X = _resolveComponent("X", true)`: a component that uses itself, which is
2245
- * how a tree renders. Any other component resolved by name is one this compiler cannot see. */
2246
- if (st.type === "VariableDeclaration" && st.declarations.length === 1) {
2247
- const d = st.declarations[0];
2248
- const init = d.init;
2249
- if (init?.type === "CallExpression" && init.callee.type === "Identifier" &&
2250
- init.callee.name === "_resolveComponent" && init.arguments[0]?.type === "StringLiteral" &&
2251
- init.arguments[0].value === s.comp.name && init.arguments[1]?.type === "BooleanLiteral") {
2252
- s.selfAlias.name = d.id.name;
2253
- continue;
2254
- }
2255
- const routed = init?.type === "CallExpression" && init.callee.type === "Identifier" &&
2256
- init.callee.name === "_resolveComponent" && init.arguments.length === 1 &&
2257
- init.arguments[0]?.type === "StringLiteral" ? init.arguments[0].value : null;
2258
- if (routed === "RouterLink" || routed === "RouterView") {
2259
- s.router.set(d.id.name, routed);
2260
- continue;
2261
- }
2262
- // `const _directive_focus = _resolveDirective("focus")`: a globally registered directive.
2263
- if (init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "_resolveDirective" && init.arguments[0]?.type === "StringLiteral") {
2264
- s.directives.set(d.id.name, init.arguments[0].value);
2265
- continue;
2266
- }
2267
- fail(s.comp, "a component the template resolves by name must be imported, or be this one", st);
2268
- }
2269
- if (st.type === "IfStatement") {
2270
- const body = (b) => (b.type === "BlockStatement" ? b.body : [b]);
2271
- /* `a && b && …`, where a leading operand is an optional value: present for the whole branch,
2272
- * so it is bound and narrowed there, as TypeScript narrows it — a Rust let-chain, which is why
2273
- * generated code needs edition 2024. */
2274
- if (st.test.type === "LogicalExpression" && st.test.operator === "&&") {
2275
- const operands = [];
2276
- const flatten = (n) => {
2277
- if (n.type === "LogicalExpression" && n.operator === "&&") {
2278
- flatten(n.left);
2279
- flatten(n.right);
2280
- }
2281
- else
2282
- operands.push(n);
2283
- };
2284
- flatten(st.test);
2285
- const narrowed = new Map(s.narrowed);
2286
- const parts = [];
2287
- for (const op of operands) {
2288
- const path = pathOf(op);
2289
- const inner = { ...s, narrowed };
2290
- const v = path !== null ? expr(inner, op) : null;
2291
- if (path !== null && v && v.ty.k === "opt") {
2292
- const name = `n${++narrowCount}`;
2293
- const of = v.ty.of;
2294
- const keep = of.k === "str" || of.k === "int" || of.k === "bool";
2295
- parts.push(`let Some(${name}) = ${keep ? `(${v.code}).filter(|v| ${truthy({ code: "*v", ty: of })})` : v.code}`);
2296
- narrowed.set(path, { code: name, ty: of });
2297
- }
2298
- else {
2299
- parts.push(cond(inner, op));
2300
- }
2301
- }
2302
- if (narrowed.size > s.narrowed.size) {
2303
- e.open(`if ${parts.join(" && ")}`);
2304
- statements({ ...s, narrowed }, e, body(st.consequent));
2305
- if (st.alternate) {
2306
- e.close(" else {");
2307
- statements(s, e, body(st.alternate));
2308
- }
2309
- e.close();
2310
- continue;
2311
- }
2312
- }
2313
- const compound = (st.test.type === "LogicalExpression" && st.test.operator !== "??") ||
2314
- (st.test.type === "UnaryExpression" && st.test.operator === "!");
2315
- const t = compound ? { code: cond(s, st.test), ty: BOOL } : expr(s, st.test);
2316
- if (t.konst !== undefined) {
2317
- const taken = t.konst ? st.consequent : st.alternate;
2318
- if (taken)
2319
- statements(s, e, body(taken));
2320
- continue;
2321
- }
2322
- /* An optional value tested for presence is narrowed inside the branch, as TypeScript narrows
2323
- * it: bound by `if let`, so `user.name` under `v-if="user"` reads the bound value. The
2324
- * test keeps JavaScript's truthiness — an empty string, a 0 and `false` are not taken — while
2325
- * an object and a list, empty or not, always are. */
2326
- const path = pathOf(st.test);
2327
- if (path !== null && t.ty.k === "opt") {
2328
- const name = `n${++narrowCount}`;
2329
- const inner = t.ty.of;
2330
- const keep = inner.k === "str" || inner.k === "int" || inner.k === "bool";
2331
- const test = keep ? `(${t.code}).filter(|v| ${truthy({ code: "*v", ty: inner })})` : t.code;
2332
- e.open(`if let Some(${name}) = ${test}`);
2333
- const narrowed = new Map(s.narrowed).set(path, { code: name, ty: inner });
2334
- statements({ ...s, narrowed }, e, body(st.consequent));
2335
- }
2336
- else {
2337
- e.open(`if ${cond(s, st.test)}`);
2338
- statements(s, e, body(st.consequent));
2339
- }
2340
- if (st.alternate) {
2341
- e.close(" else {");
2342
- statements(s, e, body(st.alternate));
2343
- }
2344
- e.close();
2345
- continue;
2346
- }
2347
- fail(s.comp, `\`${st.type}\` in the compiled template`, st);
2348
- }
2349
- }
2350
- function list(s, e, c) {
2351
- const src = expr(s, c.arguments[0]);
2352
- const fn = c.arguments[1];
2353
- if (fn.type !== "ArrowFunctionExpression" || fn.body.type !== "BlockStatement") {
2354
- fail(s.comp, "unexpected `ssrRenderList` callback", c);
2355
- }
2356
- const [item, index] = fn.params;
2357
- const idx = index ? snake(index.name) : null;
2358
- // The item's name in Rust: its own when it is a plain name, a placeholder when it is destructured.
2359
- const itemName = item.type === "Identifier" ? snake(item.name) : `fv_item${++narrowCount}`;
2360
- const inner = new Map(s.locals);
2361
- let of;
2362
- if (src.ty.k === "int") {
2363
- // `v-for="n in 5"`: 1 to 5, as `renderList` counts a number.
2364
- of = INT;
2365
- e.open(idx ? `for (${idx}, ${itemName}) in (1..=${src.code}).enumerate()` : `for ${itemName} in 1..=${src.code}`);
2366
- }
2367
- else if (src.ty.k === "list") {
2368
- of = src.ty.of;
2369
- if (of.k === "undef")
2370
- fail(s.comp, "`v-for` over an empty array literal", c);
2371
- const itemCode = of.k === "str" ? `&**${itemName}_ref` : `${itemName}_ref`;
2372
- e.open(idx
2373
- ? `for (${idx}, ${itemName}_ref) in (${src.code}).iter().enumerate()`
2374
- : `for ${itemName}_ref in (${src.code}).iter()`);
2375
- if (of.k === "str")
2376
- e.stmt(`let ${itemName}: &str = ${itemCode};`);
2377
- // An object — a struct, or another component's props — is borrowed; only a scalar is copied.
2378
- else if (of.k === "struct" || of.k === "child")
2379
- e.stmt(`let ${itemName} = ${itemCode};`);
2380
- else
2381
- e.stmt(`let ${itemName} = *${itemName}_ref;`);
2382
- }
2383
- else {
2384
- return fail(s.comp, "`v-for` walks an array, or counts to a number", c);
2385
- }
2386
- if (idx)
2387
- e.stmt(`let ${idx} = ${idx} as i64;`);
2388
- if (item.type === "Identifier")
2389
- inner.set(item.name, { code: itemName, ty: of });
2390
- else if (item.type === "ObjectPattern") {
2391
- // `v-for="{ name, id: key } in items"`: each name is that field of the item.
2392
- for (const p of item.properties) {
2393
- if (p.type !== "ObjectProperty" || p.computed || p.value.type !== "Identifier") {
2394
- fail(s.comp, "a destructured `v-for` item binds plain names, without defaults", p);
2395
- }
2396
- inner.set(p.value.name, fieldVal(s.comp, itemName, of, p.key.name ?? p.key.value, p));
2397
- }
2398
- }
2399
- else
2400
- fail(s.comp, "a `v-for` item is a name or an object pattern", item);
2401
- if (index)
2402
- inner.set(index.name, { code: idx, ty: INT });
2403
- const before = e.literalBytes;
2404
- statements({ ...s, locals: inner }, e, fn.body.body);
2405
- e.close();
2406
- // The body's markup is written once per item, not once.
2407
- const body = e.literalBytes - before;
2408
- e.literalBytes = before;
2409
- // Counted up front only when the list is reachable from there: one a `v-if` narrowed, or a loop
2410
- // variable, exists only inside its block, and the reservation is an estimate either way.
2411
- if (body > 0 && src.ty.k === "list" && /^\(?props\./.test(src.code))
2412
- e.perItem.push(`${body} * (${src.code}).len()`);
2413
- }
2414
- /* ── Script setup ────────────────────────────────────────────────────────────────────────── */
2415
- /** The store module an import names, resolved and without its extension, when it is one. */
2416
- function storeImport(comp, from) {
2417
- if (!from.startsWith(".") || STORES.size === 0)
2418
- return null;
2419
- const target = resolve(ROOT_DIR, dirname(comp.file), from).replace(/\.ts$/, "");
2420
- return [...STORES.values()].some((s) => s.module === target) ? target : null;
2421
- }
2422
- /** Every store in the configured directory, and the interfaces their files declare. */
2423
- function readStores(root, dir) {
2424
- STORES = new Map();
2425
- STORE_STRUCTS = new Map();
2426
- STORE_FILES = new Map();
2427
- const files = readdirSync(join(root, dir)).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts")).sort();
2428
- for (const f of files) {
2429
- const path = join(root, dir, f);
2430
- const comp = storeHome(relative(root, path));
2431
- const ast = parseJs(readFileSync(path, "utf8"), { sourceType: "module", plugins: ["typescript"] }).program.body;
2432
- const decls = ast.map((st) => (st.type === "ExportNamedDeclaration" ? st.declaration : st)).filter(Boolean);
2433
- const interfaces = decls.filter((d) => d.type === "TSInterfaceDeclaration");
2434
- for (const d of interfaces) {
2435
- if (STORE_STRUCTS.has(d.id.name))
2436
- fail(comp, `\`${d.id.name}\` is declared by another store too`, d);
2437
- STORE_STRUCTS.set(d.id.name, { name: d.id.name, fields: [] });
2438
- STORE_FILES.set(d.id.name, comp.file);
2439
- }
2440
- for (const d of interfaces) {
2441
- const st = structOf(comp, d.id.name, d.body.body, STORE_STRUCTS);
2442
- for (const field of st.fields)
2443
- field.ty = markStore(field.ty);
2444
- STORE_STRUCTS.set(d.id.name, st);
2445
- }
2446
- for (const d of decls) {
2447
- if (d.type !== "VariableDeclaration")
2448
- continue;
2449
- for (const v of d.declarations) {
2450
- const init = v.init;
2451
- if (init?.type !== "CallExpression" || init.callee.type !== "Identifier" || init.callee.name !== "defineStore")
2452
- continue;
2453
- const [id, options] = init.arguments;
2454
- if (id?.type !== "StringLiteral")
2455
- fail(comp, "a store's id is a string literal", init);
2456
- if (options?.type !== "ObjectExpression")
2457
- fail(comp, "a store is an option store: `defineStore(id, { state, … })`", init);
2458
- const state = options.properties.find((p) => (p.key?.name ?? p.key?.value) === "state");
2459
- const fn = state?.value ?? (state?.type === "ObjectMethod" ? state : null);
2460
- const ret = fn?.returnType?.typeAnnotation;
2461
- if (ret?.type !== "TSTypeReference" || !STORE_STRUCTS.has(ret.typeName.name)) {
2462
- fail(comp, "a store's `state` declares its return type, an interface in the same file: `state: (): State => ({ … })`", state ?? init);
2463
- }
2464
- const getters = new Map();
2465
- const getterObj = options.properties.find((p) => (p.key?.name ?? p.key?.value) === "getters");
2466
- if (getterObj && getterObj.value?.type !== "ObjectExpression")
2467
- fail(comp, "a store's `getters` is an object literal", getterObj);
2468
- for (const g of getterObj?.value.properties ?? []) {
2469
- const fn = g.type === "ObjectMethod" ? g : g.value;
2470
- const name = g.key?.name ?? g.key?.value;
2471
- if (!fn || !["ArrowFunctionExpression", "FunctionExpression", "ObjectMethod"].includes(fn.type)) {
2472
- fail(comp, `getter \`${name}\` is a function of the state`, g);
2473
- }
2474
- let body = fn.body;
2475
- if (body.type === "BlockStatement") {
2476
- const only = body.body.length === 1 ? body.body[0] : null;
2477
- body = only?.type === "ReturnStatement" ? only.argument : null;
2478
- }
2479
- const param = fn.params[0];
2480
- // A getter the server cannot translate fails where a component reads it, not here.
2481
- getters.set(name, { param: param?.type === "Identifier" ? param.name : null, body, file: comp.file });
2482
- }
2483
- STORES.set(v.id.name, {
2484
- hook: v.id.name,
2485
- id: id.value,
2486
- field: snake(id.value),
2487
- state: ret.typeName.name,
2488
- module: path.replace(/\.ts$/, ""),
2489
- getters,
2490
- });
2491
- }
2492
- }
2493
- }
2494
- }
2495
- /** A store file, as the thing its types are declared in: what errors name, and where they resolve. */
2496
- function storeHome(file) {
2497
- return {
2498
- name: basename(file),
2499
- module: "stores",
2500
- file,
2501
- props: { name: "Props", fields: [] },
2502
- structs: STORE_STRUCTS,
2503
- trustedName: null,
2504
- childProps: new Map(),
2505
- imports: new Set(),
2506
- slotNames: [],
2507
- routerView: false,
2508
- routerLink: false,
2509
- readsRoute: false,
2510
- usesRoute: false,
2511
- readsStores: false,
2512
- usesStores: false,
2513
- models: new Map(),
2514
- aliases: new Map(),
2515
- importedTypes: new Map(),
2516
- slotShapes: new Map(),
2517
- };
2518
- }
2519
- function markStore(ty) {
2520
- if (ty.k === "struct")
2521
- return { ...ty, store: true };
2522
- if (ty.k === "opt" || ty.k === "list")
2523
- return { ...ty, of: markStore(ty.of) };
2524
- return ty;
2525
- }
2526
- /** Lifecycle hooks, which never run on the server: setup may register them freely. */
2527
- const CLIENT_HOOKS = new Set([
2528
- "onMounted", "onBeforeMount", "onUnmounted", "onBeforeUnmount", "onUpdated", "onBeforeUpdate",
2529
- "onActivated", "onDeactivated", "onErrorCaptured", "onRenderTracked", "onRenderTriggered",
2530
- ]);
2531
- /** Compiler macros and calls with no effect on the server's render. */
2532
- const INERT_CALLS = new Set(["defineEmits", "defineSlots", "defineOptions", "defineExpose", "defineProps", "withDefaults", "provide"]);
2533
- /** A statement in setup that is a call on its own: allowed only when it cannot change the render. */
2534
- function setupStatement(comp, st) {
2535
- const c = st.expression;
2536
- const name = c?.type === "CallExpression" && c.callee.type === "Identifier" ? c.callee.name : null;
2537
- if (name !== null && (CLIENT_HOOKS.has(name) || INERT_CALLS.has(name)))
2538
- return;
2539
- if (name === "watch") {
2540
- // A watcher's callback runs on the server only with `immediate`, and could then change state.
2541
- const options = c.arguments[2];
2542
- const immediate = options?.type === "ObjectExpression" && options.properties.some((p) => p.type === "ObjectProperty" && (p.key.name ?? p.key.value) === "immediate" && !(p.value.type === "BooleanLiteral" && !p.value.value));
2543
- if (!immediate)
2544
- return;
2545
- fail(comp, "`watch` with `immediate` runs on the server, where its effect is not translated", st);
2546
- }
2547
- if (name === "watchEffect" || name === "watchSyncEffect" || name === "watchPostEffect") {
2548
- fail(comp, `\`${name}\` runs once on the server, where its effect is not translated; use \`watch\` or \`onMounted\``, st);
2549
- }
2550
- if (name === "onServerPrefetch")
2551
- fail(comp, "`onServerPrefetch` fetches on the server; pass the data in as props instead", st);
2552
- fail(comp, `\`${st.expression?.type === "CallExpression" ? `${name ?? "a call"}()` : st.expression?.type}\` in setup could change what renders, and is not translated`, st);
2553
- }
2554
- /** What a setup binding's value is computed from: `ref(x)` and `shallowRef(x)` hold `x`,
2555
- * `computed(() => x)` is `x`, and a plain `const y = x` is `x`. */
2556
- function setupSource(init) {
2557
- if (init.type === "CallExpression" && init.callee.type === "Identifier") {
2558
- const name = init.callee.name;
2559
- if ((name === "ref" || name === "shallowRef") && init.arguments.length === 1)
2560
- return init.arguments[0];
2561
- if (name === "computed") {
2562
- const fn = init.arguments[0];
2563
- if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression")
2564
- return null;
2565
- if (fn.body.type !== "BlockStatement")
2566
- return fn.body;
2567
- const only = fn.body.body.length === 1 ? fn.body.body[0] : null;
2568
- return only?.type === "ReturnStatement" && only.argument ? only.argument : null;
2569
- }
2570
- }
2571
- if (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression" || init.type === "TSNonNullExpression")
2572
- return setupSource(init.expression);
2573
- return init;
2574
- }
2575
- /** The names a destructuring pattern binds. */
2576
- function patternNames(p) {
2577
- switch (p?.type) {
2578
- case "Identifier":
2579
- return [p.name];
2580
- case "ObjectPattern":
2581
- return p.properties.flatMap((q) => patternNames(q.type === "RestElement" ? q.argument : q.value));
2582
- case "ArrayPattern":
2583
- return p.elements.flatMap((q) => (q ? patternNames(q) : []));
2584
- case "AssignmentPattern":
2585
- return patternNames(p.left);
2586
- case "RestElement":
2587
- return patternNames(p.argument);
2588
- default:
2589
- return [];
2590
- }
2591
- }
2592
- function scopeFor(comp, ast, components) {
2593
- const scope = {
2594
- comp,
2595
- components,
2596
- setup: new Map(),
2597
- children: new Map(),
2598
- helpers: new Map(),
2599
- locals: new Map(),
2600
- propsIdent: null,
2601
- refs: new Set(),
2602
- clientOnly: new Map(),
2603
- narrowed: new Map(),
2604
- selfAlias: { name: null },
2605
- helperBytes: { n: 0 },
2606
- router: new Map(),
2607
- directives: new Map(),
2608
- fill: false,
2609
- vnode: false,
2610
- };
2611
- const lets = [];
2612
- const storeHooks = new Map();
2613
- let storeToRefsName = null;
2614
- let useRouteName = null;
2615
- /** Setup bindings holding a store, by name. */
2616
- const storeValues = new Map();
2617
- for (const st of ast) {
2618
- if (st.type === "ImportDeclaration") {
2619
- const from = st.source.value;
2620
- if (from.endsWith(".vue")) {
2621
- const def = st.specifiers.find((x) => x.type === "ImportDefaultSpecifier");
2622
- if (def)
2623
- scope.children.set(def.local.name, basename(from, ".vue"));
2624
- }
2625
- else if (storeImport(comp, from)) {
2626
- for (const sp of st.specifiers) {
2627
- const hook = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
2628
- // A type from the store's file, which `readComponent` has already resolved.
2629
- if (st.importKind === "type" || sp.importKind === "type" || (hook !== null && STORE_STRUCTS.has(hook)))
2630
- continue;
2631
- const store = hook ? STORES.get(hook) : undefined;
2632
- if (!store || store.module !== storeImport(comp, from))
2633
- fail(comp, "import a store by its `use…` hook", sp);
2634
- storeHooks.set(sp.local.name, store);
2635
- }
2636
- }
2637
- else if (from === "vue-router") {
2638
- for (const sp of st.specifiers) {
2639
- const name = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
2640
- if (name === "useRoute")
2641
- useRouteName = sp.local.name;
2642
- else if (name === "RouterLink" || name === "RouterView")
2643
- scope.router.set(sp.local.name, name);
2644
- else
2645
- scope.clientOnly.set(sp.local.name, `\`${name}\` from vue-router does not run on the server`);
2646
- }
2647
- }
2648
- else if (from === "pinia") {
2649
- for (const sp of st.specifiers) {
2650
- const name = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
2651
- if (name === "storeToRefs")
2652
- storeToRefsName = sp.local.name;
2653
- else
2654
- scope.clientOnly.set(sp.local.name, `\`${name}\` from Pinia does not run on the server`);
2655
- }
2656
- }
2657
- else if (HELPER_MODULE !== null && from === HELPER_MODULE) {
2658
- for (const sp of st.specifiers) {
2659
- if (!HELPERS[sp.imported.name])
2660
- fail(comp, `\`${sp.imported.name}\` has no Rust twin in HELPERS`, sp);
2661
- scope.helpers.set(sp.local.name, sp.imported.name);
2662
- }
2663
- }
2664
- continue;
2665
- }
2666
- if (st.type === "FunctionDeclaration") {
2667
- if (st.id)
2668
- scope.clientOnly.set(st.id.name, "it is a function, which only an event handler may call");
2669
- continue;
2670
- }
2671
- const decl = st.type === "ExportNamedDeclaration" ? st.declaration : st;
2672
- if (decl?.type === "TSInterfaceDeclaration" || decl?.type === "TSTypeAliasDeclaration")
2673
- continue;
2674
- if (st.type === "ExpressionStatement") {
2675
- setupStatement(comp, st);
2676
- continue;
2677
- }
2678
- // Setup runs on the server too, so a statement there can change what renders: `x.value = ...`
2679
- // after a `ref`, an `if`, a loop. None of it is translated, so none of it may exist.
2680
- if (st.type !== "VariableDeclaration")
2681
- fail(comp, `\`${st.type}\` in setup is not supported`, st);
2682
- for (const d of st.declarations) {
2683
- const init0 = d.init;
2684
- // `const { size = "md", label: l } = defineProps<...>()`: each name reads that prop, whose
2685
- // default `compileScript` already resolved.
2686
- if (d.id.type === "ObjectPattern" && definePropsType(init0)) {
2687
- for (const p of d.id.properties) {
2688
- if (p.type !== "ObjectProperty" || p.computed)
2689
- fail(comp, "destructured props are plain names: `...rest` has no Rust type", p);
2690
- const key = p.key.type === "Identifier" ? p.key.name : p.key.value;
2691
- const local = p.value.type === "AssignmentPattern" ? p.value.left : p.value;
2692
- if (local.type !== "Identifier")
2693
- fail(comp, "destructured props are plain names", p);
2694
- scope.setup.set(local.name, fieldVal(comp, "props", { k: "struct", name: "Props" }, key, p));
2695
- }
2696
- continue;
2697
- }
2698
- const calls = (name) => name !== null && init0?.type === "CallExpression" && init0.callee.type === "Identifier" && init0.callee.name === name;
2699
- // `const { density, label: l } = storeToRefs(prefs)`: each name is that field of the state.
2700
- if (d.id.type === "ObjectPattern" && calls(storeToRefsName)) {
2701
- const arg = init0.arguments[0];
2702
- const store = arg?.type === "Identifier" ? storeValues.get(arg.name) : undefined;
2703
- if (!store || init0.arguments.length !== 1)
2704
- fail(comp, "`storeToRefs` takes a store bound in this setup", d);
2705
- for (const p of d.id.properties) {
2706
- if (p.type !== "ObjectProperty" || p.computed || p.value.type !== "Identifier") {
2707
- fail(comp, "`storeToRefs` is destructured into plain names", p);
2708
- }
2709
- const key = p.key.type === "Identifier" ? p.key.name : p.key.value;
2710
- const state = { code: `fv_stores.${store.field}`, ty: { k: "struct", name: store.state, store: true } };
2711
- scope.setup.set(p.value.name, storeGetter(scope, state, key, p) ?? fieldVal(comp, state.code, state.ty, key, p));
2712
- scope.refs.add(p.value.name);
2713
- }
2714
- comp.readsStores = true;
2715
- continue;
2716
- }
2717
- if (d.id.type !== "Identifier") {
2718
- // Destructuring anything else: client-side state the template may not read.
2719
- for (const name of patternNames(d.id))
2720
- scope.clientOnly.set(name, "it is destructured from a value the server does not have");
2721
- continue;
2722
- }
2723
- const local = d.id.name;
2724
- // `const prefs = usePrefs()`: the store's state, as the server was given it.
2725
- const hook = init0?.type === "CallExpression" && init0.callee.type === "Identifier" ? storeHooks.get(init0.callee.name) : undefined;
2726
- if (hook) {
2727
- if (init0.arguments.length)
2728
- fail(comp, "a store hook takes no arguments", d);
2729
- storeValues.set(local, hook);
2730
- scope.setup.set(local, { code: `fv_stores.${hook.field}`, ty: { k: "struct", name: hook.state, store: true } });
2731
- comp.readsStores = true;
2732
- continue;
2733
- }
2734
- // `const route = useRoute()`: the reader's location.
2735
- if (useRouteName !== null && init0?.type === "CallExpression" && init0.callee.type === "Identifier" && init0.callee.name === useRouteName) {
2736
- scope.setup.set(local, theRoute(scope, d));
2737
- continue;
2738
- }
2739
- // `const model = defineModel<string>()`: the model's prop.
2740
- const model = comp.models.get(local);
2741
- if (model !== undefined) {
2742
- scope.setup.set(local, fieldVal(comp, "props", { k: "struct", name: "Props" }, model, d));
2743
- scope.refs.add(local);
2744
- continue;
2745
- }
2746
- if (!init0) {
2747
- scope.clientOnly.set(local, "it has no initial value");
2748
- continue;
2749
- }
2750
- if (definePropsType(init0)) {
2751
- scope.propsIdent = local;
2752
- continue;
2753
- }
2754
- const source = setupSource(init0);
2755
- const isRef = init0.type === "CallExpression" && init0.callee.type === "Identifier" && ["ref", "shallowRef", "computed"].includes(init0.callee.name);
2756
- if (isRef)
2757
- scope.refs.add(local);
2758
- if (!source) {
2759
- scope.clientOnly.set(local, "it is not a value the server computes: only `ref(…)`, `computed(() => …)` and plain expressions are");
2760
- continue;
2761
- }
2762
- // Evaluated once, as setup is on the server. A value the server cannot evaluate is client-side
2763
- // state — a template ref, an element, a `reactive` object — which the template may still name
2764
- // from an event handler; a reference the server needs fails in `expr` with this reason.
2765
- let v;
2766
- try {
2767
- v = expr(scope, source);
2768
- }
2769
- catch (e) {
2770
- if (!(e instanceof GenError))
2771
- throw e;
2772
- scope.clientOnly.set(local, e.message.replace(/^[^:]*: /, ""));
2773
- continue;
2774
- }
2775
- if (v.ty.k === "undef") {
2776
- scope.setup.set(local, v);
2777
- continue;
2778
- }
2779
- const name = `s_${snake(local).replace(/^r#/, "")}`;
2780
- // A list or an object is a place in the props: borrowed, never moved out of them.
2781
- const place = v.ty.k === "list" || v.ty.k === "struct" || v.ty.k === "child";
2782
- lets.push(`let ${name} = ${place ? `&(${v.code})` : v.code};`);
2783
- scope.setup.set(local, { code: name, ty: v.ty, ...(v.konst !== undefined ? { konst: v.konst } : {}) });
2784
- }
2785
- }
2786
- return { scope, lets };
2787
- }
2788
- /* ── Rust source ─────────────────────────────────────────────────────────────────────────── */
2789
- function needsLifetime(ty, comp, seen = new Set()) {
2790
- switch (ty.k) {
2791
- case "str":
2792
- return true;
2793
- case "opt":
2794
- case "list":
2795
- return needsLifetime(ty.of, comp, seen);
2796
- case "struct": {
2797
- // A struct that holds itself borrows only if something else in it does.
2798
- if (seen.has(ty.name))
2799
- return false;
2800
- const { st, owner } = lookupStruct(comp, ty);
2801
- const inner = new Set(seen).add(ty.name);
2802
- return !!st && st.fields.some((f) => needsLifetime(f.ty, owner, inner));
2803
- }
2804
- case "child": {
2805
- const child = childOf(ty.name);
2806
- return structLifetime(child.props, child);
2807
- }
2808
- // A configured type that borrows says so by naming the props' lifetime: `Trusted<'a>`.
2809
- case "html":
2810
- return TRUSTED_HTML?.includes("'a") ?? false;
2811
- default:
2812
- return false;
2813
- }
2814
- }
2815
- function structLifetime(st, comp) {
2816
- return st.fields.some((f) => needsLifetime(f.ty, comp, new Set([st.name])));
2817
- }
2818
- function rustTy(ty, comp) {
2819
- switch (ty.k) {
2820
- case "str":
2821
- return "Cow<'a, str>";
2822
- case "int":
2823
- return "i64";
2824
- case "bool":
2825
- return "bool";
2826
- case "opt":
2827
- return `Option<${rustTy(ty.of, comp)}>`;
2828
- case "list":
2829
- return `Vec<${rustTy(ty.of, comp)}>`;
2830
- case "struct": {
2831
- const { st, owner, path } = lookupStruct(comp, ty);
2832
- if (!st)
2833
- throw new GenError(`no type \`${ty.name}\``);
2834
- return `${path}${ty.name}${structLifetime(st, owner) ? "<'a>" : ""}`;
2835
- }
2836
- case "html":
2837
- return TRUSTED_HTML;
2838
- case "child": {
2839
- const child = childOf(ty.name);
2840
- return `super::${child.module}::Props${structLifetime(child.props, child) ? "<'a>" : ""}`;
2841
- }
2842
- default:
2843
- throw new GenError("no Rust type for `undefined`");
2844
- }
2845
- }
2846
- function structSource(st, comp, doc) {
2847
- const life = structLifetime(st, comp) ? "<'a>" : "";
2848
- const fields = st.fields
2849
- .map((f) => {
2850
- const attrs = f.ty.k === "opt"
2851
- ? ` #[serde(rename = ${rustStr(f.js)}, default, skip_serializing_if = "Option::is_none")]`
2852
- : ` #[serde(rename = ${rustStr(f.js)})]`;
2853
- return `${attrs}\n pub ${f.rust}: ${rustTy(f.ty, comp)},`;
2854
- })
2855
- .join("\n");
2856
- /* `Deserialize` only for the conformance suite. Props go out as JSON and never come back in, and a
2857
- * `Deserialize` in production would be a way to make a `TrustedHtml` value out of any string. */
2858
- return `${doc}#[derive(Debug, Clone, serde::Serialize)]
2859
- #[cfg_attr(test, derive(serde::Deserialize))]
2860
- pub struct ${st.name}${life} {
2861
- ${fields}
2862
- }
2863
- `;
2864
- }
2865
- function header(source, edit = "the `.vue` file") {
2866
- return `// @generated by ferrovue from ${source}. Do not edit: change ${edit} and run
2867
- // \`ferrovue\`.
2868
- `;
2869
- }
2870
- /** Rust expressions summing the lengths of the strings a value of type `ty` at `place` holds. */
2871
- function textLen(comp, place, ty, seen = new Set()) {
2872
- switch (ty.k) {
2873
- case "str":
2874
- return [`${place}.len()`];
2875
- case "html":
2876
- return [`fv::TrustedHtml::trusted_html(&${place}).len()`];
2877
- case "opt":
2878
- if (ty.of.k === "str")
2879
- return [`${place}.as_deref().map_or(0, str::len)`];
2880
- if (ty.of.k === "html")
2881
- return [`${place}.as_ref().map_or(0, |v| fv::TrustedHtml::trusted_html(v).len())`];
2882
- if (ty.of.k === "struct") {
2883
- const inner = textLen(comp, "v", ty.of, seen);
2884
- return inner.length ? [`${place}.as_ref().map_or(0, |v| ${inner.join(" + ")})`] : [];
2885
- }
2886
- return [];
2887
- case "struct": {
2888
- // A recursive structure is counted to its first level: the reservation is an estimate.
2889
- if (seen.has(ty.name))
2890
- return [];
2891
- const { st, owner } = lookupStruct(comp, ty);
2892
- const inner = new Set(seen).add(ty.name);
2893
- return (st?.fields ?? []).flatMap((f) => textLen(owner, `${place}.${f.rust}`, f.ty, inner));
2894
- }
2895
- case "list": {
2896
- const inner = textLen(comp, "v", ty.of, seen);
2897
- return inner.length ? [`${place}.iter().map(|v| ${inner.join(" + ")}).sum::<usize>()`] : [];
2898
- }
2899
- default:
2900
- return [];
2901
- }
2902
- }
2903
- function componentSource(comp, ast, ssr, components) {
2904
- const { scope, lets } = scopeFor(comp, ast, components);
2905
- const program = parseJs(ssr, { sourceType: "module" }).program;
2906
- const fn = program.body.find((s) => s.type === "ExportNamedDeclaration" && s.declaration?.id?.name === "ssrRender")?.declaration;
2907
- if (!fn)
2908
- fail(comp, "the compiled template has no `ssrRender`");
2909
- const e = new Emitter();
2910
- for (const l of lets)
2911
- e.stmt(l);
2912
- statements(scope, e, fn.body.body);
2913
- e.flush();
2914
- /* The literal markup, a loop's once per item, what the helpers can write, and every string the
2915
- * props hold. Literals in untaken branches overcount and escaping or a string used twice
2916
- * undercounts, so it is an estimate — close enough that the buffer is sized once, where
2917
- * reserving the literals alone leaves it to grow again at the first long label. */
2918
- const text = textLen(comp, "props", { k: "struct", name: "Props" });
2919
- const fixed = e.literalBytes + scope.helperBytes.n;
2920
- e.lines.unshift(` out.reserve(${[String(fixed), ...e.perItem, ...text].join(" + ")});`);
2921
- const life = structLifetime(comp.props, comp) ? "<'_>" : "";
2922
- const gen = life ? "<'p, 'a>" : "<'p>";
2923
- const named = life ? "<'a>" : "";
2924
- // An `interface Props` that `defineProps` takes is the props struct itself, written once below.
2925
- const structs = [...comp.structs.values()]
2926
- .filter((st) => st.name !== "Props" && !st.slot)
2927
- .map((st) => structSource(st, comp, `/// \`${st.name}\` in \`${basename(comp.file)}\`.\n`))
2928
- .join("\n");
2929
- // `Cow` is imported only where a field is one: a lifetime that comes from another component's props
2930
- // alone borrows through that type, not through a `Cow` written here.
2931
- const usesCow = /Cow</.test(structs + structSource(comp.props, comp, ""));
2932
- const plain = !takesSlots(comp) && !comp.usesRoute && !comp.usesStores;
2933
- const slotFields = [
2934
- ...comp.slotNames.map((n) => {
2935
- const outlet = `\`<slot${n === "default" ? "" : ` name="${n}"`}>\``;
2936
- const type = comp.slotShapes.has(n) ? `&'s ${slotTypeName(n, "Slot")}<'s>` : "fv::Slot<'s>";
2937
- return ` /// ${outlet}\n pub ${snake(n)}: Option<${type}>,`;
2938
- }),
2939
- ...(comp.routerView ? [" /// The page `<RouterView>` shows.\n pub router_view: fv::Slot<'s>,"] : []),
2940
- ];
2941
- const slotTypes = [...comp.slotShapes.entries()]
2942
- .map(([n, shape]) => {
2943
- const outlet = `\`<slot${n === "default" ? "" : ` name="${n}"`}>\``;
2944
- const life = shape.fields.some((f) => slotFieldBorrows(f.ty));
2945
- const fields = shape.fields.map((f) => ` pub ${f.rust}: ${slotFieldTy(f.ty, comp)},`).join("\n");
2946
- const props = `${shape.name}${life ? "<'v>" : ""}`;
2947
- return `/// The props ${outlet} passes the content a parent gives it, borrowed for the render.
2948
- pub struct ${props} {
2949
- ${fields}
2950
- }
2951
-
2952
- /// A parent's content for ${outlet}, given its props: returns whether it wrote anything but comments.
2953
- pub type ${slotTypeName(n, "Slot")}<'s> = dyn ${life ? "for<'v> " : ""}Fn(&mut String, &${props}) -> bool + 's;
2954
-
2955
- `;
2956
- })
2957
- .join("");
2958
- const slotsStruct = takesSlots(comp)
2959
- ? `${slotTypes}/// What a parent puts in the slots \`${basename(comp.file)}\` renders.
2960
- #[derive(Clone, Copy${comp.routerView ? "" : ", Default"})]
2961
- pub struct Slots<'s> {
2962
- ${slotFields.join("\n")}
2963
- }
2964
-
2965
- `
2966
- : "";
2967
- const args = (takesSlots(comp) ? ", fv_slots" : "") + (comp.usesRoute ? ", fv_route" : "") + (comp.usesStores ? ", fv_stores" : "");
2968
- const params = (takesSlots(comp) ? ", fv_slots: Slots<'p>" : "") +
2969
- (comp.usesRoute ? ", fv_route: &'p fv::Route<'p>" : "") +
2970
- (comp.usesStores ? ", fv_stores: &'p super::stores::Stores<'p>" : "");
2971
- const wrappers = plain
2972
- ? `/// The component's markup, for a maud page that shows it without hydrating it.
2973
- pub fn html${gen}(props: &'p Props${named}) -> fv::Html<'p, Props${named}> {
2974
- fv::Html::markup(props, render)
2975
- }
2976
-
2977
- /// The component as an island the client hydrates.
2978
- pub fn island${gen}(props: &'p Props${named}) -> fv::Html<'p, Props${named}> {
2979
- fv::Html::island(NAME, props, render)
2980
- }
2981
- `
2982
- : `/// The component's markup, for a maud page that shows it.
2983
- pub fn html${gen}(props: &'p Props${named}${params}) -> fv::Html<'p, Props${named}, impl Fn(&mut String, &Props${named}) + 'p> {
2984
- fv::Html::markup(props, move |out: &mut String, props: &Props${named}| render(out, props${args}))
2985
- }
2986
- `;
2987
- return `${header(comp.file)}
2988
- ${usesCow ? "use std::borrow::Cow;\n\n" : ""}use ferrovue as fv;
2989
-
2990
- /// The component's name, as \`data-island\` carries it.
2991
- pub const NAME: &str = ${rustStr(comp.name)};
2992
-
2993
- ${structs ? structs + "\n" : ""}${structSource(comp.props, comp, `/// The props \`${basename(comp.file)}\` declares.\n`)}
2994
- ${slotsStruct}/// Write the component's server render into \`out\`.
2995
- pub fn render(out: &mut String, props: &Props${life}${extraParams(comp)}) {
2996
- ${e.lines.join("\n")}
2997
- }
2998
-
2999
- ${wrappers}`;
3000
- }
3001
- function modSource(comps) {
3002
- const arms = comps
3003
- .map((c) => {
3004
- const lines = [`let props: ${c.module}::Props = serde_json::from_str(json).map_err(|e| e.to_string())?;`];
3005
- let args = "";
3006
- if (takesSlots(c) || c.usesRoute || c.usesStores)
3007
- lines.push("let fixture: Fixture = serde_json::from_str(json).map_err(|e| e.to_string())?;");
3008
- if (takesSlots(c)) {
3009
- const names = [...c.slotNames, ...(c.routerView ? ["routerView"] : [])];
3010
- for (const n of names) {
3011
- const local = `s_${snake(n).replace(/^r#/, "")}`;
3012
- const shape = c.slotShapes.get(n);
3013
- if (shape) {
3014
- // A fixture's content for a scoped slot is static: it is given the props and ignores them.
3015
- const life = shape.fields.some((f) => slotFieldBorrows(f.ty)) ? "<'_>" : "";
3016
- lines.push(`let ${local} = |out: &mut String, _: &${c.module}::${shape.name}${life}| -> bool { out.push_str(fixture.slot(${rustStr(n)}).unwrap_or_default()); true };`);
3017
- }
3018
- else
3019
- lines.push(`let ${local} = |out: &mut String| out.push_str(fixture.slot(${rustStr(n)}).unwrap_or_default());`);
3020
- }
3021
- const fields = c.slotNames.map((n) => {
3022
- const local = `s_${snake(n).replace(/^r#/, "")}`;
3023
- const value = c.slotShapes.has(n) ? `&${local} as &${c.module}::${slotTypeName(n, "Slot")}` : `ferrovue::Slot::new(&${local})`;
3024
- return `${snake(n)}: fixture.slot(${rustStr(n)}).map(|_| ${value})`;
3025
- });
3026
- if (c.routerView)
3027
- fields.push("router_view: ferrovue::Slot::new(&s_router_view)");
3028
- args += `, ${c.module}::Slots { ${fields.join(", ")} }`;
3029
- }
3030
- if (c.usesRoute) {
3031
- lines.push("let router = route_table::router();", "let route = router.at(&fixture.route);");
3032
- args += ", &route";
3033
- }
3034
- if (c.usesStores) {
3035
- lines.push("let state: stores::Stores = serde_json::from_value(fixture.stores.clone()).map_err(|e| e.to_string())?;");
3036
- args += ", &state";
3037
- }
3038
- lines.push(`${c.module}::render(&mut out, &props${args});`);
3039
- return ` ${rustStr(c.name)} => {\n${lines.map((l) => " " + l).join("\n")}\n }`;
3040
- })
3041
- .join("\n");
3042
- const fixture = comps.some((c) => takesSlots(c) || c.usesRoute || c.usesStores)
3043
- ? `
3044
- /// What a fixture holds besides the props: each slot's content, and the location it renders at.
3045
- #[cfg(test)]
3046
- #[derive(serde::Deserialize)]
3047
- struct Fixture {
3048
- #[serde(rename = "$slots", default)]
3049
- slots: std::collections::HashMap<String, String>,
3050
- #[serde(rename = "$route", default = "Fixture::root")]
3051
- route: String,
3052
- #[serde(rename = "$stores", default = "Fixture::no_stores")]
3053
- stores: serde_json::Value,
3054
- }
3055
-
3056
- #[cfg(test)]
3057
- impl Fixture {
3058
- fn root() -> String {
3059
- "/".to_owned()
3060
- }
3061
-
3062
- fn no_stores() -> serde_json::Value {
3063
- serde_json::Value::Object(Default::default())
3064
- }
3065
-
3066
- fn slot(&self, name: &str) -> Option<&str> {
3067
- self.slots.get(name).map(String::as_str)
3068
- }
3069
- }
3070
- `
3071
- : "";
3072
- return `${header(componentsDir)}
3073
- //! The component renderers, one module per \`.vue\` file.
3074
-
3075
- #![allow(dead_code, unused_parens, unused_variables, clippy::all)]
3076
-
3077
- ${comps.map((c) => `pub mod ${c.module};`).join("\n")}${ROUTES ? "\npub mod route_table;" : ""}${STORES.size ? "\npub mod stores;" : ""}${TYPE_STRUCTS.size ? "\npub mod types;" : ""}
3078
- ${fixture}
3079
- /// Render one component from its props as JSON, for the conformance suite.
3080
- #[cfg(test)]
3081
- pub fn render_json(component: &str, json: &str) -> Result<String, String> {
3082
- let mut out = String::new();
3083
- match component {
3084
- ${arms}
3085
- other => return Err(format!("no component called {other}")),
3086
- }
3087
- Ok(out)
3088
- }
3089
- `;
3090
- }
3091
- function storesSource(dir) {
3092
- const home = storeHome(dir);
3093
- // Test-only `Default` lets a fixture name only the stores it reads; the rest are never looked at.
3094
- const testDerive = (src) => src.replace("#[cfg_attr(test, derive(serde::Deserialize))]", "#[cfg_attr(test, derive(Default, serde::Deserialize))]\n#[cfg_attr(test, serde(default))]");
3095
- const structs = [...STORE_STRUCTS.values()]
3096
- .map((st) => testDerive(structSource(st, home, `/// \`${st.name}\` in \`${STORE_FILES.get(st.name)}\`.\n`)))
3097
- .join("\n");
3098
- const all = {
3099
- name: "Stores",
3100
- fields: [...STORES.values()].map((st) => ({ js: st.id, rust: st.field, ty: { k: "struct", name: st.state, store: true } })),
3101
- };
3102
- const top = testDerive(structSource(all, home, "/// Every store's state, keyed by id as `pinia.state.value` is: what the page sends the client.\n"));
3103
- return `${header(dir, "the store files")}
3104
- //! The Pinia stores' state, which components read while they render on the server.
3105
-
3106
- ${/Cow</.test(structs) ? "use std::borrow::Cow;\n\n" : ""}${structs}
3107
- ${top}`;
3108
- }
3109
- function typesSource() {
3110
- const home = storeHome("types");
3111
- home.structs = TYPE_STRUCTS;
3112
- home.module = "types";
3113
- const files = [...new Set(TYPE_FILES.values())].sort();
3114
- const structs = [...TYPE_STRUCTS.values()]
3115
- .map((st) => structSource(st, home, `/// \`${st.name}\` in \`${TYPE_FILES.get(st.name)}\`.\n`))
3116
- .join("\n");
3117
- return `${header(files.join(", "), "the type files")}
3118
- //! The types components import from shared \`.ts\` files, written once so that components passing
3119
- //! them to one another agree on them.
3120
-
3121
- ${/Cow</.test(structs) ? "use std::borrow::Cow;\n\n" : ""}${structs}`;
3122
- }
3123
- function routesSource(routes, file) {
3124
- return `${header(file)}
3125
- //! The app's routes: what \`<RouterLink>\` resolves against and \`useRoute()\` reads.
3126
-
3127
- /// Each route's vue-router path, and its name if it has one.
3128
- pub const ROUTES: &[(&str, Option<&str>)] = &[
3129
- ${routes.map((r) => ` (${rustStr(r.path)}, ${r.name === undefined ? "None" : `Some(${rustStr(r.name)})`}),`).join("\n")}
3130
- ];
3131
-
3132
- /// The routes' paths alone.
3133
- pub const PATHS: &[&str] = &[
3134
- ${routes.map((r) => ` ${rustStr(r.path)},`).join("\n")}
3135
- ];
3136
-
3137
- /// The history's base, which every link's \`href\` starts with.
3138
- pub const BASE: &str = ${rustStr(ROUTER_BASE)};
3139
-
3140
- /// The router these routes make: build it once, and resolve each request's location with
3141
- /// [\`ferrovue::Router::at\`].
3142
- pub fn router() -> ferrovue::Router {
3143
- ferrovue::Router::named(ROUTES).with_base(BASE)
3144
- }
3145
- `;
3146
- }
3147
- /** The directory the components being compiled come from, for the generated headers. */
3148
- let componentsDir = "";
3149
- /** The routes file: vue-router paths, each a string or `{ "path", "name" }`. */
3150
- function readRoutes(root, file) {
3151
- const raw = JSON.parse(readFileSync(join(root, file), "utf8"));
3152
- if (!Array.isArray(raw))
3153
- throw new GenError(`${file} lists the routes in an array`);
3154
- return raw.map((r) => {
3155
- if (typeof r === "string")
3156
- return { path: r };
3157
- const o = r;
3158
- if (typeof o?.path !== "string" || (o.name !== undefined && typeof o.name !== "string")) {
3159
- throw new GenError(`${file}: a route is a path, or \`{ "path": "…", "name": "…" }\``);
3160
- }
3161
- return o.name === undefined ? { path: o.path } : { path: o.path, name: o.name };
3162
- });
3163
- }
16
+ * be held to exactly what this produces.
17
+ *
18
+ * This module is the public API; the translation lives in the modules beside it. */
19
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
+ import { join, resolve } from "node:path";
21
+ import { ctx, loadConfig, tyOfName } from "./context.js";
22
+ import { readComponent } from "./component.js";
23
+ import { readRoutes } from "./router.js";
24
+ import { readStores } from "./stores.js";
25
+ import { i18nSource, readLocales } from "./i18n.js";
26
+ import { scopeFor } from "./script.js";
27
+ import { scopeFlow } from "./scoped.js";
28
+ import { componentSource, modSource, routesSource, storesSource, typesSource } from "./rust.js";
3164
29
  /** Every generated file, keyed by its name in the output directory. */
3165
30
  export function generate(root, config = loadConfig(root)) {
3166
- componentsDir = config.components.replace(/\/?$/, "/");
3167
- HELPER_MODULE = config.helpers?.module ?? null;
3168
- TRUSTED_HTML = config.trustedHtml ?? null;
31
+ ctx.componentsDir = config.components.replace(/\/?$/, "/");
32
+ ctx.helperModule = config.helpers?.module ?? null;
33
+ ctx.trustedHtml = config.trustedHtml ?? null;
3169
34
  const router = config.router ?? (config.routes ? { routes: config.routes } : null);
3170
- ROUTES = router ? readRoutes(root, router.routes) : null;
3171
- ROUTER_BASE = router?.base ?? "";
3172
- CLIENT_DIRECTIVES = new Set(config.clientDirectives ?? []);
3173
- LINK_ACTIVE = router?.linkActiveClass ?? "router-link-active";
3174
- LINK_EXACT_ACTIVE = router?.linkExactActiveClass ?? "router-link-exact-active";
3175
- ROOT_DIR = root;
3176
- TYPE_STRUCTS = new Map();
3177
- TYPE_ALIASES = new Map();
3178
- TYPE_FILES = new Map();
3179
- TYPE_READ = new Set();
35
+ ctx.routes = router ? readRoutes(root, router.routes) : null;
36
+ ctx.routerBase = router?.base ?? "";
37
+ ctx.clientDirectives = new Set(config.clientDirectives ?? []);
38
+ ctx.i18n = config.i18n ? readLocales(root, config.i18n) : null;
39
+ ctx.linkActive = router?.linkActiveClass ?? "router-link-active";
40
+ ctx.linkExactActive = router?.linkExactActiveClass ?? "router-link-exact-active";
41
+ ctx.rootDir = root;
42
+ ctx.scopeId = config.scopeId ?? "filepath-source";
43
+ ctx.viteRoot = resolve(root, config.viteRoot ?? ".");
44
+ ctx.typeStructs = new Map();
45
+ ctx.typeAliases = new Map();
46
+ ctx.typeFiles = new Map();
47
+ ctx.typeRead = new Set();
3180
48
  if (config.stores)
3181
49
  readStores(root, config.stores);
3182
50
  else {
3183
- STORES = new Map();
3184
- STORE_STRUCTS = new Map();
51
+ ctx.stores = new Map();
52
+ ctx.storeStructs = new Map();
3185
53
  }
3186
- HELPERS = Object.fromEntries(Object.entries(config.helpers?.functions ?? {}).map(([name, h]) => [
54
+ ctx.helpers = Object.fromEntries(Object.entries(config.helpers?.functions ?? {}).map(([name, h]) => [
3187
55
  name,
3188
56
  { rust: h.rust, params: h.params.map(tyOfName), ret: tyOfName(h.returns), maxLen: h.maxLen ?? 0 },
3189
57
  ]));
3190
58
  const dir = join(root, config.components);
3191
59
  const files = readdirSync(dir)
3192
60
  .filter((f) => f.endsWith(".vue"))
3193
- .sort()
61
+ .toSorted()
3194
62
  .map((f) => join(dir, f));
3195
63
  const read = files.map((f) => readComponent(f, root));
3196
64
  const components = new Map(read.map((r) => [r.comp.name, r.comp]));
3197
- ALL = components;
65
+ ctx.components = components;
3198
66
  // Whether a component reads a store or the route is known once its setup is read; both reach
3199
67
  // every component on the way down to one that does, and the route every one on the way down to
3200
68
  // a `<RouterLink>`.
3201
- for (const r of read)
3202
- scopeFor(r.comp, r.ast, components);
69
+ const children = read.map((r) => scopeFor(r.comp, r.ast, components).scope.children);
70
+ // Which roots may be handed scope ids, and which slot content given a slot scope id.
71
+ scopeFlow(read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: children[i] })));
3203
72
  for (const c of components.values())
3204
73
  c.usesRoute = c.routerLink || c.readsRoute;
3205
74
  for (let changed = true; changed;) {
@@ -3210,6 +79,26 @@ export function generate(root, config = loadConfig(root)) {
3210
79
  }
3211
80
  }
3212
81
  }
82
+ for (const c of components.values())
83
+ c.usesI18n = c.readsI18n;
84
+ for (let changed = true; changed;) {
85
+ changed = false;
86
+ for (const c of components.values()) {
87
+ if (!c.usesI18n && [...c.imports].some((i) => components.get(i)?.usesI18n)) {
88
+ c.usesI18n = changed = true;
89
+ }
90
+ }
91
+ }
92
+ for (const c of components.values())
93
+ c.usesTeleports = c.readsTeleports;
94
+ for (let changed = true; changed;) {
95
+ changed = false;
96
+ for (const c of components.values()) {
97
+ if (!c.usesTeleports && [...c.imports].some((i) => components.get(i)?.usesTeleports)) {
98
+ c.usesTeleports = changed = true;
99
+ }
100
+ }
101
+ }
3213
102
  for (const c of components.values())
3214
103
  c.usesStores = c.readsStores;
3215
104
  for (let changed = true; changed;) {
@@ -3238,25 +127,45 @@ export function generate(root, config = loadConfig(root)) {
3238
127
  read.forEach(place);
3239
128
  for (const r of ordered) {
3240
129
  // Numbered per component, so a file's text depends on its own source alone.
3241
- narrowCount = 0;
130
+ ctx.narrowCount = 0;
3242
131
  out.set(`${r.comp.module}.rs`, componentSource(r.comp, r.ast, r.ssr, components));
3243
132
  }
3244
133
  out.set("mod.rs", modSource(read.map((r) => r.comp)));
3245
- if (ROUTES)
3246
- out.set("route_table.rs", routesSource(ROUTES, router.routes));
3247
- if (STORES.size)
134
+ if (ctx.routes)
135
+ out.set("route_table.rs", routesSource(ctx.routes, router.routes));
136
+ if (ctx.stores.size)
3248
137
  out.set("stores.rs", storesSource(config.stores.replace(/\/?$/, "/")));
3249
- if (TYPE_STRUCTS.size)
138
+ if (ctx.typeStructs.size)
3250
139
  out.set("types.rs", typesSource());
140
+ if (ctx.i18n)
141
+ out.set("i18n.rs", i18nSource(ctx.i18n));
3251
142
  return out;
3252
143
  }
3253
- /** Write what `generate` produces to the configured directory, replacing what is there. */
144
+ /** Write what \`generate\` produces to the configured directory: only the files whose text changed,
145
+ * so a Rust build that watches them rebuilds no more than it must, and removing what no component
146
+ * produces any more. */
3254
147
  export function write(root, config = loadConfig(root)) {
3255
148
  const files = generate(root, config);
3256
149
  const target = join(root, config.out);
3257
- rmSync(target, { recursive: true, force: true });
3258
150
  mkdirSync(target, { recursive: true });
3259
- for (const [name, text] of files)
3260
- writeFileSync(join(target, name), text);
3261
- return [...files.keys()];
151
+ const changed = [];
152
+ for (const [name, text] of files) {
153
+ let old = null;
154
+ try {
155
+ old = readFileSync(join(target, name), "utf8");
156
+ }
157
+ catch {
158
+ // A new file.
159
+ }
160
+ if (old !== text) {
161
+ writeFileSync(join(target, name), text);
162
+ changed.push(name);
163
+ }
164
+ }
165
+ const removed = readdirSync(target).filter((f) => f.endsWith(".rs") && !files.has(f));
166
+ for (const f of removed)
167
+ rmSync(join(target, f));
168
+ return { files: [...files.keys()], changed, removed };
3262
169
  }
170
+ export { CONFIG_FILE, loadConfig, TYPES_MODULE } from "./context.js";
171
+ export { GenError } from "./model.js";