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
package/dist/cli.js CHANGED
@@ -1,14 +1,77 @@
1
1
  #!/usr/bin/env node
2
- /* `ferrovue` — compile the project's components, run from its root.
2
+ /* `ferrovue`: compile the project's components, run from its root.
3
3
  *
4
- * ferrovue write the Rust modules to the configured output directory
5
- * ferrovue --check write nothing; exit 1 if the committed modules are not what would be written */
6
- import { readFileSync, readdirSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { generate, loadConfig, write } from "./compiler.js";
4
+ * ferrovue write the Rust modules to the configured output directory
5
+ * ferrovue --check write nothing; exit 1 if the committed modules are not what would be written
6
+ * ferrovue --check --diff show a unified diff of differences
7
+ * ferrovue --watch write them, then again whenever a component, store, type file, the routes
8
+ * or the configuration changes, until interrupted
9
+ * ferrovue init scaffold a starter ferrovue.config.json and components directory
10
+ * ferrovue -c, --config path to configuration file (default: ferrovue.config.json)
11
+ * ferrovue -v, --version print the version and exit
12
+ * ferrovue -h, --help print this help and exit */
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
14
+ import { dirname, join, relative, resolve, sep } from "node:path";
15
+ import { CONFIG_FILE, GenError, generate, loadConfig, VERSION, write } from "./compiler.js";
16
+ import { unifiedDiff } from "./diff.js";
9
17
  const root = process.cwd();
10
- const config = loadConfig(root);
11
- if (process.argv.includes("--check")) {
18
+ const HELP = `Usage: ferrovue [command] [options]
19
+
20
+ Compile Vue components to Rust render functions, run from the project root.
21
+
22
+ Commands:
23
+ init scaffold starter ferrovue.config.json and components
24
+
25
+ Options:
26
+ --check check that generated files match committed files without writing
27
+ -d, --diff show unified diff of changes when checking (use with --check)
28
+ --watch watch components, stores, routes and config for changes and regenerate
29
+ -c, --config <path> path to configuration file (default: ferrovue.config.json)
30
+ -v, --version print the version and exit
31
+ -h, --help print this help and exit
32
+ `;
33
+ /** A refused construct, or a broken configuration: the message alone, which names the file. */
34
+ function report(e) {
35
+ if (e instanceof GenError || e instanceof SyntaxError)
36
+ console.error(`error: ${e.message}`);
37
+ else
38
+ throw e;
39
+ }
40
+ function initProject(targetRoot, configPath) {
41
+ const configFile = configPath ? resolve(targetRoot, configPath) : join(targetRoot, CONFIG_FILE);
42
+ if (existsSync(configFile)) {
43
+ console.error(`error: configuration file already exists: ${relative(targetRoot, configFile) || CONFIG_FILE}`);
44
+ return 1;
45
+ }
46
+ const defaultComponents = "components";
47
+ const defaultOut = "src/generated";
48
+ mkdirSync(dirname(configFile), { recursive: true });
49
+ const config = {
50
+ components: defaultComponents,
51
+ out: defaultOut,
52
+ };
53
+ writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
54
+ console.log(`created ${relative(targetRoot, configFile) || CONFIG_FILE}`);
55
+ const compDir = join(targetRoot, defaultComponents);
56
+ mkdirSync(compDir, { recursive: true });
57
+ const helloVue = join(compDir, "Hello.vue");
58
+ if (!existsSync(helloVue)) {
59
+ writeFileSync(helloVue, `<script setup lang="ts">
60
+ defineProps<{ name: string }>();
61
+ </script>
62
+
63
+ <template>
64
+ <p>Hello, {{ name }}!</p>
65
+ </template>
66
+ `);
67
+ console.log(`created ${join(defaultComponents, "Hello.vue")}`);
68
+ }
69
+ const outDir = join(targetRoot, defaultOut);
70
+ mkdirSync(outDir, { recursive: true });
71
+ console.log("\nNext steps:\n 1. Run `ferrovue` to generate Rust render functions into src/generated\n 2. In your Rust crate, declare `mod generated;` to use them");
72
+ return 0;
73
+ }
74
+ function check(config, showDiff = false) {
12
75
  const want = generate(root, config);
13
76
  const dir = join(root, config.out);
14
77
  let have = [];
@@ -28,16 +91,122 @@ if (process.argv.includes("--check")) {
28
91
  });
29
92
  const extra = have.filter((name) => !want.has(name));
30
93
  if (stale.length || extra.length) {
31
- for (const name of stale)
94
+ const committed = (name) => {
95
+ try {
96
+ return readFileSync(join(dir, name), "utf8");
97
+ }
98
+ catch {
99
+ return "";
100
+ }
101
+ };
102
+ for (const name of stale) {
32
103
  console.error(`stale: ${config.out}/${name}`);
33
- for (const name of extra)
104
+ if (showDiff)
105
+ console.error(unifiedDiff(`${config.out}/${name}`, committed(name), want.get(name)));
106
+ }
107
+ for (const name of extra) {
34
108
  console.error(`not generated: ${config.out}/${name}`);
109
+ if (showDiff)
110
+ console.error(unifiedDiff(`${config.out}/${name}`, committed(name), ""));
111
+ }
35
112
  console.error("run `ferrovue` to regenerate");
36
- process.exit(1);
113
+ return 1;
37
114
  }
38
115
  console.log(`${config.out} is up to date`);
116
+ return 0;
117
+ }
118
+ function once(config) {
119
+ const { files, changed, removed } = write(root, config);
120
+ const what = changed.length || removed.length ? `${changed.length} changed, ${removed.length} removed` : "nothing changed";
121
+ console.log(`${config.out}: ${files.length} files, ${what}`);
122
+ }
123
+ /** Regenerate on every change to what the components are compiled from, reporting errors and
124
+ * carrying on. The output directory itself, dependencies and build output are ignored. */
125
+ function watchProject(configPath) {
126
+ const run = () => {
127
+ try {
128
+ once(loadConfig(root, configPath));
129
+ }
130
+ catch (e) {
131
+ report(e);
132
+ }
133
+ };
134
+ run();
135
+ const ignored = (file) => {
136
+ let out = "";
137
+ try {
138
+ out = loadConfig(root, configPath).out;
139
+ }
140
+ catch {
141
+ // A broken configuration ignores nothing; the next run reports it.
142
+ }
143
+ const parts = file.split(sep);
144
+ return (parts.some((p) => p === "node_modules" || p === "target" || p === ".git" || p === "dist") ||
145
+ (out !== "" && (file === out || file.startsWith(out + sep))) ||
146
+ !/\.(vue|ts|json)$/.test(file));
147
+ };
148
+ let timer = null;
149
+ watch(root, { recursive: true }, (_event, name) => {
150
+ if (!name || ignored(relative(root, join(root, name))))
151
+ return;
152
+ // Editors write a file in several steps: one run once they are done.
153
+ if (timer)
154
+ clearTimeout(timer);
155
+ timer = setTimeout(run, 50);
156
+ });
157
+ console.log("watching for changes (Ctrl-C to stop)");
158
+ }
159
+ try {
160
+ const rawArgs = process.argv.slice(2);
161
+ let configPath;
162
+ const args = [];
163
+ for (let i = 0; i < rawArgs.length; i++) {
164
+ const arg = rawArgs[i];
165
+ if (arg === "-c" || arg === "--config") {
166
+ if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("-")) {
167
+ console.error(`error: option '${arg}' requires an argument`);
168
+ process.exit(1);
169
+ }
170
+ configPath = rawArgs[++i];
171
+ }
172
+ else if (arg.startsWith("--config=")) {
173
+ configPath = arg.slice("--config=".length);
174
+ }
175
+ else {
176
+ args.push(arg);
177
+ }
178
+ }
179
+ const known = new Set(["init", "--check", "--diff", "-d", "--watch", "-v", "--version", "-h", "--help"]);
180
+ const unknown = args.find((a) => !known.has(a));
181
+ if (unknown !== undefined) {
182
+ console.error(`error: unknown option or command '${unknown}'\n\nRun \`ferrovue --help\` for usage.`);
183
+ process.exit(1);
184
+ }
185
+ if ((args.includes("--diff") || args.includes("-d")) && !args.includes("--check")) {
186
+ console.error("error: '--diff' requires '--check'");
187
+ process.exit(1);
188
+ }
189
+ if (args.includes("--version") || args.includes("-v")) {
190
+ console.log(VERSION);
191
+ }
192
+ else if (args.includes("--help") || args.includes("-h")) {
193
+ console.log(HELP.trimEnd());
194
+ }
195
+ else if (args.includes("init")) {
196
+ process.exitCode = initProject(root, configPath);
197
+ }
198
+ else if (args.includes("--watch")) {
199
+ watchProject(configPath);
200
+ }
201
+ else if (args.includes("--check")) {
202
+ const showDiff = args.includes("--diff") || args.includes("-d");
203
+ process.exitCode = check(loadConfig(root, configPath), showDiff);
204
+ }
205
+ else {
206
+ once(loadConfig(root, configPath));
207
+ }
39
208
  }
