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
@@ -0,0 +1,176 @@
1
+ import { escapeHtml } from "@vue/shared";
2
+ import { fail, rustStr, STR } from "./model.js";
3
+ import { expr } from "./expr.js";
4
+ import { cond, known, truthy } from "./narrowing.js";
5
+ import { meet } from "./strings.js";
6
+ import { atom, bare, condition, logical, not, strArg } from "./parens.js";
7
+ import { Emitter } from "./emitter.js";
8
+ import { valueAttr } from "./attrs.js";
9
+ export function classItems(s, n) {
10
+ switch (n.type) {
11
+ case "StringLiteral": {
12
+ const t = n.value.trim();
13
+ return t ? [{ lit: t }] : [];
14
+ }
15
+ case "ArrayExpression":
16
+ return n.elements.flatMap((el) => (el ? classItems(s, el) : []));
17
+ case "ObjectExpression":
18
+ const literalNames = n.properties
19
+ .filter((p) => p.type === "ObjectProperty" && !p.computed)
20
+ .map((p) => (p.key.type === "Identifier" ? p.key.name : String(p.key.value)));
21
+ const arrayIndex = (k) => /^(0|[1-9]\d*)$/.test(k) && Number(k) < 2 ** 32 - 1;
22
+ if (n.properties.some((p) => p.type === "ObjectProperty" && p.computed) ||
23
+ literalNames.some(arrayIndex) ||
24
+ new Set(literalNames).size !== literalNames.length) {
25
+ const keys = [];
26
+ const entries = n.properties.map((p) => {
27
+ if (p.type !== "ObjectProperty")
28
+ fail(s.comp, "a class object holds `name: condition` pairs", p);
29
+ const key = p.computed ? expr(s, p.key) : { code: rustStr(p.key.type === "Identifier" ? p.key.name : String(p.key.value)), ty: STR };
30
+ if (key.ty.k !== "str")
31
+ fail(s.comp, "a computed class name is a string", p.key);
32
+ for (const other of keys)
33
+ meet(s.comp, other, key, "a class object's names", p.key, "equal");
34
+ keys.push(key);
35
+ return `(${bare(cond(s, p.value))}, ${bare(key.code)})`;
36
+ });
37
+ return [{ code: `&*fv::class_object(&[${entries.join(", ")}])` }];
38
+ }
39
+ return n.properties.flatMap((p) => {
40
+ if (p.type !== "ObjectProperty")
41
+ fail(s.comp, "a class object holds `name: condition` pairs", p);
42
+ let name;
43
+ if (!p.computed) {
44
+ const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
45
+ if (key !== key.trim() || !key)
46
+ fail(s.comp, `class name \`${key}\` has spaces around it`, p);
47
+ name = { lit: key };
48
+ }
49
+ else {
50
+ const k = expr(s, p.key);
51
+ if (k.ty.k !== "str")
52
+ fail(s.comp, "a computed class name is a string", p.key);
53
+ name = { code: `fv::js_trim(${strArg(k.code)})` };
54
+ }
55
+ const v = expr(s, p.value);
56
+ const on = known(v);
57
+ if (on !== undefined)
58
+ return on ? [name] : [];
59
+ const text = "lit" in name ? rustStr(name.lit) : name.code;
60
+ return [{ code: `if ${condition(truthy(v))} { ${bare(text)} } else { "" }` }];
61
+ });
62
+ case "LogicalExpression":
63
+ if (n.operator === "&&") {
64
+ return conditional(cond(s, n.left), classItems(s, n.right));
65
+ }
66
+ break;
67
+ case "ConditionalExpression":
68
+ if (n.consequent.type === "NullLiteral" || n.alternate.type === "NullLiteral") {
69
+ const branch = n.consequent.type === "NullLiteral" ? n.alternate : n.consequent;
70
+ const test = cond(s, n.test);
71
+ return conditional(n.consequent.type === "NullLiteral" ? not(test) : test, classItems(s, branch));
72
+ }
73
+ break;
74
+ }
75
+ const v = expr(s, n);
76
+ if (v.ty.k === "str")
77
+ return [{ code: v.code }];
78
+ if (v.ty.k === "opt" && v.ty.of.k === "str")
79
+ return [{ code: `${atom(v.code)}.unwrap_or("")` }];
80
+ if (v.ty.k === "undef")
81
+ return [];
82
+ return fail(s.comp, "a class is a string, an array, or an object of conditions", n);
83
+ }
84
+ function conditional(test, items) {
85
+ if (test === "true" || test === "false")
86
+ return test === "true" ? items : [];
87
+ return items.map((it) => ({ code: `if ${condition(test)} { ${"lit" in it ? rustStr(it.lit) : bare(it.code)} } else { "" }` }));
88
+ }
89
+ export function renderClass(s, e, n, after = false) {
90
+ const items = classItems(s, n);
91
+ let wrote = after;
92
+ let i = 0;
93
+ for (; i < items.length; i++) {
94
+ const it = items[i];
95
+ if (!("lit" in it))
96
+ break;
97
+ e.lit((wrote ? " " : "") + escapeHtml(it.lit));
98
+ wrote = true;
99
+ }
100
+ if (i < items.length) {
101
+ const rest = items.slice(i).map((it) => ("lit" in it ? rustStr(it.lit) : it.code));
102
+ e.stmt(`fv::class_into(out, ${wrote}, &[${rest.join(", ")}]);`);
103
+ }
104
+ }
105
+ export function classPresent(s, n) {
106
+ if (n.fvMerged)
107
+ return n.elements.reduce((acc, el) => logical(acc, "||", classPresent(s, el)), "false");
108
+ switch (n.type) {
109
+ case "StringLiteral":
110
+ case "NullLiteral":
111
+ case "ArrayExpression":
112
+ case "ObjectExpression":
113
+ return "true";
114
+ case "LogicalExpression":
115
+ if (n.operator === "&&")
116
+ return logical(not(cond(s, n.left)), "||", classPresent(s, n.right));
117
+ break;
118
+ case "ConditionalExpression":
119
+ if (n.consequent.type === "NullLiteral")
120
+ return logical(cond(s, n.test), "||", classPresent(s, n.alternate));
121
+ if (n.alternate.type === "NullLiteral")
122
+ return logical(not(cond(s, n.test)), "||", classPresent(s, n.consequent));
123
+ break;
124
+ }
125
+ const v = expr(s, n);
126
+ if (v.ty.k === "undef")
127
+ return "false";
128
+ if (v.ty.k === "opt")
129
+ return `${atom(v.code)}.is_some()`;
130
+ return "true";
131
+ }
132
+ export function maybeEqual(s, n) {
133
+ if (n.type === "StringLiteral")
134
+ return true;
135
+ if (n.fvMerged || ["ArrayExpression", "ObjectExpression", "NullLiteral", "LogicalExpression", "ConditionalExpression"].includes(n.type))
136
+ return false;
137
+ const v = expr(s, n);
138
+ return v.ty.k === "str" || (v.ty.k === "opt" && v.ty.of.k === "str");
139
+ }
140
+ export function literalClass(n) {
141
+ if (n.type === "StringLiteral")
142
+ return n.value.trim();
143
+ if (!n.fvMerged)
144
+ return null;
145
+ const parts = n.elements.map(literalClass);
146
+ return parts.includes(null) ? null : parts.filter(Boolean).join(" ");
147
+ }
148
+ export function classAttr(s, n, side) {
149
+ switch (n.type) {
150
+ case "StringLiteral":
151
+ return `fv::Attr::str(${rustStr(n.value)})`;
152
+ case "NullLiteral":
153
+ return 'fv::Attr::str("")';
154
+ case "ArrayExpression":
155
+ case "ObjectExpression": {
156
+ const items = classItems(s, n);
157
+ const names = items.every((it) => "lit" in it)
158
+ ? rustStr(items.map((it) => ("lit" in it ? it.lit : "")).join(" "))
159
+ : `fv::class_names(&[${items.map((it) => ("lit" in it ? rustStr(it.lit) : it.code)).join(", ")}])`;
160
+ const literal = names.startsWith('"');
161
+ if (side === "vnode")
162
+ return literal ? `fv::Attr::str(${names})` : `fv::Attr::from(${names})`;
163
+ return `fv::Attr::Names(${literal ? `std::borrow::Cow::Borrowed(${names})` : `${names}.into()`})`;
164
+ }
165
+ case "LogicalExpression":
166
+ if (n.operator === "&&")
167
+ return `if ${condition(cond(s, n.left))} { ${classAttr(s, n.right, side)} } else { fv::Attr::str("") }`;
168
+ break;
169
+ case "ConditionalExpression":
170
+ return `if ${condition(cond(s, n.test))} { ${classAttr(s, n.consequent, side)} } else { ${classAttr(s, n.alternate, side)} }`;
171
+ }
172
+ const v = expr(s, n);
173
+ if (v.ty.k === "str" || v.ty.k === "undef" || (v.ty.k === "opt" && v.ty.of.k === "str"))
174
+ return valueAttr(s, v, n);
175
+ return fail(s.comp, "a class is a string, an array, or an object of conditions", n);
176
+ }
package/dist/cli.js CHANGED
@@ -1,22 +1,65 @@
1
1
  #!/usr/bin/env node
