ferrovue 0.3.0 → 0.5.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 (106) hide show
  1. package/README.md +225 -42
  2. package/dist/attrs.d.ts +3 -53
  3. package/dist/attrs.js +41 -469
  4. package/dist/calls.d.ts +4 -0
  5. package/dist/calls.js +221 -0
  6. package/dist/children.d.ts +16 -0
  7. package/dist/children.js +286 -0
  8. package/dist/classes.d.ts +13 -0
  9. package/dist/classes.js +176 -0
  10. package/dist/cli.js +31 -22
  11. package/dist/client.d.ts +35 -2
  12. package/dist/client.js +77 -14
  13. package/dist/compiler.d.ts +1 -1
  14. package/dist/compiler.js +31 -92
  15. package/dist/component.d.ts +3 -1
  16. package/dist/component.js +123 -104
  17. package/dist/constants.d.ts +42 -0
  18. package/dist/constants.js +292 -0
  19. package/dist/context.d.ts +29 -67
  20. package/dist/context.js +13 -38
  21. package/dist/diagnostics.d.ts +23 -0
  22. package/dist/diagnostics.js +30 -0
  23. package/dist/diff.d.ts +0 -3
  24. package/dist/diff.js +0 -9
  25. package/dist/emitter.d.ts +0 -8
  26. package/dist/emitter.js +0 -12
  27. package/dist/errors.d.ts +867 -0
  28. package/dist/errors.js +330 -0
  29. package/dist/expr.d.ts +1 -70
  30. package/dist/expr.js +142 -934
  31. package/dist/fallthrough.d.ts +9 -0
  32. package/dist/fallthrough.js +78 -0
  33. package/dist/fixture.d.ts +4 -10
  34. package/dist/fixture.js +38 -27
  35. package/dist/hydration.d.ts +15 -0
  36. package/dist/hydration.js +80 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.js +2 -0
  39. package/dist/islands.d.ts +1 -0
  40. package/dist/islands.js +1 -0
  41. package/dist/link-router.d.ts +24 -0
  42. package/dist/link-router.js +36 -0
  43. package/dist/lists.d.ts +0 -11
  44. package/dist/lists.js +34 -60
  45. package/dist/loops.d.ts +3 -0
  46. package/dist/loops.js +168 -0
  47. package/dist/model.d.ts +46 -144
  48. package/dist/model.js +150 -18
  49. package/dist/narrowing.d.ts +27 -0
  50. package/dist/narrowing.js +157 -0
  51. package/dist/numbers.d.ts +8 -0
  52. package/dist/numbers.js +73 -0
  53. package/dist/parens.d.ts +0 -27
  54. package/dist/parens.js +0 -44
  55. package/dist/plugin.d.ts +84 -0
  56. package/dist/plugin.js +26 -0
  57. package/dist/plugins/client-only.d.ts +8 -0
  58. package/dist/plugins/client-only.js +107 -0
  59. package/dist/plugins/i18n.d.ts +23 -0
  60. package/dist/{i18n.js → plugins/i18n.js} +88 -31
  61. package/dist/plugins/index.d.ts +2 -0
  62. package/dist/plugins/index.js +10 -0
  63. package/dist/plugins/router-link.d.ts +8 -0
  64. package/dist/{router.js → plugins/router-link.js} +52 -91
  65. package/dist/plugins/router.d.ts +42 -0
  66. package/dist/plugins/router.js +256 -0
  67. package/dist/plugins/scoped.d.ts +12 -0
  68. package/dist/plugins/scoped.js +125 -0
  69. package/dist/plugins/shared-types.d.ts +2 -0
  70. package/dist/plugins/shared-types.js +7 -0
  71. package/dist/plugins/stores.d.ts +37 -0
  72. package/dist/plugins/stores.js +379 -0
  73. package/dist/plugins/teleport.d.ts +5 -0
  74. package/dist/plugins/teleport.js +55 -0
  75. package/dist/plugins/twins.d.ts +10 -0
  76. package/dist/plugins/twins.js +164 -0
  77. package/dist/routes.d.ts +10 -0
  78. package/dist/routes.js +6 -0
  79. package/dist/rust.d.ts +1 -7
  80. package/dist/rust.js +56 -174
  81. package/dist/script.d.ts +3 -6
  82. package/dist/script.js +127 -140
  83. package/dist/settle.d.ts +6 -0
  84. package/dist/settle.js +34 -0
  85. package/dist/slots.d.ts +13 -0
  86. package/dist/slots.js +160 -0
  87. package/dist/strings.d.ts +13 -0
  88. package/dist/strings.js +208 -0
  89. package/dist/styles.d.ts +14 -0
  90. package/dist/styles.js +181 -0
  91. package/dist/template.d.ts +1 -42
  92. package/dist/template.js +25 -749
  93. package/dist/testing.d.ts +2 -0
  94. package/dist/testing.js +4 -10
  95. package/dist/types.d.ts +2 -0
  96. package/dist/typescript.d.ts +2 -14
  97. package/dist/typescript.js +53 -59
  98. package/dist/vite.d.ts +2 -1
  99. package/dist/vite.js +11 -32
  100. package/package.json +15 -6
  101. package/dist/i18n.d.ts +0 -27
  102. package/dist/router.d.ts +0 -17
  103. package/dist/scoped.d.ts +0 -24
  104. package/dist/scoped.js +0 -177
  105. package/dist/stores.d.ts +0 -6
  106. package/dist/stores.js +0 -229