40
- else {
41
- const files = write(root, config);
42
- console.log(`wrote ${files.length} files to ${config.out}`);
209
+ catch (e) {
210
+ report(e);
211
+ process.exitCode = 1;
43
212
  }
package/dist/client.d.ts CHANGED
@@ -1,4 +1,40 @@
1
+ import { type App, type Component, type Plugin } from "vue";
1
2
  import type { Pinia } from "pinia";
3
+ import type { Router } from "vue-router";
2
4
  /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
3
5
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
4
6
  export declare function hydrateState(pinia: Pinia, id?: string, doc?: Document): void;
7
+ export interface MountOptions {
8
+ /** One Pinia for every island, so they share stores. Call `hydrateState` on it first. */
9
+ pinia?: Pinia;
10
+ /** One router for every island that renders a `<RouterLink>` or reads the route. Mounting waits
11
+ * until it has resolved the current location, which the server rendered against. */
12
+ router?: Router;
13
+ /** Anything else each island's app should `use`: i18n, a component library. */
14
+ plugins?: Plugin[];
15
+ /** Where to look for islands: the whole document by default. */
16
+ root?: ParentNode;
17
+ /** Told about an island left as the server rendered it, because its component is not among
18
+ * those given or its props are malformed. Warns on the console by default. */
19
+ onError?: (element: Element, problem: string) => void;
20
+ }
21
+ /** A component, or a function that loads one — `() => import("./Counter.vue")` — so that the code
22
+ * of an island not on the page is never fetched. A function is taken for a loader as vue-router
23
+ * takes one for a lazy route: unless it has `props` or `displayName`, which mark a functional
24
+ * component, or `__vccOpts`, a class component. `ferrovue/islands` maps the name of every island
25
+ * to a loader. */
26
+ export type IslandComponent = Component | (() => Promise<Component | {
27
+ default: Component;
28
+ }>);
29
+ /** The islands mounted. */
30
+ export interface Islands {
31
+ /** One app per island, in document order. */
32
+ apps: App[];
33
+ /** Unmount every island, as a page leaving would. */
34
+ unmount(): void;
35
+ }
36
+ /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
37
+ * `Html::island` wrote becomes an app of the component of that name, given the props the server
38
+ * rendered it with, mounted where it is. A loader is called only for an island the page holds, all
39
+ * of them at once, and the islands mount in document order once every one has loaded. */
40
+ export declare function mountIslands(components: Record<string, IslandComponent>, options?: MountOptions): Promise<Islands>;
package/dist/client.js CHANGED
@@ -1,7 +1,99 @@
1
+ /* What the browser does with a page ferrovue rendered. */
2
+ import { createSSRApp } from "vue";
3
+ /** A JSON string, skipped whole as it may hold the same letters, or a bare number JSON has no word
4
+ * for, which the server writes as JavaScript does where `serde_json` alone would write `null`. */
5
+ const BARE = /"(?:[^"\\]|\\.)*"|-?Infinity|NaN/g;
6
+ const NON_FINITE = { NaN: Number.NaN, Infinity: Number.POSITIVE_INFINITY, "-Infinity": Number.NEGATIVE_INFINITY };
7
+ /** `JSON.parse`, reading back the bare `NaN`, `Infinity` and `-Infinity` that an island's props and
8
+ * the state script hold for a number that is not finite, so the client renders what the server did.
9
+ * Throws as `JSON.parse` does on anything else that is not JSON. */
10
+ function parseJson(text) {
11
+ // Plain JSON: almost everything the server writes, and all it wrote before it wrote these.
12
+ if (!/NaN|Infinity/.test(text))
13
+ return JSON.parse(text);
14
+ // Each token becomes a string that starts with more NULs in a row than any string in the text
15
+ // holds (JSON has one way to write a NUL, `\u0000`), so no other value can be mistaken for one.
16
+ let escaped = "\\u0000";
17
+ while (text.includes(escaped))
18
+ escaped += "\\u0000";
19
+ const tag = JSON.parse(`"${escaped}"`);
20
+ const quoted = text.replace(BARE, (match) => (match.startsWith('"') ? match : `"${escaped}${match}"`));
21
+ return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
22
+ }
1
23
  /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
