ferrovue 0.1.0 → 0.2.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.
package/dist/testing.d.ts CHANGED
@@ -1,23 +1,3 @@
1
- import { type App, type Component } from "vue";
2
- export interface Fixture {
3
- props: Record<string, unknown>;
4
- slots: Record<string, string>;
5
- route: string;
6
- stores: Record<string, unknown>;
7
- }
8
- /** A fixture's JSON, split into the props and the rest. */
9
- export declare function readFixture(json: Record<string, unknown>): Fixture;
1
+ import type { Component } from "vue";
2
+ export { fixtureApp, readFixture, routeRecords, type Fixture, type RouteEntry, type RouterOptions } from "./fixture.ts";
10
3
  export declare function attachSsrRender(file: string, name: string, component: Component): void;
11
- /** A route as a routes file lists it: a path, or a path and a name. */
12
- export type RouteEntry = string | {
13
- path: string;
14
- name?: string;
15
- };
16
- /** The router options ferrovue reproduces, as the project's configuration gives them. */
17
- export interface RouterOptions {
18
- base?: string;
19
- linkActiveClass?: string;
20
- linkExactActiveClass?: string;
21
- }
22
- /** An app rendering one fixture of `component`: with a router over `routes` when there are any. */
23
- export declare function fixtureApp(component: Component, fixture: Fixture, routes: RouteEntry[] | null, options?: RouterOptions): Promise<App>;
package/dist/testing.js CHANGED
@@ -1,30 +1,10 @@
1
1
  /* What a conformance suite needs from Vue: a component's real server render, and an app that
2
- * renders one fixture — its props, its slots' content, and the location it is rendered at, under
3
- * the real vue-router.
4
- *
5
- * A fixture is a JSON object of props, plus two keys that are not props:
6
- * - `$slots`: each slot's content as HTML, rendered as a static node so both sides write it
7
- * exactly as given; `routerView` is the page `<RouterView>` shows.
8
- * - `$route`: the reader's location, `/` when absent.
9
- * - `$stores`: Pinia's state, by store id — every store the component reads, in full, because a
10
- * store absent here is built from its own `state()`, which the server never runs. */
2
+ * renders one fixture (`fixture.ts`, which describes a fixture's keys). */
11
3
  import { readFileSync } from "node:fs";
12
4
  import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
13
5
  import * as vue from "vue";
14
- import { createSSRApp, createStaticVNode, defineComponent, h } from "vue";
15
6
  import * as serverRenderer from "vue/server-renderer";
16
- import { createPinia } from "pinia";
17
- import { createMemoryHistory, createRouter } from "vue-router";
18
- /** A fixture's JSON, split into the props and the rest. */
19
- export function readFixture(json) {
20
- const { $slots, $route, $stores, ...props } = json;
21
- return {
22
- props,
23
- slots: ($slots ?? {}),
24
- route: $route ?? "/",
25
- stores: ($stores ?? {}),
26
- };
27
- }
7
+ export { fixtureApp, readFixture, routeRecords } from "./fixture.js";
28
8
  /* A bundler compiles a `.vue` file for the browser, and `renderToString` on a component without
29
9
  * `ssrRender` falls back to rendering its virtual DOM — a different path with different output
30
10
  * (attribute order, `v-model` state, comment markers). So each component is given the `ssrRender`
@@ -32,10 +12,18 @@ export function readFixture(json) {
32
12
  export function attachSsrRender(file, name, component) {
33
13
  const { descriptor } = parseSfc(readFileSync(file, "utf8"), { filename: file });
34
14
  const script = compileScript(descriptor, { id: name });
15
+ // A `<style scoped>` component's id is the one `@vitejs/plugin-vue` gave it, which its client
16
+ // build carries and which ferrovue computes the same way.
17
+ const scopeId = component.__scopeId;
18
+ if (!scopeId && descriptor.styles.some((st) => st.scoped)) {
19
+ throw new Error(`${file}: a \`<style scoped>\` component without \`__scopeId\`: load it through \`@vitejs/plugin-vue\``);
20
+ }
35
21
  const { code } = compileTemplate({
36
22
  source: descriptor.template.content,
37
23
  filename: file,
38
- id: name,
24
+ id: scopeId ?? name,
25
+ scoped: !!scopeId,
26
+ slotted: descriptor.slotted,
39
27
  ssr: true,
40
28
  ssrCssVars: [],
41
29
  compilerOptions: { bindingMetadata: script.bindings },
@@ -47,35 +35,3 @@ export function attachSsrRender(file, name, component) {
47
35
  // eslint-disable-next-line @typescript-eslint/no-implied-eval
48
36
  component.ssrRender = new Function("__vue", "__sr", body)(vue, serverRenderer);
49
37
  }
50
- /** The top-level nodes in a piece of HTML, which a static node needs to hydrate. */
51
- function nodeCount(html) {
52
- const t = document.createElement("template");
53
- t.innerHTML = html;
54
- return t.content.childNodes.length;
55
- }
56
- const staticNode = (html) => createStaticVNode(html, nodeCount(html));
57
- /** An app rendering one fixture of `component`: with a router over `routes` when there are any. */
58
- export async function fixtureApp(component, fixture, routes, options = {}) {
59
- const slots = Object.fromEntries(Object.entries(fixture.slots)
60
- .filter(([name]) => name !== "routerView")
61
- .map(([name, html]) => [name, () => [staticNode(html)]]));
62
- const app = createSSRApp({ render: () => h(component, fixture.props, slots) });
63
- // As the client hydrates: the state the server rendered with, set before any store is first used.
64
- const pinia = createPinia();
65
- pinia.state.value = structuredClone(fixture.stores);
66
- app.use(pinia);
67
- if (routes) {
68
- const view = fixture.slots.routerView ?? "";
69
- const View = defineComponent({ render: () => staticNode(view) });
70
- const router = createRouter({
71
- history: createMemoryHistory(options.base),
72
- routes: routes.map((r) => (typeof r === "string" ? { path: r, component: View } : { ...r, component: View })),
73
- ...(options.linkActiveClass !== undefined ? { linkActiveClass: options.linkActiveClass } : {}),
74
- ...(options.linkExactActiveClass !== undefined ? { linkExactActiveClass: options.linkExactActiveClass } : {}),
75
- });
76
- app.use(router);
77
- await router.push(fixture.route);
78
- await router.isReady();
79
- }
80
- return app;
81
- }
package/dist/types.d.ts CHANGED
@@ -2,4 +2,5 @@ declare const trusted: unique symbol;
2
2
  export type TrustedHtml = string & {
3
3
  readonly [trusted]: true;
4
4
  };
5
+ export type Float = number;
5
6
  export {};
@@ -0,0 +1,31 @@
1
+ import { type Component, type Field, type N, type Struct, type Ty } from "./model.ts";
2
+ /** The local names \`TrustedHtml\` and \`Float\` are imported under from \`ferrovue/types\`. */
3
+ export declare function typesImports(comp: Component, body: N[]): void;
4
+ export declare function tyOfTs(comp: Component, t: N, structs: Map<string, Struct>, seen?: Set<string>): Ty;
5
+ /** Every local name an interface or object type alias declares a struct for, and every other
6
+ * alias: what a type in that block may name. */
7
+ export declare function declareTypes(comp: Component, body: N[], structs: Map<string, Struct>, aliases: Map<string, N>): N[];
8
+ /** A relative import's file, resolved as a bundler resolves a TypeScript import. */
9
+ export declare function resolveImport(fromFile: string, spec: string): string | null;
10
+ /** A shared `.ts` file of types, read once: its interfaces and object types become structs in
11
+ * `types.rs`, its other aliases are resolved where they are used. */
12
+ export declare function readTypeFile(file: string): void;
13
+ /** A type read in the file that declares it, marked with where it lives for readers elsewhere. */
14
+ export declare function markHome(ty: Ty, home: string): Ty;
15
+ /** Where a struct type is declared: its fields, the component whose types its fields name, and
16
+ * the path the generated code names it by. */
17
+ export declare function lookupStruct(comp: Component, ty: Ty & {
18
+ k: "struct";
19
+ }): {
20
+ st: Struct | undefined;
21
+ owner: Component;
22
+ path: string;
23
+ };
24
+ export declare function structOf(comp: Component, name: string, members: N[], structs: Map<string, Struct>): Struct;
25
+ /** The type literal (or interface) `defineProps<...>()` was given, seen through `withDefaults`. */
26
+ export declare function definePropsType(call: N): N | null;
27
+ /** The `default` of each prop in the runtime declaration `compileScript` wrote, by prop name. */
28
+ export declare function runtimeDefaults(comp: Component, content: string): Map<string, N>;
29
+ /** A prop's default as a Rust value of its type: a literal, or an empty list from `() => []`. */
30
+ export declare function defaultValue(comp: Component, f: Field, node: N): string;
31
+ export declare function markStore(ty: Ty): Ty;
@@ -0,0 +1,310 @@
1
+ /* TypeScript types read as Rust ones: props, interfaces, aliases, shared type files, defaults. */
2
+ import { parse as parseJs } from "@babel/parser";
3
+ import { readFileSync, statSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { BOOL, fail, FLOAT, INT, opt, RUST_PRELUDE, rustStr, sameTy, snake, STR, tagAst } from "./model.js";
6
+ import { CONFIG_FILE, ctx, TYPES_MODULE } from "./context.js";
7
+ import { childOf } from "./expr.js";
8
+ import { storeHome } from "./stores.js";
9
+ /** The local names \`TrustedHtml\` and \`Float\` are imported under from \`ferrovue/types\`. */
10
+ export function typesImports(comp, body) {
11
+ for (const s of body) {
12
+ if (s.type !== "ImportDeclaration" || s.source.value !== TYPES_MODULE)
13
+ continue;
14
+ for (const sp of s.specifiers) {
15
+ if (sp.type !== "ImportSpecifier")
16
+ continue;
17
+ const name = sp.imported.name ?? sp.imported.value;
18
+ if (name === "TrustedHtml")
19
+ comp.trustedName = sp.local.name;
20
+ if (name === "Float")
21
+ comp.floatName = sp.local.name;
22
+ }
23
+ }
24
+ }
25
+ export function tyOfTs(comp, t, structs, seen = new Set()) {
26
+ switch (t.type) {
27
+ case "TSStringKeyword":
28
+ return STR;
29
+ case "TSNumberKeyword":
30
+ return INT;
31
+ case "TSBooleanKeyword":
32
+ return BOOL;
33
+ case "TSArrayType":
34
+ return { k: "list", of: tyOfTs(comp, t.elementType, structs, seen) };
35
+ case "TSParenthesizedType":
36
+ return tyOfTs(comp, t.typeAnnotation, structs, seen);
37
+ case "TSTypeLiteral": {
38
+ // `{ [key: string]: T }`, which is `Record<string, T>`. Any other object type is an interface.
39
+ const [sig] = t.members;
40
+ const key = sig?.parameters?.[0]?.typeAnnotation?.typeAnnotation;
41
+ if (t.members.length === 1 && sig.type === "TSIndexSignature" && key?.type === "TSStringKeyword" && sig.typeAnnotation) {
42
+ return recordOf(comp, sig.typeAnnotation.typeAnnotation, structs, seen);
43
+ }
44
+ return fail(comp, "an object type in place: declare it as an interface, or as `{ [key: string]: T }` for a dictionary", t);
45
+ }
46
+ case "TSTypeOperator":
47
+ // `readonly string[]`: the same list, which the server never writes to anyway.
48
+ if (t.operator === "readonly")
49
+ return tyOfTs(comp, t.typeAnnotation, structs, seen);
50
+ break;
51
+ case "TSLiteralType":
52
+ // A literal type is a value of its kind: `"sm"` a string, `3` a number.
53
+ if (t.literal.type === "StringLiteral" || t.literal.type === "TemplateLiteral")
54
+ return STR;
55
+ if (t.literal.type === "NumericLiteral")
56
+ return Number.isInteger(t.literal.value) ? INT : FLOAT;
57
+ if (t.literal.type === "BooleanLiteral")
58
+ return BOOL;
59
+ break;
60
+ case "TSUnionType": {
61
+ // `"sm" | "md"` is a string; `T | undefined` is an optional `T`. `null` is a different value.
62
+ const parts = t.types.filter((u) => u.type !== "TSUndefinedKeyword");
63
+ if (t.types.some((u) => u.type === "TSNullKeyword" || (u.type === "TSLiteralType" && u.literal.type === "NullLiteral"))) {
64
+ return fail(comp, "`null` in a type: use `undefined`, which is what an absent value is", t);
65
+ }
66
+ const tys = parts.map((u) => tyOfTs(comp, u, structs, seen));
67
+ const first = tys[0];
68
+ if (!first || tys.some((x) => !sameTy(x, first)))
69
+ return fail(comp, "a union of different types has no Rust type", t);
70
+ return parts.length < t.types.length ? opt(first) : first;
71
+ }
72
+ case "TSTypeReference": {
73
+ const name = t.typeName.type === "Identifier" ? t.typeName.name : undefined;
74
+ if (name === undefined)
75
+ break;
76
+ // `Array<T>` and `ReadonlyArray<T>`, as `T[]`.
77
+ if ((name === "Array" || name === "ReadonlyArray") && t.typeParameters?.params?.length === 1) {
78
+ return { k: "list", of: tyOfTs(comp, t.typeParameters.params[0], structs, seen) };
79
+ }
80
+ // `Record<string, T>`, an object used as a dictionary.
81
+ if (name === "Record" && t.typeParameters?.params?.length === 2) {
82
+ const [key, value] = t.typeParameters.params;
83
+ if (key.type !== "TSStringKeyword")
84
+ fail(comp, "a `Record` is keyed by `string`", key);
85
+ return recordOf(comp, value, structs, seen);
86
+ }
87
+ if (structs.has(name))
88
+ return { k: "struct", name };
89
+ if (comp.childProps.has(name))
90
+ return { k: "child", name: comp.childProps.get(name) };
91
+ if (comp.floatName !== null && name === comp.floatName)
92
+ return FLOAT;
93
+ if (comp.trustedName !== null && name === comp.trustedName) {
94
+ if (ctx.trustedHtml === null) {
95
+ fail(comp, `a \`TrustedHtml\` prop needs \`trustedHtml\` in ${CONFIG_FILE}: the Rust type it is`, t);
96
+ }
97
+ return { k: "html" };
98
+ }
99
+ const imported = comp.importedTypes.get(name);
100
+ if (imported)
101
+ return imported;
102
+ const alias = comp.aliases.get(name);
103
+ if (alias) {
104
+ if (seen.has(name))
105
+ fail(comp, `type \`${name}\` refers to itself through an alias`, t);
106
+ return tyOfTs(comp, alias, structs, new Set(seen).add(name));
107
+ }
108
+ return fail(comp, `unsupported prop type \`${name}\`: declare it as an interface in the component, or import it from a \`.ts\` file`, t);
109
+ }
110
+ }
111
+ return fail(comp, `unsupported prop type \`${t.type}\``, t);
112
+ }
113
+ /** A dictionary's type: its values strings, numbers, booleans, objects or lists of those. A value
114
+ * may not be optional: JSON has no `undefined` to hold. */
115
+ function recordOf(comp, value, structs, seen) {
116
+ const of = tyOfTs(comp, value, structs, seen);
117
+ const plain = (t) => ["str", "int", "float", "bool", "struct", "child"].includes(t.k) || (t.k === "list" && plain(t.of));
118
+ if (!plain(of))
119
+ fail(comp, "a `Record`'s values are strings, numbers, booleans, objects or lists of those", value);
120
+ return { k: "record", of };
121
+ }
122
+ /** Every local name an interface or object type alias declares a struct for, and every other
123
+ * alias: what a type in that block may name. */
124
+ export function declareTypes(comp, body, structs, aliases) {
125
+ const decls = [];
126
+ for (const st of body) {
127
+ const d = st.type === "ExportNamedDeclaration" ? st.declaration : st;
128
+ if (d?.type === "TSInterfaceDeclaration")
129
+ decls.push({ name: d.id.name, members: d.body.body, node: d });
130
+ else if (d?.type === "TSTypeAliasDeclaration") {
131
+ // An object type is a struct — but for a dictionary, `{ [key: string]: T }`, an alias of its own.
132
+ const dictionary = d.typeAnnotation.members?.some((m) => m.type === "TSIndexSignature");
133
+ if (d.typeAnnotation.type === "TSTypeLiteral" && !dictionary)
134
+ decls.push({ name: d.id.name, members: d.typeAnnotation.members, node: d });
135
+ else
136
+ aliases.set(d.id.name, d.typeAnnotation);
137
+ }
138
+ }
139
+ for (const d of decls) {
140
+ if (RUST_PRELUDE.has(d.name)) {
141
+ fail(comp, `an interface called \`${d.name}\` would hide Rust's own \`${d.name}\` in the generated code; rename it`, d.node);
142
+ }
143
+ structs.set(d.name, { name: d.name, fields: [] });
144
+ }
145
+ return decls;
146
+ }
147
+ /** A relative import's file, resolved as a bundler resolves a TypeScript import. */
148
+ export function resolveImport(fromFile, spec) {
149
+ if (!spec.startsWith("."))
150
+ return null;
151
+ const base = resolve(ctx.rootDir, dirname(fromFile), spec);
152
+ for (const candidate of [base, `${base}.ts`, join(base, "index.ts"), base.replace(/\.js$/, ".ts")]) {
153
+ try {
154
+ if (statSync(candidate).isFile() && candidate.endsWith(".ts"))
155
+ return candidate;
156
+ }
157
+ catch {
158
+ // not this one
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+ /** A shared `.ts` file of types, read once: its interfaces and object types become structs in
164
+ * `types.rs`, its other aliases are resolved where they are used. */
165
+ export function readTypeFile(file) {
166
+ if (ctx.typeRead.has(file))
167
+ return;
168
+ ctx.typeRead.add(file);
169
+ const rel = relative(ctx.rootDir, file);
170
+ const home = storeHome(rel);
171
+ home.structs = ctx.typeStructs;
172
+ home.aliases = ctx.typeAliases;
173
+ home.source = readFileSync(file, "utf8");
174
+ const body = parseJs(home.source, { sourceType: "module", plugins: ["typescript"] }).program.body;
175
+ tagAst(body, "source");
176
+ typesImports(home, body);
177
+ const before = new Set(ctx.typeStructs.keys());
178
+ const local = new Map();
179
+ const decls = declareTypes(home, body, local, ctx.typeAliases);
180
+ for (const d of decls) {
181
+ if (before.has(d.name))
182
+ fail(home, `\`${d.name}\` is declared by ${ctx.typeFiles.get(d.name)} too`, d.node);
183
+ ctx.typeStructs.set(d.name, local.get(d.name));
184
+ ctx.typeFiles.set(d.name, rel);
185
+ }
186
+ for (const d of decls) {
187
+ const st = structOf(home, d.name, d.members, ctx.typeStructs);
188
+ for (const f of st.fields)
189
+ f.ty = markHome(f.ty, "types");
190
+ ctx.typeStructs.set(d.name, st);
191
+ }
192
+ }
193
+ /** A type read in the file that declares it, marked with where it lives for readers elsewhere. */
194
+ export function markHome(ty, home) {
195
+ if (ty.k === "struct" && !ty.store && !ty.home && ty.name !== "Props")
196
+ return { ...ty, home };
197
+ if (ty.k === "opt" || ty.k === "list" || ty.k === "record")
198
+ return { ...ty, of: markHome(ty.of, home) };
199
+ return ty;
200
+ }
201
+ /** Where a struct type is declared: its fields, the component whose types its fields name, and
202
+ * the path the generated code names it by. */
203
+ export function lookupStruct(comp, ty) {
204
+ // Named from the module being written: plainly within its own, by path from any other.
205
+ if (ty.store)
206
+ return { st: ctx.storeStructs.get(ty.name), owner: comp, path: comp.module === "stores" ? "" : "super::stores::" };
207
+ if (ty.home === "types")
208
+ return { st: ctx.typeStructs.get(ty.name), owner: comp, path: comp.module === "types" ? "" : "super::types::" };
209
+ if (ty.home !== undefined && ty.home !== comp.name) {
210
+ const owner = childOf(ty.home);
211
+ return { st: owner.structs.get(ty.name), owner, path: `super::${owner.module}::` };
212
+ }
213
+ return { st: ty.name === "Props" ? comp.props : comp.structs.get(ty.name), owner: comp, path: "" };
214
+ }
215
+ export function structOf(comp, name, members, structs) {
216
+ const fields = [];
217
+ for (const m of members) {
218
+ if (m.type !== "TSPropertySignature" || m.key.type !== "Identifier") {
219
+ fail(comp, `\`${name}\` may only hold plain named fields`, m);
220
+ }
221
+ const base = tyOfTs(comp, m.typeAnnotation.typeAnnotation, structs);
222
+ if (m.optional && base.k === "opt") {
223
+ fields.push({ js: m.key.name, rust: snake(m.key.name), ty: base });
224
+ continue;
225
+ }
226
+ fields.push({ js: m.key.name, rust: snake(m.key.name), ty: m.optional ? opt(base) : base });
227
+ }
228
+ return { name, fields };
229
+ }
230
+ /** The type literal (or interface) `defineProps<...>()` was given, seen through `withDefaults`. */
231
+ export function definePropsType(call) {
232
+ if (call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "withDefaults") {
233
+ return definePropsType(call.arguments[0]);
234
+ }
235
+ if (call?.type === "CallExpression" &&
236
+ call.callee.type === "Identifier" &&
237
+ call.callee.name === "defineProps") {
238
+ return call.typeParameters?.params?.[0] ?? null;
239
+ }
240
+ return null;
241
+ }
242
+ /** The `default` of each prop in the runtime declaration `compileScript` wrote, by prop name. */
243
+ export function runtimeDefaults(comp, content) {
244
+ const out = new Map();
245
+ let program;
246
+ try {
247
+ program = parseJs(content, { sourceType: "module", plugins: ["typescript"] }).program.body;
248
+ }
249
+ catch {
250
+ return out;
251
+ }
252
+ /** An object literal's properties, with `...{ … }` spreads opened, as `defineModel` writes them. */
253
+ const props = (o) => o?.type !== "ObjectExpression"
254
+ ? []
255
+ : o.properties.flatMap((p) => (p.type === "SpreadElement" ? props(p.argument) : [p]));
256
+ const visit = (n) => {
257
+ if (!n || typeof n !== "object")
258
+ return;
259
+ if (n.type === "ObjectProperty" && !n.computed && (n.key.name ?? n.key.value) === "props") {
260
+ // `props: { … }`, or `_mergeModels({ … }, { … })` when there is a `defineModel`.
261
+ const objects = n.value.type === "CallExpression" ? n.value.arguments : [n.value];
262
+ for (const o of objects) {
263
+ for (const p of props(o)) {
264
+ if (p.type !== "ObjectProperty")
265
+ continue;
266
+ const d = props(p.value).find((q) => q.type === "ObjectProperty" && (q.key.name ?? q.key.value) === "default");
267
+ if (d)
268
+ out.set(p.key.name ?? p.key.value, d.value);
269
+ }
270
+ }
271
+ return;
272
+ }
273
+ for (const k of Object.keys(n)) {
274
+ if (k === "loc" || k === "start" || k === "end")
275
+ continue;
276
+ const v = n[k];
277
+ if (Array.isArray(v))
278
+ v.forEach(visit);
279
+ else if (v && typeof v === "object" && typeof v.type === "string")
280
+ visit(v);
281
+ }
282
+ };
283
+ program.forEach(visit);
284
+ void comp;
285
+ return out;
286
+ }
287
+ /** A prop's default as a Rust value of its type: a literal, or an empty list from `() => []`. */
288
+ export function defaultValue(comp, f, node) {
289
+ const of = f.ty.k === "opt" ? f.ty.of : f.ty;
290
+ if (of.k === "str" && node.type === "StringLiteral")
291
+ return rustStr(node.value);
292
+ if (of.k === "int" && node.type === "NumericLiteral" && Number.isInteger(node.value))
293
+ return `${node.value}i64`;
294
+ if (of.k === "int" && node.type === "UnaryExpression" && node.operator === "-" && node.argument.type === "NumericLiteral" && Number.isInteger(node.argument.value)) {
295
+ return `-${node.argument.value}i64`;
296
+ }
297
+ if (of.k === "bool" && node.type === "BooleanLiteral")
298
+ return String(node.value);
299
+ if (of.k === "list" && node.type === "ArrowFunctionExpression" && node.body.type === "ArrayExpression" && node.body.elements.length === 0) {
300
+ return "&[]";
301
+ }
302
+ return fail(comp, `the default of \`${f.js}\` must be a literal of its type, or \`() => []\` for a list`, node);
303
+ }
304
+ export function markStore(ty) {
305
+ if (ty.k === "struct")
306
+ return { ...ty, store: true };
307
+ if (ty.k === "opt" || ty.k === "list" || ty.k === "record")
308
+ return { ...ty, of: markStore(ty.of) };
309
+ return ty;
310
+ }
package/dist/vite.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { Plugin, ResolvedConfig } from "vite";
2
+ export interface FerrovueOptions {
3
+ /** The directory holding `ferrovue.config.json`: Vite's working directory by default. */
4
+ root?: string;
5
+ }
6
+ /** Whether a changed file can change what is generated: a component, a store or type file, the
7
+ * routes or the configuration — anything but the output itself, dependencies and build output. */
8
+ export declare function affects(root: string, file: string): boolean;
9
+ /** How `@vitejs/plugin-vue`, as this build configures it, computes a `<style scoped>` id: from the
10
+ * path alone or the path and source, hashed from Vite's root. `null` without the plugin, or with an
11
+ * id generator of its own. */
12
+ export declare function vueScopeIds(config: ResolvedConfig): {
13
+ mode: "filepath" | "filepath-source";
14
+ root: string;
15
+ } | null;
16
+ /** Why the server's scope ids would differ from the client's, when a component has `<style scoped>`
17
+ * and `ferrovue.config.json` computes them otherwise than plugin-vue: a page would hydrate cleanly
18
+ * and show unstyled. */
19
+ export declare function scopeIdMismatch(root: string, vue: {
20
+ mode: string;
21
+ root: string;
22
+ } | null): string | null;
23
+ export default function ferrovue(options?: FerrovueOptions): Plugin;
package/dist/vite.js ADDED
@@ -0,0 +1,114 @@
1
+ /* `ferrovue/vite`: the Rust renderers regenerated as part of a Vite build and its dev server.
2
+ *
3
+ * import ferrovue from "ferrovue/vite";
4
+ * export default defineConfig({ plugins: [vue(), ferrovue()] });
5
+ *
6
+ * A build regenerates them once and fails on a construct ferrovue refuses, naming the line in the
7
+ * `.vue` file. The dev server regenerates them whenever a component, store, type file, the routes
8
+ * or the configuration changes, and shows a refusal in its error overlay. Only files whose text
9
+ * changed are rewritten, so `cargo watch` rebuilds no more than it must. */
10
+ import { readdirSync, readFileSync } from "node:fs";
11
+ import { join, relative, resolve, sep } from "node:path";
12
+ import { CONFIG_FILE, GenError, loadConfig, write } from "./compiler.js";
13
+ /** Whether a changed file can change what is generated: a component, a store or type file, the
14
+ * routes or the configuration — anything but the output itself, dependencies and build output. */
15
+ export function affects(root, file) {
16
+ const rel = relative(root, file);
17
+ if (rel.startsWith("..") || !/\.(vue|ts|json)$/.test(rel))
18
+ return false;
19
+ let out = "";
20
+ try {
21
+ out = loadConfig(root).out;
22
+ }
23
+ catch {
24
+ // A broken configuration ignores nothing; regenerating reports it.
25
+ }
26
+ const parts = rel.split(sep);
27
+ if (parts.some((p) => p === "node_modules" || p === "target" || p === ".git" || p === "dist"))
28
+ return false;
29
+ return out === "" || !(rel === out || rel.startsWith(out + sep));
30
+ }
31
+ /** How `@vitejs/plugin-vue`, as this build configures it, computes a `<style scoped>` id: from the
32
+ * path alone or the path and source, hashed from Vite's root. `null` without the plugin, or with an
33
+ * id generator of its own. */
34
+ export function vueScopeIds(config) {
35
+ const vue = config.plugins.find((p) => p.name === "vite:vue");
36
+ const generator = vue?.api?.options?.features?.componentIdGenerator;
37
+ if (!vue || (generator !== undefined && generator !== "filepath" && generator !== "filepath-source"))
38
+ return null;
39
+ return { mode: generator ?? (config.isProduction ? "filepath-source" : "filepath"), root: resolve(config.root) };
40
+ }
41
+ /** Why the server's scope ids would differ from the client's, when a component has `<style scoped>`
42
+ * and `ferrovue.config.json` computes them otherwise than plugin-vue: a page would hydrate cleanly
43
+ * and show unstyled. */
44
+ export function scopeIdMismatch(root, vue) {
45
+ if (!vue)
46
+ return null;
47
+ const config = loadConfig(root);
48
+ const mode = config.scopeId ?? "filepath-source";
49
+ const viteRoot = resolve(root, config.viteRoot ?? ".");
50
+ if (mode === vue.mode && viteRoot === vue.root)
51
+ return null;
52
+ let files = [];
53
+ try {
54
+ files = readdirSync(join(root, config.components)).filter((f) => f.endsWith(".vue"));
55
+ }
56
+ catch {
57
+ // No components: generating reports it.
58
+ }
59
+ if (!files.some((f) => /<style\b[^>]*\bscoped\b/.test(readFileSync(join(root, config.components, f), "utf8"))))
60
+ return null;
61
+ return `\`<style scoped>\` ids: @vitejs/plugin-vue hashes "${vue.mode}" from ${vue.root}, ${CONFIG_FILE} "${mode}" from ${viteRoot}; set \`scopeId\` and \`viteRoot\` to match, or plugin-vue's \`features.componentIdGenerator\``;
62
+ }
63
+ export default function ferrovue(options = {}) {
64
+ const root = resolve(options.root ?? process.cwd());
65
+ const regenerate = () => write(root, loadConfig(root));
66
+ let vue = null;
67
+ let building = true;
68
+ return {
69
+ name: "ferrovue",
70
+ configResolved(config) {
71
+ vue = vueScopeIds(config);
72
+ building = config.command === "build";
73
+ },
74
+ buildStart() {
75
+ try {
76
+ regenerate();
77
+ // A build whose styles would not apply to the server's pages fails, as a refusal does; the
78
+ // dev server warns.
79
+ const mismatch = scopeIdMismatch(root, vue);
80
+ if (mismatch && building)
81
+ this.error(`ferrovue: ${mismatch}`);
82
+ if (mismatch)
83
+ this.warn(`ferrovue: ${mismatch}`);
84
+ }
85
+ catch (e) {
86
+ if (e instanceof GenError || e instanceof SyntaxError)
87
+ this.error(e.message);
88
+ throw e;
89
+ }
90
+ },
91
+ configureServer(server) {
92
+ server.watcher.add(resolve(root, CONFIG_FILE));
93
+ const onChange = (file) => {
94
+ if (!affects(root, file))
95
+ return;
96
+ try {
97
+ const { changed, removed } = regenerate();
98
+ if (changed.length || removed.length) {
99
+ server.config.logger.info(`ferrovue: ${changed.length} changed, ${removed.length} removed`, { timestamp: true });
100
+ }
101
+ }
102
+ catch (e) {
103
+ if (!(e instanceof GenError || e instanceof SyntaxError))
104
+ throw e;
105
+ server.config.logger.error(`ferrovue: ${e.message}`, { timestamp: true });
106
+ server.ws.send({ type: "error", err: { message: `ferrovue: ${e.message}`, stack: "", plugin: "ferrovue" } });
107
+ }
108
+ };
109
+ server.watcher.on("change", onChange);
110
+ server.watcher.on("add", onChange);
111
+ server.watcher.on("unlink", onChange);
112
+ },
113
+ };
114
+ }