ferrovue 0.6.0 → 0.7.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 (79) hide show
  1. package/README.md +106 -13
  2. package/dist/args.d.ts +28 -0
  3. package/dist/args.js +69 -0
  4. package/dist/attrs.js +3 -0
  5. package/dist/calls.js +5 -2
  6. package/dist/children.js +18 -3
  7. package/dist/cli.js +80 -81
  8. package/dist/client-render.d.ts +23 -0
  9. package/dist/client-render.js +143 -0
  10. package/dist/client.d.ts +65 -6
  11. package/dist/client.js +269 -27
  12. package/dist/compiler.d.ts +9 -5
  13. package/dist/compiler.js +48 -14
  14. package/dist/component.d.ts +7 -3
  15. package/dist/component.js +37 -25
  16. package/dist/conformance.d.ts +24 -3
  17. package/dist/conformance.js +58 -7
  18. package/dist/constants.js +4 -2
  19. package/dist/context.d.ts +51 -4
  20. package/dist/context.js +173 -5
  21. package/dist/diagnostics.d.ts +6 -4
  22. package/dist/diagnostics.js +7 -3
  23. package/dist/editor.d.ts +18 -0
  24. package/dist/editor.js +59 -0
  25. package/dist/errors.d.ts +45 -3
  26. package/dist/errors.js +33 -3
  27. package/dist/expr.d.ts +2 -0
  28. package/dist/expr.js +91 -12
  29. package/dist/fallthrough.d.ts +1 -0
  30. package/dist/fallthrough.js +10 -2
  31. package/dist/files.d.ts +13 -0
  32. package/dist/files.js +49 -0
  33. package/dist/fixture.d.ts +3 -3
  34. package/dist/hydration.d.ts +12 -1
  35. package/dist/hydration.js +47 -1
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +1 -1
  38. package/dist/inputs.d.ts +24 -0
  39. package/dist/inputs.js +40 -0
  40. package/dist/link-router.d.ts +6 -2
  41. package/dist/link-router.js +7 -4
  42. package/dist/lists.d.ts +12 -1
  43. package/dist/lists.js +76 -2
  44. package/dist/model.d.ts +9 -1
  45. package/dist/model.js +2 -0
  46. package/dist/page-routes.d.ts +1 -0
  47. package/dist/paths.d.ts +5 -0
  48. package/dist/paths.js +7 -0
  49. package/dist/plugin.d.ts +2 -0
  50. package/dist/plugins/css-modules.d.ts +39 -0
  51. package/dist/plugins/css-modules.js +135 -0
  52. package/dist/plugins/head.js +4 -4
  53. package/dist/plugins/i18n.js +5 -12
  54. package/dist/plugins/index.js +2 -1
  55. package/dist/plugins/provide.js +4 -2
  56. package/dist/plugins/router.js +3 -2
  57. package/dist/plugins/scoped.d.ts +10 -0
  58. package/dist/plugins/scoped.js +17 -4
  59. package/dist/plugins/shared-types.js +2 -1
  60. package/dist/plugins/stores.js +7 -6
  61. package/dist/rust.d.ts +9 -1
  62. package/dist/rust.js +38 -15
  63. package/dist/schema.d.ts +25 -0
  64. package/dist/schema.js +73 -0
  65. package/dist/script.js +12 -5
  66. package/dist/slots.js +2 -0
  67. package/dist/ssr.js +42 -4
  68. package/dist/styles.d.ts +5 -1
  69. package/dist/styles.js +131 -4
  70. package/dist/testing.d.ts +2 -1
  71. package/dist/testing.js +1 -1
  72. package/dist/typescript.d.ts +11 -1
  73. package/dist/typescript.js +124 -33
  74. package/dist/vite.d.ts +18 -10
  75. package/dist/vite.js +106 -51
  76. package/dist/volar.d.ts +15 -0
  77. package/dist/volar.js +87 -0
  78. package/package.json +27 -7
  79. package/schema.json +333 -0
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createSSRApp, createTextVNode, defineComponent, h, hydrateOnIdle, hydrateOnMediaQuery, hydrateOnVisible, onMounted, ref, } from "vue";
1
+ import { createApp, 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) {
@@ -12,12 +12,212 @@ function parseJson(text) {
12
12
  return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
13
13
  }
14
14
  /** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
15
- * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
16
- export function hydrateState(pinia, id = "__pinia", doc = document) {
17
- const text = doc.getElementById(id)?.textContent;
15
+ * the app mounts, so every store starts from it and the markup agrees with the server's. The state
16
+ * is read from the script with the `id` given (`__pinia` by default) in `doc`, or given as
17
+ * `{ text }`: the `state` of a fetched page that `readPage` read. Either way the numbers JSON
18
+ * cannot carry (`NaN`, `Infinity`) are read back. Pinia is left alone when there is no state. */
19
+ export function hydrateState(pinia, from = "__pinia", doc = document) {
20
+ const text = typeof from === "string" ? doc.getElementById(from)?.textContent : from.text;
18
21
  if (text)
19
22
  pinia.state.value = parseJson(text);
20
23
  }
24
+ const SPACE = /[\t\n\f\r ]/;
25
+ const TAG_END = /[\t\n\f\r />]/;
26
+ const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript"]);
27
+ const FOREIGN = new Set(["svg", "math"]);
28
+ const LEAVES_FOREIGN = new Set("b big blockquote body br center code dd div dl dt em embed h1 h2 h3 h4 h5 h6 head hr i img li listing menu meta nobr ol p pre ruby s small span strong strike sub sup table tt u ul var".split(" "));
29
+ const REFERENCES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
30
+ function decodeAttr(value) {
31
+ if (!value.includes("&"))
32
+ return value;
33
+ return value.replace(/&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|(amp|lt|gt|quot|apos);)/g, (_match, dec, hex, named) => {
34
+ if (named)
35
+ return REFERENCES[named];
36
+ const code = dec ? Number.parseInt(dec, 10) : Number.parseInt(hex, 16);
37
+ return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : "\uFFFD";
38
+ });
39
+ }
40
+ function readTag(html, at) {
41
+ let i = at;
42
+ while (i < html.length && !TAG_END.test(html[i]))
43
+ i++;
44
+ const name = html.slice(at, i).toLowerCase();
45
+ const attrs = new Map();
46
+ let selfClosing = false;
47
+ for (;;) {
48
+ while (i < html.length && (SPACE.test(html[i]) || html[i] === "/"))
49
+ selfClosing = html[i++] === "/";
50
+ if (i >= html.length)
51
+ return undefined;
52
+ if (html[i] === ">")
53
+ return { name, attrs, end: i + 1, selfClosing };
54
+ selfClosing = false;
55
+ const start = i++;
56
+ while (i < html.length && !/[\t\n\f\r />=]/.test(html[i]))
57
+ i++;
58
+ const key = html.slice(start, i).toLowerCase();
59
+ while (i < html.length && SPACE.test(html[i]))
60
+ i++;
61
+ let value = "";
62
+ if (html[i] === "=") {
63
+ i++;
64
+ while (i < html.length && SPACE.test(html[i]))
65
+ i++;
66
+ const quote = html[i];
67
+ if (quote === '"' || quote === "'") {
68
+ const close = html.indexOf(quote, i + 1);
69
+ if (close < 0)
70
+ return undefined;
71
+ value = html.slice(i + 1, close);
72
+ i = close + 1;
73
+ }
74
+ else {
75
+ const from = i;
76
+ while (i < html.length && !/[\t\n\f\r >]/.test(html[i]))
77
+ i++;
78
+ value = html.slice(from, i);
79
+ }
80
+ }
81
+ if (!attrs.has(key))
82
+ attrs.set(key, decodeAttr(value));
83
+ }
84
+ }
85
+ function tagAt(lower, at, name, closing) {
86
+ const open = closing ? `</${name}` : `<${name}`;
87
+ return lower.startsWith(open, at) && TAG_END.test(lower[at + open.length] ?? "");
88
+ }
89
+ function scriptDataEnd(lower, from) {
90
+ let escaped = 0;
91
+ let dashes = 0;
92
+ for (let i = from; i < lower.length; i++) {
93
+ if (escaped === 0) {
94
+ if (lower.startsWith("<!--", i)) {
95
+ escaped = 1;
96
+ dashes = 2;
97
+ i += 3;
98
+ }
99
+ else if (tagAt(lower, i, "script", true))
100
+ return i;
101
+ continue;
102
+ }
103
+ const ch = lower[i];
104
+ if (ch === "-") {
105
+ dashes++;
106
+ continue;
107
+ }
108
+ if (ch === ">" && dashes >= 2)
109
+ escaped = 0;
110
+ else if (ch === "<" && tagAt(lower, i, "script", true)) {
111
+ if (escaped === 1)
112
+ return i;
113
+ escaped = 1;
114
+ }
115
+ else if (ch === "<" && escaped === 1 && tagAt(lower, i, "script", false))
116
+ escaped = 2;
117
+ dashes = 0;
118
+ }
119
+ return -1;
120
+ }
121
+ function rawTextEnd(lower, from, name) {
122
+ if (name === "script")
123
+ return scriptDataEnd(lower, from);
124
+ for (let i = lower.indexOf(`</${name}`, from); i >= 0; i = lower.indexOf(`</${name}`, i + 1))
125
+ if (tagAt(lower, i, name, true))
126
+ return i;
127
+ return -1;
128
+ }
129
+ function commentEnd(html, from) {
130
+ if (html.startsWith(">", from))
131
+ return from + 1;
132
+ if (html.startsWith("->", from))
133
+ return from + 2;
134
+ for (let i = html.indexOf("--", from); i >= 0; i = html.indexOf("--", i + 1)) {
135
+ if (html[i + 2] === ">")
136
+ return i + 3;
137
+ if (html.startsWith("!>", i + 2))
138
+ return i + 4;
139
+ }
140
+ return html.length;
141
+ }
142
+ function scriptText(html, id) {
143
+ const lower = html.toLowerCase();
144
+ let template = 0;
145
+ let foreign = 0;
146
+ let i = 0;
147
+ for (;;) {
148
+ const lt = html.indexOf("<", i);
149
+ if (lt < 0)
150
+ return undefined;
151
+ i = lt + 1;
152
+ if (html.startsWith("<!--", lt)) {
153
+ i = commentEnd(html, lt + 4);
154
+ continue;
155
+ }
156
+ if (foreign > 0 && html.startsWith("<![CDATA[", lt)) {
157
+ const close = html.indexOf("]]>", lt);
158
+ i = close < 0 ? html.length : close + 3;
159
+ continue;
160
+ }
161
+ const closing = html[lt + 1] === "/";
162
+ const at = closing ? lt + 2 : lt + 1;
163
+ if (!/[a-zA-Z]/.test(html[at] ?? "")) {
164
+ if (html[lt + 1] === "!" || html[lt + 1] === "?" || (closing && at < html.length)) {
165
+ const close = html.indexOf(">", at);
166
+ i = close < 0 ? html.length : close + 1;
167
+ }
168
+ continue;
169
+ }
170
+ const tag = readTag(html, at);
171
+ if (!tag)
172
+ return undefined;
173
+ i = tag.end;
174
+ if (FOREIGN.has(tag.name)) {
175
+ if (closing)
176
+ foreign = Math.max(0, foreign - 1);
177
+ else if (!tag.selfClosing)
178
+ foreign++;
179
+ continue;
180
+ }
181
+ if (foreign > 0) {
182
+ if (closing ? tag.name === "br" || tag.name === "p" : LEAVES_FOREIGN.has(tag.name))
183
+ foreign = 0;
184
+ else
185
+ continue;
186
+ }
187
+ if (tag.name === "template") {
188
+ template = closing ? Math.max(0, template - 1) : template + 1;
189
+ continue;
190
+ }
191
+ if (closing || !RAW_TEXT.has(tag.name)) {
192
+ if (tag.name === "plaintext")
193
+ return undefined;
194
+ continue;
195
+ }
196
+ const end = rawTextEnd(lower, i, tag.name);
197
+ if (tag.name === "script" && template === 0 && tag.attrs.get("id") === id)
198
+ return html.slice(i, end < 0 ? html.length : end);
199
+ if (end < 0)
200
+ return undefined;
201
+ const close = readTag(html, end + 2);
202
+ if (!close)
203
+ return undefined;
204
+ i = close.end;
205
+ }
206
+ }
207
+ /** Read the record and the state of a page out of the HTML of its document, as a navigation that
208
+ * fetches the next page receives it. It needs no `DOMParser`, so it also runs in a worker and on a
209
+ * server. Each script is the one the browser's `getElementById` would find in the parsed document:
210
+ * the first `<script>` with that `id` outside a `<template>`, an `<svg>` and a `<math>`, with
211
+ * comments, attribute values and the text of `<script>`, `<style>`, `<textarea>`, `<title>` and the
212
+ * other raw-text elements passed over as the HTML parser passes over them. Throws when the page
213
+ * carries no record. */
214
+ export function readPage(html, options = {}) {
215
+ const id = options.record ?? "__fv_page";
216
+ const text = scriptText(html, id);
217
+ if (!text)
218
+ throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
219
+ return { record: parseJson(text), state: scriptText(html, options.state ?? "__pinia") || undefined };
220
+ }
21
221
  const INTERACTIONS = ["pointerenter", "click", "focus"];
