ferrovue 0.3.0 → 0.5.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.
Files changed (106) hide show
  1. package/README.md +225 -42
  2. package/dist/attrs.d.ts +3 -53
  3. package/dist/attrs.js +41 -469
  4. package/dist/calls.d.ts +4 -0
  5. package/dist/calls.js +221 -0
  6. package/dist/children.d.ts +16 -0
  7. package/dist/children.js +286 -0
  8. package/dist/classes.d.ts +13 -0
  9. package/dist/classes.js +176 -0
  10. package/dist/cli.js +31 -22
  11. package/dist/client.d.ts +35 -2
  12. package/dist/client.js +77 -14
  13. package/dist/compiler.d.ts +1 -1
  14. package/dist/compiler.js +31 -92
  15. package/dist/component.d.ts +3 -1
  16. package/dist/component.js +123 -104
  17. package/dist/constants.d.ts +42 -0
  18. package/dist/constants.js +292 -0
  19. package/dist/context.d.ts +29 -67
  20. package/dist/context.js +13 -38
  21. package/dist/diagnostics.d.ts +23 -0
  22. package/dist/diagnostics.js +30 -0
  23. package/dist/diff.d.ts +0 -3
  24. package/dist/diff.js +0 -9
  25. package/dist/emitter.d.ts +0 -8
  26. package/dist/emitter.js +0 -12
  27. package/dist/errors.d.ts +867 -0
  28. package/dist/errors.js +330 -0
  29. package/dist/expr.d.ts +1 -70
  30. package/dist/expr.js +142 -934
  31. package/dist/fallthrough.d.ts +9 -0
  32. package/dist/fallthrough.js +78 -0
  33. package/dist/fixture.d.ts +4 -10
  34. package/dist/fixture.js +38 -27
  35. package/dist/hydration.d.ts +15 -0
  36. package/dist/hydration.js +80 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.js +2 -0
  39. package/dist/islands.d.ts +1 -0
  40. package/dist/islands.js +1 -0
  41. package/dist/link-router.d.ts +24 -0
  42. package/dist/link-router.js +36 -0
  43. package/dist/lists.d.ts +0 -11
  44. package/dist/lists.js +34 -60
  45. package/dist/loops.d.ts +3 -0
  46. package/dist/loops.js +168 -0
  47. package/dist/model.d.ts +46 -144
  48. package/dist/model.js +150 -18
  49. package/dist/narrowing.d.ts +27 -0
  50. package/dist/narrowing.js +157 -0
  51. package/dist/numbers.d.ts +8 -0
  52. package/dist/numbers.js +73 -0
  53. package/dist/parens.d.ts +0 -27
  54. package/dist/parens.js +0 -44
  55. package/dist/plugin.d.ts +84 -0
  56. package/dist/plugin.js +26 -0
  57. package/dist/plugins/client-only.d.ts +8 -0
  58. package/dist/plugins/client-only.js +107 -0
  59. package/dist/plugins/i18n.d.ts +23 -0
  60. package/dist/{i18n.js → plugins/i18n.js} +88 -31
  61. package/dist/plugins/index.d.ts +2 -0
  62. package/dist/plugins/index.js +10 -0
  63. package/dist/plugins/router-link.d.ts +8 -0
  64. package/dist/{router.js → plugins/router-link.js} +52 -91
  65. package/dist/plugins/router.d.ts +42 -0
  66. package/dist/plugins/router.js +256 -0
  67. package/dist/plugins/scoped.d.ts +12 -0
  68. package/dist/plugins/scoped.js +125 -0
  69. package/dist/plugins/shared-types.d.ts +2 -0
  70. package/dist/plugins/shared-types.js +7 -0
  71. package/dist/plugins/stores.d.ts +37 -0
  72. package/dist/plugins/stores.js +379 -0
  73. package/dist/plugins/teleport.d.ts +5 -0
  74. package/dist/plugins/teleport.js +55 -0
  75. package/dist/plugins/twins.d.ts +10 -0
  76. package/dist/plugins/twins.js +164 -0
  77. package/dist/routes.d.ts +10 -0
  78. package/dist/routes.js +6 -0
  79. package/dist/rust.d.ts +1 -7
  80. package/dist/rust.js +56 -174
  81. package/dist/script.d.ts +3 -6
  82. package/dist/script.js +127 -140
  83. package/dist/settle.d.ts +6 -0
  84. package/dist/settle.js +34 -0
  85. package/dist/slots.d.ts +13 -0
  86. package/dist/slots.js +160 -0
  87. package/dist/strings.d.ts +13 -0
  88. package/dist/strings.js +208 -0
  89. package/dist/styles.d.ts +14 -0
  90. package/dist/styles.js +181 -0
  91. package/dist/template.d.ts +1 -42
  92. package/dist/template.js +25 -749
  93. package/dist/testing.d.ts +2 -0
  94. package/dist/testing.js +4 -10
  95. package/dist/types.d.ts +2 -0
  96. package/dist/typescript.d.ts +2 -14
  97. package/dist/typescript.js +53 -59
  98. package/dist/vite.d.ts +2 -1
  99. package/dist/vite.js +11 -32
  100. package/package.json +15 -6
  101. package/dist/i18n.d.ts +0 -27
  102. package/dist/router.d.ts +0 -17
  103. package/dist/scoped.d.ts +0 -24
  104. package/dist/scoped.js +0 -177
  105. package/dist/stores.d.ts +0 -6
  106. package/dist/stores.js +0 -229
