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
@@ -0,0 +1,64 @@
1
+ import type { App, Component } from "vue";
2
+ import { type Config } from "./compiler.ts";
3
+ import { type RouteEntry, type RouterOptions } from "./fixture.ts";
4
+ /** What `conformanceSuite` checks: the project, its components and its fixtures. `pinia`,
5
+ * `vueRouter` and `vueI18n` are the application's own modules (`pinia: await import("pinia")`),
6
+ * which the fixtures install so that its stores and composables find them. */
7
+ export interface ConformanceOptions extends Pick<RouterOptions, "pinia" | "vueRouter" | "vueI18n"> {
8
+ /** The project's `ferrovue.config.json`. */
9
+ config: string;
10
+ /** Every component in the configured `components` directory, by file name or by the path
11
+ * `import.meta.glob` gives it: `import.meta.glob("../components/*.vue", { eager: true })`; and
12
+ * with a folder of pages, every page, by the path `import.meta.glob("../pages/**\/*.vue")` gives
13
+ * it or by its component name (`BooksId`). */
14
+ components: Record<string, Component | {
15
+ default: Component;
16
+ }>;
17
+ /** The fixtures, one directory per component holding `<case>.json` and `<case>.html`:
18
+ * `fixtures` beside the configuration when not given. */
19
+ fixtures?: string;
20
+ /** Write each fixture's `.html` from Vue's render instead of comparing it: when
21
+ * `FERROVUE_FIXTURES_WRITE=1` is set, if not given. */
22
+ record?: boolean;
23
+ }
24
+ interface TestApi {
25
+ describe(name: string, body: () => void): void;
26
+ it(name: string, body: () => Promise<void> | void): void;
27
+ }
28
+ export declare const TELEPORTS = "<!--fv-teleports-->";
29
+ export declare const HEAD = "<!--fv-head-->";
30
+ /** What unhead's server renderer wrote for a recorded render's head, as `renderSSRHead` gives it. */
31
+ export interface RecordedHead {
32
+ headTags: string;
33
+ bodyTags: string;
34
+ bodyTagsOpen: string;
35
+ htmlAttrs: string;
36
+ bodyAttrs: string;
37
+ }
38
+ /** The head a recorded render wrote after its body, if it wrote one. */
39
+ export declare function recordedHead(html: string): RecordedHead | null;
40
+ /** Render a fixture's app as the conformance suite records it: Vue's HTML, then what was teleported
41
+ * and what `useHead` asked for, each after its marker. */
42
+ export declare function renderFixture(app: App): Promise<string>;
43
+ /** Put a recorded render's head into the document, as a page would write it: its tags in `<head>`,
44
+ * and its attributes on `<html>` and `<body>`. */
45
+ export declare function placeHead(head: RecordedHead | null, doc?: Document): void;
46
+ /** A recorded render as a page body to hydrate: the main HTML in `<div id="root">`, what was
47
+ * teleported in each target, and the tags the head puts at the start and end of `<body>`. */
48
+ export declare function hydrationBody(html: string): string;
49
+ /** Where two renders first differ, with the text around it in each. */
50
+ export declare function firstDifference(recorded: string, rendered: string): string;
51
+ /** The routes and the router and vue-i18n options a project's fixtures render with, from its configuration. */
52
+ export declare function fixtureOptions(root: string, config: Config): {
53
+ routes: RouteEntry[] | null;
54
+ options: RouterOptions;
55
+ };
56
+ /** Register the conformance suite with the given `describe` and `it`. */
57
+ export declare function registerConformance(api: TestApi, options: ConformanceOptions): void;
58
+ /** Register vitest tests holding a project's components to Vue, as ferrovue's own suite does:
59
+ * every component has fixtures and every fixture a component, the generated Rust is what the
60
+ * generator writes now, Vue renders each fixture to its recorded `.html` (or records it, with
61
+ * `FERROVUE_FIXTURES_WRITE=1`), and each recorded `.html` hydrates with no warning and the server's
62
+ * nodes kept. Await it at the top of a test file run in a DOM environment such as happy-dom. */
63
+ export declare function conformanceSuite(options: ConformanceOptions): Promise<void>;
64
+ export {};
@@ -0,0 +1,276 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { renderToString } from "vue/server-renderer";
4
+ import { generate, loadConfig } from "./compiler.js";
5
+ import { unifiedDiff } from "./diff.js";
6
+ import { allPages, fileRoutes, pagesFolder, routeEntries } from "./file-routes.js";
7
+ import { fixtureApp, peer, readFixture } from "./fixture.js";
8
+ import { headRendered, settled } from "./settle.js";
9
+ import { attachSsrRender } from "./ssr.js";
10
+ export const TELEPORTS = "<!--fv-teleports-->";
11
+ export const HEAD = "<!--fv-head-->";
12
+ /** The head a recorded render wrote after its body, if it wrote one. */
13
+ export function recordedHead(html) {
14
+ const at = html.indexOf(HEAD);
15
+ return at < 0 ? null : JSON.parse(html.slice(at + HEAD.length));
16
+ }
17
+ /** Render a fixture's app as the conformance suite records it: Vue's HTML, then what was teleported
18
+ * and what `useHead` asked for, each after its marker. */
19
+ export async function renderFixture(app) {
20
+ const ssr = {};
21
+ const main = await renderToString(app, ssr);
22
+ const teleported = Object.entries(ssr.teleports ?? {});
23
+ const html = teleported.length ? `${main}${TELEPORTS}${JSON.stringify(Object.fromEntries(teleported))}` : main;
24
+ const head = app.config.globalProperties.$unhead?.render();
25
+ return head && Object.values(head).some(Boolean) ? `${html}${HEAD}${JSON.stringify(head)}` : html;
26
+ }
27
+ /** Put a recorded render's head into the document, as a page would write it: its tags in `<head>`,
28
+ * and its attributes on `<html>` and `<body>`. */
29
+ export function placeHead(head, doc = document) {
30
+ doc.head.innerHTML = head?.headTags ?? "";
31
+ const attrs = (el, written) => {
32
+ for (const name of el.getAttributeNames())
33
+ el.removeAttribute(name);
34
+ const t = doc.createElement("template");
35
+ t.innerHTML = `<div${written}></div>`;
36
+ const parsed = t.content.firstElementChild;
37
+ for (const name of parsed.getAttributeNames())
38
+ el.setAttribute(name, parsed.getAttribute(name));
39
+ };
40
+ attrs(doc.documentElement, head?.htmlAttrs ?? "");
41
+ attrs(doc.body, head?.bodyAttrs ?? "");
42
+ }
43
+ /** A recorded render as a page body to hydrate: the main HTML in `<div id="root">`, what was
44
+ * teleported in each target, and the tags the head puts at the start and end of `<body>`. */
45
+ export function hydrationBody(html) {
46
+ const head = recordedHead(html);
47
+ const [main, teleported] = html.split(HEAD)[0].split(TELEPORTS);
48
+ const targets = Object.entries(JSON.parse(teleported ?? "{}"));
49
+ const intoBody = targets
50
+ .filter(([t]) => t === "body")
51
+ .map(([, content]) => content)
52
+ .join("");
53
+ const elsewhere = targets
54
+ .filter(([t]) => t !== "body")
55
+ .map(([t, content]) => `<div id="${t.replace(/^#/, "")}">${content}</div>`)
56
+ .join("");
57
+ return `${head?.bodyTagsOpen ?? ""}${intoBody}<div id="root">${main}</div>${elsewhere}${head?.bodyTags ?? ""}`;
58
+ }
59
+ /** Where two renders first differ, with the text around it in each. */
60
+ export function firstDifference(recorded, rendered) {
61
+ let at = 0;
62
+ while (at < recorded.length && at < rendered.length && recorded[at] === rendered[at])
63
+ at++;
64
+ const lines = recorded.slice(0, at).split("\n");
65
+ const around = (text) => JSON.stringify(text.slice(Math.max(0, at - 40), at + 40));
66
+ return `first difference at character ${at} (line ${lines.length}, column ${lines.at(-1).length + 1}):\n recorded: ${around(recorded)}\n rendered: ${around(rendered)}`;
67
+ }
68
+ function componentsByName(given, pages) {
69
+ const nameOf = (key) => {
70
+ const path = key.replaceAll("\\", "/");
71
+ return pages.find((p) => path.endsWith(`/${p.within}`))?.name ?? basename(key, ".vue");
72
+ };
73
+ return new Map(Object.entries(given).map(([key, value]) => [
74
+ nameOf(key),
75
+ value.default ?? value,
76
+ ]));
77
+ }
78
+ /** The routes and the router and vue-i18n options a project's fixtures render with, from its configuration. */
79
+ export function fixtureOptions(root, config) {
80
+ const router = config.router ?? (config.routes ? { routes: config.routes } : null);
81
+ const pages = pagesFolder(config);
82
+ const routes = pages !== null
83
+ ? routeEntries(fileRoutes(root, pages))
84
+ : typeof router?.routes === "string"
85
+ ? JSON.parse(readFileSync(join(root, router.routes), "utf8"))
86
+ : null;
87
+ const options = {};
88
+ if (router?.base !== undefined)
89
+ options.base = router.base;
90
+ if (router?.linkActiveClass !== undefined)
91
+ options.linkActiveClass = router.linkActiveClass;
92
+ if (router?.linkExactActiveClass !== undefined)
93
+ options.linkExactActiveClass = router.linkExactActiveClass;
94
+ if (config.i18n) {
95
+ const dir = join(root, config.i18n.messages);
96
+ options.i18n = {
97
+ messages: Object.fromEntries(readdirSync(dir)
98
+ .filter((f) => f.endsWith(".json"))
99
+ .map((f) => [basename(f, ".json"), JSON.parse(readFileSync(join(dir, f), "utf8"))])),
100
+ locale: config.i18n.locale ?? "en",
101
+ ...(config.i18n.fallbackLocale !== undefined ? { fallbackLocale: config.i18n.fallbackLocale } : {}),
102
+ };
103
+ }
104
+ return { routes, options };
105
+ }
106
+ function casesIn(fixtures) {
107
+ if (!existsSync(fixtures))
108
+ return [];
109
+ return readdirSync(fixtures, { withFileTypes: true })
110
+ .filter((d) => d.isDirectory())
111
+ .toSorted((a, b) => a.name.localeCompare(b.name))
112
+ .flatMap((d) => readdirSync(join(fixtures, d.name))
113
+ .filter((f) => f.endsWith(".json"))
114
+ .toSorted()
115
+ .map((f) => ({ component: d.name, name: f, base: join(fixtures, d.name, basename(f, ".json")) })));
116
+ }
117
+ function recordedHtml(c) {
118
+ try {
119
+ return readFileSync(`${c.base}.html`, "utf8");
120
+ }
121
+ catch {
122
+ throw new Error(`${c.component}/${c.name} has no recorded HTML: record it from Vue with FERROVUE_FIXTURES_WRITE=1`);
123
+ }
124
+ }
125
+ function coverageProblems(fixtures, components, cases, files, given) {
126
+ const problems = [];
127
+ if (!existsSync(fixtures))
128
+ problems.push(`there is no fixtures directory at ${fixtures}`);
129
+ if (!files.length)
130
+ problems.push(`there are no components in ${components}`);
131
+ const withFixtures = new Set(cases.map((c) => c.component));
132
+ for (const name of files) {
133
+ if (!withFixtures.has(name))
134
+ problems.push(`${name} has no fixtures: add ${name}/<case>.json under ${fixtures}`);
135
+ if (!given.has(name))
136
+ problems.push(`${name} is not in \`components\``);
137
+ }
138
+ for (const name of withFixtures)
139
+ if (!files.includes(name))
140
+ problems.push(`the fixtures in ${name}/ name no component`);
141
+ for (const name of given.keys())
142
+ if (!files.includes(name))
143
+ problems.push(`\`components\` has ${name}, which is not a .vue file the configuration compiles`);
144
+ return problems;
145
+ }
146
+ function driftProblems(root, config) {
147
+ const out = join(root, config.out);
148
+ const files = generate(root, config);
149
+ const problems = [];
150
+ for (const [file, text] of files) {
151
+ const path = join(out, file);
152
+ if (!existsSync(path))
153
+ problems.push(`${file} is missing`);
154
+ else {
155
+ const committed = readFileSync(path, "utf8");
156
+ if (committed !== text)
157
+ problems.push(unifiedDiff(relative(root, path), committed, text).trimEnd());
158
+ }
159
+ }
160
+ const stale = existsSync(out) ? readdirSync(out).filter((f) => f.endsWith(".rs") && !files.has(f)) : [];
161
+ for (const file of stale.toSorted())
162
+ problems.push(`${file} is no longer generated`);
163
+ return problems;
164
+ }
165
+ async function hydrateFixture(app, html) {
166
+ if (typeof document === "undefined")
167
+ throw new Error("hydrating a fixture needs a DOM: run the suite with `environment: \"happy-dom\"` (or jsdom)");
168
+ placeHead(recordedHead(html));
169
+ document.body.innerHTML = hydrationBody(html);
170
+ const root = document.getElementById("root");
171
+ const first = root.firstChild;
172
+ const problems = [];
173
+ const { warn, error } = console;
174
+ console.warn = (...args) => void problems.push(`console.warn: ${args.map(String).join(" ")}`);
175
+ console.error = (...args) => void problems.push(`console.error: ${args.map(String).join(" ")}`);
176
+ app.config.warnHandler = (message) => void problems.push(`warning: ${message}`);
177
+ try {
178
+ app.mount(root);
179
+ await settled(app);
180
+ await headRendered();
181
+ }
182
+ finally {
183
+ console.warn = warn;
184
+ console.error = error;
185
+ }
186
+ if (root.firstChild !== first)
187
+ problems.push("Vue replaced the server's first node");
188
+ app.unmount();
189
+ await headRendered();
190
+ document.body.innerHTML = "";
191
+ placeHead(null);
192
+ return problems;
193
+ }
194
+ /** Register the conformance suite with the given `describe` and `it`. */
195
+ export function registerConformance(api, options) {
196
+ const configFile = resolve(options.config);
197
+ const root = dirname(configFile);
198
+ const config = loadConfig(root, configFile);
199
+ const fixtures = resolve(root, options.fixtures ?? "fixtures");
200
+ const record = options.record ?? process.env.FERROVUE_FIXTURES_WRITE === "1";
201
+ const dir = join(root, config.components);
202
+ const pagesDir = pagesFolder(config)?.replace(/\/+$/, "") ?? null;
203
+ const pages = pagesDir === null
204
+ ? []
205
+ : allPages(fileRoutes(root, pagesDir)).map((p) => ({ name: p.component, file: join(root, p.file), within: `${basename(pagesDir)}/${p.file.slice(pagesDir.length + 1)}` }));
206
+ const sources = new Map([
207
+ ...(existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".vue")) : []).map((f) => [basename(f, ".vue"), join(dir, f)]),
208
+ ...pages.map((p) => [p.name, p.file]),
209
+ ]);
210
+ const files = [...sources.keys()].toSorted();
211
+ const given = componentsByName(options.components, pages);
212
+ for (const [name, component] of given) {
213
+ const source = sources.get(name);
214
+ if (source)
215
+ attachSsrRender(source, name, component);
216
+ }
217
+ const cases = casesIn(fixtures);
218
+ const { routes, options: configured } = fixtureOptions(root, config);
219
+ const appOptions = { ...configured, pinia: options.pinia, vueRouter: options.vueRouter, vueI18n: options.vueI18n };
220
+ const app = async (c, hydrate = false) => {
221
+ const component = given.get(c.component);
222
+ if (!component)
223
+ throw new Error(`the fixtures in ${c.component}/ name no component in \`components\``);
224
+ const json = JSON.parse(readFileSync(`${c.base}.json`, "utf8"));
225
+ return fixtureApp(component, readFixture(json), routes, { ...appOptions, hydrate });
226
+ };
227
+ api.describe("conformance", () => {
228
+ api.it("has fixtures for every component, and a component for every fixture", () => {
229
+ const problems = coverageProblems(fixtures, dir, cases, files, given);
230
+ if (problems.length)
231
+ throw new Error(`the fixtures do not cover the components:\n${problems.join("\n")}`);
232
+ });
233
+ api.it("has generated Rust that is what the generator writes now", () => {
234
+ const problems = driftProblems(root, config);
235
+ if (problems.length) {
236
+ throw new Error(`the generated Rust in ${config.out} is not what ferrovue writes now: run \`ferrovue\` and commit the result\n${problems.join("\n")}`);
237
+ }
238
+ });
239
+ api.describe("Vue renders each fixture to its recorded HTML", () => {
240
+ for (const c of cases) {
241
+ api.it(`${c.component}/${c.name}`, async () => {
242
+ const html = await renderFixture(await app(c));
243
+ if (record) {
244
+ writeFileSync(`${c.base}.html`, html);
245
+ return;
246
+ }
247
+ const want = recordedHtml(c);
248
+ if (html !== want) {
249
+ const message = `${c.component}/${c.name}: Vue renders this fixture differently from its recorded HTML, ${firstDifference(want, html)}`;
250
+ throw Object.assign(new Error(message), { actual: html, expected: want, showDiff: true });
251
+ }
252
+ });
253
+ }
254
+ });
255
+ if (record)
256
+ return;
257
+ api.describe("the recorded HTML hydrates without a mismatch", () => {
258
+ for (const c of cases) {
259
+ api.it(`${c.component}/${c.name}`, async () => {
260
+ const problems = await hydrateFixture(await app(c, true), recordedHtml(c));
261
+ if (problems.length)
262
+ throw new Error(`${c.component}/${c.name} did not hydrate exactly:\n${problems.join("\n")}`);
263
+ });
264
+ }
265
+ });
266
+ });
267
+ }
268
+ /** Register vitest tests holding a project's components to Vue, as ferrovue's own suite does:
269
+ * every component has fixtures and every fixture a component, the generated Rust is what the
270
+ * generator writes now, Vue renders each fixture to its recorded `.html` (or records it, with
271
+ * `FERROVUE_FIXTURES_WRITE=1`), and each recorded `.html` hydrates with no warning and the server's
272
+ * nodes kept. Await it at the top of a test file run in a DOM environment such as happy-dom. */
273
+ export async function conformanceSuite(options) {
274
+ const vitest = await peer("vitest", "`conformanceSuite` registers vitest tests", () => import("vitest"));
275
+ registerConformance(vitest, options);
276
+ }
@@ -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 {};