ferrovue 0.2.0 → 0.4.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 (86) hide show
  1. package/README.md +103 -15
  2. package/dist/attrs.d.ts +8 -42
  3. package/dist/attrs.js +148 -297
  4. package/dist/calls.d.ts +4 -0
  5. package/dist/calls.js +219 -0
  6. package/dist/children.d.ts +9 -0
  7. package/dist/children.js +273 -0
  8. package/dist/classes.d.ts +13 -0
  9. package/dist/classes.js +176 -0
  10. package/dist/cli.js +121 -27
  11. package/dist/client.d.ts +11 -2
  12. package/dist/client.js +45 -9
  13. package/dist/compiler.d.ts +4 -0
  14. package/dist/compiler.js +37 -90
  15. package/dist/component.js +19 -82
  16. package/dist/context.d.ts +6 -68
  17. package/dist/context.js +25 -38
  18. package/dist/diff.d.ts +1 -0
  19. package/dist/diff.js +68 -0
  20. package/dist/emitter.d.ts +0 -8
  21. package/dist/emitter.js +0 -12
  22. package/dist/expr.d.ts +0 -70
  23. package/dist/expr.js +30 -855
  24. package/dist/fallthrough.d.ts +9 -0
  25. package/dist/fallthrough.js +78 -0
  26. package/dist/fixture.d.ts +2 -1
  27. package/dist/fixture.js +37 -22
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.js +1 -0
  30. package/dist/islands.d.ts +6 -0
  31. package/dist/islands.js +5 -0
  32. package/dist/lists.d.ts +0 -11
  33. package/dist/lists.js +4 -39
  34. package/dist/loops.d.ts +3 -0
  35. package/dist/loops.js +164 -0
  36. package/dist/model.d.ts +22 -130
  37. package/dist/model.js +37 -9
  38. package/dist/narrowing.d.ts +19 -0
  39. package/dist/narrowing.js +126 -0
  40. package/dist/numbers.d.ts +8 -0
  41. package/dist/numbers.js +73 -0
  42. package/dist/parens.d.ts +0 -27
  43. package/dist/parens.js +0 -44
  44. package/dist/plugin.d.ts +84 -0
  45. package/dist/plugin.js +26 -0
  46. package/dist/plugins/i18n.d.ts +23 -0
  47. package/dist/{i18n.js → plugins/i18n.js} +76 -19
  48. package/dist/plugins/index.d.ts +2 -0
  49. package/dist/plugins/index.js +8 -0
  50. package/dist/plugins/router-link.d.ts +8 -0
  51. package/dist/{router.js → plugins/router-link.js} +65 -57
  52. package/dist/plugins/router.d.ts +42 -0
  53. package/dist/plugins/router.js +256 -0
  54. package/dist/plugins/scoped.d.ts +12 -0
  55. package/dist/{scoped.js → plugins/scoped.js} +55 -20
  56. package/dist/plugins/shared-types.d.ts +2 -0
  57. package/dist/plugins/shared-types.js +7 -0
  58. package/dist/plugins/stores.d.ts +37 -0
  59. package/dist/plugins/stores.js +377 -0
  60. package/dist/plugins/teleport.d.ts +5 -0
  61. package/dist/plugins/teleport.js +55 -0
  62. package/dist/rust.d.ts +2 -5
  63. package/dist/rust.js +66 -161
  64. package/dist/script.d.ts +0 -6
  65. package/dist/script.js +22 -121
  66. package/dist/slots.d.ts +13 -0
  67. package/dist/slots.js +160 -0
  68. package/dist/strings.d.ts +13 -0
  69. package/dist/strings.js +208 -0
  70. package/dist/styles.d.ts +14 -0
  71. package/dist/styles.js +181 -0
  72. package/dist/template.d.ts +2 -40
  73. package/dist/template.js +40 -691
  74. package/dist/testing.d.ts +1 -0
  75. package/dist/testing.js +1 -8
  76. package/dist/types.d.ts +2 -0
  77. package/dist/typescript.d.ts +0 -13
  78. package/dist/typescript.js +8 -41
  79. package/dist/vite.d.ts +1 -0
  80. package/dist/vite.js +35 -14
  81. package/package.json +11 -2
  82. package/dist/i18n.d.ts +0 -27
  83. package/dist/router.d.ts +0 -17
  84. package/dist/scoped.d.ts +0 -10
  85. package/dist/stores.d.ts +0 -6
  86. package/dist/stores.js +0 -227
