ferrovue 0.5.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 (69) hide show
  1. package/README.md +201 -50
  2. package/dist/attrs.d.ts +2 -1
  3. package/dist/attrs.js +7 -4
  4. package/dist/children.d.ts +2 -2
  5. package/dist/children.js +66 -13
  6. package/dist/classes.d.ts +4 -0
  7. package/dist/classes.js +23 -1
  8. package/dist/client.d.ts +9 -4
  9. package/dist/client.js +106 -17
  10. package/dist/compiler.d.ts +1 -1
  11. package/dist/compiler.js +38 -10
  12. package/dist/component.d.ts +1 -1
  13. package/dist/component.js +2 -3
  14. package/dist/conformance.d.ts +64 -0
  15. package/dist/conformance.js +276 -0
  16. package/dist/context.d.ts +15 -6
  17. package/dist/context.js +3 -0
  18. package/dist/dynamic.d.ts +18 -0
  19. package/dist/dynamic.js +576 -0
  20. package/dist/errors.d.ts +168 -1
  21. package/dist/errors.js +96 -1
  22. package/dist/file-routes.d.ts +33 -0
  23. package/dist/file-routes.js +224 -0
  24. package/dist/fixture.d.ts +16 -0
  25. package/dist/fixture.js +22 -7
  26. package/dist/index.d.ts +1 -1
  27. package/dist/link-router.d.ts +1 -1
  28. package/dist/link-router.js +1 -0
  29. package/dist/lists.d.ts +4 -0
  30. package/dist/lists.js +2 -2
  31. package/dist/loops.js +4 -4
  32. package/dist/model.d.ts +11 -0
  33. package/dist/model.js +44 -1
  34. package/dist/numbers.js +3 -2
  35. package/dist/page-routes.d.ts +9 -0
  36. package/dist/page-routes.js +10 -0
  37. package/dist/plugin.d.ts +13 -1
  38. package/dist/plugin.js +3 -0
  39. package/dist/plugins/client-only.js +3 -0
  40. package/dist/plugins/head.d.ts +15 -0
  41. package/dist/plugins/head.js +385 -0
  42. package/dist/plugins/index.js +3 -1
  43. package/dist/plugins/provide.d.ts +44 -0
  44. package/dist/plugins/provide.js +528 -0
  45. package/dist/plugins/router-link.js +7 -3
  46. package/dist/plugins/router.d.ts +4 -1
  47. package/dist/plugins/router.js +56 -10
  48. package/dist/plugins/stores.js +19 -9
  49. package/dist/routes.d.ts +5 -2
  50. package/dist/routes.js +8 -2
  51. package/dist/rust.js +46 -18
  52. package/dist/script.js +19 -3
  53. package/dist/settle.d.ts +2 -0
  54. package/dist/settle.js +5 -0
  55. package/dist/slots.js +18 -4
  56. package/dist/ssr.d.ts +3 -0
  57. package/dist/ssr.js +29 -0
  58. package/dist/strings.d.ts +3 -0
  59. package/dist/strings.js +16 -2
  60. package/dist/template.js +92 -11
  61. package/dist/testing.d.ts +2 -3
  62. package/dist/testing.js +2 -29
  63. package/dist/types.d.ts +5 -0
  64. package/dist/typescript.js +7 -3
  65. package/dist/vhtml.d.ts +4 -0
  66. package/dist/vhtml.js +63 -0
  67. package/dist/vite.d.ts +7 -1
  68. package/dist/vite.js +65 -5
  69. package/package.json +15 -1
