ferrovue 0.4.0 → 0.6.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 (92) hide show
  1. package/README.md +378 -63
  2. package/dist/attrs.d.ts +3 -2
  3. package/dist/attrs.js +31 -26
  4. package/dist/calls.js +17 -15
  5. package/dist/children.d.ts +11 -4
  6. package/dist/children.js +110 -44
  7. package/dist/classes.d.ts +4 -0
  8. package/dist/classes.js +33 -11
  9. package/dist/cli.js +31 -5
  10. package/dist/client.d.ts +44 -6
  11. package/dist/client.js +182 -18
  12. package/dist/compiler.d.ts +1 -1
  13. package/dist/compiler.js +42 -10
  14. package/dist/component.d.ts +3 -1
  15. package/dist/component.js +114 -20
  16. package/dist/conformance.d.ts +64 -0
  17. package/dist/conformance.js +276 -0
  18. package/dist/constants.d.ts +42 -0
  19. package/dist/constants.js +292 -0
  20. package/dist/context.d.ts +40 -7
  21. package/dist/context.js +14 -5
  22. package/dist/diagnostics.d.ts +23 -0
  23. package/dist/diagnostics.js +30 -0
  24. package/dist/dynamic.d.ts +18 -0
  25. package/dist/dynamic.js +576 -0
  26. package/dist/errors.d.ts +1034 -0
  27. package/dist/errors.js +425 -0
  28. package/dist/expr.d.ts +1 -0
  29. package/dist/expr.js +120 -49
  30. package/dist/file-routes.d.ts +33 -0
  31. package/dist/file-routes.js +224 -0
  32. package/dist/fixture.d.ts +19 -10
  33. package/dist/fixture.js +23 -12
  34. package/dist/hydration.d.ts +15 -0
  35. package/dist/hydration.js +80 -0
  36. package/dist/index.d.ts +3 -2
  37. package/dist/index.js +2 -1
  38. package/dist/link-router.d.ts +24 -0
  39. package/dist/link-router.js +37 -0
  40. package/dist/lists.d.ts +4 -0
  41. package/dist/lists.js +32 -23
  42. package/dist/loops.js +18 -14
  43. package/dist/model.d.ts +43 -1
  44. package/dist/model.js +167 -7
  45. package/dist/narrowing.d.ts +8 -0
  46. package/dist/narrowing.js +40 -9
  47. package/dist/numbers.js +3 -2
  48. package/dist/page-routes.d.ts +9 -0
  49. package/dist/page-routes.js +10 -0
  50. package/dist/plugin.d.ts +13 -1
  51. package/dist/plugin.js +3 -0
  52. package/dist/plugins/client-only.d.ts +8 -0
  53. package/dist/plugins/client-only.js +110 -0
  54. package/dist/plugins/head.d.ts +15 -0
  55. package/dist/plugins/head.js +385 -0
  56. package/dist/plugins/i18n.js +14 -14
  57. package/dist/plugins/index.js +5 -1
  58. package/dist/plugins/provide.d.ts +44 -0
  59. package/dist/plugins/provide.js +528 -0
  60. package/dist/plugins/router-link.js +39 -32
  61. package/dist/plugins/router.d.ts +4 -1
  62. package/dist/plugins/router.js +65 -19
  63. package/dist/plugins/scoped.js +1 -1
  64. package/dist/plugins/shared-types.js +1 -1
  65. package/dist/plugins/stores.js +45 -33
  66. package/dist/plugins/teleport.js +3 -3
  67. package/dist/plugins/twins.d.ts +10 -0
  68. package/dist/plugins/twins.js +164 -0
  69. package/dist/routes.d.ts +13 -0
  70. package/dist/routes.js +12 -0
  71. package/dist/rust.js +64 -32
  72. package/dist/script.d.ts +3 -0
  73. package/dist/script.js +138 -20
  74. package/dist/settle.d.ts +8 -0
  75. package/dist/settle.js +39 -0
  76. package/dist/slots.js +36 -22
  77. package/dist/ssr.d.ts +3 -0
  78. package/dist/ssr.js +29 -0
  79. package/dist/strings.d.ts +3 -0
  80. package/dist/strings.js +27 -13
  81. package/dist/styles.js +13 -13
  82. package/dist/template.js +102 -21
  83. package/dist/testing.d.ts +3 -3
  84. package/dist/testing.js +3 -29
  85. package/dist/types.d.ts +5 -0
  86. package/dist/typescript.d.ts +2 -1
  87. package/dist/typescript.js +52 -21
  88. package/dist/vhtml.d.ts +4 -0
  89. package/dist/vhtml.js +63 -0
  90. package/dist/vite.d.ts +8 -2
  91. package/dist/vite.js +75 -14
  92. package/package.json +23 -5
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createSSRApp } from "vue";
1
+ import { createSSRApp, createTextVNode, defineComponent, h, hydrateOnIdle, hydrateOnMediaQuery, hydrateOnVisible, onMounted, ref, } from "vue";
2
2
  const BARE = /"(?:[^"\\]|\\.)*"|-?Infinity|NaN/g;