22
222
  const strategy = (wait) => (el, hydrate) => wait(() => void hydrate(), (each) => void each(el)) ?? (() => { });
23
223
  const idle = (el, hydrate) => {
@@ -100,15 +300,22 @@ export async function mountIslands(components, options = {}) {
100
300
  report(el, `the props of ${name} are not JSON`);
101
301
  return;
102
302
  }
103
- const app = createSSRApp(component, props);
104
- if (options.pinia)
105
- app.use(options.pinia);
106
- if (options.router)
107
- app.use(options.router);
108
- for (const plugin of options.plugins ?? [])
109
- app.use(plugin);
110
- app.mount(el);
111
- apps.push(app);
303
+ // One island that throws as it mounts (its setup, a plugin) is reported, and leaves the others
304
+ // to hydrate.
305
+ try {
306
+ const app = createSSRApp(component, props);
307
+ if (options.pinia)
308
+ app.use(options.pinia);
309
+ if (options.router)
310
+ app.use(options.router);
311
+ for (const plugin of options.plugins ?? [])
312
+ app.use(plugin);
313
+ app.mount(el);
314
+ apps.push(app);
315
+ }
316
+ catch (e) {
317
+ report(el, `${name} did not mount: ${e instanceof Error ? e.message : String(e)}`);
318
+ }
112
319
  };
