ferrovue 0.1.0 → 0.3.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 (50) hide show
  1. package/README.md +277 -22
  2. package/dist/attrs.d.ts +67 -0
  3. package/dist/attrs.js +769 -0
  4. package/dist/cli.js +183 -14
  5. package/dist/client.d.ts +36 -0
  6. package/dist/client.js +93 -1
  7. package/dist/compiler.d.ts +20 -53
  8. package/dist/compiler.js +99 -3178
  9. package/dist/component.d.ts +6 -0
  10. package/dist/component.js +264 -0
  11. package/dist/context.d.ts +151 -0
  12. package/dist/context.js +102 -0
  13. package/dist/diff.d.ts +4 -0
  14. package/dist/diff.js +77 -0
  15. package/dist/emitter.d.ts +23 -0
  16. package/dist/emitter.js +58 -0
  17. package/dist/expr.d.ts +76 -0
  18. package/dist/expr.js +1305 -0
  19. package/dist/fixture.d.ts +38 -0
  20. package/dist/fixture.js +78 -0
  21. package/dist/i18n.d.ts +27 -0
  22. package/dist/i18n.js +175 -0
  23. package/dist/islands.d.ts +5 -0
  24. package/dist/islands.js +4 -0
  25. package/dist/lists.d.ts +19 -0
  26. package/dist/lists.js +253 -0
  27. package/dist/model.d.ts +269 -0
  28. package/dist/model.js +115 -0
  29. package/dist/parens.d.ts +51 -0
  30. package/dist/parens.js +239 -0
  31. package/dist/router.d.ts +17 -0
  32. package/dist/router.js +322 -0
  33. package/dist/rust.d.ts +17 -0
  34. package/dist/rust.js +523 -0
  35. package/dist/scoped.d.ts +24 -0
  36. package/dist/scoped.js +177 -0
  37. package/dist/script.d.ts +16 -0
  38. package/dist/script.js +332 -0
  39. package/dist/stores.d.ts +6 -0
  40. package/dist/stores.js +229 -0
  41. package/dist/template.d.ts +48 -0
  42. package/dist/template.js +1012 -0
  43. package/dist/testing.d.ts +2 -22
  44. package/dist/testing.js +11 -55
  45. package/dist/types.d.ts +1 -0
  46. package/dist/typescript.d.ts +31 -0
  47. package/dist/typescript.js +310 -0
  48. package/dist/vite.d.ts +23 -0
  49. package/dist/vite.js +157 -0
  50. package/package.json +30 -8