2
24
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
3
25
  export function hydrateState(pinia, id = "__pinia", doc = document) {
4
26
  const text = doc.getElementById(id)?.textContent;
5
27
  if (text)
6
- pinia.state.value = JSON.parse(text);
28
+ pinia.state.value = parseJson(text);
29
+ }
30
+ /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
31
+ * `Html::island` wrote becomes an app of the component of that name, given the props the server
32
+ * rendered it with, mounted where it is. A loader is called only for an island the page holds, all
33
+ * of them at once, and the islands mount in document order once every one has loaded. */
34
+ export async function mountIslands(components, options = {}) {
35
+ const root = options.root ?? document;
36
+ const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
37
+ const elements = Array.from(root.querySelectorAll("[data-island]"));
38
+ // Each component the page names, loaded once however many islands it has.
39
+ const loading = new Map();
40
+ for (const el of elements) {
41
+ const name = el.dataset.island ?? "";
42
+ if (Object.hasOwn(components, name) && !loading.has(name))
43
+ loading.set(name, load(components[name]));
44
+ }
45
+ const [loaded] = await Promise.all([
46
+ Promise.all([...loading].map(async ([name, pending]) => [name, await pending])).then((pairs) => new Map(pairs)),
47
+ options.router?.isReady(),
48
+ ]);
49
+ const apps = [];
50
+ for (const el of elements) {
51
+ const name = el.dataset.island ?? "";
52
+ const component = loaded.get(name);
53
+ if (!component) {
54
+ report(el, `no component called ${JSON.stringify(name)} was given (\`ferrovue/islands\` holds every component that has an \`island()\`)`);
55
+ continue;
56
+ }
57
+ if (component instanceof Error) {
58
+ report(el, `${name} did not load: ${component.message}`);
59
+ continue;
60
+ }
61
+ let props;
62
+ try {
63
+ props = parseJson(el.dataset.props ?? "{}");
64
+ }
65
+ catch {
66
+ report(el, `the props of ${name} are not JSON`);
67
+ continue;
68
+ }
69
+ const app = createSSRApp(component, props);
70
+ if (options.pinia)
71
+ app.use(options.pinia);
72
+ if (options.router)
73
+ app.use(options.router);
74
+ for (const plugin of options.plugins ?? [])
75
+ app.use(plugin);
76
+ app.mount(el);
77
+ apps.push(app);
78
+ }
79
+ return {
80
+ apps,
81
+ unmount() {
82
+ for (const app of apps.splice(0))
83
+ app.unmount();
84
+ },
85
+ };
86
+ }
87
+ /** The component itself, or what its loader resolves to: a module's default export, or the
88
+ * component. A loader that rejects gives its error, which leaves only its own islands unhydrated. */
89
+ async function load(component) {
90
+ if (typeof component !== "function" || "props" in component || "displayName" in component || "__vccOpts" in component)
91
+ return component;
92
+ try {
93
+ const loaded = await component();
94
+ return typeof loaded === "object" && "default" in loaded ? loaded.default : loaded;
95
+ }
96
+ catch (e) {
97
+ return e instanceof Error ? e : new Error(String(e));
98
+ }
7
99
  }