package/dist/compiler.js CHANGED
@@ -1,56 +1,29 @@
1
- /* ferrovue's compiler: `.vue` → Rust render functions.
2
- *
3
- * The input is not the template. It is what `@vue/compiler-sfc` compiles the template to for
4
- * server rendering — straight-line `_push` calls, `if`, `_ssrRenderList`, and a handful of
5
- * `@vue/server-renderer` helpers — so element structure, attribute order, whitespace and the
6
- * fragment markers are decided by Vue, and this file only has to translate a small, closed
7
- * vocabulary of JavaScript into Rust that writes the same bytes. The generated code calls the
8
- * `ferrovue` crate for everything Vue's runtime does.
9
- *
10
- * Anything outside that vocabulary is an error naming the component and the construct, never a
11
- * best guess: a guess that is wrong by one byte is a hydration mismatch in a browser, found by a
12
- * reader.
13
- *
14
- * A project describes itself in `ferrovue.config.json` at its root — see `Config` — and runs the
15
- * `ferrovue` command there. `generate()` is also what a drift test calls, so the committed files can
16
- * be held to exactly what this produces.
17
- *
18
- * This module is the public API; the translation lives in the modules beside it. */
19
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
- import { join, resolve } from "node:path";
2
+ import { join } from "node:path";
21
3
  import { ctx, loadConfig, tyOfName } from "./context.js";
22
4
  import { readComponent } from "./component.js";
23
- import { readRoutes } from "./router.js";
24
- import { readStores } from "./stores.js";
25
- import { i18nSource, readLocales } from "./i18n.js";
26
5
  import { scopeFor } from "./script.js";
27
- import { scopeFlow } from "./scoped.js";
28
- import { componentSource, modSource, routesSource, storesSource, typesSource } from "./rust.js";
6
+ import { attrsFlow } from "./fallthrough.js";
7
+ import { componentSource, isIsland, modSource } from "./rust.js";
8
+ import { renderParams } from "./plugin.js";
9
+ import { PLUGINS } from "./plugins/index.js";
29
10
  /** Every generated file, keyed by its name in the output directory. */