@@ -0,0 +1,6 @@
1
+ import { type Component, type N } from "./model.ts";
2
+ export declare function readComponent(file: string, root: string): {
3
+ comp: Component;
4
+ ast: N[];
5
+ ssr: string;
6
+ };
@@ -0,0 +1,264 @@
1
+ /* One `.vue` file read: its props, models, imported types, and Vue's SSR compilation of its template. */
2
+ import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
3
+ import { createHash } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
5
+ import { SourceMapConsumer } from "source-map-js";
6
+ import { basename, relative, resolve, sep } from "node:path";
7
+ import { fail, opt, snake, tagAst } from "./model.js";
8
+ import { ctx } from "./context.js";
9
+ import { typesImports, declareTypes, defaultValue, definePropsType, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
10
+ /** A Vue compiler error's position as a node \`fail\` can point at: its line and 1-based column,
11
+ * offset by where the template starts when the error is in the template's content. */
12
+ function vueErrorNode(err, within) {
13
+ const loc = err.loc;
14
+ if (!loc)
15
+ return undefined;
16
+ const { line, column } = loc.start;
17
+ const at = !within ? { line, column: column - 1 } : line === 1 ? { line: within.line, column: within.column - 1 + column - 1 } : { line: within.line + line - 1, column: column - 1 };
18
+ return { type: "VueError", loc: { start: at }, __fv: "source" };
19
+ }
20
+ /** `getHash` in `@vitejs/plugin-vue`: the first 8 hex digits of the SHA-256 of the file's path from
21
+ * Vite's root, with `/` between its parts, followed in `"filepath-source"` mode by its source. */
22
+ function scopeHash(file, source) {
23
+ const path = relative(ctx.viteRoot, resolve(file)).split(sep).join("/");
24
+ return createHash("sha256")
25
+ .update(ctx.scopeId === "filepath" ? path : path + source)
26
+ .digest("hex")
27
+ .slice(0, 8);
28
+ }
29
+ /** `inheritAttrs` as `defineOptions({ inheritAttrs })` or a plain `<script>`'s `export default`
30
+ * sets it: a literal, since it decides what reaches the root. */
31
+ function inheritAttrs(comp, statements) {
32
+ let found = true;
33
+ for (const st of statements) {
34
+ const call = st.type === "ExpressionStatement" ? st.expression : null;
35
+ const options = call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "defineOptions"
36
+ ? call.arguments[0]
37
+ : st.type === "ExportDefaultDeclaration"
38
+ ? st.declaration
39
+ : null;
40
+ if (options?.type !== "ObjectExpression")
41
+ continue;
42
+ for (const p of options.properties) {
43
+ if (p.type !== "ObjectProperty" || p.computed || (p.key.name ?? p.key.value) !== "inheritAttrs")
44
+ continue;
45
+ if (p.value.type !== "BooleanLiteral")
46
+ fail(comp, "`inheritAttrs` is `true` or `false`", p.value);
47
+ found = p.value.value;
48
+ }
49
+ }
50
+ return found;
51
+ }
52
+ export function readComponent(file, root) {
53
+ const name = basename(file, ".vue");
54
+ const source = readFileSync(file, "utf8");
55
+ const { descriptor, errors } = parseSfc(source, { filename: file });
56
+ const rel = relative(root, file);
57
+ const comp = {
58
+ name,
59
+ module: snake(name),
60
+ file: rel,
61
+ props: { name: "Props", fields: [] },
62
+ structs: new Map(),
63
+ trustedName: null,
64
+ floatName: null,
65
+ childProps: new Map(),
66
+ imports: new Set(),
67
+ slotNames: [],
68
+ routerView: false,
69
+ routerLink: false,
70
+ readsRoute: false,
71
+ usesRoute: false,
72
+ readsStores: false,
73
+ usesStores: false,
74
+ readsI18n: false,
75
+ usesI18n: false,
76
+ readsTeleports: false,
77
+ usesTeleports: false,
78
+ models: new Map(),
79
+ aliases: new Map(),
80
+ importedTypes: new Map(),
81
+ slotShapes: new Map(),
82
+ scopeId: null,
83
+ slotted: false,
84
+ inheritAttrs: true,
85
+ inherits: false,
86
+ passesSlotIds: false,
87
+ attrNames: new Set(),
88
+ idsInAttrs: false,
89
+ };
90
+ comp.source = source;
91
+ if (errors.length)
92
+ fail(comp, String(errors[0]), vueErrorNode(errors[0]));
93
+ if (!descriptor.scriptSetup || !descriptor.template) {
94
+ fail(comp, "an island needs `<script setup lang=\"ts\">` and a `<template>`");
95
+ }
96
+ // A global `<style>` block changes no markup. A scoped one adds `data-v-…` attributes, whose id is
97
+ // computed here as the bundler computes it. A CSS module renames classes, and `v-bind()` in CSS
98
+ // writes variables onto the root, neither of which the server reproduces.
99
+ for (const st of descriptor.styles) {
100
+ if (st.module)
101
+ fail(comp, "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet");
102
+ }
103
+ if (descriptor.cssVars.length)
104
+ fail(comp, "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead");
105
+ if (descriptor.styles.some((st) => st.scoped))
106
+ comp.scopeId = `data-v-${scopeHash(file, source)}`;
107
+ comp.slotted = descriptor.slotted;
108
+ const script = compileScript(descriptor, { id: name });
109
+ const ast = script.scriptSetupAst ?? [];
110
+ tagAst(ast, "source");
111
+ // A plain `<script>` beside the setup one may declare the types the setup block uses.
112
+ const plainAst = script.scriptAst ?? [];
113
+ tagAst(plainAst, "source");
114
+ for (const s of [...plainAst, ...ast]) {
115
+ if (s.type !== "ImportDeclaration" || !s.source.value.endsWith(".vue"))
116
+ continue;
117
+ const from = basename(s.source.value, ".vue");
118
+ if (s.specifiers.some((sp) => sp.type === "ImportDefaultSpecifier"))
119
+ comp.imports.add(from);
120
+ for (const sp of s.specifiers) {
121
+ if (sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === "Props") {
122
+ comp.childProps.set(sp.local.name, from);
123
+ }
124
+ }
125
+ }
126
+ typesImports(comp, [...plainAst, ...ast]);
127
+ comp.inheritAttrs = inheritAttrs(comp, [...plainAst, ...ast]);
128
+ // Types imported from elsewhere: a store's file, a shared `.ts` file, or another component.
129
+ for (const st of [...plainAst, ...ast]) {
130
+ if (st.type !== "ImportDeclaration")
131
+ continue;
132
+ const from = st.source.value;
133
+ for (const sp of st.specifiers) {
134
+ if (sp.type !== "ImportSpecifier")
135
+ continue;
136
+ const typeName = sp.imported.name ?? sp.imported.value;
137
+ if (from.endsWith(".vue")) {
138
+ if (typeName !== "Props")
139
+ comp.importedTypes.set(sp.local.name, { k: "struct", name: typeName, home: basename(from, ".vue") });
140
+ continue;
141
+ }
142
+ const isType = st.importKind === "type" || sp.importKind === "type";
143
+ const typeFile = resolveImport(comp.file, from);
144
+ if (!typeFile)
145
+ continue;
146
+ const module = typeFile.replace(/\.ts$/, "");
147
+ if ([...ctx.stores.values()].some((x) => x.module === module)) {
148
+ if (ctx.storeStructs.has(typeName))
149
+ comp.importedTypes.set(sp.local.name, { k: "struct", name: typeName, store: true });
150
+ continue;
151
+ }
152
+ if (ctx.helperModule !== null && from === ctx.helperModule && !isType)
153
+ continue;
154
+ readTypeFile(typeFile);
155
+ if (ctx.typeStructs.has(typeName))
156
+ comp.importedTypes.set(sp.local.name, { k: "struct", name: typeName, home: "types" });
157
+ else if (ctx.typeAliases.has(typeName))
158
+ comp.aliases.set(sp.local.name, ctx.typeAliases.get(typeName));
159
+ }
160
+ }
161
+ // Interfaces in two passes: every name first, so one may name another declared after it, or
162
+ // itself — a tree node's children are tree nodes.
163
+ const decls = declareTypes(comp, [...plainAst, ...ast], comp.structs, comp.aliases);
164
+ for (const d of decls)
165
+ comp.structs.set(d.name, structOf(comp, d.name, d.members, comp.structs));
166
+ let propsTy = null;
167
+ for (const s of ast) {
168
+ const call = s.type === "ExpressionStatement" ? s.expression : s.type === "VariableDeclaration" ? s.declarations[0]?.init : null;
169
+ const inner = call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "withDefaults" ? call.arguments[0] : call;
170
+ if (inner?.type === "CallExpression" && inner.callee.type === "Identifier" && inner.callee.name === "defineProps" && !inner.typeParameters) {
171
+ fail(comp, "props are declared with a type, `defineProps<{ ... }>()`: a runtime declaration has no Rust type", inner);
172
+ }
173
+ const found = definePropsType(call);
174
+ if (found)
175
+ propsTy = found;
176
+ }
177
+ // A component without `defineProps` takes no props.
178
+ if (!propsTy) {
179
+ // nothing to read
180
+ }
181
+ else if (propsTy.type === "TSTypeLiteral") {
182
+ comp.props = structOf(comp, "Props", propsTy.members, comp.structs);
183
+ }
184
+ else if (propsTy.type === "TSTypeReference" && comp.structs.has(propsTy.typeName.name)) {
185
+ comp.props = { name: "Props", fields: comp.structs.get(propsTy.typeName.name).fields };
186
+ }
187
+ else {
188
+ fail(comp, "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
189
+ }
190
+ // `defineModel`: a prop of its own (`modelValue` unless named), which the binding reads.
191
+ for (const st of ast) {
192
+ if (st.type !== "VariableDeclaration")
193
+ continue;
194
+ for (const d of st.declarations) {
195
+ const call = d.init;
196
+ if (call?.type !== "CallExpression" || call.callee.type !== "Identifier" || call.callee.name !== "defineModel")
197
+ continue;
198
+ const t = call.typeParameters?.params?.[0];
199
+ if (!t)
200
+ fail(comp, "`defineModel` needs its type: `defineModel<string>()`", call);
201
+ const named = call.arguments[0]?.type === "StringLiteral" ? call.arguments[0].value : "modelValue";
202
+ const options = call.arguments.find((a) => a.type === "ObjectExpression");
203
+ const required = options?.properties.some((p) => p.type === "ObjectProperty" && (p.key.name ?? p.key.value) === "required" && p.value.type === "BooleanLiteral" && p.value.value);
204
+ const base = tyOfTs(comp, t, comp.structs);
205
+ comp.props.fields.push({ js: named, rust: snake(named), ty: required ? base : opt(base) });
206
+ comp.models.set(d.id.name, named);
207
+ }
208
+ }
209
+ // What Vue uses for an absent optional prop: the default `compileScript` resolved — from
210
+ // `withDefaults`, a destructured default, or `defineModel`'s options — or `false` for a boolean.
211
+ const defaults = runtimeDefaults(comp, script.content);
212
+ for (const f of comp.props.fields) {
213
+ if (f.ty.k !== "opt")
214
+ continue;
215
+ const node = defaults.get(f.js);
216
+ if (node)
217
+ f.dflt = defaultValue(comp, f, node);
218
+ else if (f.ty.of.k === "bool")
219
+ f.dflt = "false";
220
+ }
221
+ const compiled = compileTemplate({
222
+ source: descriptor.template.content,
223
+ filename: file,
224
+ // As `@vitejs/plugin-vue` compiles it: the scope id written onto every element, and passed to
225
+ // slot content only with `:slotted()` styles.
226
+ id: comp.scopeId ?? name,
227
+ scoped: comp.scopeId !== null,
228
+ slotted: comp.slotted,
229
+ ssr: true,
230
+ ssrCssVars: [],
231
+ compilerOptions: { bindingMetadata: script.bindings, sourceMap: true },
232
+ });
233
+ // `ssrInjectFallthroughAttrs`: a root `<Transition>` or `<KeepAlive>` passes `_attrs` on only to
234
+ // a lone element or component without `v-if` or `v-for`.
235
+ const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));
236
+ const roots = descriptor.template.ast ? kids(descriptor.template.ast) : [];
237
+ const wrapper = roots.length === 1 && roots[0].type === 1 && roots[0].tagType === 1 && ["Transition", "transition", "KeepAlive", "keep-alive"].includes(roots[0].tag) ? roots[0] : null;
238
+ if (wrapper) {
239
+ const inner = kids(wrapper);
240
+ const lone = inner.length === 1 && inner[0].type === 1 && (inner[0].tagType === 0 || inner[0].tagType === 1) && !inner[0].props.some((p) => p.type === 7 && ["if", "else-if", "else", "for"].includes(p.name));
241
+ if (!lone)
242
+ comp.attrsDropped = { type: "VueTemplate", loc: { start: { line: wrapper.loc.start.line, column: wrapper.loc.start.column - 1 } }, __fv: "source" };
243
+ }
244
+ const start = descriptor.template.loc.start;
245
+ comp.templateStart = { line: start.line, column: start.column };
246
+ if (compiled.map)
247
+ comp.templateMap = new SourceMapConsumer(compiled.map);
248
+ // A template error's position is within the template's content.
249
+ if (compiled.errors.length)
250
+ fail(comp, String(compiled.errors[0]), vueErrorNode(compiled.errors[0], comp.templateStart));
251
+ for (const m of compiled.code.matchAll(/_ssrRenderSlot\(_ctx\.\$slots, "([^"]+)"/g)) {
252
+ if (!comp.slotNames.includes(m[1]))
253
+ comp.slotNames.push(m[1]);
254
+ }
255
+ // Resolved by name, as globally registered components are, or imported from `vue-router`.
256
+ const imported = (exported) => [...plainAst, ...ast].some((st) => st.type === "ImportDeclaration" && st.source.value === "vue-router" &&
257
+ st.specifiers.some((sp) => sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === exported));
258
+ comp.routerLink = compiled.code.includes('_resolveComponent("RouterLink")') || imported("RouterLink");
259
+ comp.routerView = compiled.code.includes('_resolveComponent("RouterView")') || imported("RouterView");
260
+ comp.readsRoute = compiled.code.includes("_ctx.$route");
261
+ comp.readsI18n = compiled.code.includes("_ctx.$t(");
262
+ comp.readsTeleports = compiled.code.includes("_ssrRenderTeleport(");
263
+ return { comp, ast, ssr: compiled.code };
264
+ }
@@ -0,0 +1,151 @@
1
+ import type { I18nSetup } from "./i18n.ts";
2
+ import { type Component, type N, type Struct, type Ty } from "./model.ts";
3
+ /** A type a helper takes or returns, as the configuration spells it. */
4
+ export type TypeName = "string" | "string?" | "int" | "int?" | "float" | "float?" | "bool";
5
+ /** A function a template may call, and the Rust function that is its twin. */
6
+ export interface HelperSpec {
7
+ /** The Rust path the generated code calls, e.g. `crate::helpers::format_count`. */
8
+ rust: string;
9
+ params: TypeName[];
10
+ returns: TypeName;
11
+ /** The longest string it returns, which is what a call adds to the buffer reservation. */
12
+ maxLen?: number;
13
+ }
14
+ /** What `ferrovue.config.json` holds. Paths are relative to the project root. */
15
+ export interface Config {
16
+ /** The directory of `.vue` files to compile. */
17
+ components: string;
18
+ /** The directory the Rust modules are written to. Everything in it is replaced. */
19
+ out: string;
20
+ /** The module components import their helpers from, and the Rust twin of each export. */
21
+ helpers?: {
22
+ module: string;
23
+ functions: Record<string, HelperSpec>;
24
+ };
25
+ /** The Rust type a `TrustedHtml` prop is, which must implement `ferrovue::TrustedHtml`, e.g.
26
+ * `crate::sanitize::SafeHtml`. A type that borrows names the props' lifetime, `'a`:
27
+ * `crate::render::Trusted<'a>`. Without it, a component cannot use `v-html` at all. */
28
+ trustedHtml?: string;
29
+ /** A JSON file listing the app's routes as vue-router paths (`/users/:id`), which `<RouterLink>`
30
+ * resolves against. Without it, a component cannot use `<RouterLink>`. */
31
+ routes?: string;
32
+ /** The router, in full: its routes file, the history's base, and the class names
33
+ * `createRouter` gives active links. `routes` alone is shorthand for `{ routes }`. */
34
+ router?: {
35
+ routes: string;
36
+ base?: string;
37
+ linkActiveClass?: string;
38
+ linkExactActiveClass?: string;
39
+ };
40
+ /** The directory of Pinia stores (`.ts` files), whose state a component may read while it renders
41
+ * on the server. */
42
+ stores?: string;
43
+ /** Custom directives that render nothing on the server — no `getSSRProps` — by name, without
44
+ * `v-`: `["focus", "click-outside"]`. Any other custom directive is refused, since the server
45
+ * cannot know what its `getSSRProps` would add. */
46
+ clientDirectives?: string[];
47
+ /** vue-i18n: the directory of locale files (`en.json`, `nl.json`), the locale a page renders in
48
+ * when it names none, and the locales a missing message falls back to. */
49
+ i18n?: {
50
+ messages: string;
51
+ locale?: string;
52
+ fallbackLocale?: string | string[];
53
+ };
54
+ /** How a `<style scoped>` component's `data-v-` id is computed, which must be how
55
+ * `@vitejs/plugin-vue` computes it for the client: from the file's path (`"filepath"`, the
56
+ * plugin's choice in development, and with `features.componentIdGenerator: "filepath"`), or its
57
+ * path and source (`"filepath-source"`, the plugin's choice for a production build). */
58
+ scopeId?: ScopeIdMode;
59
+ /** Vite's root, from which a scope id hashes a component's path: the project root by default. */
60
+ viteRoot?: string;
61
+ }
62
+ export type ScopeIdMode = "filepath" | "filepath-source";
63
+ export declare const CONFIG_FILE = "ferrovue.config.json";
64
+ /** Read the project's configuration. */
65
+ export declare function loadConfig(root: string, configPath?: string): Config;
66
+ export declare function tyOfName(name: TypeName): Ty;
67
+ /** A route as the routes file lists it: a vue-router path, and the name it may have. */
68
+ export interface RouteDef {
69
+ /** The path as written: relative to the parent's unless it starts with \`/\`. */
70
+ path: string;
71
+ name?: string;
72
+ /** The routes nested in it. */
73
+ children?: RouteDef[];
74
+ /** The path with its ancestors', as vue-router normalises it. */
75
+ fullPath: string;
76
+ }
77
+ /** Every route, nested ones included, parents first. */
78
+ export declare function allRoutes(routes: RouteDef[]): RouteDef[];
79
+ /** A getter: the parameter naming the state (an option store's), and the expression it returns.
80
+ * \`setup\` marks a setup store's \`computed\`, which reads the state's refs and the other getters by
81
+ * name, through \`.value\`. */
82
+ export interface StoreGetter {
83
+ param: string | null;
84
+ body: N;
85
+ file: string;
86
+ setup?: true;
87
+ }
88
+ /** A Pinia option store: `export const usePrefs = defineStore("prefs", { state: (): PrefsState => ... })`. */
89
+ export interface Store {
90
+ /** `usePrefs` */
91
+ hook: string;
92
+ /** `prefs`: its key in `pinia.state.value`. */
93
+ id: string;
94
+ /** `prefs`: its field in the generated `Stores`. */
95
+ field: string;
96
+ /** `PrefsState`: the interface its state is. */
97
+ state: string;
98
+ /** The file, without its extension, as an import names it once resolved. */
99
+ module: string;
100
+ /** Its getters: name → the parameter that names the state, and the expression returned. */
101
+ getters: Map<string, StoreGetter>;
102
+ }
103
+ /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
104
+ export declare const TYPES_MODULE = "ferrovue/types";
105
+ /** What the configuration being compiled says, and what has been read so far: set up by
106
+ * `generate()` at the start of each run, which is synchronous, so one run never sees another's. */
107
+ export declare const ctx: {
108
+ /** The helpers of the configuration being compiled: name → Rust twin, typed. */
109
+ helpers: Record<string, {
110
+ rust: string;
111
+ params: Ty[];
112
+ ret: Ty;
113
+ maxLen: number;
114
+ }>;
115
+ helperModule: string | null;
116
+ trustedHtml: string | null;
117
+ /** Custom directives declared to render nothing on the server. */
118
+ clientDirectives: Set<string>;
119
+ /** The configured routes, when there are any. */
120
+ routes: RouteDef[] | null;
121
+ /** The history's base, and the class names active links take, from the configured router. */
122
+ routerBase: string;
123
+ linkActive: string;
124
+ linkExactActive: string;
125
+ /** The configured stores, by hook name, and every interface their files declare. */
126
+ stores: Map<string, Store>;
127
+ storeStructs: Map<string, Struct>;
128
+ /** The store file each of those interfaces is declared in. */
129
+ storeFiles: Map<string, string>;
130
+ /** The project root, for resolving a component's imports. */
131
+ rootDir: string;
132
+ /** Interfaces and object types declared in shared `.ts` files that components import, and type
133
+ * aliases there; the file each comes from. They are written once, to `types.rs`. */
134
+ typeStructs: Map<string, Struct>;
135
+ typeAliases: Map<string, any>;
136
+ typeFiles: Map<string, string>;
137
+ /** The shared files read so far. */
138
+ typeRead: Set<string>;
139
+ /** Every component being compiled, by name, for a type that names another one's props. */
140
+ components: Map<string, Component>;
141
+ /** Numbers the bindings narrowing introduces, restarted per component so that a file's text
142
+ * depends on its own source alone. */
143
+ narrowCount: number;
144
+ /** The directory the components being compiled come from, for the generated headers. */
145
+ componentsDir: string;
146
+ /** The configured locales and their messages, when there are any. */
147
+ i18n: I18nSetup | null;
148
+ /** How scope ids are computed, and the directory a component's path is hashed from. */
149
+ scopeId: ScopeIdMode;
150
+ viteRoot: string;
151
+ };
@@ -0,0 +1,102 @@
1
+ /* The project's configuration, and the state one `generate()` run builds up. */
2
+ import { readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { BOOL, FLOAT, GenError, INT, opt, STR } from "./model.js";
5
+ export const CONFIG_FILE = "ferrovue.config.json";
6
+ /** Read the project's configuration. */
7
+ export function loadConfig(root, configPath) {
8
+ const filePath = configPath ? resolve(root, configPath) : join(root, CONFIG_FILE);
9
+ const displayName = configPath ?? CONFIG_FILE;
10
+ let text = "";
11
+ try {
12
+ text = readFileSync(filePath, "utf8");
13
+ }
14
+ catch (e) {
15
+ if (e.code === "ENOENT") {
16
+ throw new GenError(`cannot find \`${displayName}\` in ${root}`);
17
+ }
18
+ throw e;
19
+ }
20
+ let raw;
21
+ try {
22
+ raw = JSON.parse(text);
23
+ }
24
+ catch (e) {
25
+ throw new GenError(`failed to parse \`${displayName}\`: ${e.message}`);
26
+ }
27
+ if (typeof raw.components !== "string" || typeof raw.out !== "string") {
28
+ throw new GenError(`${displayName} needs \`components\` and \`out\` directories`);
29
+ }
30
+ if (raw.scopeId !== undefined && raw.scopeId !== "filepath" && raw.scopeId !== "filepath-source") {
31
+ throw new GenError(`\`scopeId\` in ${displayName} is "filepath" or "filepath-source", as \`@vitejs/plugin-vue\` computes it`);
32
+ }
33
+ return raw;
34
+ }
35
+ export function tyOfName(name) {
36
+ switch (name) {
37
+ case "string":
38
+ return STR;
39
+ case "string?":
40
+ return opt(STR);
41
+ case "int":
42
+ return INT;
43
+ case "int?":
44
+ return opt(INT);
45
+ case "float":
46
+ return FLOAT;
47
+ case "float?":
48
+ return opt(FLOAT);
49
+ case "bool":
50
+ return BOOL;
51
+ default:
52
+ throw new GenError(`unknown helper type \`${String(name)}\``);
53
+ }
54
+ }
55
+ /** Every route, nested ones included, parents first. */
56
+ export function allRoutes(routes) {
57
+ return routes.flatMap((r) => [r, ...allRoutes(r.children ?? [])]);
58
+ }
59
+ /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
60
+ export const TYPES_MODULE = "ferrovue/types";
61
+ /** What the configuration being compiled says, and what has been read so far: set up by
62
+ * `generate()` at the start of each run, which is synchronous, so one run never sees another's. */
63
+ export const ctx = {
64
+ /** The helpers of the configuration being compiled: name → Rust twin, typed. */
65
+ helpers: {},
66
+ helperModule: null,
67
+ trustedHtml: null,
68
+ /** Custom directives declared to render nothing on the server. */
69
+ clientDirectives: new Set(),
70
+ /** The configured routes, when there are any. */
71
+ routes: null,
72
+ /** The history's base, and the class names active links take, from the configured router. */
73
+ routerBase: "",
74
+ linkActive: "router-link-active",
75
+ linkExactActive: "router-link-exact-active",
76
+ /** The configured stores, by hook name, and every interface their files declare. */
77
+ stores: new Map(),
78
+ storeStructs: new Map(),
79
+ /** The store file each of those interfaces is declared in. */
80
+ storeFiles: new Map(),
81
+ /** The project root, for resolving a component's imports. */
82
+ rootDir: "",
83
+ /** Interfaces and object types declared in shared `.ts` files that components import, and type
84
+ * aliases there; the file each comes from. They are written once, to `types.rs`. */
85
+ typeStructs: new Map(),
86
+ typeAliases: new Map(),
87
+ typeFiles: new Map(),
88
+ /** The shared files read so far. */
89
+ typeRead: new Set(),
90
+ /** Every component being compiled, by name, for a type that names another one's props. */
91
+ components: new Map(),
92
+ /** Numbers the bindings narrowing introduces, restarted per component so that a file's text
93
+ * depends on its own source alone. */
94
+ narrowCount: 0,
95
+ /** The directory the components being compiled come from, for the generated headers. */
96
+ componentsDir: "",
97
+ /** The configured locales and their messages, when there are any. */
98
+ i18n: null,
99
+ /** How scope ids are computed, and the directory a component's path is hashed from. */
100
+ scopeId: "filepath-source",
101
+ viteRoot: "",
102
+ };
package/dist/diff.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** A unified diff of a module, as \`diff -u\` writes it, with three lines of context: what
2
+ * \`ferrovue\` would write against what is committed. Lines the two share at the start and the end
3
+ * are set aside first, so that a module changed in one place is compared over that place alone. */
4
+ export declare function unifiedDiff(path: string, before: string, after: string): string;
package/dist/diff.js ADDED
@@ -0,0 +1,77 @@
1
+ /* `ferrovue --check --diff`: what would be written, against what is committed. */
2
+ /** A unified diff of a module, as \`diff -u\` writes it, with three lines of context: what
3
+ * \`ferrovue\` would write against what is committed. Lines the two share at the start and the end
4
+ * are set aside first, so that a module changed in one place is compared over that place alone. */
5
+ export function unifiedDiff(path, before, after) {
6
+ const lines = (text) => (text === "" ? [] : text.replace(/\n$/, "").split("\n"));
7
+ const a = lines(before);
8
+ const b = lines(after);
9
+ let head = 0;
10
+ while (head < a.length && head < b.length && a[head] === b[head])
11
+ head++;
12
+ let tail = 0;
13
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
14
+ tail++;
15
+ const midA = a.slice(head, a.length - tail);
16
+ const midB = b.slice(head, b.length - tail);
17
+ if (!midA.length && !midB.length)
18
+ return "";
19
+ // The longest common subsequence of the middle, by dynamic programming over its lines.
20
+ const lcs = Array.from({ length: midA.length + 1 }, () => new Uint32Array(midB.length + 1));
21
+ for (let i = midA.length - 1; i >= 0; i--) {
22
+ for (let j = midB.length - 1; j >= 0; j--) {
23
+ lcs[i][j] = midA[i] === midB[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
24
+ }
25
+ }
26
+ const edits = a.slice(0, head).map((text) => ({ op: " ", text }));
27
+ let i = 0;
28
+ let j = 0;
29
+ // Of two ways to keep as many lines, a removal comes first, as `diff -u` orders them.
30
+ while (i < midA.length || j < midB.length) {
31
+ if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
32
+ edits.push({ op: " ", text: midA[i++] });
33
+ j++;
34
+ }
35
+ else if (j < midB.length && (i === midA.length || lcs[i][j + 1] > lcs[i + 1][j]))
36
+ edits.push({ op: "+", text: midB[j++] });
37
+ else
38
+ edits.push({ op: "-", text: midA[i++] });
39
+ }
40
+ edits.push(...a.slice(a.length - tail).map((text) => ({ op: " ", text })));
41
+ // Each changed line with three lines around it, runs that touch joined into one hunk.
42
+ const CONTEXT = 3;
43
+ const hunks = [];
44
+ edits.forEach((e, k) => {
45
+ if (e.op === " ")
46
+ return;
47
+ const from = Math.max(0, k - CONTEXT);
48
+ const to = Math.min(edits.length - 1, k + CONTEXT);
49
+ const last = hunks.at(-1);
50
+ if (last && from <= last[1] + 1)
51
+ last[1] = to;
52
+ else
53
+ hunks.push([from, to]);
54
+ });
55
+ const out = [`--- ${a.length ? `a/${path}` : "/dev/null"}`, `+++ ${b.length ? `b/${path}` : "/dev/null"}`];
56
+ // Line numbers in each file where every edit starts.
57
+ let lineA = 1;
58
+ let lineB = 1;
59
+ const at = edits.map((e) => {
60
+ const here = [lineA, lineB];
61
+ if (e.op !== "+")
62
+ lineA++;
63
+ if (e.op !== "-")
64
+ lineB++;
65
+ return here;
66
+ });
67
+ for (const [from, to] of hunks) {
68
+ const span = edits.slice(from, to + 1);
69
+ const countA = span.filter((e) => e.op !== "+").length;
70
+ const countB = span.filter((e) => e.op !== "-").length;
71
+ // An empty side is numbered by the line before it, as `diff -u` numbers it.
72
+ const startA = countA ? at[from][0] : at[from][0] - 1;
73
+ const startB = countB ? at[from][1] : at[from][1] - 1;
74
+ out.push(`@@ -${startA},${countA} +${startB},${countB} @@`, ...span.map((e) => `${e.op}${e.text}`));
75
+ }
76
+ return out.join("\n");
77
+ }
@@ -0,0 +1,23 @@
1
+ /** Statements, with adjacent literal pushes merged into one `push_str`. */
2
+ export declare class Emitter {
3
+ lines: string[];
4
+ /** Bytes of markup written once per render, part of what `render` reserves up front: the literal
5
+ * markup, and what the numbers written at run time are expected to take. */
6
+ literalBytes: number;
7
+ /** Rust expressions for the rest of the reservation: a loop's markup once per item. */
8
+ perItem: string[];
9
+ private pending;
10
+ private depth;
11
+ lit(s: string): void;
12
+ /** `bytes` more expected of a value written at run time. */
13
+ expect(bytes: number): void;
14
+ stmt(code: string): void;
15
+ open(code: string): void;
16
+ close(tail?: string): void;
17
+ /** Whether the lines from \`from\` on read \`name\`. Literals are skipped, so markup that happens to
18
+ * spell the name does not count, and so are fields of that name. */
19
+ reads(name: string, from: number): boolean;
20
+ /** Replace \`from\` with \`to\` in line \`index\`: an unused binding renamed to \`_\`. */
21
+ replace(index: number, from: string, to: string): void;
22
+ flush(): void;
23
+ }