@@ -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
+ }
package/dist/context.d.ts CHANGED
@@ -38,15 +38,17 @@ export interface Config {
38
38
  twins?: Record<string, TwinSpec>;
39
39
  /** The Rust type a `TrustedHtml` prop is, which must implement `ferrovue::TrustedHtml`, e.g.
40
40
  * `crate::sanitize::SafeHtml`. A type that borrows names the props' lifetime, `'a`:
41
- * `crate::render::Trusted<'a>`. Without it, a component cannot use `v-html` at all. */
41
+ * `crate::render::Trusted<'a>`. `ferrovue::InlineHtml` makes every `TrustedHtml` prop inline HTML.
42
+ * Without it, only an `InlineHtml` prop may reach `v-html`. */
42
43
  trustedHtml?: string;
43
44
  /** A JSON file listing the app's routes as vue-router paths (`/users/:id`), which `<RouterLink>`
44
- * resolves against. Without it, a component cannot use `<RouterLink>`. */
45
- routes?: string;
46
- /** The router, in full: its routes file, the history's base, and the class names
47
- * `createRouter` gives active links. `routes` alone is shorthand for `{ routes }`. */
45
+ * resolves against, or `{ pages }`: a folder of pages whose file names give the routes, as
46
+ * vue-router's file-based routing reads them. Without it, a component cannot use `<RouterLink>`. */
47
+ routes?: RoutesSource;
48
+ /** The router, in full: its routes, the history's base, and the class names `createRouter`
49
+ * gives active links. `routes` alone is shorthand for `{ routes }`. */
48
50
  router?: {
49
- routes: string;
51
+ routes: RoutesSource;
50
52
  base?: string;
51
53
  linkActiveClass?: string;
52
54
  linkExactActiveClass?: string;
@@ -77,10 +79,16 @@ export interface Config {
77
79
  builders?: boolean;
78
80
  }
79
81
  export type ScopeIdMode = "filepath" | "filepath-source";
82
+ /** Where the routes come from: a JSON file of routes, or a folder of pages. */
83
+ export type RoutesSource = string | {
84
+ pages: string;
85
+ };
80
86
  export declare const CONFIG_FILE = "ferrovue.config.json";
81
87
  /** Read the project's configuration. */
82
88
  export declare function loadConfig(root: string, configPath?: string): Config;
83
89
  export declare function tyOfName(name: TypeName): Ty;
90
+ /** The Rust type of an `InlineHtml` prop, and of a `TrustedHtml` one when `trustedHtml` names it. */
91
+ export declare const INLINE_HTML = "ferrovue::InlineHtml";
84
92
  /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
85
93
  export declare const TYPES_MODULE = "ferrovue/types";
86
94
  export declare const ctx: {
@@ -110,4 +118,5 @@ export declare const ctx: {
110
118
  components: Map<string, Component>;
111
119
  narrowCount: number;
112
120
  componentsDir: string;
121
+ vnodeTag: string | null;
113
122
  };
package/dist/context.js CHANGED
@@ -54,6 +54,8 @@ export function tyOfName(name) {
54
54
  throw new GenError("FV1107", `unknown helper type \`${String(name)}\``);
55
55
  }
56
56
  }
57
+ /** The Rust type of an `InlineHtml` prop, and of a `TrustedHtml` one when `trustedHtml` names it. */
58
+ export const INLINE_HTML = "ferrovue::InlineHtml";
57
59
  /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
58
60
  export const TYPES_MODULE = "ferrovue/types";
59
61
  export const ctx = {
@@ -74,4 +76,5 @@ export const ctx = {
74
76
  components: new Map(),
75
77
  narrowCount: 0,
76
78
  componentsDir: "",
79
+ vnodeTag: null,
77
80
  };
@@ -0,0 +1,18 @@
1
+ import { type Component, type N, type Scope } from "./model.ts";
2
+ import { Emitter } from "./emitter.ts";
3
+ /** Whether the content a parent gives `slot` of `child` is rendered as virtual nodes, as it is
4
+ * inside an element a `<component :is>` chooses. */
5
+ export declare function slotAsVnodes(child: Component, slot: string): boolean;
6
+ /** Whether a statement of a compiled template renders a `<component :is>`. */
7
+ export declare function isDynamicComponent(st: N): boolean;
8
+ /** Render `_ssrRenderVNode` of a `<component :is>`, or return false for a statement that is not one. */
9
+ export declare function dynamicComponent(s: Scope, e: Emitter, st: N): boolean;
10
+ /** Resolve every `<component :is>` of the components before any is generated: the slots whose
11
+ * content renders as virtual nodes, and the compiled templates as the analyses read them, with
12
+ * each `<component :is>` written out as its choices. */
13
+ export declare function prepareDynamic(read: {
14
+ comp: Component;
15
+ ast: N[];
16
+ ssr: string;
17
+ scope: Scope;
18
+ }[]): string[];