@@ -1,56 +1,23 @@
1
- /** A type a helper takes or returns, as the configuration spells it. */
2
- export type TypeName = "string" | "string?" | "int" | "int?" | "bool";
3
- /** A function a template may call, and the Rust function that is its twin. */
4
- export interface HelperSpec {
5
- /** The Rust path the generated code calls, e.g. `crate::helpers::format_count`. */
6
- rust: string;
7
- params: TypeName[];
8
- returns: TypeName;
9
- /** The longest string it returns, which is what a call adds to the buffer reservation. */
10
- maxLen?: number;
11
- }
12
- /** What `ferrovue.config.json` holds. Paths are relative to the project root. */
13
- export interface Config {
14
- /** The directory of `.vue` files to compile. */
15
- components: string;
16
- /** The directory the Rust modules are written to. Everything in it is replaced. */
17
- out: string;
18
- /** The module components import their helpers from, and the Rust twin of each export. */
19
- helpers?: {
20
- module: string;
21
- functions: Record<string, HelperSpec>;
22
- };
23
- /** The Rust type a `TrustedHtml` prop is, which must implement `ferrovue::TrustedHtml`, e.g.
24
- * `crate::sanitize::SafeHtml`. A type that borrows names the props' lifetime, `'a`:
25
- * `crate::render::Trusted<'a>`. Without it, a component cannot use `v-html` at all. */
26
- trustedHtml?: string;
27
- /** A JSON file listing the app's routes as vue-router paths (`/users/:id`), which `<RouterLink>`
28
- * resolves against. Without it, a component cannot use `<RouterLink>`. */
29
- routes?: string;
30
- /** The router, in full: its routes file, the history's base, and the class names
31
- * `createRouter` gives active links. `routes` alone is shorthand for `{ routes }`. */
32
- router?: {
33
- routes: string;
34
- base?: string;
35
- linkActiveClass?: string;
36
- linkExactActiveClass?: string;
37
- };
38
- /** The directory of Pinia stores (`.ts` files), whose state a component may read while it renders
39
- * on the server. */
40
- stores?: string;
41
- /** Custom directives that render nothing on the server — no `getSSRProps` — by name, without
42
- * `v-`: `["focus", "click-outside"]`. Any other custom directive is refused, since the server
43
- * cannot know what its `getSSRProps` would add. */
44
- clientDirectives?: string[];
45
- }
46
- export declare const CONFIG_FILE = "ferrovue.config.json";
47
- /** Read the project's configuration. */
48
- export declare function loadConfig(root: string): Config;
49
- /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
50
- export declare const TYPES_MODULE = "ferrovue/types";
51
- export declare class GenError extends Error {
52
- }
1
+ import { type Config } from "./context.ts";
53
2
  /** Every generated file, keyed by its name in the output directory. */
54
3
  export declare function generate(root: string, config?: Config): Map<string, string>;
55
4
  /** Write what `generate` produces to the configured directory, replacing what is there. */
56
- export declare function write(root: string, config?: Config): string[];
5
+ /** What \`write\` changed in the output directory. */
6
+ export interface Written {
7
+ /** Every file the output directory now holds. */
8
+ files: string[];
9
+ /** The files whose text changed, or that are new. */
10
+ changed: string[];
11
+ /** The files removed, which the components no longer produce. */
12
+ removed: string[];
13
+ /** Every component that has an `island()`, by the name `data-island` carries, with its `.vue`
14
+ * file relative to the root: what `ferrovue/islands` loads. */
15
+ islands: Record<string, string>;
16
+ }
17
+ /** Write what \`generate\` produces to the configured directory: only the files whose text changed,
18
+ * so a Rust build that watches them rebuilds no more than it must, and removing what no component
19
+ * produces any more. */
20
+ export declare function write(root: string, config?: Config): Written;
21
+ export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type TypeName } from "./context.ts";
22
+ export { GenError } from "./model.ts";
23
+ export declare const VERSION: string;