package/dist/compiler.js CHANGED
@@ -1,56 +1,32 @@
1
- /* ferrovue's compiler: `.vue` → Rust render functions.
2
- *
3
- * The input is not the template. It is what `@vue/compiler-sfc` compiles the template to for
4
- * server rendering — straight-line `_push` calls, `if`, `_ssrRenderList`, and a handful of
5
- * `@vue/server-renderer` helpers — so element structure, attribute order, whitespace and the
6
- * fragment markers are decided by Vue, and this file only has to translate a small, closed
7
- * vocabulary of JavaScript into Rust that writes the same bytes. The generated code calls the
8
- * `ferrovue` crate for everything Vue's runtime does.
9
- *
10
- * Anything outside that vocabulary is an error naming the component and the construct, never a
11
- * best guess: a guess that is wrong by one byte is a hydration mismatch in a browser, found by a
12
- * reader.
13
- *
14
- * A project describes itself in `ferrovue.config.json` at its root — see `Config` — and runs the
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
- *
18
- * This module is the public API; the translation lives in the modules beside it. */
19
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
- import { join, resolve } from "node:path";
2
+ import { basename, join } from "node:path";
21
3
  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";
4
+ import { importsOf, readComponent } from "./component.js";
26
5
  import { scopeFor } from "./script.js";
27
- import { attrsFlow, scopeFlow } from "./scoped.js";
28
- import { componentSource, isIsland, modSource, routesSource, storesSource, typesSource } from "./rust.js";
6
+ import { attrsFlow } from "./fallthrough.js";
7
+ import { componentSource, isIsland, modSource } from "./rust.js";
8
+ import { renderParams } from "./plugin.js";
9
+ import { PLUGINS } from "./plugins/index.js";
29
10
  /** Every generated file, keyed by its name in the output directory. */