3
3
  const NON_FINITE = { NaN: Number.NaN, Infinity: Number.POSITIVE_INFINITY, "-Infinity": Number.NEGATIVE_INFINITY };
4
4
  function parseJson(text) {
@@ -11,42 +11,86 @@ function parseJson(text) {
11
11
  const quoted = text.replace(BARE, (match) => (match.startsWith('"') ? match : `"${escaped}${match}"`));
12
12
  return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
13
13
  }
14
- /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
14
+ /** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
15
15
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
16
16
  export function hydrateState(pinia, id = "__pinia", doc = document) {
17
17
  const text = doc.getElementById(id)?.textContent;
18
18
  if (text)
19
19
  pinia.state.value = parseJson(text);
20
20
  }
21
+ const INTERACTIONS = ["pointerenter", "click", "focus"];
22
+ const strategy = (wait) => (el, hydrate) => wait(() => void hydrate(), (each) => void each(el)) ?? (() => { });
23
+ const idle = (el, hydrate) => {
24
+ if (typeof requestIdleCallback === "function")
25
+ return strategy(hydrateOnIdle())(el, hydrate);
26
+ const timer = setTimeout(() => void hydrate(), 200);
27
+ return () => clearTimeout(timer);
28
+ };
29
+ const interaction = (names) => (el, hydrate) => {
30
+ const events = names.length ? names : INTERACTIONS;
31
+ const early = [];
32
+ const stop = () => {
33
+ for (const name of events)
34
+ el.removeEventListener(name, listen, true);
35
+ };
36
+ const replay = () => {
37
+ stop();
38
+ for (const e of early.splice(0)) {
39
+ if (e.target instanceof Node && e.target.isConnected)
40
+ e.target.dispatchEvent(new e.constructor(e.type, e));
41
+ }
42
+ };
43
+ function listen(e) {
44
+ if (early.push(e) === 1)
45
+ void hydrate().then((mounted) => (mounted ? replay() : stop()));
46
+ }
47
+ for (const name of events)
48
+ el.addEventListener(name, listen, true);
49
+ return stop;
50
+ };
51
+ function trigger(when) {
52
+ const colon = when.indexOf(":");
53
+ const [kind, arg] = colon < 0 ? [when, undefined] : [when.slice(0, colon), when.slice(colon + 1)];
54
+ if (kind === "visible")
55
+ return strategy(hydrateOnVisible(arg ? { rootMargin: arg } : undefined));
56
+ if (kind === "idle" && arg === undefined)
57
+ return idle;
58
+ if (kind === "interaction")
59
+ return interaction((arg ?? "").split(/\s+/).filter(Boolean));
60
+ if (kind === "media" && arg !== undefined)
61
+ return strategy(hydrateOnMediaQuery(arg || "all"));
62
+ return undefined;
63
+ }
21
64
  /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
22
65
  * `Html::island` wrote becomes an app of the component of that name, given the props the server
23
66
  * rendered it with, mounted where it is. A loader is called only for an island the page holds, all
24
- * of them at once, and the islands mount in document order once every one has loaded. */
67
+ * of them at once, and the islands mount in document order once every one has loaded. An island
68
+ * with a `data-hydrate` (`Html::hydrate`) waits instead: once it is visible (or within a root margin
69
+ * of the viewport), the browser is idle,
70
+ * it is interacted with or a media query matches, its component is loaded and it hydrates. */
25
71
  export async function mountIslands(components, options = {}) {
26
72
  const root = options.root ?? document;
27
73
  const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
28
74
  const elements = Array.from(root.querySelectorAll("[data-island]"));
29
75
  const loading = new Map();
30
- for (const el of elements) {
31
- const name = el.dataset.island ?? "";
32
- if (Object.hasOwn(components, name) && !loading.has(name))
33
- loading.set(name, load(components[name]));
34
- }
35
- const [loaded] = await Promise.all([
36
- Promise.all([...loading].map(async ([name, pending]) => [name, await pending])).then((pairs) => new Map(pairs)),
37
- options.router?.isReady(),
38
- ]);
76
+ const loadIsland = (name) => {
77
+ let pending = loading.get(name);
78
+ if (!pending)
79
+ loading.set(name, (pending = load(components[name])));
80
+ return pending;
81
+ };
82
+ const ready = options.router?.isReady();
39
83
  const apps = [];
40
- for (const el of elements) {
41
- const name = el.dataset.island ?? "";
42
- const component = loaded.get(name);
84
+ const waiting = [];
85
+ let stopped = false;
86
+ const mount = (el, name, component) => {
43
87
  if (!component) {
44
88
  report(el, `no component called ${JSON.stringify(name)} was given (\`ferrovue/islands\` holds every component that has an \`island()\`)`);
45
- continue;
89
+ return;
46
90
  }
47
91
  if (component instanceof Error) {
48
92
  report(el, `${name} did not load: ${component.message}`);
49
- continue;
93
+ return;
50
94
  }
51
95
  let props;
52
96
  try {
@@ -54,7 +98,7 @@ export async function mountIslands(components, options = {}) {
54
98
  }
55
99
  catch {
56
100
  report(el, `the props of ${name} are not JSON`);
57
- continue;
101
+ return;
58
102
  }
59
103
  const app = createSSRApp(component, props);
60
104
  if (options.pinia)
@@ -65,15 +109,86 @@ export async function mountIslands(components, options = {}) {
65
109
  app.use(plugin);
66
110
  app.mount(el);
67
111
  apps.push(app);
112
+ };
113
+ const now = [];
114
+ for (const el of elements) {
115
+ const name = el.dataset.island ?? "";
116
+ const given = Object.hasOwn(components, name);
117
+ const when = el.dataset.hydrate;
118
+ const wait = given && when !== undefined ? trigger(when) : undefined;
119
+ if (!wait) {
120
+ if (given && when !== undefined)
121
+ report(el, `${name} has data-hydrate=${JSON.stringify(when)}, which this version of ferrovue does not know, so it hydrated at once`);
122
+ if (given)
123
+ void loadIsland(name);
124
+ now.push(el);
125
+ continue;
126
+ }
127
+ let started = false;
128
+ try {
129
+ waiting.push(wait(el, async () => {
130
+ if (started || stopped)
131
+ return false;
132
+ started = true;
133
+ const [component] = await Promise.all([loadIsland(name), ready]);
134
+ if (stopped)
135
+ return false;
136
+ mount(el, name, component);
137
+ return true;
138
+ }));
139
+ }
140
+ catch (e) {
141
+ report(el, `${name} has data-hydrate=${JSON.stringify(when)}, which the browser rejects (${e instanceof Error ? e.message : String(e)}), so it hydrated at once`);
142
+ void loadIsland(name);
143
+ now.push(el);
144
+ }
145
+ }
146
+ const names = new Set(now.map((el) => el.dataset.island ?? "").filter((name) => Object.hasOwn(components, name)));
147
+ const [loaded] = await Promise.all([
148
+ Promise.all([...names].map(async (name) => [name, await loadIsland(name)])).then((pairs) => new Map(pairs)),
149
+ ready,
150
+ ]);
151
+ for (const el of now) {
152
+ const name = el.dataset.island ?? "";
153
+ mount(el, name, loaded.get(name));
68
154
  }
69
155
  return {
70
156
  apps,
71
157
  unmount() {
158
+ stopped = true;
159
+ for (const stop of waiting.splice(0))
160
+ stop();
72
161
  for (const app of apps.splice(0))
73
162
  app.unmount();
74
163
  },
75
164
  };
76
165
  }
166
+ /** Content only the browser renders. The server writes the `#fallback` slot, or nothing; the
167
+ * browser hydrates that fallback, then swaps in the default slot once mounted. ferrovue does not
168
+ * compile the default slot, so anything may go there: a component library's components, code that
169
+ * reads `window`. The swap happens only where Vue runs on the page, as in an island. */
170
+ export const ClientOnly = Object.assign(defineComponent({
171
+ name: "ClientOnly",
172
+ inheritAttrs: false,
173
+ setup(_, { slots }) {
174
+ const mounted = ref(false);
175
+ onMounted(() => {
176
+ mounted.value = true;
177
+ });
178
+ return () => (mounted.value ? slots.default?.() : slots.fallback?.());
179
+ },
180
+ }), {
181
+ ssrRender(ctx, push, parent) {
182
+ const fallback = ctx.$slots.fallback;
183
+ if (!fallback) {
184
+ push("<!---->");
185
+ return;
186
+ }
187
+ push("<!--[-->");
188
+ fallback({}, push, parent, "");
189
+ push("<!--]-->");
190
+ },
191
+ });
77
192
  async function load(component) {
78
193
  if (typeof component !== "function" || "props" in component || "displayName" in component || "__vccOpts" in component)
79
194
  return component;
@@ -85,3 +200,52 @@ async function load(component) {
85
200
  return e instanceof Error ? e : new Error(String(e));
86
201
  }
87
202
  }
203
+ /** The app of a page: its root renders `layout` with the record's props, and each slot as the
204
+ * components the record names, in order, as a plain array, which hydrates the markup the server
205
+ * wrote with no fragment of its own. `components` must hold every component the record names. */
206
+ export function createPageApp(layout, record, components, options = {}) {
207
+ const slots = Object.fromEntries(Object.entries(record.slots).map(([name, parts]) => [
208
+ name,
209
+ () => (parts.length ? parts.map((part) => h(components[part.c], part.p)) : [createTextVNode("")]),
210
+ ]));
211
+ const app = createSSRApp({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
212
+ if (options.pinia)
213
+ app.use(options.pinia);
214
+ if (options.router)
215
+ app.use(options.router);
216
+ for (const plugin of options.plugins ?? [])
217
+ app.use(plugin);
218
+ return app;
219
+ }
220
+ /** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
221
+ * no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
222
+ * as the server rendered it, when the container or the record is missing or a component is not
223
+ * given or does not load. */
224
+ export async function mountPage(layout, components, options = {}) {
225
+ const doc = options.doc ?? document;
226
+ const selector = options.container ?? "#app";
227
+ const container = typeof selector === "string" ? doc.querySelector(selector) : selector;
228
+ if (!container)
229
+ throw new Error(`[ferrovue] the page has no ${selector} to mount on`);
230
+ const id = options.record ?? "__fv_page";
231
+ const text = doc.getElementById(id)?.textContent;
232
+ if (!text)
233
+ throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
234
+ const record = parseJson(text);
235
+ const names = new Set(Object.values(record.slots).flatMap((parts) => parts.map((part) => part.c)));
236
+ const loading = [...names].map(async (name) => {
237
+ if (!Object.hasOwn(components, name))
238
+ throw new Error(`[ferrovue] no component called ${JSON.stringify(name)} was given to mountPage`);
239
+ return [name, await loadOrThrow(name, components[name])];
240
+ });
241
+ const [root, parts] = await Promise.all([loadOrThrow("the layout", layout), Promise.all(loading), options.router?.isReady()]);
242
+ const app = createPageApp(root, record, Object.fromEntries(parts), options);
243
+ app.mount(container);
244
+ return app;
245
+ }
246
+ async function loadOrThrow(name, component) {
247
+ const result = await load(component);
248
+ if (result instanceof Error)
249
+ throw new Error(`[ferrovue] ${name} did not load: ${result.message}`, { cause: result });
250
+ return result;
251
+ }
@@ -18,6 +18,6 @@ export interface Written {
18
18
  * so a Rust build that watches them rebuilds no more than it must, and removing what no component
19
19
  * produces any more. */
20
20
  export declare function write(root: string, config?: Config): Written;
21
- export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type TypeName } from "./context.ts";
21
+ export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type RoutesSource, type TwinSpec, type TypeName } from "./context.ts";
22
22
  export { GenError } from "./model.ts";
23
23
  export declare const VERSION: string;
package/dist/compiler.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join, relative } from "node:path";
3
3
  import { ctx, loadConfig, tyOfName } from "./context.js";
4
- import { readComponent } from "./component.js";
4
+ import { failIn, snake } from "./model.js";
5
+ import { importsOf, readComponent } from "./component.js";
5
6
  import { scopeFor } from "./script.js";
6
7
  import { attrsFlow } from "./fallthrough.js";
8
+ import { prepareDynamic } from "./dynamic.js";
7
9
  import { componentSource, isIsland, modSource } from "./rust.js";
8
10
  import { renderParams } from "./plugin.js";
9
11
  import { PLUGINS } from "./plugins/index.js";
@@ -12,12 +14,16 @@ export function generate(root, config = loadConfig(root)) {
12
14
  ctx.componentsDir = config.components.replace(/\/?$/, "/");
13
15
  ctx.helperModule = config.helpers?.module ?? null;
14
16
  ctx.trustedHtml = config.trustedHtml ?? null;
17
+ ctx.builders = config.builders ?? true;
15
18
  ctx.clientDirectives = new Set(config.clientDirectives ?? []);
16
19
  ctx.rootDir = root;
20
+ ctx.vnodeTag = null;
17
21
  ctx.typeStructs = new Map();
18
22
  ctx.typeAliases = new Map();
19
23
  ctx.typeFiles = new Map();
20
24
  ctx.typeRead = new Set();
25
+ ctx.constDecls = new Map();
26
+ ctx.typeConsts = new Map();
21
27
  ctx.plugins = PLUGINS;
22
28
  ctx.runs = new Map();
23
29
  for (const p of PLUGINS)
@@ -29,15 +35,34 @@ export function generate(root, config = loadConfig(root)) {
29
35
  { rust: h.rust, params: h.params.map(tyOfName), ret: tyOfName(h.returns), maxLen: h.maxLen ?? 0 },
30
36
  ]));
31
37
  const dir = join(root, config.components);
32
- const files = readdirSync(dir)
33
- .filter((f) => f.endsWith(".vue"))
34
- .toSorted()
35
- .map((f) => join(dir, f));
36
- const read = files.map((f) => readComponent(f, root));
38
+ const files = [
39
+ ...readdirSync(dir)
40
+ .filter((f) => f.endsWith(".vue"))
41
+ .toSorted()
42
+ .map((f) => ({ file: join(dir, f), name: basename(f, ".vue") })),
43
+ ...PLUGINS.flatMap((p) => p.components?.() ?? []).map((c) => ({ file: join(root, c.file), name: c.name })),
44
+ ];
45
+ const modulesOf = new Map();
46
+ for (const f of files) {
47
+ const rel = relative(root, f.file);
48
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(f.name)) {
49
+ failIn(rel, "FV0007", `the component is called \`${f.name}\` after its file, which is not a Rust name: name the file in PascalCase, letters and digits (\`UserCard.vue\`)`);
50
+ }
51
+ const file = moduleFile(snake(f.name));
52
+ if (file === "mod.rs")
53
+ failIn(rel, "FV0007", `the component's module would be \`mod.rs\`, which holds the generated modules: rename the file`);
54
+ const taken = modulesOf.get(file);
55
+ if (taken)
56
+ failIn(rel, "FV0007", `the component's module would be \`${file}\`, as ${taken}'s is: rename one of them`);
57
+ modulesOf.set(file, rel);
58
+ }
59
+ const children = new Set(files.flatMap((f) => importsOf(readFileSync(f.file, "utf8")).filter((c) => c !== f.name)));
60
+ const read = files.map((f) => readComponent(f.file, root, children.has(f.name), f.name));
37
61
  const components = new Map(read.map((r) => [r.comp.name, r.comp]));
38
62
  ctx.components = components;
39
63
  const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
40
- const all = read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
64
+ const analysed = prepareDynamic(read.map((r, i) => ({ ...r, scope: scopes[i] })));
65
+ const all = read.map((r, i) => ({ comp: r.comp, ssr: analysed[i], children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
41
66
  for (const p of PLUGINS)
42
67
  p.analyse?.(all);
43
68
  attrsFlow(all);
@@ -71,14 +96,21 @@ export function generate(root, config = loadConfig(root)) {
71
96
  read.forEach(place);
72
97
  for (const r of ordered) {
73
98
  ctx.narrowCount = 0;
74
- out.set(`${r.comp.module}.rs`, componentSource(r.comp, r.ast, r.ssr, components));
99
+ out.set(moduleFile(r.comp.module), componentSource(r.comp, r.ast, r.ssr, components));
75
100
  }
76
101
  const modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
77
102
  out.set("mod.rs", modSource(read.map((r) => r.comp), modules.map(([file]) => file)));
78
- for (const [file, text] of modules)
103
+ for (const [file, text] of modules) {
104
+ const taken = modulesOf.get(file);
105
+ if (taken)
106
+ failIn(taken, "FV0007", `the component's module would be \`${file}\`, which ferrovue writes for the project's ${file.replace(/\.rs$/, "").replaceAll("_", " ")}: rename the file`);
79
107
  out.set(file, text);
108
+ }
80
109
  return out;
81
110
  }
111
+ function moduleFile(module) {
112
+ return `${module.replace(/^r#/, "")}.rs`;
113
+ }
82
114
  /** Write what \`generate\` produces to the configured directory: only the files whose text changed,
83
115
  * so a Rust build that watches them rebuilds no more than it must, and removing what no component
84
116
  * produces any more. */
@@ -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, name?: string): {
3
5
  comp: Component;
4
6
  ast: N[];
5
7
  ssr: string;
package/dist/component.js CHANGED
@@ -1,11 +1,13 @@
1
+ import { parse as parseJs } from "@babel/parser";
1
2
  import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { SourceMapConsumer } from "source-map-js";
4
5
  import { basename, relative } from "node:path";
5
- import { blankComponent, fail, opt, snake, tagAst } from "./model.js";
6
+ import { absence, blankComponent, fail, GenError, opt, snake, sourceAt, tagAst } from "./model.js";
6
7
  import { ctx } from "./context.js";
7
- import { typesImports, declareTypes, defaultValue, definePropsType, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
8
+ import { typesImports, declareTypes, defaultValue, definePropsType, ONE_NOTHING, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
8
9
  import { claim } from "./plugin.js";
10
+ import { asyncChildren } from "./script.js";
9
11
  function vueErrorNode(err, within) {
10
12
  const loc = err.loc;
11
13
  if (!loc)
@@ -29,36 +31,116 @@ function inheritAttrs(comp, statements) {
29
31
  if (p.type !== "ObjectProperty" || p.computed || (p.key.name ?? p.key.value) !== "inheritAttrs")
30
32
  continue;
31
33
  if (p.value.type !== "BooleanLiteral")
32
- fail(comp, "`inheritAttrs` is `true` or `false`", p.value);
34
+ fail(comp, "FV0101", "`inheritAttrs` is `true` or `false`", p.value);
33
35
  found = p.value.value;
34
36
  }
35
37
  }
36
38
  return found;
37
39
  }
38
- export function readComponent(file, root) {
39
- const name = basename(file, ".vue");
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, name = basename(file, ".vue")) {
40
118
  const source = readFileSync(file, "utf8");
41
119
  const { descriptor, errors } = parseSfc(source, { filename: file });
42
120
  const rel = relative(root, file);
43
121
  const comp = blankComponent(name, snake(name), rel);
44
122
  comp.source = source;
45
123
  if (errors.length)
46
- fail(comp, String(errors[0]), vueErrorNode(errors[0]));
47
- if (!descriptor.scriptSetup || !descriptor.template) {
48
- fail(comp, "an island needs `<script setup lang=\"ts\">` and a `<template>`");
124
+ fail(comp, "FV0002", String(errors[0]).replaceAll(file, rel), vueErrorNode(errors[0]) ?? sourceAt(source, 0));
125
+ if (descriptor.script && !descriptor.scriptSetup) {
126
+ const needs = isChild ? "a child component must have" : "an island needs";
127
+ 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));
49
128
  }
129
+ if (!descriptor.template)
130
+ fail(comp, "FV0004", "a component needs a `<template>`: a render function is not translated", sourceAt(source, 0));
50
131
  for (const st of descriptor.styles) {
51
132
  if (st.module)
52
- fail(comp, "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet");
133
+ fail(comp, "FV1005", "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet", blockAt(source, st));
53
134
  }
135
+ const cssVar = descriptor.styles.find((st) => /\bv-bind\s*\(/.test(st.content));
54
136
  if (descriptor.cssVars.length)
55
- fail(comp, "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead");
137
+ fail(comp, "FV1006", "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead", cssVar ? blockAt(source, cssVar) : undefined);
56
138
  for (const p of ctx.plugins)
57
139
  p.sfc?.(comp, descriptor, file, source);
58
- const script = compileScript(descriptor, { id: name });
59
- const ast = script.scriptSetupAst ?? [];
140
+ const script = descriptor.scriptSetup ? scriptOf(comp, descriptor) : null;
141
+ const ast = script?.scriptSetupAst ?? [];
60
142
  tagAst(ast, "source");
61
- const plainAst = script.scriptAst ?? [];
143
+ const plainAst = script?.scriptAst ?? [];
62
144
  tagAst(plainAst, "source");
63
145
  for (const s of [...plainAst, ...ast]) {
64
146
  if (s.type !== "ImportDeclaration" || !s.source.value.endsWith(".vue"))
@@ -72,6 +154,8 @@ export function readComponent(file, root) {
72
154
  }
73
155
  }
74
156
  }
157
+ for (const loaded of asyncChildren(comp, ast).values())
158
+ comp.imports.add(loaded);
75
159
  typesImports(comp, [...plainAst, ...ast]);
76
160
  comp.inheritAttrs = inheritAttrs(comp, [...plainAst, ...ast]);
77
161
  for (const st of [...plainAst, ...ast]) {
@@ -102,6 +186,8 @@ export function readComponent(file, root) {
102
186
  comp.aliases.set(sp.local.name, ctx.typeAliases.get(typeName));
103
187
  }
104
188
  }
189
+ if (descriptor.scriptSetup)
190
+ generics(comp, source, descriptor.scriptSetup);
105
191
  const decls = declareTypes(comp, [...plainAst, ...ast], comp.structs, comp.aliases);
106
192
  for (const d of decls)
107
193
  comp.structs.set(d.name, structOf(comp, d.name, d.members, comp.structs));
@@ -110,7 +196,7 @@ export function readComponent(file, root) {
110
196
  const call = s.type === "ExpressionStatement" ? s.expression : s.type === "VariableDeclaration" ? s.declarations[0]?.init : null;
111
197
  const inner = call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "withDefaults" ? call.arguments[0] : call;
112
198
  if (inner?.type === "CallExpression" && inner.callee.type === "Identifier" && inner.callee.name === "defineProps" && !inner.typeParameters) {
113
- fail(comp, "props are declared with a type, `defineProps<{ ... }>()`: a runtime declaration has no Rust type", inner);
199
+ fail(comp, "FV0303", "props are declared with a type, `defineProps<{ ... }>()`: a runtime declaration has no Rust type", inner);
114
200
  }
115
201
  const found = definePropsType(call);
116
202
  if (found)
@@ -125,7 +211,7 @@ export function readComponent(file, root) {
125
211
  comp.props = { name: "Props", fields: comp.structs.get(propsTy.typeName.name).fields };
126
212
  }
127
213
  else {
128
- fail(comp, "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
214
+ fail(comp, "FV0304", "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
129
215
  }
130
216
  for (const st of ast) {
131
217
  if (st.type !== "VariableDeclaration")
@@ -136,22 +222,29 @@ export function readComponent(file, root) {
136
222
  continue;
137
223
  const t = call.typeParameters?.params?.[0];
138
224
  if (!t)
139
- fail(comp, "`defineModel` needs its type: `defineModel<string>()`", call);
225
+ fail(comp, "FV0305", "`defineModel` needs its type: `defineModel<string>()`", call);
140
226
  const named = call.arguments[0]?.type === "StringLiteral" ? call.arguments[0].value : "modelValue";
141
227
  const options = call.arguments.find((a) => a.type === "ObjectExpression");
142
228
  const required = options?.properties.some((p) => p.type === "ObjectProperty" && (p.key.name ?? p.key.value) === "required" && p.value.type === "BooleanLiteral" && p.value.value);
143
229
  const base = tyOfTs(comp, t, comp.structs);
230
+ if (!required && absence(base) === "null")
231
+ fail(comp, "FV0306", `a \`defineModel\` of \`T | null\` that is not \`required\` may be absent, which is \`undefined\`, or \`null\`: ${ONE_NOTHING}`, call);
144
232
  comp.props.fields.push({ js: named, rust: snake(named), ty: required ? base : opt(base) });
145
233
  comp.models.set(d.id.name, named);
146
234
  }
147
235
  }
148
- const defaults = runtimeDefaults(comp, script.content);
236
+ const defaults = runtimeDefaults(comp, script?.content ?? "");
149
237
  for (const f of comp.props.fields) {
150
238
  if (f.ty.k !== "opt")
151
239
  continue;
152
240
  const node = defaults.get(f.js);
241
+ if (f.ty.none !== undefined) {
242
+ if (node)
243
+ 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);
244
+ continue;
245
+ }
153
246
  if (node)
154
- f.dflt = defaultValue(comp, f, node);
247
+ f.dflt = defaultValue(comp, f, node, sourceDefault(ast, f.js) ?? undefined);
155
248
  else if (f.ty.of.k === "bool")
156
249
  f.dflt = "false";
157
250
  }
@@ -161,7 +254,7 @@ export function readComponent(file, root) {
161
254
  ...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false }),
162
255
  ssr: true,
163
256
  ssrCssVars: [],
164
- compilerOptions: { bindingMetadata: script.bindings, sourceMap: true },
257
+ compilerOptions: { ...(script ? { bindingMetadata: script.bindings } : {}), sourceMap: true },
165
258
  });
166
259
  const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));
167
260
  const roots = descriptor.template.ast ? kids(descriptor.template.ast) : [];
@@ -172,12 +265,13 @@ export function readComponent(file, root) {
172
265
  if (!lone)
173
266
  comp.attrsDropped = { type: "VueTemplate", loc: { start: { line: wrapper.loc.start.line, column: wrapper.loc.start.column - 1 } }, __fv: "source" };
174
267
  }
268
+ comp.templateAst = descriptor.template.ast;
175
269
  const start = descriptor.template.loc.start;
176
270
  comp.templateStart = { line: start.line, column: start.column };
177
271
  if (compiled.map)
178
272
  comp.templateMap = new SourceMapConsumer(compiled.map);
179
273
  if (compiled.errors.length)
180
- fail(comp, String(compiled.errors[0]), vueErrorNode(compiled.errors[0], comp.templateStart));
274
+ fail(comp, "FV0005", String(compiled.errors[0]), vueErrorNode(compiled.errors[0], comp.templateStart));
181
275
  for (const m of compiled.code.matchAll(/_ssrRenderSlot\(_ctx\.\$slots, "([^"]+)"/g)) {
182
276
  if (!comp.slotNames.includes(m[1]))
183
277
  comp.slotNames.push(m[1]);