2
- /* `ferrovue` — compile the project's components, run from its root.
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
- * ferrovue --watch write them, then again whenever a component, store, type file, the routes
7
- * or the configuration changes, until interrupted */
8
- import { readFileSync, readdirSync, watch } from "node:fs";
9
- import { join, relative, sep } from "node:path";
10
- import { GenError, generate, loadConfig, write } from "./compiler.js";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+ import { CONFIG_FILE, GenError, generate, loadConfig, VERSION, write } from "./compiler.js";
5
+ import { unifiedDiff } from "./diff.js";
11
6
  const root = process.cwd();
12
- /** A refused construct, or a broken configuration: the message alone, which names the file. */
7
+ const HELP = `Usage: ferrovue [command] [options]
8
+
9
+ Compile Vue components to Rust render functions, run from the project root.
10
+
11
+ Commands:
12
+ init scaffold starter ferrovue.config.json and components
13
+
14
+ Options:
15
+ --check check that generated files match committed files without writing
16
+ -d, --diff show unified diff of changes when checking (use with --check)
17
+ --watch watch components, stores, routes and config for changes and regenerate
18
+ -c, --config <path> path to configuration file (default: ferrovue.config.json)
19
+ -v, --version print the version and exit
20
+ -h, --help print this help and exit
21
+ `;
13
22
  function report(e) {
14
23
  if (e instanceof GenError || e instanceof SyntaxError)
15
24
  console.error(`error: ${e.message}`);
16
25
  else
17
26
  throw e;
18
27
  }