113
320
  const now = [];
114
321
  for (const el of elements) {
@@ -202,13 +409,15 @@ async function load(component) {
202
409
  }
203
410
  /** The app of a page: its root renders `layout` with the record's props, and each slot as the
204
411
  * 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. */
412
+ * wrote with no fragment of its own. With `hydrate: false` the same tree is rendered from scratch,
413
+ * so the first page and every page shown after it are one shape of app. `components` must hold
414
+ * every component the record names. */
206
415
  export function createPageApp(layout, record, components, options = {}) {
207
416
  const slots = Object.fromEntries(Object.entries(record.slots).map(([name, parts]) => [
208
417
  name,
209
418
  () => (parts.length ? parts.map((part) => h(components[part.c], part.p)) : [createTextVNode("")]),
210
419
  ]));
211
- const app = createSSRApp({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
420
+ const app = (options.hydrate === false ? createApp : createSSRApp)({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
212
421
  if (options.pinia)
213
422
  app.use(options.pinia);
214
423
  if (options.router)
@@ -217,32 +426,65 @@ export function createPageApp(layout, record, components, options = {}) {
217
426
  app.use(plugin);
218
427
  return app;
219
428
  }
429
+ function containerOf(options) {
430
+ const selector = options.container ?? "#app";
431
+ const container = typeof selector === "string" ? (options.doc ?? document).querySelector(selector) : selector;
432
+ if (!container)
433
+ throw new Error(`[ferrovue] the page has no ${selector} to mount on`);
434
+ return container;
435
+ }
436
+ async function loadPage(caller, layout, record, components) {
437
+ const names = new Set(Object.values(record.slots).flatMap((parts) => parts.map((part) => part.c)));
438
+ const loading = [...names].map(async (name) => {
439
+ if (!Object.hasOwn(components, name))
440
+ throw new Error(`[ferrovue] no component called ${JSON.stringify(name)} was given to ${caller}`);
441
+ return [name, await loadOrThrow(name, components[name])];
442
+ });
443
+ const [root, parts] = await Promise.all([loadOrThrow("the layout", layout), Promise.all(loading)]);
444
+ return [root, Object.fromEntries(parts)];
445
+ }
220
446
  /** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
221
447
  * no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
222
448
  * as the server rendered it, when the container or the record is missing or a component is not
223
449
  * given or does not load. */
224
450
  export async function mountPage(layout, components, options = {}) {
225
451
  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`);
452
+ const container = containerOf(options);
230
453
  const id = options.record ?? "__fv_page";
231
454
  const text = doc.getElementById(id)?.textContent;
232
455
  if (!text)
233
456
  throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
234
457
  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);
458
+ const [[root, parts]] = await Promise.all([loadPage("mountPage", layout, record, components), options.router?.isReady()]);
459
+ const app = createPageApp(root, record, parts, options);
243
460
  app.mount(container);
244
461
  return app;
245
462
  }
463
+ /** Show the next page of a navigation from its record, as `readPage` read it out of the fetched
464
+ * document: load the components it names (and no others), render the app `createPageApp` builds
465
+ * with `hydrate: false` into a copy of the container with no children, wait for the router, put the
466
+ * copy in the container's place, mount, and then unmount `previous`. The next app is mounted
467
+ * before the previous one is unmounted, so a router both use stays started. `router` must be at the
468
+ * next page's location: `linkRouter` with the `location` the navigation goes to. Rejects, leaving
469
+ * the page shown as it is, when the container is missing or a component is not given or does not
470
+ * load. */
471
+ export async function renderPage(layout, components, record, options = {}) {
472
+ const container = containerOf(options);
473
+ const [root, parts] = await loadPage("renderPage", layout, record, components);
474
+ const app = createPageApp(root, record, parts, { ...options, hydrate: false });
475
+ await options.router?.isReady();
476
+ const next = container.cloneNode(false);
477
+ container.replaceWith(next);
478
+ try {
479
+ app.mount(next);
480
+ }
481
+ catch (e) {
482
+ next.replaceWith(container);
483
+ throw e;
484
+ }
485
+ options.previous?.unmount();
486
+ return app;
487
+ }
246
488
  async function loadOrThrow(name, component) {
247
489
  const result = await load(component);
248
490
  if (result instanceof Error)
@@ -1,7 +1,10 @@
1
1
  import { type Config } from "./context.ts";
2
- /** Every generated file, keyed by its name in the output directory. */
3
- export declare function generate(root: string, config?: Config): Map<string, string>;
4
- /** Write what `generate` produces to the configured directory, replacing what is there. */
2
+ /** Every generated file, keyed by its name in the output directory. `sources` holds the text of
3
+ * components by path, read in place of their files, as an editor's unsaved buffers. */
4
+ export declare function generate(root: string, config?: Config, sources?: ReadonlyMap<string, string>): Map<string, string>;
5
+ /** Whether a file starts with the header ferrovue writes, so `write` removes only what an earlier
6
+ * run wrote and never a module of the app's own put in the output directory by mistake. */
7
+ export declare function isGenerated(file: string): boolean;
5
8
  /** What \`write\` changed in the output directory. */
6
9
  export interface Written {
7
10
  /** Every file the output directory now holds. */
@@ -18,6 +21,7 @@ export interface Written {
18
21
  * so a Rust build that watches them rebuilds no more than it must, and removing what no component
19
22
  * produces any more. */
20
23
  export declare function write(root: string, config?: Config): Written;
21
- export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type RoutesSource, type TwinSpec, type TypeName } from "./context.ts";
22
- export { GenError } from "./model.ts";
24
+ export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type RoutesSource, type ScopeIdMode, type TwinSpec, type TypeName } from "./context.ts";
25
+ export { GenError, type Location as SourceLocation } from "./model.ts";
26
+ export { docsUrl, ERRORS, type Code as ErrorCode, type ErrorDoc } from "./errors.ts";
23
27
  export declare const VERSION: string;
package/dist/compiler.js CHANGED
@@ -1,16 +1,21 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { basename, join, relative } from "node:path";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { relativePath } from "./paths.js";
3
4
  import { ctx, loadConfig, tyOfName } from "./context.js";
4
5
  import { failIn, snake } from "./model.js";
5
- import { importsOf, readComponent } from "./component.js";
6
+ import { readComponent, refuseOptionsApi } from "./component.js";
7
+ import { listDir } from "./files.js";
6
8
  import { scopeFor } from "./script.js";
7
9
  import { attrsFlow } from "./fallthrough.js";
8
10
  import { prepareDynamic } from "./dynamic.js";
9
- import { componentSource, isIsland, modSource } from "./rust.js";
11
+ import { componentSource, GENERATED, isIsland, modSource } from "./rust.js";
12
+ import { requireWhole } from "./typescript.js";
10
13
  import { renderParams } from "./plugin.js";
11
14
  import { PLUGINS } from "./plugins/index.js";
12
- /** Every generated file, keyed by its name in the output directory. */
13
- export function generate(root, config = loadConfig(root)) {
15
+ /** Every generated file, keyed by its name in the output directory. `sources` holds the text of
16
+ * components by path, read in place of their files, as an editor's unsaved buffers. */
17
+ export function generate(root, config = loadConfig(root), sources = new Map()) {
18
+ ctx.sources = new Map([...sources].map(([file, text]) => [resolve(root, file), text]));
14
19
  ctx.componentsDir = config.components.replace(/\/?$/, "/");
15
20
  ctx.helperModule = config.helpers?.module ?? null;
16
21
  ctx.trustedHtml = config.trustedHtml ?? null;
@@ -21,6 +26,8 @@ export function generate(root, config = loadConfig(root)) {
21
26
  ctx.typeStructs = new Map();
22
27
  ctx.typeAliases = new Map();
23
28
  ctx.typeFiles = new Map();
29
+ ctx.typeDecls = new Map();
30
+ ctx.typeReached = new Set();
24
31
  ctx.typeRead = new Set();
25
32
  ctx.constDecls = new Map();
26
33
  ctx.typeConsts = new Map();
@@ -36,7 +43,7 @@ export function generate(root, config = loadConfig(root)) {
36
43
  ]));
37
44
  const dir = join(root, config.components);
38
45
  const files = [
39
- ...readdirSync(dir)
46
+ ...listDir(root, config.components, "components")
40
47
  .filter((f) => f.endsWith(".vue"))
41
48
  .toSorted()
42
49
  .map((f) => ({ file: join(dir, f), name: basename(f, ".vue") })),
@@ -44,7 +51,7 @@ export function generate(root, config = loadConfig(root)) {
44
51
  ];
45
52
  const modulesOf = new Map();
46
53
  for (const f of files) {
47
- const rel = relative(root, f.file);
54
+ const rel = relativePath(root, f.file);
48
55
  if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(f.name)) {
49
56
  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
57
  }
@@ -56,10 +63,17 @@ export function generate(root, config = loadConfig(root)) {
56
63
  failIn(rel, "FV0007", `the component's module would be \`${file}\`, as ${taken}'s is: rename one of them`);
57
64
  modulesOf.set(file, rel);
58
65
  }
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));
66
+ const read = files.map((f) => readComponent(f.file, root, f.name));
67
+ const children = new Set(read.flatMap((r) => [...r.comp.imports, ...r.comp.childProps.values()].filter((c) => c !== r.comp.name)));
68
+ for (const r of read)
69
+ if (r.optionsApi)
70
+ refuseOptionsApi(r.comp, r.optionsApi, children.has(r.comp.name));
71
+ const byName = new Map(read.map((r) => [r.comp.name, r]));
61
72
  const components = new Map(read.map((r) => [r.comp.name, r.comp]));
62
73
  ctx.components = components;
74
+ for (const c of components.values())
75
+ for (const f of c.props.fields)
76
+ requireWhole(c, f.ty);
63
77
  const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
64
78
  const analysed = prepareDynamic(read.map((r, i) => ({ ...r, scope: scopes[i] })));
65
79
  const all = read.map((r, i) => ({ comp: r.comp, ssr: analysed[i], children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
@@ -87,18 +101,27 @@ export function generate(root, config = loadConfig(root)) {
87
101
  return;
88
102
  placed.add(r.comp.name);
89
103
  for (const i of r.comp.imports) {
90
- const dep = read.find((x) => x.comp.name === i);
104
+ const dep = byName.get(i);
91
105
  if (dep)
92
106
  place(dep);
93
107
  }
94
108
  ordered.push(r);
95
109
  };
96
110
  read.forEach(place);
97
- for (const r of ordered) {
111
+ const built = ordered.map((r) => {
98
112
  ctx.narrowCount = 0;
99
- out.set(moduleFile(r.comp.module), componentSource(r.comp, r.ast, r.ssr, components));
113
+ const file = moduleFile(r.comp.module);
114
+ out.set(file, "");
115
+ return { file, source: componentSource(r.comp, r.ast, r.ssr, components) };
116
+ });
117
+ const reached = () => ctx.typeReached.size + [...components.values()].reduce((n, c) => n + c.reached.size, 0);
118
+ let modules = [];
119
+ for (let before = -1; before !== reached();) {
120
+ before = reached();
121
+ for (const { file, source } of built)
122
+ out.set(file, source());
123
+ modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
100
124
  }
101
- const modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
102
125
  out.set("mod.rs", modSource(read.map((r) => r.comp), modules.map(([file]) => file)));
103
126
  for (const [file, text] of modules) {
104
127
  const taken = modulesOf.get(file);
@@ -108,6 +131,16 @@ export function generate(root, config = loadConfig(root)) {
108
131
  }
109
132
  return out;
110
133
  }
134
+ /** Whether a file starts with the header ferrovue writes, so `write` removes only what an earlier
135
+ * run wrote and never a module of the app's own put in the output directory by mistake. */
136
+ export function isGenerated(file) {
137
+ try {
138
+ return readFileSync(file, "utf8").startsWith(GENERATED);
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
111
144
  function moduleFile(module) {
112
145
  return `${module.replace(/^r#/, "")}.rs`;
113
146
  }
@@ -131,7 +164,7 @@ export function write(root, config = loadConfig(root)) {
131
164
  changed.push(name);
132
165
  }
133
166
  }
134
- const removed = readdirSync(target).filter((f) => f.endsWith(".rs") && !files.has(f));
167
+ const removed = readdirSync(target).filter((f) => f.endsWith(".rs") && !files.has(f) && isGenerated(join(target, f)));
135
168
  for (const f of removed)
136
169
  rmSync(join(target, f));
137
170
  const islands = Object.fromEntries([...ctx.components.values()].filter(isIsland).map((c) => [c.name, c.file]));
@@ -139,6 +172,7 @@ export function write(root, config = loadConfig(root)) {
139
172
  }
140
173
  export { CONFIG_FILE, loadConfig, TYPES_MODULE } from "./context.js";
141
174
  export { GenError } from "./model.js";
175
+ export { docsUrl, ERRORS } from "./errors.js";
142
176
  export const VERSION = (() => {
143
177
  try {
144
178
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -1,8 +1,12 @@
1
1
  import { type Component, type N } from "./model.ts";
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): {
2
+ /** Refuse a component written with the Options API, which `readComponent` read no further: as a
3
+ * child when another component imports it, as an island otherwise. */
4
+ export declare function refuseOptionsApi(comp: Component, script: N, isChild: boolean): never;
5
+ /** A component, read and its template compiled. One written with the Options API is read no
6
+ * further than its `<script>`, `optionsApi`, for `refuseOptionsApi` once every component is read. */
7
+ export declare function readComponent(file: string, root: string, name?: string): {
5
8
  comp: Component;
6
9
  ast: N[];
7
10
  ssr: string;
11
+ optionsApi?: N;
8
12
  };
package/dist/component.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { parse as parseJs } from "@babel/parser";
2
2
  import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
3
- import { readFileSync } from "node:fs";
4
3
  import { SourceMapConsumer } from "source-map-js";
5
- import { basename, relative } from "node:path";
4
+ import { basename } from "node:path";
5
+ import { relativePath } from "./paths.js";
6
6
  import { absence, blankComponent, fail, GenError, opt, snake, sourceAt, tagAst } from "./model.js";
7
- import { ctx } from "./context.js";
8
- import { typesImports, declareTypes, defaultValue, definePropsType, ONE_NOTHING, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
7
+ import { ctx, readSource } from "./context.js";
8
+ import { typesImports, declareTypes, defaultValue, definePropsType, ONE_NOTHING, reachLocal, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
9
9
  import { claim } from "./plugin.js";
10
10
  import { asyncChildren } from "./script.js";
11
11
  function vueErrorNode(err, within) {
@@ -59,9 +59,17 @@ function sourceDefault(ast, key) {
59
59
  function blockAt(source, block) {
60
60
  return sourceAt(source, source.lastIndexOf(`<${block.type}`, block.loc.start.offset));
61
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]);
62
+ function cssVarsAt(source, descriptor) {
63
+ const at = new Map();
64
+ for (const st of descriptor.styles) {
65
+ for (const m of st.content.matchAll(/v-bind\s*\(\s*(?:'([^']*)'|"([^"]*)"|([^)'"]*))/g)) {
66
+ const raw = m[1] ?? m[2] ?? m[3].trim();
67
+ if (!at.has(raw))
68
+ at.set(raw, sourceAt(source, st.loc.start.offset + m.index));
69
+ }
70
+ }
71
+ const block = descriptor.styles.find((st) => /\bv-bind\s*\(/.test(st.content));
72
+ return descriptor.cssVars.map((v) => at.get(v) ?? (block ? blockAt(source, block) : sourceAt(source, 0)));
65
73
  }
66
74
  function generics(comp, source, block) {
67
75
  const text = block.attrs.generic;
@@ -114,27 +122,27 @@ function scriptOf(comp, descriptor) {
114
122
  throw new GenError("FV0001", e.message, { file: comp.file, ...where }, e.message.split("\n")[0]);
115
123
  }
116
124
  }
117
- export function readComponent(file, root, isChild, name = basename(file, ".vue")) {
118
- const source = readFileSync(file, "utf8");
125
+ /** Refuse a component written with the Options API, which `readComponent` read no further: as a
126
+ * child when another component imports it, as an island otherwise. */
127
+ export function refuseOptionsApi(comp, script, isChild) {
128
+ const needs = isChild ? "a child component must have" : "an island needs";
129
+ return fail(comp, "FV0003", `${needs} \`<script setup lang="ts">\`, or no script at all: a \`<script>\` without \`setup\` (the Options API, \`defineComponent\`) is not translated`, script);
130
+ }
131
+ /** A component, read and its template compiled. One written with the Options API is read no
132
+ * further than its `<script>`, `optionsApi`, for `refuseOptionsApi` once every component is read. */
133
+ export function readComponent(file, root, name = basename(file, ".vue")) {
134
+ const source = readSource(file);
119
135
  const { descriptor, errors } = parseSfc(source, { filename: file });
120
- const rel = relative(root, file);
136
+ const rel = relativePath(root, file);
121
137
  const comp = blankComponent(name, snake(name), rel);
122
138
  comp.source = source;
123
139
  if (errors.length)
124
140
  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));
128
- }
141
+ if (descriptor.script && !descriptor.scriptSetup)
142
+ return { comp, ast: [], ssr: "", optionsApi: blockAt(source, descriptor.script) };
129
143
  if (!descriptor.template)
130
144
  fail(comp, "FV0004", "a component needs a `<template>`: a render function is not translated", sourceAt(source, 0));
131
- for (const st of descriptor.styles) {
132
- if (st.module)
133
- fail(comp, "FV1005", "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet", blockAt(source, st));
134
- }
135
- const cssVar = descriptor.styles.find((st) => /\bv-bind\s*\(/.test(st.content));
136
- if (descriptor.cssVars.length)
137
- fail(comp, "FV1006", "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead", cssVar ? blockAt(source, cssVar) : undefined);
145
+ comp.cssVarsAt = cssVarsAt(source, descriptor);
138
146
  for (const p of ctx.plugins)
139
147
  p.sfc?.(comp, descriptor, file, source);
140
148
  const script = descriptor.scriptSetup ? scriptOf(comp, descriptor) : null;
@@ -190,7 +198,7 @@ export function readComponent(file, root, isChild, name = basename(file, ".vue")
190
198
  generics(comp, source, descriptor.scriptSetup);
191
199
  const decls = declareTypes(comp, [...plainAst, ...ast], comp.structs, comp.aliases);
192
200
  for (const d of decls)
193
- comp.structs.set(d.name, structOf(comp, d.name, d.members, comp.structs));
201
+ comp.typeDecls.set(d.name, { members: d.members, node: d.node });
194
202
  let propsTy = null;
195
203
  for (const s of ast) {
196
204
  const call = s.type === "ExpressionStatement" ? s.expression : s.type === "VariableDeclaration" ? s.declarations[0]?.init : null;
@@ -208,7 +216,12 @@ export function readComponent(file, root, isChild, name = basename(file, ".vue")
208
216
  comp.props = structOf(comp, "Props", propsTy.members, comp.structs);
209
217
  }
210
218
  else if (propsTy.type === "TSTypeReference" && comp.structs.has(propsTy.typeName.name)) {
211
- comp.props = { name: "Props", fields: comp.structs.get(propsTy.typeName.name).fields };
219
+ reachLocal(comp, propsTy.typeName.name);
220
+ const st = comp.structs.get(propsTy.typeName.name);
221
+ const refused = st.refused?.values().next().value;
222
+ if (refused !== undefined)
223
+ throw refused;
224
+ comp.props = { name: "Props", fields: st.fields };
212
225
  }
213
226
  else {
214
227
  fail(comp, "FV0304", "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
@@ -251,9 +264,8 @@ export function readComponent(file, root, isChild, name = basename(file, ".vue")
251
264
  const compiled = compileTemplate({
252
265
  source: descriptor.template.content,
253
266
  filename: file,
254
- ...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false }),
267
+ ...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false, ssrCssVars: [], isProd: true }),
255
268
  ssr: true,
256
- ssrCssVars: [],
257
269
  compilerOptions: { ...(script ? { bindingMetadata: script.bindings } : {}), sourceMap: true },
258
270
  });
259
271
  const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));