30
11
  export function generate(root, config = loadConfig(root)) {
31
12
  ctx.componentsDir = config.components.replace(/\/?$/, "/");
32
13
  ctx.helperModule = config.helpers?.module ?? null;
33
14
  ctx.trustedHtml = config.trustedHtml ?? null;
34
- const router = config.router ?? (config.routes ? { routes: config.routes } : null);
35
- ctx.routes = router ? readRoutes(root, router.routes) : null;
36
- ctx.routerBase = router?.base ?? "";
15
+ ctx.builders = config.builders ?? true;
37
16
  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
17
  ctx.rootDir = root;
42
- ctx.scopeId = config.scopeId ?? "filepath-source";
43
- ctx.viteRoot = resolve(root, config.viteRoot ?? ".");
44
18
  ctx.typeStructs = new Map();
45
19
  ctx.typeAliases = new Map();
46
20
  ctx.typeFiles = new Map();
47
21
  ctx.typeRead = new Set();
48
- if (config.stores)
49
- readStores(root, config.stores);
50
- else {
51
- ctx.stores = new Map();
52
- ctx.storeStructs = new Map();
53
- }
22
+ ctx.constDecls = new Map();
23
+ ctx.typeConsts = new Map();
24
+ ctx.plugins = PLUGINS;
25
+ ctx.runs = new Map();
26
+ for (const p of PLUGINS)
27
+ ctx.runs.set(p, p.configure?.(config, root));
28
+ for (const p of PLUGINS)
29
+ p.prepare?.(root);
54
30
  ctx.helpers = Object.fromEntries(Object.entries(config.helpers?.functions ?? {}).map(([name, h]) => [
55
31
  name,
56
32
  { rust: h.rust, params: h.params.map(tyOfName), ret: tyOfName(h.returns), maxLen: h.maxLen ?? 0 },
@@ -60,59 +36,29 @@ export function generate(root, config = loadConfig(root)) {
60
36
  .filter((f) => f.endsWith(".vue"))
61
37
  .toSorted()
62
38
  .map((f) => join(dir, f));
63
- const read = files.map((f) => readComponent(f, root));
39
+ const children = new Set(files.flatMap((f) => importsOf(readFileSync(f, "utf8")).filter((c) => c !== basename(f, ".vue"))));
40
+ const read = files.map((f) => readComponent(f, root, children.has(basename(f, ".vue"))));
64
41
  const components = new Map(read.map((r) => [r.comp.name, r.comp]));
65
42
  ctx.components = components;
66
- // Whether a component reads a store or the route is known once its setup is read; both reach
67
- // every component on the way down to one that does, and the route every one on the way down to
68
- // a `<RouterLink>`.
69
43
  const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
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: scopes[i].children })));
72
- // Which components may be passed attributes beyond their props.
73
- attrsFlow(read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: scopes[i].children, attrsBindings: scopes[i].attrsBindings })));
74
- for (const c of components.values())
75
- c.usesRoute = c.routerLink || c.readsRoute;
76
- for (let changed = true; changed;) {
77
- changed = false;
78
- for (const c of components.values()) {
79
- if (!c.usesRoute && [...c.imports].some((i) => components.get(i)?.usesRoute)) {
80
- c.usesRoute = changed = true;
81
- }
82
- }
83
- }
84
- for (const c of components.values())
85
- c.usesI18n = c.readsI18n;
86
- for (let changed = true; changed;) {
87
- changed = false;
88
- for (const c of components.values()) {
89
- if (!c.usesI18n && [...c.imports].some((i) => components.get(i)?.usesI18n)) {
90
- c.usesI18n = changed = true;
91
- }
92
- }
93
- }
94
- for (const c of components.values())
95
- c.usesTeleports = c.readsTeleports;
96
- for (let changed = true; changed;) {
97
- changed = false;
98
- for (const c of components.values()) {
99
- if (!c.usesTeleports && [...c.imports].some((i) => components.get(i)?.usesTeleports)) {
100
- c.usesTeleports = changed = true;
101
- }
102
- }
103
- }
44
+ const all = read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
45
+ for (const p of PLUGINS)
46
+ p.analyse?.(all);
47
+ attrsFlow(all);
104
48
  for (const c of components.values())