19
- function check(config) {
28
+ function initProject(targetRoot, configPath) {
29
+ const configFile = configPath ? resolve(targetRoot, configPath) : join(targetRoot, CONFIG_FILE);
30
+ if (existsSync(configFile)) {
31
+ console.error(`error: configuration file already exists: ${relative(targetRoot, configFile) || CONFIG_FILE}`);
32
+ return 1;
33
+ }
34
+ const defaultComponents = "components";
35
+ const defaultOut = "src/generated";
36
+ mkdirSync(dirname(configFile), { recursive: true });
37
+ const config = {
38
+ components: defaultComponents,
39
+ out: defaultOut,
40
+ };
41
+ writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
42
+ console.log(`created ${relative(targetRoot, configFile) || CONFIG_FILE}`);
43
+ const compDir = join(targetRoot, defaultComponents);
44
+ mkdirSync(compDir, { recursive: true });
45
+ const helloVue = join(compDir, "Hello.vue");
46
+ if (!existsSync(helloVue)) {
47
+ writeFileSync(helloVue, `<script setup lang="ts">
48
+ defineProps<{ name: string }>();
49
+ </script>
50
+
51
+ <template>
52
+ <p>Hello, {{ name }}!</p>
53
+ </template>
54
+ `);
55
+ console.log(`created ${join(defaultComponents, "Hello.vue")}`);
56
+ }
57
+ const outDir = join(targetRoot, defaultOut);
58
+ mkdirSync(outDir, { recursive: true });
59
+ 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");
60
+ return 0;
61
+ }
62
+ function check(config, showDiff = false) {
20
63
  const want = generate(root, config);
21
64
  const dir = join(root, config.out);
22
65
  let have = [];
@@ -24,7 +67,6 @@ function check(config) {
24
67
  have = readdirSync(dir).filter((f) => f.endsWith(".rs"));
25
68
  }
26
69
  catch {
27
- // An absent directory is simply stale.
28
70
  }
29
71
  const stale = [...want.keys()].filter((name) => {
30
72
  try {
@@ -36,10 +78,24 @@ function check(config) {
36
78
  });
37
79
  const extra = have.filter((name) => !want.has(name));
38
80
  if (stale.length || extra.length) {
39
- for (const name of stale)
81
+ const committed = (name) => {
82
+ try {
83
+ return readFileSync(join(dir, name), "utf8");
84
+ }
85
+ catch {
86
+ return "";
87
+ }
88
+ };
89
+ for (const name of stale) {
40
90
  console.error(`stale: ${config.out}/${name}`);
41
- for (const name of extra)
91
+ if (showDiff)
92
+ console.error(unifiedDiff(`${config.out}/${name}`, committed(name), want.get(name)));
93
+ }
94
+ for (const name of extra) {
42
95
  console.error(`not generated: ${config.out}/${name}`);
96
+ if (showDiff)
97
+ console.error(unifiedDiff(`${config.out}/${name}`, committed(name), ""));
98
+ }
43
99
  console.error("run `ferrovue` to regenerate");
44
100
  return 1;
45
101
  }
@@ -51,12 +107,10 @@ function once(config) {
51
107
  const what = changed.length || removed.length ? `${changed.length} changed, ${removed.length} removed` : "nothing changed";
52
108
  console.log(`${config.out}: ${files.length} files, ${what}`);
53
109
  }
54
- /** Regenerate on every change to what the components are compiled from, reporting errors and
55
- * carrying on. The output directory itself, dependencies and build output are ignored. */
56
- function watchProject() {
110
+ function watchProject(configPath) {
57
111
  const run = () => {
58
112
  try {
59
- once(loadConfig(root));
113
+ once(loadConfig(root, configPath));
60
114
  }
61
115
  catch (e) {
62
116
  report(e);
@@ -66,10 +120,9 @@ function watchProject() {
66
120
  const ignored = (file) => {
67
121
  let out = "";
68
122
  try {
69
- out = loadConfig(root).out;
123
+ out = loadConfig(root, configPath).out;
70
124
  }
71
125
  catch {
72
- // A broken configuration ignores nothing; the next run reports it.
73
126
  }
74
127
  const parts = file.split(sep);
75
128
  return (parts.some((p) => p === "node_modules" || p === "target" || p === ".git" || p === "dist") ||
@@ -80,7 +133,6 @@ function watchProject() {
80
133
  watch(root, { recursive: true }, (_event, name) => {
81
134
  if (!name || ignored(relative(root, join(root, name))))
82
135
  return;
83
- // Editors write a file in several steps: one run once they are done.
84
136
  if (timer)
85
137
  clearTimeout(timer);
86
138
  timer = setTimeout(run, 50);
@@ -88,12 +140,54 @@ function watchProject() {
88
140
  console.log("watching for changes (Ctrl-C to stop)");
89
141
  }
90
142
  try {
91
- if (process.argv.includes("--watch"))
92
- watchProject();
93
- else if (process.argv.includes("--check"))
94
- process.exitCode = check(loadConfig(root));
95
- else
96
- once(loadConfig(root));
143
+ const rawArgs = process.argv.slice(2);
144
+ let configPath;
145
+ const args = [];
146
+ for (let i = 0; i < rawArgs.length; i++) {
147
+ const arg = rawArgs[i];
148
+ if (arg === "-c" || arg === "--config") {
149
+ if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("-")) {
150
+ console.error(`error: option '${arg}' requires an argument`);
151
+ process.exit(1);
152
+ }
153
+ configPath = rawArgs[++i];
154
+ }
155
+ else if (arg.startsWith("--config=")) {
156
+ configPath = arg.slice("--config=".length);
157
+ }
158
+ else {
159
+ args.push(arg);
160
+ }
161
+ }
162
+ const known = new Set(["init", "--check", "--diff", "-d", "--watch", "-v", "--version", "-h", "--help"]);
163
+ const unknown = args.find((a) => !known.has(a));
164
+ if (unknown !== undefined) {
165
+ console.error(`error: unknown option or command '${unknown}'\n\nRun \`ferrovue --help\` for usage.`);
166
+ process.exit(1);
167
+ }
168
+ if ((args.includes("--diff") || args.includes("-d")) && !args.includes("--check")) {
169
+ console.error("error: '--diff' requires '--check'");
170
+ process.exit(1);
171
+ }
172
+ if (args.includes("--version") || args.includes("-v")) {
173
+ console.log(VERSION);
174
+ }
175
+ else if (args.includes("--help") || args.includes("-h")) {
176
+ console.log(HELP.trimEnd());
177
+ }
178
+ else if (args.includes("init")) {
179
+ process.exitCode = initProject(root, configPath);
180
+ }
181
+ else if (args.includes("--watch")) {
182
+ watchProject(configPath);
183
+ }
184
+ else if (args.includes("--check")) {
185
+ const showDiff = args.includes("--diff") || args.includes("-d");
186
+ process.exitCode = check(loadConfig(root, configPath), showDiff);
187
+ }
188
+ else {
189
+ once(loadConfig(root, configPath));
190
+ }
97
191
  }
98
192
  catch (e) {
99
193
  report(e);
package/dist/client.d.ts CHANGED
@@ -18,6 +18,14 @@ export interface MountOptions {
18
18
  * those given or its props are malformed. Warns on the console by default. */
19
19
  onError?: (element: Element, problem: string) => void;
20
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
+ }>);
21
29
  /** The islands mounted. */
22
30
  export interface Islands {
23
31
  /** One app per island, in document order. */
@@ -27,5 +35,6 @@ export interface Islands {
27
35
  }
28
36
  /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
29
37
  * `Html::island` wrote becomes an app of the component of that name, given the props the server
30
- * rendered it with, mounted where it is. */
31
- export declare function mountIslands(components: Record<string, Component>, options?: MountOptions): Promise<Islands>;
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,31 +1,56 @@
1
- /* What the browser does with a page ferrovue rendered. */
2
1
  import { createSSRApp } from "vue";
2
+ const BARE = /"(?:[^"\\]|\\.)*"|-?Infinity|NaN/g;
3
+ const NON_FINITE = { NaN: Number.NaN, Infinity: Number.POSITIVE_INFINITY, "-Infinity": Number.NEGATIVE_INFINITY };
4
+ function parseJson(text) {
5
+ if (!/NaN|Infinity/.test(text))
6
+ return JSON.parse(text);
7
+ let escaped = "\\u0000";
8
+ while (text.includes(escaped))
9
+ escaped += "\\u0000";
10
+ const tag = JSON.parse(`"${escaped}"`);
11
+ const quoted = text.replace(BARE, (match) => (match.startsWith('"') ? match : `"${escaped}${match}"`));
12
+ return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
13
+ }
3
14
  /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
4
15
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
5
16
  export function hydrateState(pinia, id = "__pinia", doc = document) {
6
17
  const text = doc.getElementById(id)?.textContent;
7
18
  if (text)
8
- pinia.state.value = JSON.parse(text);
19
+ pinia.state.value = parseJson(text);
9
20
  }
10
21
  /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
11
22
  * `Html::island` wrote becomes an app of the component of that name, given the props the server
12
- * rendered it with, mounted where it is. */
23
+ * rendered it with, mounted where it is. A loader is called only for an island the page holds, all
24
+ * of them at once, and the islands mount in document order once every one has loaded. */
13
25
  export async function mountIslands(components, options = {}) {
14
26
  const root = options.root ?? document;
15
27
  const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
16
- if (options.router)
17
- await options.router.isReady();
28
+ const elements = Array.from(root.querySelectorAll("[data-island]"));
29
+ const loading = new Map();
30
+ for (const el of elements) {
31
+ const name = el.dataset.island ?? "";
32
+ if (Object.hasOwn(components, name) && !loading.has(name))
33
+ loading.set(name, load(components[name]));
34
+ }
35
+ const [loaded] = await Promise.all([
36
+ Promise.all([...loading].map(async ([name, pending]) => [name, await pending])).then((pairs) => new Map(pairs)),
37
+ options.router?.isReady(),
38
+ ]);
18
39
  const apps = [];
19
- for (const el of Array.from(root.querySelectorAll("[data-island]"))) {
40
+ for (const el of elements) {
20
41
  const name = el.dataset.island ?? "";
21
- const component = components[name];
42
+ const component = loaded.get(name);
22
43
  if (!component) {
23
- report(el, `no component called ${JSON.stringify(name)} was given`);
44
+ report(el, `no component called ${JSON.stringify(name)} was given (\`ferrovue/islands\` holds every component that has an \`island()\`)`);
45
+ continue;
46
+ }
47
+ if (component instanceof Error) {
48
+ report(el, `${name} did not load: ${component.message}`);
24
49
  continue;
25
50
  }
26
51
  let props;
27
52
  try {
28
- props = JSON.parse(el.dataset.props ?? "{}");
53
+ props = parseJson(el.dataset.props ?? "{}");
29
54
  }
30
55
  catch {
31
56
  report(el, `the props of ${name} are not JSON`);
@@ -49,3 +74,14 @@ export async function mountIslands(components, options = {}) {
49
74
  },
50
75
  };
51
76
  }
77
+ async function load(component) {
78
+ if (typeof component !== "function" || "props" in component || "displayName" in component || "__vccOpts" in component)
79
+ return component;
80
+ try {
81
+ const loaded = await component();
82
+ return typeof loaded === "object" && "default" in loaded ? loaded.default : loaded;
83
+ }
84
+ catch (e) {
85
+ return e instanceof Error ? e : new Error(String(e));
86
+ }
87
+ }
@@ -10,6 +10,9 @@ export interface Written {
10
10
  changed: string[];
11
11
  /** The files removed, which the components no longer produce. */
12
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>;
13
16
  }
14
17
  /** Write what \`generate\` produces to the configured directory: only the files whose text changed,
15
18
  * so a Rust build that watches them rebuilds no more than it must, and removing what no component
@@ -17,3 +20,4 @@ export interface Written {
17
20
  export declare function write(root: string, config?: Config): Written;
18
21
  export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type TypeName } from "./context.ts";
19
22
  export { GenError } from "./model.ts";
23
+ export declare const VERSION: string;