30
11
  export function generate(root, config = loadConfig(root)) {
31
12
  ctx.componentsDir = config.components.replace(/\/?$/, "/");
32
13
  ctx.helperModule = config.helpers?.module ?? null;
33
14
  ctx.trustedHtml = config.trustedHtml ?? null;
34
- const router = config.router ?? (config.routes ? { routes: config.routes } : null);
35
- ctx.routes = router ? readRoutes(root, router.routes) : null;
36
- ctx.routerBase = router?.base ?? "";
37
15
  ctx.clientDirectives = new Set(config.clientDirectives ?? []);
38
- ctx.i18n = config.i18n ? readLocales(root, config.i18n) : null;
39
- ctx.linkActive = router?.linkActiveClass ?? "router-link-active";
40
- ctx.linkExactActive = router?.linkExactActiveClass ?? "router-link-exact-active";
41
16
  ctx.rootDir = root;
42
- ctx.scopeId = config.scopeId ?? "filepath-source";
43
- ctx.viteRoot = resolve(root, config.viteRoot ?? ".");
44
17
  ctx.typeStructs = new Map();
45
18
  ctx.typeAliases = new Map();
46
19
  ctx.typeFiles = new Map();
47
20
  ctx.typeRead = new Set();
48
- if (config.stores)
49
- readStores(root, config.stores);
50
- else {
51
- ctx.stores = new Map();
52
- ctx.storeStructs = new Map();
53
- }
21
+ ctx.plugins = PLUGINS;
22
+ ctx.runs = new Map();
23
+ for (const p of PLUGINS)
24
+ ctx.runs.set(p, p.configure?.(config, root));
25
+ for (const p of PLUGINS)
26
+ p.prepare?.(root);
54
27
  ctx.helpers = Object.fromEntries(Object.entries(config.helpers?.functions ?? {}).map(([name, h]) => [
55
28
  name,
56
29
  { rust: h.rust, params: h.params.map(tyOfName), ret: tyOfName(h.returns), maxLen: h.maxLen ?? 0 },
@@ -63,54 +36,25 @@ export function generate(root, config = loadConfig(root)) {
63
36
  const read = files.map((f) => readComponent(f, root));
64
37
  const components = new Map(read.map((r) => [r.comp.name, r.comp]));
65
38
  ctx.components = components;
66
- // Whether a component reads a store or the route is known once its setup is read; both reach
67
- // every component on the way down to one that does, and the route every one on the way down to
68
- // a `<RouterLink>`.
69
- const children = read.map((r) => scopeFor(r.comp, r.ast, components).scope.children);
70
- // Which roots may be handed scope ids, and which slot content given a slot scope id.
71
- scopeFlow(read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: children[i] })));
72
- for (const c of components.values())
73
- c.usesRoute = c.routerLink || c.readsRoute;
74
- for (let changed = true; changed;) {
75
- changed = false;
76
- for (const c of components.values()) {
77
- if (!c.usesRoute && [...c.imports].some((i) => components.get(i)?.usesRoute)) {
78
- c.usesRoute = changed = true;
79
- }
80
- }
81
- }
82
- for (const c of components.values())
83
- c.usesI18n = c.readsI18n;
84
- for (let changed = true; changed;) {
85
- changed = false;
86
- for (const c of components.values()) {
87
- if (!c.usesI18n && [...c.imports].some((i) => components.get(i)?.usesI18n)) {
88
- c.usesI18n = changed = true;
89
- }
90
- }
91
- }
92
- for (const c of components.values())
93
- c.usesTeleports = c.readsTeleports;
94
- for (let changed = true; changed;) {
95
- changed = false;
96
- for (const c of components.values()) {
97
- if (!c.usesTeleports && [...c.imports].some((i) => components.get(i)?.usesTeleports)) {
98
- c.usesTeleports = changed = true;
99
- }
100
- }
101
- }
39
+ const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
40
+ const all = read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
41
+ for (const p of PLUGINS)
42
+ p.analyse?.(all);
43
+ attrsFlow(all);
102
44
  for (const c of components.values())
103
- c.usesStores = c.readsStores;
45
+ c.takes = new Set(renderParams().filter((p) => p.reads(c)).map((p) => p.name));
104
46
  for (let changed = true; changed;) {
105
47
  changed = false;
106
48
  for (const c of components.values()) {
107
- if (!c.usesStores && [...c.imports].some((i) => components.get(i)?.usesStores)) {
108
- c.usesStores = changed = true;
49
+ for (const name of [...c.imports].flatMap((i) => [...(components.get(i)?.takes ?? [])])) {
50
+ if (!c.takes.has(name)) {
51
+ c.takes.add(name);
52
+ changed = true;
53
+ }
109
54
  }
110
55
  }
111
56
  }
112
57
  const out = new Map();
113
- // A child before its parents, which read the props its scoped slots pass.
114
58
  const ordered = [];
115
59
  const placed = new Set();
116
60
  const place = (r) => {
@@ -126,19 +70,13 @@ export function generate(root, config = loadConfig(root)) {
126
70
  };
127
71
  read.forEach(place);
128
72
  for (const r of ordered) {
129
- // Numbered per component, so a file's text depends on its own source alone.
130
73
  ctx.narrowCount = 0;
131
74
  out.set(`${r.comp.module}.rs`, componentSource(r.comp, r.ast, r.ssr, components));
132
75
  }
133
- out.set("mod.rs", modSource(read.map((r) => r.comp)));
134
- if (ctx.routes)
135
- out.set("route_table.rs", routesSource(ctx.routes, router.routes));
136
- if (ctx.stores.size)
137
- out.set("stores.rs", storesSource(config.stores.replace(/\/?$/, "/")));
138
- if (ctx.typeStructs.size)
139
- out.set("types.rs", typesSource());
140
- if (ctx.i18n)
141
- out.set("i18n.rs", i18nSource(ctx.i18n));
76
+ const modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
77
+ out.set("mod.rs", modSource(read.map((r) => r.comp), modules.map(([file]) => file)));
78
+ for (const [file, text] of modules)
79
+ out.set(file, text);
142
80
  return out;
143
81
  }
144
82
  /** Write what \`generate\` produces to the configured directory: only the files whose text changed,
@@ -155,7 +93,6 @@ export function write(root, config = loadConfig(root)) {
155
93
  old = readFileSync(join(target, name), "utf8");
156
94
  }
157
95
  catch {
158
- // A new file.
159
96
  }
160
97
  if (old !== text) {
161
98
  writeFileSync(join(target, name), text);
@@ -165,7 +102,17 @@ export function write(root, config = loadConfig(root)) {
165
102
  const removed = readdirSync(target).filter((f) => f.endsWith(".rs") && !files.has(f));
166
103
  for (const f of removed)
167
104
  rmSync(join(target, f));
168
- return { files: [...files.keys()], changed, removed };
105
+ const islands = Object.fromEntries([...ctx.components.values()].filter(isIsland).map((c) => [c.name, c.file]));
106
+ return { files: [...files.keys()], changed, removed, islands };
169
107
  }
170
108
  export { CONFIG_FILE, loadConfig, TYPES_MODULE } from "./context.js";
171
109
  export { GenError } from "./model.js";
110
+ export const VERSION = (() => {
111
+ try {
112
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
113
+ return pkg.version ?? "unknown";
114
+ }
115
+ catch {
116
+ return "unknown";
117
+ }
118
+ })();
package/dist/component.js CHANGED
@@ -1,14 +1,11 @@
1
- /* One `.vue` file read: its props, models, imported types, and Vue's SSR compilation of its template. */
2
1
  import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
3
- import { createHash } from "node:crypto";
4
2
  import { readFileSync } from "node:fs";
5
3
  import { SourceMapConsumer } from "source-map-js";
6
- import { basename, relative, resolve, sep } from "node:path";
7
- import { fail, opt, snake, tagAst } from "./model.js";
4
+ import { basename, relative } from "node:path";
5
+ import { blankComponent, fail, opt, snake, tagAst } from "./model.js";
8
6
  import { ctx } from "./context.js";
9
7
  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. */
8
+ import { claim } from "./plugin.js";
12
9
  function vueErrorNode(err, within) {
13
10
  const loc = err.loc;
14
11
  if (!loc)
@@ -17,17 +14,6 @@ function vueErrorNode(err, within) {
17
14
  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
15
  return { type: "VueError", loc: { start: at }, __fv: "source" };
19
16
  }
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
17
  function inheritAttrs(comp, statements) {
32
18
  let found = true;
33
19
  for (const st of statements) {
@@ -54,59 +40,24 @@ export function readComponent(file, root) {
54
40
  const source = readFileSync(file, "utf8");
55
41
  const { descriptor, errors } = parseSfc(source, { filename: file });
56
42
  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
- };
43
+ const comp = blankComponent(name, snake(name), rel);
88
44
  comp.source = source;
89
45
  if (errors.length)
90
46
  fail(comp, String(errors[0]), vueErrorNode(errors[0]));
91
47
  if (!descriptor.scriptSetup || !descriptor.template) {
92
48
  fail(comp, "an island needs `<script setup lang=\"ts\">` and a `<template>`");
93
49
  }
94
- // A global `<style>` block changes no markup. A scoped one adds `data-v-…` attributes, whose id is
95
- // computed here as the bundler computes it. A CSS module renames classes, and `v-bind()` in CSS
96
- // writes variables onto the root, neither of which the server reproduces.
97
50
  for (const st of descriptor.styles) {
98
51
  if (st.module)
99
52
  fail(comp, "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet");
100
53
  }
101
54
  if (descriptor.cssVars.length)
102
55
  fail(comp, "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead");
103
- if (descriptor.styles.some((st) => st.scoped))
104
- comp.scopeId = `data-v-${scopeHash(file, source)}`;
105
- comp.slotted = descriptor.slotted;
56
+ for (const p of ctx.plugins)
57
+ p.sfc?.(comp, descriptor, file, source);
106
58
  const script = compileScript(descriptor, { id: name });
107
59
  const ast = script.scriptSetupAst ?? [];
108
60
  tagAst(ast, "source");
109
- // A plain `<script>` beside the setup one may declare the types the setup block uses.
110
61
  const plainAst = script.scriptAst ?? [];
111
62
  tagAst(plainAst, "source");
112
63
  for (const s of [...plainAst, ...ast]) {
@@ -123,7 +74,6 @@ export function readComponent(file, root) {
123
74
  }
124
75
  typesImports(comp, [...plainAst, ...ast]);
125
76
  comp.inheritAttrs = inheritAttrs(comp, [...plainAst, ...ast]);
126
- // Types imported from elsewhere: a store's file, a shared `.ts` file, or another component.
127
77
  for (const st of [...plainAst, ...ast]) {
128
78
  if (st.type !== "ImportDeclaration")
129
79
  continue;
@@ -141,12 +91,8 @@ export function readComponent(file, root) {
141
91
  const typeFile = resolveImport(comp.file, from);
142
92
  if (!typeFile)
143
93
  continue;
144
- const module = typeFile.replace(/\.ts$/, "");
145
- if ([...ctx.stores.values()].some((x) => x.module === module)) {
146
- if (ctx.storeStructs.has(typeName))
147
- comp.importedTypes.set(sp.local.name, { k: "struct", name: typeName, store: true });
94
+ if (ctx.plugins.some((p) => p.importedType?.(comp, typeFile, typeName, sp.local.name)))
148
95
  continue;
149
- }
150
96
  if (ctx.helperModule !== null && from === ctx.helperModule && !isType)
151
97
  continue;
152
98
  readTypeFile(typeFile);
@@ -156,8 +102,6 @@ export function readComponent(file, root) {
156
102
  comp.aliases.set(sp.local.name, ctx.typeAliases.get(typeName));
157
103
  }
158
104
  }
159
- // Interfaces in two passes: every name first, so one may name another declared after it, or
160
- // itself — a tree node's children are tree nodes.
161
105
  const decls = declareTypes(comp, [...plainAst, ...ast], comp.structs, comp.aliases);
162
106
  for (const d of decls)
163
107
  comp.structs.set(d.name, structOf(comp, d.name, d.members, comp.structs));
@@ -172,9 +116,7 @@ export function readComponent(file, root) {
172
116
  if (found)
173
117
  propsTy = found;
174
118
  }
175
- // A component without `defineProps` takes no props.
176
119
  if (!propsTy) {
177
- // nothing to read
178
120
  }
179
121
  else if (propsTy.type === "TSTypeLiteral") {
180
122
  comp.props = structOf(comp, "Props", propsTy.members, comp.structs);
@@ -185,7 +127,6 @@ export function readComponent(file, root) {
185
127
  else {
186
128
  fail(comp, "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
187
129
  }
188
- // `defineModel`: a prop of its own (`modelValue` unless named), which the binding reads.
189
130
  for (const st of ast) {
190
131
  if (st.type !== "VariableDeclaration")
191
132
  continue;
@@ -204,8 +145,6 @@ export function readComponent(file, root) {
204
145
  comp.models.set(d.id.name, named);
205
146
  }
206
147
  }
207
- // What Vue uses for an absent optional prop: the default `compileScript` resolved — from
208
- // `withDefaults`, a destructured default, or `defineModel`'s options — or `false` for a boolean.
209
148
  const defaults = runtimeDefaults(comp, script.content);
210
149
  for (const f of comp.props.fields) {
211
150
  if (f.ty.k !== "opt")
@@ -219,33 +158,31 @@ export function readComponent(file, root) {
219
158
  const compiled = compileTemplate({
220
159
  source: descriptor.template.content,
221
160
  filename: file,
222
- // As `@vitejs/plugin-vue` compiles it: the scope id written onto every element, and passed to
223
- // slot content only with `:slotted()` styles.
224
- id: comp.scopeId ?? name,
225
- scoped: comp.scopeId !== null,
226
- slotted: comp.slotted,
161
+ ...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false }),
227
162
  ssr: true,
228
163
  ssrCssVars: [],
229
164
  compilerOptions: { bindingMetadata: script.bindings, sourceMap: true },
230
165
  });
166
+ const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));
167
+ const roots = descriptor.template.ast ? kids(descriptor.template.ast) : [];
168
+ 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;
169
+ if (wrapper) {
170
+ const inner = kids(wrapper);
171
+ 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));
172
+ if (!lone)
173
+ comp.attrsDropped = { type: "VueTemplate", loc: { start: { line: wrapper.loc.start.line, column: wrapper.loc.start.column - 1 } }, __fv: "source" };
174
+ }
231
175
  const start = descriptor.template.loc.start;
232
176
  comp.templateStart = { line: start.line, column: start.column };
233
177
  if (compiled.map)
234
178
  comp.templateMap = new SourceMapConsumer(compiled.map);
235
- // A template error's position is within the template's content.
236
179
  if (compiled.errors.length)
237
180
  fail(comp, String(compiled.errors[0]), vueErrorNode(compiled.errors[0], comp.templateStart));
238
181
  for (const m of compiled.code.matchAll(/_ssrRenderSlot\(_ctx\.\$slots, "([^"]+)"/g)) {
239
182
  if (!comp.slotNames.includes(m[1]))
240
183
  comp.slotNames.push(m[1]);
241
184
  }
242
- // Resolved by name, as globally registered components are, or imported from `vue-router`.
243
- const imported = (exported) => [...plainAst, ...ast].some((st) => st.type === "ImportDeclaration" && st.source.value === "vue-router" &&
244
- st.specifiers.some((sp) => sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === exported));
245
- comp.routerLink = compiled.code.includes('_resolveComponent("RouterLink")') || imported("RouterLink");
246
- comp.routerView = compiled.code.includes('_resolveComponent("RouterView")') || imported("RouterView");
247
- comp.readsRoute = compiled.code.includes("_ctx.$route");
248
- comp.readsI18n = compiled.code.includes("_ctx.$t(");
249
- comp.readsTeleports = compiled.code.includes("_ssrRenderTeleport(");
185
+ for (const p of ctx.plugins)
186
+ p.compiled?.(comp, compiled.code, [...plainAst, ...ast]);
250
187
  return { comp, ast, ssr: compiled.code };
251
188
  }
package/dist/context.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { I18nSetup } from "./i18n.ts";
2
- import { type Component, type N, type Struct, type Ty } from "./model.ts";
1
+ import type { Plugin } from "./plugin.ts";
2
+ import { type Component, type Struct, type Ty } from "./model.ts";
3
3
  /** A type a helper takes or returns, as the configuration spells it. */
4
4
  export type TypeName = "string" | "string?" | "int" | "int?" | "float" | "float?" | "bool";
5
5
  /** A function a template may call, and the Rust function that is its twin. */
@@ -44,7 +44,7 @@ export interface Config {
44
44
  * `v-`: `["focus", "click-outside"]`. Any other custom directive is refused, since the server
45
45
  * cannot know what its `getSSRProps` would add. */
46
46
  clientDirectives?: string[];
47
- /** vue-i18n: the directory of locale files (\`en.json\`, \`nl.json\`), the locale a page renders in
47
+ /** vue-i18n: the directory of locale files (`en.json`, `nl.json`), the locale a page renders in
48
48
  * when it names none, and the locales a missing message falls back to. */
49
49
  i18n?: {
50
50
  messages: string;
@@ -62,50 +62,11 @@ export interface Config {
62
62
  export type ScopeIdMode = "filepath" | "filepath-source";
63
63
  export declare const CONFIG_FILE = "ferrovue.config.json";
64
64
  /** Read the project's configuration. */
65
- export declare function loadConfig(root: string): Config;
65
+ export declare function loadConfig(root: string, configPath?: string): Config;
66
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
67
  /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
104
68
  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
69
  export declare const ctx: {
108
- /** The helpers of the configuration being compiled: name → Rust twin, typed. */
109
70
  helpers: Record<string, {
110
71
  rust: string;
111
72
  params: Ty[];
@@ -114,38 +75,15 @@ export declare const ctx: {
114
75
  }>;
115
76
  helperModule: string | null;
116
77
  trustedHtml: string | null;
117
- /** Custom directives declared to render nothing on the server. */
118
78
  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. */
79
+ plugins: readonly Plugin[];
80
+ runs: Map<Plugin<unknown, unknown>, unknown>;
131
81
  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
82
  typeStructs: Map<string, Struct>;
135
83
  typeAliases: Map<string, any>;
136
84
  typeFiles: Map<string, string>;
137
- /** The shared files read so far. */
138
85
  typeRead: Set<string>;
139
- /** Every component being compiled, by name, for a type that names another one's props. */
140
86
  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
87
  narrowCount: number;
144
- /** The directory the components being compiled come from, for the generated headers. */
145
88
  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
89
  };
package/dist/context.js CHANGED
@@ -1,16 +1,33 @@
1
- /* The project's configuration, and the state one `generate()` run builds up. */
2
1
  import { readFileSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
4
3
  import { BOOL, FLOAT, GenError, INT, opt, STR } from "./model.js";
5
4
  export const CONFIG_FILE = "ferrovue.config.json";
6
5
  /** Read the project's configuration. */
7
- export function loadConfig(root) {
8
- const raw = JSON.parse(readFileSync(join(root, CONFIG_FILE), "utf8"));
6
+ export function loadConfig(root, configPath) {
7
+ const filePath = configPath ? resolve(root, configPath) : join(root, CONFIG_FILE);
8
+ const displayName = configPath ?? CONFIG_FILE;
9
+ let text = "";
10
+ try {
11
+ text = readFileSync(filePath, "utf8");
12
+ }
13
+ catch (e) {
14
+ if (e.code === "ENOENT") {
15
+ throw new GenError(`cannot find \`${displayName}\` in ${root}`);
16
+ }
17
+ throw e;
18
+ }
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(text);
22
+ }
23
+ catch (e) {
24
+ throw new GenError(`failed to parse \`${displayName}\`: ${e.message}`);
25
+ }
9
26
  if (typeof raw.components !== "string" || typeof raw.out !== "string") {
10
- throw new GenError(`${CONFIG_FILE} needs \`components\` and \`out\` directories`);
27
+ throw new GenError(`${displayName} needs \`components\` and \`out\` directories`);
11
28
  }
12
29
  if (raw.scopeId !== undefined && raw.scopeId !== "filepath" && raw.scopeId !== "filepath-source") {
13
- throw new GenError(`\`scopeId\` in ${CONFIG_FILE} is "filepath" or "filepath-source", as \`@vitejs/plugin-vue\` computes it`);
30
+ throw new GenError(`\`scopeId\` in ${displayName} is "filepath" or "filepath-source", as \`@vitejs/plugin-vue\` computes it`);
14
31
  }
15
32
  return raw;
16
33
  }
@@ -34,51 +51,21 @@ export function tyOfName(name) {
34
51
  throw new GenError(`unknown helper type \`${String(name)}\``);
35
52
  }
36
53
  }
37
- /** Every route, nested ones included, parents first. */
38
- export function allRoutes(routes) {
39
- return routes.flatMap((r) => [r, ...allRoutes(r.children ?? [])]);
40
- }
41
54
  /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
42
55
  export const TYPES_MODULE = "ferrovue/types";
43
- /** What the configuration being compiled says, and what has been read so far: set up by
44
- * `generate()` at the start of each run, which is synchronous, so one run never sees another's. */
45
56
  export const ctx = {
46
- /** The helpers of the configuration being compiled: name → Rust twin, typed. */
47
57
  helpers: {},
48
58
  helperModule: null,
49
59
  trustedHtml: null,
50
- /** Custom directives declared to render nothing on the server. */
51
60
  clientDirectives: new Set(),
52
- /** The configured routes, when there are any. */
53
- routes: null,
54
- /** The history's base, and the class names active links take, from the configured router. */
55
- routerBase: "",
56
- linkActive: "router-link-active",
57
- linkExactActive: "router-link-exact-active",
58
- /** The configured stores, by hook name, and every interface their files declare. */
59
- stores: new Map(),
60
- storeStructs: new Map(),
61
- /** The store file each of those interfaces is declared in. */
62
- storeFiles: new Map(),
63
- /** The project root, for resolving a component's imports. */
61
+ plugins: [],
62
+ runs: new Map(),
64
63
  rootDir: "",
65
- /** Interfaces and object types declared in shared `.ts` files that components import, and type
66
- * aliases there; the file each comes from. They are written once, to `types.rs`. */
67
64
  typeStructs: new Map(),
68
65
  typeAliases: new Map(),
69
66
  typeFiles: new Map(),
70
- /** The shared files read so far. */
71
67
  typeRead: new Set(),
72
- /** Every component being compiled, by name, for a type that names another one's props. */
73
68
  components: new Map(),
74
- /** Numbers the bindings narrowing introduces, restarted per component so that a file's text
75
- * depends on its own source alone. */
76
69
  narrowCount: 0,
77
- /** The directory the components being compiled come from, for the generated headers. */
78
70
  componentsDir: "",
79
- /** The configured locales and their messages, when there are any. */
80
- i18n: null,
81
- /** How scope ids are computed, and the directory a component's path is hashed from. */
82
- scopeId: "filepath-source",
83
- viteRoot: "",
84
71
  };
package/dist/diff.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function unifiedDiff(path: string, before: string, after: string): string;
package/dist/diff.js ADDED
@@ -0,0 +1,68 @@
1
+ export function unifiedDiff(path, before, after) {
2
+ const lines = (text) => (text === "" ? [] : text.replace(/\n$/, "").split("\n"));
3
+ const a = lines(before);
4
+ const b = lines(after);
5
+ let head = 0;
6
+ while (head < a.length && head < b.length && a[head] === b[head])
7
+ head++;
8
+ let tail = 0;
9
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
10
+ tail++;
11
+ const midA = a.slice(head, a.length - tail);
12
+ const midB = b.slice(head, b.length - tail);
13
+ if (!midA.length && !midB.length)
14
+ return "";
15
+ const lcs = Array.from({ length: midA.length + 1 }, () => new Uint32Array(midB.length + 1));
16
+ for (let i = midA.length - 1; i >= 0; i--) {
17
+ for (let j = midB.length - 1; j >= 0; j--) {
18
+ lcs[i][j] = midA[i] === midB[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
19
+ }
20
+ }
21
+ const edits = a.slice(0, head).map((text) => ({ op: " ", text }));
22
+ let i = 0;
23
+ let j = 0;
24
+ while (i < midA.length || j < midB.length) {
25
+ if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
26
+ edits.push({ op: " ", text: midA[i++] });
27
+ j++;
28
+ }
29
+ else if (j < midB.length && (i === midA.length || lcs[i][j + 1] > lcs[i + 1][j]))
30
+ edits.push({ op: "+", text: midB[j++] });
31
+ else
32
+ edits.push({ op: "-", text: midA[i++] });
33
+ }
34
+ edits.push(...a.slice(a.length - tail).map((text) => ({ op: " ", text })));
35
+ const CONTEXT = 3;
36
+ const hunks = [];
37
+ edits.forEach((e, k) => {
38
+ if (e.op === " ")
39
+ return;
40
+ const from = Math.max(0, k - CONTEXT);
41
+ const to = Math.min(edits.length - 1, k + CONTEXT);
42
+ const last = hunks.at(-1);
43
+ if (last && from <= last[1] + 1)
44
+ last[1] = to;
45
+ else
46
+ hunks.push([from, to]);
47
+ });
48
+ const out = [`--- ${a.length ? `a/${path}` : "/dev/null"}`, `+++ ${b.length ? `b/${path}` : "/dev/null"}`];
49
+ let lineA = 1;
50
+ let lineB = 1;
51
+ const at = edits.map((e) => {
52
+ const here = [lineA, lineB];
53
+ if (e.op !== "+")
54
+ lineA++;
55
+ if (e.op !== "-")
56
+ lineB++;
57
+ return here;
58
+ });
59
+ for (const [from, to] of hunks) {
60
+ const span = edits.slice(from, to + 1);
61
+ const countA = span.filter((e) => e.op !== "+").length;
62
+ const countB = span.filter((e) => e.op !== "-").length;
63
+ const startA = countA ? at[from][0] : at[from][0] - 1;
64
+ const startB = countB ? at[from][1] : at[from][1] - 1;
65
+ out.push(`@@ -${startA},${countA} +${startB},${countB} @@`, ...span.map((e) => `${e.op}${e.text}`));
66
+ }
67
+ return out.join("\n");
68
+ }