105
- c.usesStores = c.readsStores;
49
+ c.takes = new Set(renderParams().filter((p) => p.reads(c)).map((p) => p.name));
106
50
  for (let changed = true; changed;) {
107
51
  changed = false;
108
52
  for (const c of components.values()) {
109
- if (!c.usesStores && [...c.imports].some((i) => components.get(i)?.usesStores)) {
110
- c.usesStores = changed = true;
53
+ for (const name of [...c.imports].flatMap((i) => [...(components.get(i)?.takes ?? [])])) {
54
+ if (!c.takes.has(name)) {
55
+ c.takes.add(name);
56
+ changed = true;
57
+ }
111
58
  }
112
59
  }
113
60
  }
114
61
  const out = new Map();
115
- // A child before its parents, which read the props its scoped slots pass.
116
62
  const ordered = [];
117
63
  const placed = new Set();
118
64
  const place = (r) => {
@@ -128,19 +74,13 @@ export function generate(root, config = loadConfig(root)) {
128
74
  };
129
75
  read.forEach(place);
130
76
  for (const r of ordered) {
131
- // Numbered per component, so a file's text depends on its own source alone.
132
77
  ctx.narrowCount = 0;
133
78
  out.set(`${r.comp.module}.rs`, componentSource(r.comp, r.ast, r.ssr, components));
134
79
  }
135
- out.set("mod.rs", modSource(read.map((r) => r.comp)));
136
- if (ctx.routes)
137
- out.set("route_table.rs", routesSource(ctx.routes, router.routes));
138
- if (ctx.stores.size)
139
- out.set("stores.rs", storesSource(config.stores.replace(/\/?$/, "/")));
140
- if (ctx.typeStructs.size)
141
- out.set("types.rs", typesSource());
142
- if (ctx.i18n)
143
- out.set("i18n.rs", i18nSource(ctx.i18n));
80
+ const modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
81
+ out.set("mod.rs", modSource(read.map((r) => r.comp), modules.map(([file]) => file)));
82
+ for (const [file, text] of modules)
83
+ out.set(file, text);
144
84
  return out;
145
85
  }
146
86
  /** Write what \`generate\` produces to the configured directory: only the files whose text changed,
@@ -157,7 +97,6 @@ export function write(root, config = loadConfig(root)) {
157
97
  old = readFileSync(join(target, name), "utf8");
158
98
  }
159
99
  catch {
160
- // A new file.
161
100
  }
162
101
  if (old !== text) {
163
102
  writeFileSync(join(target, name), text);
@@ -1,5 +1,7 @@
1
1
  import { type Component, type N } from "./model.ts";
2
- export declare function readComponent(file: string, root: string): {
2
+ /** The `.vue` files a component imports, by name. */
3
+ export declare function importsOf(source: string): string[];
4
+ export declare function readComponent(file: string, root: string, isChild: boolean): {
3
5
  comp: Component;
4
6
  ast: N[];
5
7
  ssr: string;
package/dist/component.js CHANGED
@@ -1,14 +1,13 @@
1
- /* One `.vue` file read: its props, models, imported types, and Vue's SSR compilation of its template. */
1
+ import { parse as parseJs } from "@babel/parser";
2
2
  import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
3
- import { createHash } from "node:crypto";
4
3
  import { readFileSync } from "node:fs";
5
4
  import { SourceMapConsumer } from "source-map-js";
6
- import { basename, relative, resolve, sep } from "node:path";
7
- import { fail, opt, snake, tagAst } from "./model.js";
5
+ import { basename, relative } from "node:path";
6
+ import { absence, blankComponent, fail, GenError, opt, snake, sourceAt, tagAst } from "./model.js";
8
7
  import { ctx } from "./context.js";
9
- import { typesImports, declareTypes, defaultValue, definePropsType, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
10
- /** A Vue compiler error's position as a node \`fail\` can point at: its line and 1-based column,
11
- * offset by where the template starts when the error is in the template's content. */
8
+ import { typesImports, declareTypes, defaultValue, definePropsType, ONE_NOTHING, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
9
+ import { claim } from "./plugin.js";
10
+ import { asyncChildren } from "./script.js";
12
11
  function vueErrorNode(err, within) {
13
12
  const loc = err.loc;
14
13
  if (!loc)
@@ -17,17 +16,6 @@ function vueErrorNode(err, within) {
17
16
  const at = !within ? { line, column: column - 1 } : line === 1 ? { line: within.line, column: within.column - 1 + column - 1 } : { line: within.line + line - 1, column: column - 1 };
18
17
  return { type: "VueError", loc: { start: at }, __fv: "source" };
19
18
  }
20
- /** `getHash` in `@vitejs/plugin-vue`: the first 8 hex digits of the SHA-256 of the file's path from
21
- * Vite's root, with `/` between its parts, followed in `"filepath-source"` mode by its source. */
22
- function scopeHash(file, source) {
23
- const path = relative(ctx.viteRoot, resolve(file)).split(sep).join("/");
24
- return createHash("sha256")
25
- .update(ctx.scopeId === "filepath" ? path : path + source)
26
- .digest("hex")
27
- .slice(0, 8);
28
- }
29
- /** `inheritAttrs` as `defineOptions({ inheritAttrs })` or a plain `<script>`'s `export default`
30
- * sets it: a literal, since it decides what reaches the root. */
31
19
  function inheritAttrs(comp, statements) {
32
20
  let found = true;
33
21
  for (const st of statements) {
@@ -43,73 +31,117 @@ function inheritAttrs(comp, statements) {
43
31
  if (p.type !== "ObjectProperty" || p.computed || (p.key.name ?? p.key.value) !== "inheritAttrs")
44
32
  continue;
45
33
  if (p.value.type !== "BooleanLiteral")
46
- fail(comp, "`inheritAttrs` is `true` or `false`", p.value);
34
+ fail(comp, "FV0101", "`inheritAttrs` is `true` or `false`", p.value);
47
35
  found = p.value.value;
48
36
  }
49
37
  }
50
38
  return found;
51
39
  }
52
- export function readComponent(file, root) {
40
+ function sourceDefault(ast, key) {
41
+ const named = (p) => (p.key?.name ?? p.key?.value) === key;
42
+ for (const st of ast) {
43
+ for (const d of st.type === "VariableDeclaration" ? st.declarations : []) {
44
+ if (d.id.type !== "ObjectPattern" || !definePropsType(d.init))
45
+ continue;
46
+ const p = d.id.properties.find((q) => q.type === "ObjectProperty" && named(q));
47
+ if (p?.value.type === "AssignmentPattern")
48
+ return p.value.right;
49
+ }
50
+ const call = st.type === "ExpressionStatement" ? st.expression : st.type === "VariableDeclaration" ? st.declarations[0]?.init : null;
51
+ if (call?.type !== "CallExpression" || call.callee.name !== "withDefaults" || call.arguments[1]?.type !== "ObjectExpression")
52
+ continue;
53
+ const p = call.arguments[1].properties.find((q) => q.type === "ObjectProperty" && named(q));
54
+ if (p)
55
+ return p.value;
56
+ }
57
+ return null;
58
+ }
59
+ function blockAt(source, block) {
60
+ return sourceAt(source, source.lastIndexOf(`<${block.type}`, block.loc.start.offset));
61
+ }
62
+ /** The `.vue` files a component imports, by name. */
63
+ export function importsOf(source) {
64
+ return [...source.matchAll(/\bfrom\s*["'](?:[^"']*\/)?([^"'/]+)\.vue["']/g)].map((m) => m[1]);
65
+ }
66
+ function generics(comp, source, block) {
67
+ const text = block.attrs.generic;
68
+ if (typeof text !== "string")
69
+ return;
70
+ const open = source.lastIndexOf("<script", block.loc.start.offset);
71
+ const attr = source.slice(open, block.loc.start.offset).search(/\bgeneric\s*=\s*["']/);
72
+ const valueAt = open + attr + /^generic\s*=\s*["']/.exec(source.slice(open + attr))[0].length;
73
+ const at = sourceAt(source, valueAt).loc.start;
74
+ const prefix = "type T<";
75
+ let params;
76
+ try {
77
+ params = parseJs(`${prefix}${text}> = 0;`, { sourceType: "module", plugins: ["typescript"] }).program.body[0].typeParameters.params;
78
+ }
79
+ catch {
80
+ return fail(comp, "FV0301", "`generic` is a list of type parameters, as `T extends string`", sourceAt(source, valueAt));
81
+ }
82
+ const rebase = (n) => {
83
+ if (!n || typeof n !== "object")
84
+ return;
85
+ if (Array.isArray(n))
86
+ return n.forEach(rebase);
87
+ const start = n.loc?.start;
88
+ if (start && typeof n.type === "string") {
89
+ n.loc = { ...n.loc, start: start.line === 1 ? { line: at.line, column: at.column + start.column - prefix.length } : { line: at.line + start.line - 1, column: start.column } };
90
+ }
91
+ for (const [k, v] of Object.entries(n))
92
+ if (k !== "loc" && v && typeof v === "object")
93
+ rebase(v);
94
+ };
95
+ rebase(params);
96
+ tagAst(params, "source");
97
+ for (const p of params) {
98
+ const name = typeof p.name === "string" ? p.name : p.name.name;
99
+ if (!p.constraint)
100
+ fail(comp, "FV0302", `\`${name}\` in \`generic\` has no constraint: the server renders \`${name}\` as the type it extends, \`${name} extends string\``, p);
101
+ comp.aliases.set(name, p.constraint);
102
+ }
103
+ }
104
+ function scriptOf(comp, descriptor) {
105
+ try {
106
+ return compileScript(descriptor, { id: comp.name });
107
+ }
108
+ catch (e) {
109
+ if (!(e instanceof SyntaxError))
110
+ throw e;
111
+ const at = e.loc;
112
+ const block = descriptor.scriptSetup.loc.start;
113
+ const where = !at || descriptor.script ? {} : at.line === 1 ? { line: block.line, column: block.column + at.column } : { line: block.line + at.line - 1, column: at.column + 1 };
114
+ throw new GenError("FV0001", e.message, { file: comp.file, ...where }, e.message.split("\n")[0]);
115
+ }
116
+ }
117
+ export function readComponent(file, root, isChild) {
53
118
  const name = basename(file, ".vue");
54
119
  const source = readFileSync(file, "utf8");
55
120
  const { descriptor, errors } = parseSfc(source, { filename: file });
56
121
  const rel = relative(root, file);
57
- const comp = {
58
- name,
59
- module: snake(name),
60
- file: rel,
61
- props: { name: "Props", fields: [] },
62
- structs: new Map(),
63
- trustedName: null,
64
- floatName: null,
65
- childProps: new Map(),
66
- imports: new Set(),
67
- slotNames: [],
68
- routerView: false,
69
- routerLink: false,
70
- readsRoute: false,
71
- usesRoute: false,
72
- readsStores: false,
73
- usesStores: false,
74
- readsI18n: false,
75
- usesI18n: false,
76
- readsTeleports: false,
77
- usesTeleports: false,
78
- models: new Map(),
79
- aliases: new Map(),
80
- importedTypes: new Map(),
81
- slotShapes: new Map(),
82
- scopeId: null,
83
- slotted: false,
84
- inheritAttrs: true,
85
- inherits: false,
86
- passesSlotIds: false,
87
- attrNames: new Set(),
88
- idsInAttrs: false,
89
- };
122
+ const comp = blankComponent(name, snake(name), rel);
90
123
  comp.source = source;
91
124
  if (errors.length)
92
- fail(comp, String(errors[0]), vueErrorNode(errors[0]));
93
- if (!descriptor.scriptSetup || !descriptor.template) {
94
- fail(comp, "an island needs `<script setup lang=\"ts\">` and a `<template>`");
125
+ fail(comp, "FV0002", String(errors[0]), vueErrorNode(errors[0]));
126
+ if (descriptor.script && !descriptor.scriptSetup) {
127
+ const needs = isChild ? "a child component must have" : "an island needs";
128
+ fail(comp, "FV0003", `${needs} \`<script setup lang="ts">\`, or no script at all: a \`<script>\` without \`setup\` (the Options API, \`defineComponent\`) is not translated`, blockAt(source, descriptor.script));
95
129
  }
96
- // A global `<style>` block changes no markup. A scoped one adds `data-v-…` attributes, whose id is
97
- // computed here as the bundler computes it. A CSS module renames classes, and `v-bind()` in CSS
98
- // writes variables onto the root, neither of which the server reproduces.
130
+ if (!descriptor.template)
131
+ fail(comp, "FV0004", "a component needs a `<template>`: a render function is not translated", sourceAt(source, 0));
99
132
  for (const st of descriptor.styles) {
100
133
  if (st.module)
101
- fail(comp, "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet");
134
+ fail(comp, "FV1005", "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet", blockAt(source, st));
102
135
  }
136
+ const cssVar = descriptor.styles.find((st) => /\bv-bind\s*\(/.test(st.content));
103
137
  if (descriptor.cssVars.length)
104
- fail(comp, "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead");
105
- if (descriptor.styles.some((st) => st.scoped))
106
- comp.scopeId = `data-v-${scopeHash(file, source)}`;
107
- comp.slotted = descriptor.slotted;
108
- const script = compileScript(descriptor, { id: name });
109
- const ast = script.scriptSetupAst ?? [];
138
+ fail(comp, "FV1006", "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead", cssVar ? blockAt(source, cssVar) : undefined);
139
+ for (const p of ctx.plugins)
140
+ p.sfc?.(comp, descriptor, file, source);
141
+ const script = descriptor.scriptSetup ? scriptOf(comp, descriptor) : null;
142
+ const ast = script?.scriptSetupAst ?? [];
110
143
  tagAst(ast, "source");
111
- // A plain `<script>` beside the setup one may declare the types the setup block uses.
112
- const plainAst = script.scriptAst ?? [];
144
+ const plainAst = script?.scriptAst ?? [];
113
145
  tagAst(plainAst, "source");
114
146
  for (const s of [...plainAst, ...ast]) {
115
147
  if (s.type !== "ImportDeclaration" || !s.source.value.endsWith(".vue"))
@@ -123,9 +155,10 @@ export function readComponent(file, root) {
123
155
  }
124
156
  }
125
157
  }
158
+ for (const loaded of asyncChildren(comp, ast).values())
159
+ comp.imports.add(loaded);
126
160
  typesImports(comp, [...plainAst, ...ast]);
127
161
  comp.inheritAttrs = inheritAttrs(comp, [...plainAst, ...ast]);
128
- // Types imported from elsewhere: a store's file, a shared `.ts` file, or another component.
129
162
  for (const st of [...plainAst, ...ast]) {
130
163
  if (st.type !== "ImportDeclaration")
131
164
  continue;
@@ -143,12 +176,8 @@ export function readComponent(file, root) {
143
176
  const typeFile = resolveImport(comp.file, from);
144
177
  if (!typeFile)
145
178
  continue;
146
- const module = typeFile.replace(/\.ts$/, "");
147
- if ([...ctx.stores.values()].some((x) => x.module === module)) {
148
- if (ctx.storeStructs.has(typeName))
149
- comp.importedTypes.set(sp.local.name, { k: "struct", name: typeName, store: true });
179
+ if (ctx.plugins.some((p) => p.importedType?.(comp, typeFile, typeName, sp.local.name)))
150
180
  continue;
151
- }
152
181
  if (ctx.helperModule !== null && from === ctx.helperModule && !isType)
153
182
  continue;
154
183
  readTypeFile(typeFile);
@@ -158,8 +187,8 @@ export function readComponent(file, root) {
158
187
  comp.aliases.set(sp.local.name, ctx.typeAliases.get(typeName));
159
188
  }
160
189
  }
161
- // Interfaces in two passes: every name first, so one may name another declared after it, or
162
- // itself — a tree node's children are tree nodes.
190
+ if (descriptor.scriptSetup)
191
+ generics(comp, source, descriptor.scriptSetup);
163
192
  const decls = declareTypes(comp, [...plainAst, ...ast], comp.structs, comp.aliases);
164
193
  for (const d of decls)
165
194
  comp.structs.set(d.name, structOf(comp, d.name, d.members, comp.structs));
@@ -168,15 +197,13 @@ export function readComponent(file, root) {
168
197
  const call = s.type === "ExpressionStatement" ? s.expression : s.type === "VariableDeclaration" ? s.declarations[0]?.init : null;
169
198
  const inner = call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "withDefaults" ? call.arguments[0] : call;
170
199
  if (inner?.type === "CallExpression" && inner.callee.type === "Identifier" && inner.callee.name === "defineProps" && !inner.typeParameters) {
171
- fail(comp, "props are declared with a type, `defineProps<{ ... }>()`: a runtime declaration has no Rust type", inner);
200
+ fail(comp, "FV0303", "props are declared with a type, `defineProps<{ ... }>()`: a runtime declaration has no Rust type", inner);
172
201
  }
173
202
  const found = definePropsType(call);
174
203
  if (found)
175
204
  propsTy = found;
176
205
  }
177
- // A component without `defineProps` takes no props.
178
206
  if (!propsTy) {
179
- // nothing to read
180
207
  }
181
208
  else if (propsTy.type === "TSTypeLiteral") {
182
209
  comp.props = structOf(comp, "Props", propsTy.members, comp.structs);
@@ -185,9 +212,8 @@ export function readComponent(file, root) {
185
212
  comp.props = { name: "Props", fields: comp.structs.get(propsTy.typeName.name).fields };
186
213
  }
187
214
  else {
188
- fail(comp, "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
215
+ fail(comp, "FV0304", "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
189
216
  }
190
- // `defineModel`: a prop of its own (`modelValue` unless named), which the binding reads.
191
217
  for (const st of ast) {
192
218
  if (st.type !== "VariableDeclaration")
193
219
  continue;
@@ -197,41 +223,40 @@ export function readComponent(file, root) {
197
223
  continue;
198
224
  const t = call.typeParameters?.params?.[0];
199
225
  if (!t)
200
- fail(comp, "`defineModel` needs its type: `defineModel<string>()`", call);
226
+ fail(comp, "FV0305", "`defineModel` needs its type: `defineModel<string>()`", call);
201
227
  const named = call.arguments[0]?.type === "StringLiteral" ? call.arguments[0].value : "modelValue";
202
228
  const options = call.arguments.find((a) => a.type === "ObjectExpression");
203
229
  const required = options?.properties.some((p) => p.type === "ObjectProperty" && (p.key.name ?? p.key.value) === "required" && p.value.type === "BooleanLiteral" && p.value.value);
204
230
  const base = tyOfTs(comp, t, comp.structs);
231
+ if (!required && absence(base) === "null")
232
+ fail(comp, "FV0306", `a \`defineModel\` of \`T | null\` that is not \`required\` may be absent, which is \`undefined\`, or \`null\`: ${ONE_NOTHING}`, call);
205
233
  comp.props.fields.push({ js: named, rust: snake(named), ty: required ? base : opt(base) });
206
234
  comp.models.set(d.id.name, named);
207
235
  }
208
236
  }
209
- // What Vue uses for an absent optional prop: the default `compileScript` resolved — from
210
- // `withDefaults`, a destructured default, or `defineModel`'s options — or `false` for a boolean.
211
- const defaults = runtimeDefaults(comp, script.content);
237
+ const defaults = runtimeDefaults(comp, script?.content ?? "");
212
238
  for (const f of comp.props.fields) {
213
239
  if (f.ty.k !== "opt")
214
240
  continue;
215
241
  const node = defaults.get(f.js);
242
+ if (f.ty.none !== undefined) {
243
+ if (node)
244
+ fail(comp, "FV0307", `a default for \`${f.js}\`, which is \`T | null\`: Vue gives it only when \`${f.js}\` is absent, which its Rust type, an \`Option\`, cannot be; fall back in the template with \`??\``, sourceDefault(ast, f.js) ?? node);
245
+ continue;
246
+ }
216
247
  if (node)
217
- f.dflt = defaultValue(comp, f, node);
248
+ f.dflt = defaultValue(comp, f, node, sourceDefault(ast, f.js) ?? undefined);
218
249
  else if (f.ty.of.k === "bool")
219
250
  f.dflt = "false";
220
251
  }
221
252
  const compiled = compileTemplate({
222
253
  source: descriptor.template.content,
223
254
  filename: file,
224
- // As `@vitejs/plugin-vue` compiles it: the scope id written onto every element, and passed to
225
- // slot content only with `:slotted()` styles.
226
- id: comp.scopeId ?? name,
227
- scoped: comp.scopeId !== null,
228
- slotted: comp.slotted,
255
+ ...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false }),
229
256
  ssr: true,
230
257
  ssrCssVars: [],
231
- compilerOptions: { bindingMetadata: script.bindings, sourceMap: true },
258
+ compilerOptions: { ...(script ? { bindingMetadata: script.bindings } : {}), sourceMap: true },
232
259
  });
233
- // `ssrInjectFallthroughAttrs`: a root `<Transition>` or `<KeepAlive>` passes `_attrs` on only to
234
- // a lone element or component without `v-if` or `v-for`.
235
260
  const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));
236
261
  const roots = descriptor.template.ast ? kids(descriptor.template.ast) : [];
237
262
  const wrapper = roots.length === 1 && roots[0].type === 1 && roots[0].tagType === 1 && ["Transition", "transition", "KeepAlive", "keep-alive"].includes(roots[0].tag) ? roots[0] : null;
@@ -241,24 +266,18 @@ export function readComponent(file, root) {
241
266
  if (!lone)
242
267
  comp.attrsDropped = { type: "VueTemplate", loc: { start: { line: wrapper.loc.start.line, column: wrapper.loc.start.column - 1 } }, __fv: "source" };
243
268
  }
269
+ comp.templateAst = descriptor.template.ast;
244
270
  const start = descriptor.template.loc.start;
245
271
  comp.templateStart = { line: start.line, column: start.column };
246
272
  if (compiled.map)
247
273
  comp.templateMap = new SourceMapConsumer(compiled.map);
248
- // A template error's position is within the template's content.
249
274
  if (compiled.errors.length)
250
- fail(comp, String(compiled.errors[0]), vueErrorNode(compiled.errors[0], comp.templateStart));
275
+ fail(comp, "FV0005", String(compiled.errors[0]), vueErrorNode(compiled.errors[0], comp.templateStart));
251
276
  for (const m of compiled.code.matchAll(/_ssrRenderSlot\(_ctx\.\$slots, "([^"]+)"/g)) {
252
277
  if (!comp.slotNames.includes(m[1]))
253
278
  comp.slotNames.push(m[1]);
254
279
  }
255
- // Resolved by name, as globally registered components are, or imported from `vue-router`.
256
- const imported = (exported) => [...plainAst, ...ast].some((st) => st.type === "ImportDeclaration" && st.source.value === "vue-router" &&
257
- st.specifiers.some((sp) => sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === exported));
258
- comp.routerLink = compiled.code.includes('_resolveComponent("RouterLink")') || imported("RouterLink");
259
- comp.routerView = compiled.code.includes('_resolveComponent("RouterView")') || imported("RouterView");
260
- comp.readsRoute = compiled.code.includes("_ctx.$route");
261
- comp.readsI18n = compiled.code.includes("_ctx.$t(");
262
- comp.readsTeleports = compiled.code.includes("_ssrRenderTeleport(");
280
+ for (const p of ctx.plugins)
281
+ p.compiled?.(comp, compiled.code, [...plainAst, ...ast]);
263
282
  return { comp, ast, ssr: compiled.code };
264
283
  }
@@ -0,0 +1,42 @@
1
+ import { type Component, type N, type Scope, type Val } from "./model.ts";
2
+ export type Const = {
3
+ k: "scalar";
4
+ js: Scalar;
5
+ } | {
6
+ k: "list";
7
+ items: Const[];
8
+ node: N;
9
+ home: Component;
10
+ path: string;
11
+ typed: string | null;
12
+ } | {
13
+ k: "object";
14
+ fields: Map<string, Const>;
15
+ node: N;
16
+ home: Component;
17
+ name: string;
18
+ };
19
+ type Scalar = string | number | boolean | null;
20
+ export interface Declared {
21
+ node: N;
22
+ home: Component;
23
+ exported: boolean;
24
+ }
25
+ /** Remember the constants and enums a file declares at its top level, to evaluate when one is used. */
26
+ export declare function declareConsts(home: Component, body: N[]): Map<string, Declared>;
27
+ /** What a declared constant or enum stands for, evaluated once. */
28
+ export declare function constOfDecl(d: Declared): Const;
29
+ /** The type an enum stands for: the union of its members' literal types. */
30
+ export declare function enumType(home: Component, d: N): N;
31
+ /** What a constant is as a value in Rust; an object has none, only its fields. */
32
+ export declare function constVal(c: Const, at: {
33
+ comp: Component;
34
+ node: N;
35
+ what: string;
36
+ }): Val;
37
+ /** The constant an expression names: an imported or enum binding, or a field of one. */
38
+ export declare function constOf(s: Scope, n: N): {
39
+ c: Const;
40
+ what: string;
41
+ } | null;
42
+ export {};