@@ -0,0 +1,176 @@
1
+ import { escapeHtml } from "@vue/shared";
2
+ import { fail, nothing, 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, "FV1001", "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, "FV1002", "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, "FV1001", "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, "FV1003", `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, "FV1002", "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 (nothing(v.ty))
81
+ return [];
82
+ return fail(s.comp, "FV1004", "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 (nothing(v.ty))
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" || nothing(v.ty) || (v.ty.k === "opt" && v.ty.of.k === "str"))
174
+ return valueAttr(s, v, n);
175
+ return fail(s.comp, "FV1004", "a class is a string, an array, or an object of conditions", n);
176
+ }
package/dist/cli.js CHANGED
@@ -1,19 +1,9 @@
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 --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
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
14
3
  import { dirname, join, relative, resolve, sep } from "node:path";
15
- import { CONFIG_FILE, GenError, generate, loadConfig, VERSION, write } from "./compiler.js";
4
+ import { CONFIG_FILE, generate, loadConfig, VERSION, write } from "./compiler.js";
16
5
  import { unifiedDiff } from "./diff.js";
6
+ import { diagnose, formatRefusal, isRefusal } from "./diagnostics.js";
17
7
  const root = process.cwd();
18
8
  const HELP = `Usage: ferrovue [command] [options]
19
9
 
@@ -25,17 +15,23 @@ Commands:
25
15
  Options:
26
16
  --check check that generated files match committed files without writing
27
17
  -d, --diff show unified diff of changes when checking (use with --check)
18
+ --format <format> how to write errors and results: human (default) or json
28
19
  --watch watch components, stores, routes and config for changes and regenerate
29
20
  -c, --config <path> path to configuration file (default: ferrovue.config.json)
30
21
  -v, --version print the version and exit
31
22
  -h, --help print this help and exit
32
23
  `;
33
- /** A refused construct, or a broken configuration: the message alone, which names the file. */
24
+ let json = false;
25
+ function emit(result) {
26
+ console.log(JSON.stringify({ diagnostics: [], ...result }));
27
+ }
34
28
  function report(e) {
35
- if (e instanceof GenError || e instanceof SyntaxError)
36
- console.error(`error: ${e.message}`);
37
- else
29
+ if (!isRefusal(e))
38
30
  throw e;
31
+ if (json)
32
+ emit({ diagnostics: [diagnose(e)] });
33
+ else
34
+ console.error(formatRefusal(e));
39
35
  }
40
36
  function initProject(targetRoot, configPath) {
41
37
  const configFile = configPath ? resolve(targetRoot, configPath) : join(targetRoot, CONFIG_FILE);
@@ -79,7 +75,6 @@ function check(config, showDiff = false) {
79
75
  have = readdirSync(dir).filter((f) => f.endsWith(".rs"));
80
76
  }
81
77
  catch {
82
- // An absent directory is simply stale.
83
78
  }
84
79
  const stale = [...want.keys()].filter((name) => {
85
80
  try {
@@ -90,6 +85,10 @@ function check(config, showDiff = false) {
90
85
  }
91
86
  });
92
87
  const extra = have.filter((name) => !want.has(name));
88
+ if (json) {
89
+ emit({ out: config.out, stale: stale.map((name) => `${config.out}/${name}`), notGenerated: extra.map((name) => `${config.out}/${name}`) });
90
+ return stale.length || extra.length ? 1 : 0;
91
+ }
93
92
  if (stale.length || extra.length) {
94
93
  const committed = (name) => {
95
94
  try {
@@ -117,11 +116,11 @@ function check(config, showDiff = false) {
117
116
  }
118
117
  function once(config) {
119
118
  const { files, changed, removed } = write(root, config);
119
+ if (json)
120
+ return emit({ out: config.out, files, changed, removed });
120
121
  const what = changed.length || removed.length ? `${changed.length} changed, ${removed.length} removed` : "nothing changed";
121
122
  console.log(`${config.out}: ${files.length} files, ${what}`);
122
123
  }
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
124
  function watchProject(configPath) {
126
125
  const run = () => {
127
126
  try {
@@ -138,7 +137,6 @@ function watchProject(configPath) {
138
137
  out = loadConfig(root, configPath).out;
139
138
  }
140
139
  catch {
141
- // A broken configuration ignores nothing; the next run reports it.
142
140
  }
143
141
  const parts = file.split(sep);
144
142
  return (parts.some((p) => p === "node_modules" || p === "target" || p === ".git" || p === "dist") ||
@@ -149,12 +147,11 @@ function watchProject(configPath) {
149
147
  watch(root, { recursive: true }, (_event, name) => {
150
148
  if (!name || ignored(relative(root, join(root, name))))
151
149
  return;
152
- // Editors write a file in several steps: one run once they are done.
153
150
  if (timer)
154
151
  clearTimeout(timer);
155
152
  timer = setTimeout(run, 50);
156
153
  });
157
- console.log("watching for changes (Ctrl-C to stop)");
154
+ (json ? console.error : console.log)("watching for changes (Ctrl-C to stop)");
158
155
  }
159
156
  try {
160
157
  const rawArgs = process.argv.slice(2);
@@ -172,6 +169,14 @@ try {
172
169
  else if (arg.startsWith("--config=")) {
173
170
  configPath = arg.slice("--config=".length);
174
171
  }
172
+ else if (arg === "--format" || arg.startsWith("--format=")) {
173
+ const format = arg === "--format" ? rawArgs[++i] : arg.slice("--format=".length);
174
+ if (format !== "human" && format !== "json") {
175
+ console.error(`error: '--format' is 'human' or 'json'`);
176
+ process.exit(1);
177
+ }
178
+ json = format === "json";
179
+ }
175
180
  else {
176
181
  args.push(arg);
177
182
  }
@@ -186,6 +191,10 @@ try {
186
191
  console.error("error: '--diff' requires '--check'");
187
192
  process.exit(1);
188
193
  }
194
+ if ((args.includes("--diff") || args.includes("-d")) && json) {
195
+ console.error("error: '--diff' writes text, and does not combine with '--format json'");
196
+ process.exit(1);
197
+ }
189
198
  if (args.includes("--version") || args.includes("-v")) {
190
199
  console.log(VERSION);
191
200
  }
package/dist/client.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type App, type Component, type Plugin } from "vue";
2
2
  import type { Pinia } from "pinia";
3
3
  import type { Router } from "vue-router";
4
- /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
4
+ /** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
5
5
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
6
6
  export declare function hydrateState(pinia: Pinia, id?: string, doc?: Document): void;
7
7
  export interface MountOptions {
@@ -18,7 +18,7 @@ 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
21
+ /** A component, or a function that loads one (`() => import("./Counter.vue")`), so that the code
22
22
  * of an island not on the page is never fetched. A function is taken for a loader as vue-router
23
23
  * takes one for a lazy route: unless it has `props` or `displayName`, which mark a functional
24
24
  * component, or `__vccOpts`, a class component. `ferrovue/islands` maps the name of every island
@@ -38,3 +38,36 @@ export interface Islands {
38
38
  * rendered it with, mounted where it is. A loader is called only for an island the page holds, all
39
39
  * of them at once, and the islands mount in document order once every one has loaded. */
40
40
  export declare function mountIslands(components: Record<string, IslandComponent>, options?: MountOptions): Promise<Islands>;
41
+ /** Content only the browser renders. The server writes the `#fallback` slot, or nothing; the
42
+ * browser hydrates that fallback, then swaps in the default slot once mounted. ferrovue does not
43
+ * compile the default slot, so anything may go there: a component library's components, code that
44
+ * reads `window`. The swap happens only where Vue runs on the page, as in an island. */
45
+ export declare const ClientOnly: Component;
46
+ /** One component the server rendered into a slot of a page's layout, as `ferrovue::Part` records
47
+ * it: its name (`c`) and the props it was rendered from (`p`). */
48
+ export interface PagePart {
49
+ c: string;
50
+ p: Record<string, unknown>;
51
+ }
52
+ /** What `ferrovue::PageRecord` writes: the layout's props, and each slot's parts in order. */
53
+ export interface PageRecord {
54
+ props: Record<string, unknown>;
55
+ slots: Record<string, PagePart[]>;
56
+ }
57
+ export interface PageOptions extends Pick<MountOptions, "pinia" | "router" | "plugins"> {
58
+ /** The element holding the layout's root, or a selector for it: `#app` by default. */
59
+ container?: Element | string;
60
+ /** The `id` of the record's script, as given to `PageRecord::script_into`: `__fv_page` by default. */
61
+ record?: string;
62
+ /** The document to read the page from. */
63
+ doc?: Document;
64
+ }
65
+ /** The app of a page: its root renders `layout` with the record's props, and each slot as the
66
+ * components the record names, in order, as a plain array, which hydrates the markup the server
67
+ * wrote with no fragment of its own. `components` must hold every component the record names. */
68
+ export declare function createPageApp(layout: Component, record: PageRecord, components: Record<string, Component>, options?: PageOptions): App;
69
+ /** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
70
+ * no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
71
+ * as the server rendered it, when the container or the record is missing or a component is not
72
+ * given or does not load. */
73
+ export declare function mountPage(layout: IslandComponent, components: Record<string, IslandComponent>, options?: PageOptions): Promise<App>;
package/dist/client.js CHANGED
@@ -1,18 +1,9 @@
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`. */
1
+ import { createSSRApp, createTextVNode, defineComponent, h, onMounted, ref } from "vue";
5
2
  const BARE = /"(?:[^"\\]|\\.)*"|-?Infinity|NaN/g;
6
3
  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
4
  function parseJson(text) {
11
- // Plain JSON: almost everything the server writes, and all it wrote before it wrote these.
12
5
  if (!/NaN|Infinity/.test(text))
13
6
  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
7
  let escaped = "\\u0000";
17
8
  while (text.includes(escaped))
18
9
  escaped += "\\u0000";
@@ -20,7 +11,7 @@ function parseJson(text) {
20
11
  const quoted = text.replace(BARE, (match) => (match.startsWith('"') ? match : `"${escaped}${match}"`));
21
12
  return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
22
13
  }
23
- /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
14
+ /** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
24
15
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
25
16
  export function hydrateState(pinia, id = "__pinia", doc = document) {
26
17
  const text = doc.getElementById(id)?.textContent;
@@ -35,7 +26,6 @@ export async function mountIslands(components, options = {}) {
35
26
  const root = options.root ?? document;
36
27
  const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
37
28
  const elements = Array.from(root.querySelectorAll("[data-island]"));
38
- // Each component the page names, loaded once however many islands it has.
39
29
  const loading = new Map();
40
30
  for (const el of elements) {
41
31
  const name = el.dataset.island ?? "";
@@ -84,8 +74,32 @@ export async function mountIslands(components, options = {}) {
84
74
  },
85
75
  };
86
76
  }
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. */
77
+ /** Content only the browser renders. The server writes the `#fallback` slot, or nothing; the
78
+ * browser hydrates that fallback, then swaps in the default slot once mounted. ferrovue does not
79
+ * compile the default slot, so anything may go there: a component library's components, code that
80
+ * reads `window`. The swap happens only where Vue runs on the page, as in an island. */
81
+ export const ClientOnly = Object.assign(defineComponent({
82
+ name: "ClientOnly",
83
+ inheritAttrs: false,
84
+ setup(_, { slots }) {
85
+ const mounted = ref(false);
86
+ onMounted(() => {
87
+ mounted.value = true;
88
+ });
89
+ return () => (mounted.value ? slots.default?.() : slots.fallback?.());
90
+ },
91
+ }), {
92
+ ssrRender(ctx, push, parent) {
93
+ const fallback = ctx.$slots.fallback;
94
+ if (!fallback) {
95
+ push("<!---->");
96
+ return;
97
+ }
98
+ push("<!--[-->");
99
+ fallback({}, push, parent, "");
100
+ push("<!--]-->");
101
+ },
102
+ });
89
103
  async function load(component) {
90
104
  if (typeof component !== "function" || "props" in component || "displayName" in component || "__vccOpts" in component)
91
105
  return component;
@@ -97,3 +111,52 @@ async function load(component) {
97
111
  return e instanceof Error ? e : new Error(String(e));
98
112
  }
99
113
  }
114
+ /** The app of a page: its root renders `layout` with the record's props, and each slot as the
115
+ * components the record names, in order, as a plain array, which hydrates the markup the server
116
+ * wrote with no fragment of its own. `components` must hold every component the record names. */
117
+ export function createPageApp(layout, record, components, options = {}) {
118
+ const slots = Object.fromEntries(Object.entries(record.slots).map(([name, parts]) => [
119
+ name,
120
+ () => (parts.length ? parts.map((part) => h(components[part.c], part.p)) : [createTextVNode("")]),
121
+ ]));
122
+ const app = createSSRApp({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
123
+ if (options.pinia)
124
+ app.use(options.pinia);
125
+ if (options.router)
126
+ app.use(options.router);
127
+ for (const plugin of options.plugins ?? [])
128
+ app.use(plugin);
129
+ return app;
130
+ }
131
+ /** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
132
+ * no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
133
+ * as the server rendered it, when the container or the record is missing or a component is not
134
+ * given or does not load. */
135
+ export async function mountPage(layout, components, options = {}) {
136
+ const doc = options.doc ?? document;
137
+ const selector = options.container ?? "#app";
138
+ const container = typeof selector === "string" ? doc.querySelector(selector) : selector;
139
+ if (!container)
140
+ throw new Error(`[ferrovue] the page has no ${selector} to mount on`);
141
+ const id = options.record ?? "__fv_page";
142
+ const text = doc.getElementById(id)?.textContent;
143
+ if (!text)
144
+ throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
145
+ const record = parseJson(text);
146
+ const names = new Set(Object.values(record.slots).flatMap((parts) => parts.map((part) => part.c)));
147
+ const loading = [...names].map(async (name) => {
148
+ if (!Object.hasOwn(components, name))
149
+ throw new Error(`[ferrovue] no component called ${JSON.stringify(name)} was given to mountPage`);
150
+ return [name, await loadOrThrow(name, components[name])];
151
+ });
152
+ const [root, parts] = await Promise.all([loadOrThrow("the layout", layout), Promise.all(loading), options.router?.isReady()]);
153
+ const app = createPageApp(root, record, Object.fromEntries(parts), options);
154
+ app.mount(container);
155
+ return app;
156
+ }
157
+ async function loadOrThrow(name, component) {
158
+ const result = await load(component);
159
+ if (result instanceof Error)
160
+ throw new Error(`[ferrovue] ${name} did not load: ${result.message}`, { cause: result });
161
+ return result;
162
+ }
@@ -18,6 +18,6 @@ export interface Written {
18
18
  * so a Rust build that watches them rebuilds no more than it must, and removing what no component
19
19
  * produces any more. */
20
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";
21
+ export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type TwinSpec, type TypeName } from "./context.ts";
22
22
  export { GenError } from "./model.ts";
23
23
  export declare const VERSION: string;