ferrovue 0.6.0 → 0.7.1

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 (79) hide show
  1. package/README.md +106 -13
  2. package/dist/args.d.ts +28 -0
  3. package/dist/args.js +69 -0
  4. package/dist/attrs.js +3 -0
  5. package/dist/calls.js +5 -2
  6. package/dist/children.js +18 -3
  7. package/dist/cli.js +80 -81
  8. package/dist/client-render.d.ts +27 -0
  9. package/dist/client-render.js +171 -0
  10. package/dist/client.d.ts +65 -6
  11. package/dist/client.js +269 -27
  12. package/dist/compiler.d.ts +9 -5
  13. package/dist/compiler.js +48 -14
  14. package/dist/component.d.ts +7 -3
  15. package/dist/component.js +37 -25
  16. package/dist/conformance.d.ts +24 -3
  17. package/dist/conformance.js +58 -7
  18. package/dist/constants.js +4 -2
  19. package/dist/context.d.ts +51 -4
  20. package/dist/context.js +173 -5
  21. package/dist/diagnostics.d.ts +6 -4
  22. package/dist/diagnostics.js +7 -3
  23. package/dist/editor.d.ts +18 -0
  24. package/dist/editor.js +59 -0
  25. package/dist/errors.d.ts +45 -3
  26. package/dist/errors.js +33 -3
  27. package/dist/expr.d.ts +2 -0
  28. package/dist/expr.js +91 -12
  29. package/dist/fallthrough.d.ts +1 -0
  30. package/dist/fallthrough.js +10 -2
  31. package/dist/files.d.ts +13 -0
  32. package/dist/files.js +49 -0
  33. package/dist/fixture.d.ts +3 -3
  34. package/dist/hydration.d.ts +12 -1
  35. package/dist/hydration.js +47 -1
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +1 -1
  38. package/dist/inputs.d.ts +24 -0
  39. package/dist/inputs.js +40 -0
  40. package/dist/link-router.d.ts +6 -2
  41. package/dist/link-router.js +7 -4
  42. package/dist/lists.d.ts +12 -1
  43. package/dist/lists.js +76 -2
  44. package/dist/model.d.ts +9 -1
  45. package/dist/model.js +2 -0
  46. package/dist/page-routes.d.ts +1 -0
  47. package/dist/paths.d.ts +5 -0
  48. package/dist/paths.js +7 -0
  49. package/dist/plugin.d.ts +2 -0
  50. package/dist/plugins/css-modules.d.ts +39 -0
  51. package/dist/plugins/css-modules.js +135 -0
  52. package/dist/plugins/head.js +4 -4
  53. package/dist/plugins/i18n.js +5 -12
  54. package/dist/plugins/index.js +2 -1
  55. package/dist/plugins/provide.js +4 -2
  56. package/dist/plugins/router.js +3 -2
  57. package/dist/plugins/scoped.d.ts +10 -0
  58. package/dist/plugins/scoped.js +17 -4
  59. package/dist/plugins/shared-types.js +2 -1
  60. package/dist/plugins/stores.js +7 -6
  61. package/dist/rust.d.ts +9 -1
  62. package/dist/rust.js +38 -15
  63. package/dist/schema.d.ts +25 -0
  64. package/dist/schema.js +73 -0
  65. package/dist/script.js +12 -5
  66. package/dist/slots.js +2 -0
  67. package/dist/ssr.js +42 -4
  68. package/dist/styles.d.ts +5 -1
  69. package/dist/styles.js +131 -4
  70. package/dist/testing.d.ts +2 -1
  71. package/dist/testing.js +1 -1
  72. package/dist/typescript.d.ts +11 -1
  73. package/dist/typescript.js +124 -33
  74. package/dist/vite.d.ts +18 -10
  75. package/dist/vite.js +106 -51
  76. package/dist/volar.d.ts +15 -0
  77. package/dist/volar.js +87 -0
  78. package/package.json +27 -7
  79. package/schema.json +333 -0
package/dist/cli.js CHANGED
@@ -1,30 +1,28 @@
1
1
  #!/usr/bin/env node
2
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, generate, loadConfig, VERSION, write } from "./compiler.js";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { relativePath } from "./paths.js";
5
+ import { CONFIG_FILE, generate, isGenerated, loadConfig, VERSION, write } from "./compiler.js";
6
+ import { helpText, parseArgs } from "./args.js";
5
7
  import { unifiedDiff } from "./diff.js";
6
- import { diagnose, formatRefusal, isRefusal } from "./diagnostics.js";
8
+ import { diagnose, formatRefusal, formatWarning, isRefusal } from "./diagnostics.js";
9
+ import { affects, inputsOf } from "./inputs.js";
7
10
  const root = process.cwd();
8
- const HELP = `Usage: ferrovue [command] [options]
9
-
10
- Compile Vue components to Rust render functions, run from the project root.
11
-
12
- Commands:
13
- init scaffold starter ferrovue.config.json and components
14
-
15
- Options:
16
- --check check that generated files match committed files without writing
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
19
- --watch watch components, stores, routes and config for changes and regenerate
20
- -c, --config <path> path to configuration file (default: ferrovue.config.json)
21
- -v, --version print the version and exit
22
- -h, --help print this help and exit
23
- `;
11
+ const SCHEMA_URL = `https://cdn.jsdelivr.net/npm/ferrovue@${VERSION}/schema.json`;
24
12
  let json = false;
13
+ let warnings = [];
14
+ function warn(warning) {
15
+ if (json)
16
+ warnings.push(diagnose(warning, "warning"));
17
+ else
18
+ console.error(formatWarning(warning));
19
+ }
25
20
  function emit(result) {
26
- console.log(JSON.stringify({ diagnostics: [], ...result }));
21
+ const { diagnostics = [], ...rest } = result;
22
+ console.log(JSON.stringify({ diagnostics: [...warnings, ...diagnostics], ...rest }));
23
+ warnings = [];
27
24
  }
25
+ const load = (configPath) => loadConfig(root, configPath, warn);
28
26
  function report(e) {
29
27
  if (!isRefusal(e))
30
28
  throw e;
@@ -36,18 +34,19 @@ function report(e) {
36
34
  function initProject(targetRoot, configPath) {
37
35
  const configFile = configPath ? resolve(targetRoot, configPath) : join(targetRoot, CONFIG_FILE);
38
36
  if (existsSync(configFile)) {
39
- console.error(`error: configuration file already exists: ${relative(targetRoot, configFile) || CONFIG_FILE}`);
37
+ console.error(`error: configuration file already exists: ${relativePath(targetRoot, configFile) || CONFIG_FILE}`);
40
38
  return 1;
41
39
  }
42
40
  const defaultComponents = "components";
43
41
  const defaultOut = "src/generated";
44
42
  mkdirSync(dirname(configFile), { recursive: true });
45
43
  const config = {
44
+ $schema: SCHEMA_URL,
46
45
  components: defaultComponents,
47
46
  out: defaultOut,
48
47
  };
49
48
  writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
50
- console.log(`created ${relative(targetRoot, configFile) || CONFIG_FILE}`);
49
+ console.log(`created ${relativePath(targetRoot, configFile) || CONFIG_FILE}`);
51
50
  const compDir = join(targetRoot, defaultComponents);
52
51
  mkdirSync(compDir, { recursive: true });
53
52
  const helloVue = join(compDir, "Hello.vue");
@@ -60,7 +59,7 @@ defineProps<{ name: string }>();
60
59
  <p>Hello, {{ name }}!</p>
61
60
  </template>
62
61
  `);
63
- console.log(`created ${join(defaultComponents, "Hello.vue")}`);
62
+ console.log(`created ${defaultComponents}/Hello.vue`);
64
63
  }
65
64
  const outDir = join(targetRoot, defaultOut);
66
65
  mkdirSync(outDir, { recursive: true });
@@ -104,7 +103,8 @@ function check(config, showDiff = false) {
104
103
  console.error(unifiedDiff(`${config.out}/${name}`, committed(name), want.get(name)));
105
104
  }
106
105
  for (const name of extra) {
107
- console.error(`not generated: ${config.out}/${name}`);
106
+ const foreign = isGenerated(join(dir, name)) ? "" : ` (not written by ferrovue, so \`ferrovue\` leaves it: move it out of ${config.out})`;
107
+ console.error(`not generated: ${config.out}/${name}${foreign}`);
108
108
  if (showDiff)
109
109
  console.error(unifiedDiff(`${config.out}/${name}`, committed(name), ""));
110
110
  }
@@ -122,97 +122,96 @@ function once(config) {
122
122
  console.log(`${config.out}: ${files.length} files, ${what}`);
123
123
  }
124
124
  function watchProject(configPath) {
125
+ let inputs;
125
126
  const run = () => {
127
+ let config = null;
126
128
  try {
127
- once(loadConfig(root, configPath));
129
+ config = load(configPath);
130
+ once(config);
128
131
  }
129
132
  catch (e) {
130
- report(e);
133
+ // A refusal is reported as one; anything else (a folder renamed away, a file read as it is
134
+ // written) is shown too, and the watch carries on for the change that puts it right.
135
+ if (isRefusal(e))
136
+ report(e);
137
+ else
138
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
131
139
  }
132
- };
133
- run();
134
- const ignored = (file) => {
135
- let out = "";
136
- try {
137
- out = loadConfig(root, configPath).out;
138
- }
139
- catch {
140
+ finally {
141
+ inputs = inputsOf(root, configPath ?? CONFIG_FILE, config);
140
142
  }
141
- const parts = file.split(sep);
142
- return (parts.some((p) => p === "node_modules" || p === "target" || p === ".git" || p === "dist") ||
143
- (out !== "" && (file === out || file.startsWith(out + sep))) ||
144
- !/\.(vue|ts|json)$/.test(file));
145
143
  };
144
+ run();
146
145
  let timer = null;
147
- watch(root, { recursive: true }, (_event, name) => {
148
- if (!name || ignored(relative(root, join(root, name))))
146
+ // One recursive watch of the root, filtered to the inputs: watching each input directory instead
147
+ // would miss one created later, and the type files may be anywhere.
148
+ const watcher = watch(root, { recursive: true }, (_event, name) => {
149
+ if (!name || !affects(inputs, join(root, name)))
149
150
  return;
150
151
  if (timer)
151
152
  clearTimeout(timer);
152
153
  timer = setTimeout(run, 50);
153
154
  });
155
+ watcher.on("error", (e) => {
156
+ console.error(`error: stopped watching: ${e.message}`);
157
+ process.exitCode = 1;
158
+ });
154
159
  (json ? console.error : console.log)("watching for changes (Ctrl-C to stop)");
155
160
  }
156
161
  try {
157
- const rawArgs = process.argv.slice(2);
158
- let configPath;
159
- const args = [];
160
- for (let i = 0; i < rawArgs.length; i++) {
161
- const arg = rawArgs[i];
162
- if (arg === "-c" || arg === "--config") {
163
- if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("-")) {
164
- console.error(`error: option '${arg}' requires an argument`);
165
- process.exit(1);
166
- }
167
- configPath = rawArgs[++i];
168
- }
169
- else if (arg.startsWith("--config=")) {
170
- configPath = arg.slice("--config=".length);
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
- }
180
- else {
181
- args.push(arg);
182
- }
162
+ const parsed = parseArgs(process.argv.slice(2));
163
+ if ("error" in parsed) {
164
+ console.error(`error: ${parsed.error}`);
165
+ process.exit(1);
183
166
  }
184
- const known = new Set(["init", "--check", "--diff", "-d", "--watch", "-v", "--version", "-h", "--help"]);
185
- const unknown = args.find((a) => !known.has(a));
186
- if (unknown !== undefined) {
187
- console.error(`error: unknown option or command '${unknown}'\n\nRun \`ferrovue --help\` for usage.`);
167
+ const { command, options, spelt } = parsed;
168
+ const configPath = options.get("--config");
169
+ const format = options.get("--format");
170
+ if (format !== undefined && format !== "human" && format !== "json") {
171
+ console.error(`error: '--format' is 'human' or 'json'`);
188
172
  process.exit(1);
189
173
  }
190
- if ((args.includes("--diff") || args.includes("-d")) && !args.includes("--check")) {
174
+ json = format === "json";
175
+ for (const w of parsed.warnings)
176
+ warn(w);
177
+ const conflict = (a, b) => {
178
+ console.error(`error: '${a}' does not combine with '${b}'`);
179
+ return process.exit(1);
180
+ };
181
+ if (command === "init") {
182
+ const other = ["--check", "--watch", "--diff"].find((o) => options.has(o));
183
+ if (other !== undefined)
184
+ conflict("init", spelt.get(other));
185
+ if (json)
186
+ conflict("init", "--format json");
187
+ }
188
+ if (options.has("--check") && options.has("--watch"))
189
+ conflict("--check", "--watch");
190
+ if (options.has("--diff") && !options.has("--check")) {
191
191
  console.error("error: '--diff' requires '--check'");
192
192
  process.exit(1);
193
193
  }
194
- if ((args.includes("--diff") || args.includes("-d")) && json) {
194
+ if (options.has("--diff") && json) {
195
195
  console.error("error: '--diff' writes text, and does not combine with '--format json'");
196
196
  process.exit(1);
197
197
  }
198
- if (args.includes("--version") || args.includes("-v")) {
198
+ if (options.has("--version")) {
199
199
  console.log(VERSION);
200
200
  }
201
- else if (args.includes("--help") || args.includes("-h")) {
202
- console.log(HELP.trimEnd());
201
+ else if (options.has("--help")) {
202
+ console.log(helpText());
203
203
  }
204
- else if (args.includes("init")) {
204
+ else if (command === "init") {
205
205
  process.exitCode = initProject(root, configPath);
206
206
  }
207
- else if (args.includes("--watch")) {
207
+ else if (options.has("--watch")) {
208
208
  watchProject(configPath);
209
209
  }
210
- else if (args.includes("--check")) {
211
- const showDiff = args.includes("--diff") || args.includes("-d");
212
- process.exitCode = check(loadConfig(root, configPath), showDiff);
210
+ else if (options.has("--check")) {
211
+ process.exitCode = check(load(configPath), options.has("--diff"));
213
212
  }
214
213
  else {
215
- once(loadConfig(root, configPath));
214
+ once(load(configPath));
216
215
  }
217
216
  }
218
217
  catch (e) {
@@ -0,0 +1,27 @@
1
+ import { type App, type Plugin } from "vue";
2
+ /** The DOM inside `container` as a client render is compared with the server's markup: comments
3
+ * left out, adjacent text joined and empty text dropped, a `class` attribute by the classes it
4
+ * holds, a `style` attribute by the declarations it holds (custom properties, which Vue's client
5
+ * sets for `v-bind()` in `<style>` after the others, by name in any order), and the state of a form control (an
6
+ * `<input>`'s `value` and `checked`, a `<textarea>`'s `value`, an `<option>`'s `selected`) by its
7
+ * property, which Vue's client sets where the server writes the attribute. */
8
+ export declare function renderedShape(container: Element): string;
9
+ /** `renderedShape` of markup as the browser parsed it, untouched since: an `<option>`'s state is
10
+ * the selection the HTML parser gives it, from the `selected` attributes (the last one in a single
11
+ * `<select>`, or its first enabled option without one), which happy-dom's property misreports. */
12
+ export declare function parsedShape(container: Element): string;
13
+ /** A plugin that takes what the app is mounted on, and its `renderedShape` as `mount` returns,
14
+ * before any update an `onMounted` hook scheduled has run. */
15
+ export declare function shapeAtMount(): {
16
+ plugin: Plugin;
17
+ container: () => Element | undefined;
18
+ shape: () => string | undefined;
19
+ };
20
+ /** Set the custom properties `v-bind()` in `<style>` gives each component's root elements, as Vue's
21
+ * client sets them once mounted, where Vue's build does not set them itself (its Node build) and a
22
+ * component tells them through `getCssVars`, as `attachSsrRender` makes it. */
23
+ export declare function applyCssVars(app: App): void;
24
+ /** Where `app`, mounted on `container`, showed something other than `expected` (a
25
+ * `renderedShape`): as mounting left it (`atMount`), and, where that differs while async
26
+ * components are loading, once they have loaded. `null` where it agrees at either point. */
27
+ export declare function renderDifference(app: App, container: Element, atMount: string, expected: string): Promise<string | null>;
@@ -0,0 +1,171 @@
1
+ import { normalizeCssVarValue } from "@vue/shared";
2
+ import { Fragment, Static } from "vue";
3
+ import { settled, stillLoading } from "./settle.js";
4
+ const escapeText = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
5
+ const STATE_ATTRS = { INPUT: ["value", "checked"], TEXTAREA: ["value"], OPTION: ["selected"] };
6
+ function selectOf(option) {
7
+ const parent = option.parentElement;
8
+ const select = parent?.tagName === "OPTGROUP" ? parent.parentElement : parent;
9
+ return select?.tagName === "SELECT" ? select : null;
10
+ }
11
+ function optionsOf(select) {
12
+ return [...select.children].flatMap((child) => (child.tagName === "OPTION" ? [child] : child.tagName === "OPTGROUP" ? [...child.children].filter((c) => c.tagName === "OPTION") : []));
13
+ }
14
+ function parsedSelected(option) {
15
+ const select = selectOf(option);
16
+ if (!select || select.hasAttribute("multiple") || Number(select.getAttribute("size")) > 1)
17
+ return option.hasAttribute("selected");
18
+ const options = optionsOf(select);
19
+ const disabled = (o) => o.hasAttribute("disabled") || (o.parentElement?.tagName === "OPTGROUP" && o.parentElement.hasAttribute("disabled"));
20
+ const chosen = options.findLast((o) => o.hasAttribute("selected")) ?? options.find((o) => !disabled(o));
21
+ return option === chosen;
22
+ }
23
+ function stateOf(el, name, parsed) {
24
+ if (parsed && el.tagName === "OPTION")
25
+ return parsedSelected(el);
26
+ return el[name];
27
+ }
28
+ function element(el, scratch, parsed) {
29
+ const state = STATE_ATTRS[el.tagName] ?? [];
30
+ const attrs = el
31
+ .getAttributeNames()
32
+ .filter((name) => name !== "style" && name !== "class" && !state.includes(name))
33
+ .toSorted()
34
+ .map((name) => ` ${name}=${JSON.stringify(el.getAttribute(name))}`);
35
+ const classes = [...new Set((el.getAttribute("class") ?? "").split(/[\t\n\f\r ]+/).filter(Boolean))].toSorted();
36
+ if (classes.length)
37
+ attrs.push(` class=${JSON.stringify(classes.join(" "))}`);
38
+ const style = styleShape(el.getAttribute("style") ?? "", scratch);
39
+ if (style)
40
+ attrs.push(` style=${JSON.stringify(style)}`);
41
+ for (const name of state)
42
+ attrs.push(` .${name}=${JSON.stringify(stateOf(el, name, parsed))}`);
43
+ const children = el.tagName === "TEXTAREA" ? "" : nodes(el.childNodes, scratch, parsed);
44
+ return `<${el.localName}${attrs.join("")}>${children}</${el.localName}>`;
45
+ }
46
+ function styleShape(text, scratch) {
47
+ scratch.setAttribute("style", text);
48
+ const { style } = scratch;
49
+ const own = [];
50
+ const custom = [];
51
+ for (let i = 0; i < style.length; i++) {
52
+ const name = style.item(i);
53
+ const declared = `${name.replace(/\\(.)/g, "$1")}: ${style.getPropertyValue(name).trim()}${style.getPropertyPriority(name) ? " !important" : ""};`;
54
+ (name.startsWith("--") ? custom : own).push(declared);
55
+ }
56
+ return [...own, ...custom.toSorted()].join(" ");
57
+ }
58
+ function nodes(list, scratch, parsed) {
59
+ let out = "";
60
+ let text = "";
61
+ for (const node of list) {
62
+ if (node.nodeType === 3) {
63
+ text += node.textContent ?? "";
64
+ continue;
65
+ }
66
+ if (node.nodeType !== 1)
67
+ continue;
68
+ out += escapeText(text) + element(node, scratch, parsed);
69
+ text = "";
70
+ }
71
+ return out + escapeText(text);
72
+ }
73
+ /** The DOM inside `container` as a client render is compared with the server's markup: comments
74
+ * left out, adjacent text joined and empty text dropped, a `class` attribute by the classes it
75
+ * holds, a `style` attribute by the declarations it holds (custom properties, which Vue's client
76
+ * sets for `v-bind()` in `<style>` after the others, by name in any order), and the state of a form control (an
77
+ * `<input>`'s `value` and `checked`, a `<textarea>`'s `value`, an `<option>`'s `selected`) by its
78
+ * property, which Vue's client sets where the server writes the attribute. */
79
+ export function renderedShape(container) {
80
+ return nodes(container.childNodes, container.ownerDocument.createElement("div"), false);
81
+ }
82
+ /** `renderedShape` of markup as the browser parsed it, untouched since: an `<option>`'s state is
83
+ * the selection the HTML parser gives it, from the `selected` attributes (the last one in a single
84
+ * `<select>`, or its first enabled option without one), which happy-dom's property misreports. */
85
+ export function parsedShape(container) {
86
+ return nodes(container.childNodes, container.ownerDocument.createElement("div"), true);
87
+ }
88
+ /** A plugin that takes what the app is mounted on, and its `renderedShape` as `mount` returns,
89
+ * before any update an `onMounted` hook scheduled has run. */
90
+ export function shapeAtMount() {
91
+ let container;
92
+ let shape;
93
+ const plugin = {
94
+ install(app) {
95
+ const mount = app.mount.bind(app);
96
+ app.mount = (...args) => {
97
+ const root = mount(...args);
98
+ const [target] = args;
99
+ container = (typeof target === "string" ? document.querySelector(target) : target);
100
+ shape = renderedShape(container);
101
+ return root;
102
+ };
103
+ },
104
+ };
105
+ return { plugin, container: () => container, shape: () => shape };
106
+ }
107
+ function setVarsOnNode(node, vars) {
108
+ if (node.nodeType !== 1)
109
+ return;
110
+ for (const key in vars)
111
+ node.style.setProperty(`--${key}`, normalizeCssVarValue(vars[key]));
112
+ }
113
+ function setVarsOnVNode(vnode, vars) {
114
+ let at = vnode && vnode.shapeFlag & 128 ? (vnode.suspense?.activeBranch ?? null) : vnode;
115
+ while (at?.component)
116
+ at = at.component.subTree;
117
+ if (!at)
118
+ return;
119
+ if (at.shapeFlag & 1 && at.el)
120
+ setVarsOnNode(at.el, vars);
121
+ else if (at.type === Fragment && Array.isArray(at.children))
122
+ for (const c of at.children)
123
+ setVarsOnVNode(c, vars);
124
+ else if (at.type === Static) {
125
+ for (let el = at.el; el; el = el.nextSibling) {
126
+ setVarsOnNode(el, vars);
127
+ if (el === at.anchor)
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ function eachInstance(vnode, visit) {
133
+ if (!vnode)
134
+ return;
135
+ if (vnode.component) {
136
+ visit(vnode.component);
137
+ eachInstance(vnode.component.subTree, visit);
138
+ }
139
+ if (vnode.shapeFlag & 128)
140
+ eachInstance(vnode.suspense?.activeBranch ?? null, visit);
141
+ if (Array.isArray(vnode.children))
142
+ for (const c of vnode.children)
143
+ if (c && typeof c === "object")
144
+ eachInstance(c, visit);
145
+ }
146
+ /** Set the custom properties `v-bind()` in `<style>` gives each component's root elements, as Vue's
147
+ * client sets them once mounted, where Vue's build does not set them itself (its Node build) and a
148
+ * component tells them through `getCssVars`, as `attachSsrRender` makes it. */
149
+ export function applyCssVars(app) {
150
+ const root = app._instance;
151
+ if (!root)
152
+ return;
153
+ const visit = (instance) => {
154
+ if (instance.getCssVars)
155
+ setVarsOnVNode(instance.subTree, instance.getCssVars());
156
+ };
157
+ visit(root);
158
+ eachInstance(root.subTree, visit);
159
+ }
160
+ /** Where `app`, mounted on `container`, showed something other than `expected` (a
161
+ * `renderedShape`): as mounting left it (`atMount`), and, where that differs while async
162
+ * components are loading, once they have loaded. `null` where it agrees at either point. */
163
+ export async function renderDifference(app, container, atMount, expected) {
164
+ let shown = atMount;
165
+ if (shown !== expected && stillLoading(app)) {
166
+ await settled(app);
167
+ applyCssVars(app);
168
+ shown = renderedShape(container);
169
+ }
170
+ return shown === expected ? null : `the client renders it differently:\n server: ${expected}\n client: ${shown}`;
171
+ }
package/dist/client.d.ts CHANGED
@@ -1,9 +1,39 @@
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
+ /** The state of a page already read: the text of its state's script, as `readPage` gives it. */
5
+ export interface StateText {
6
+ text: string | undefined;
7
+ }
4
8
  /** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
5
- * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
6
- export declare function hydrateState(pinia: Pinia, id?: string, doc?: Document): void;
9
+ * the app mounts, so every store starts from it and the markup agrees with the server's. The state
10
+ * is read from the script with the `id` given (`__pinia` by default) in `doc`, or given as
11
+ * `{ text }`: the `state` of a fetched page that `readPage` read. Either way the numbers JSON
12
+ * cannot carry (`NaN`, `Infinity`) are read back. Pinia is left alone when there is no state. */
13
+ export declare function hydrateState(pinia: Pinia, from?: string | StateText, doc?: Document): void;
14
+ /** The `id`s of the scripts `readPage` reads. */
15
+ export interface ReadPageOptions {
16
+ /** The `id` of the record's script, as given to `PageRecord::script_into`: `__fv_page` by default. */
17
+ record?: string;
18
+ /** The `id` of the state's script, as given to `ferrovue::state_script_into`: `__pinia` by default. */
19
+ state?: string;
20
+ }
21
+ /** A page read out of the HTML of its document. */
22
+ export interface ReadPage {
23
+ /** The record `ferrovue::PageRecord` wrote, with the numbers JSON cannot carry read back. */
24
+ record: PageRecord;
25
+ /** The text of the state's script, for `hydrateState(pinia, { text: state })`: `undefined` when
26
+ * the page carries no state. */
27
+ state: string | undefined;
28
+ }
29
+ /** Read the record and the state of a page out of the HTML of its document, as a navigation that
30
+ * fetches the next page receives it. It needs no `DOMParser`, so it also runs in a worker and on a
31
+ * server. Each script is the one the browser's `getElementById` would find in the parsed document:
32
+ * the first `<script>` with that `id` outside a `<template>`, an `<svg>` and a `<math>`, with
33
+ * comments, attribute values and the text of `<script>`, `<style>`, `<textarea>`, `<title>` and the
34
+ * other raw-text elements passed over as the HTML parser passes over them. Throws when the page
35
+ * carries no record. */
36
+ export declare function readPage(html: string, options?: ReadPageOptions): ReadPage;
7
37
  export interface MountOptions {
8
38
  /** One Pinia for every island, so they share stores. Call `hydrateState` on it first. */
9
39
  pinia?: Pinia;
@@ -52,11 +82,13 @@ export declare const ClientOnly: Component;
52
82
  * it: its name (`c`) and the props it was rendered from (`p`). */
53
83
  export interface PagePart {
54
84
  c: string;
55
- p: Record<string, unknown>;
85
+ /** `null` where serde_json refused the props, which the part then renders without. */
86
+ p: Record<string, unknown> | null;
56
87
  }
57
88
  /** What `ferrovue::PageRecord` writes: the layout's props, and each slot's parts in order. */
58
89
  export interface PageRecord {
59
- props: Record<string, unknown>;
90
+ /** `null` where serde_json refused the props, which the layout then renders without. */
91
+ props: Record<string, unknown> | null;
60
92
  slots: Record<string, PagePart[]>;
61
93
  }
62
94
  export interface PageOptions extends Pick<MountOptions, "pinia" | "router" | "plugins"> {
@@ -67,12 +99,39 @@ export interface PageOptions extends Pick<MountOptions, "pinia" | "router" | "pl
67
99
  /** The document to read the page from. */
68
100
  doc?: Document;
69
101
  }
102
+ export interface PageAppOptions extends Pick<MountOptions, "pinia" | "router" | "plugins"> {
103
+ /** `false` for an app that renders the page from its record into an empty container, as
104
+ * `renderPage` does, in place of hydrating the server's markup. */
105
+ hydrate?: boolean;
106
+ }
70
107
  /** The app of a page: its root renders `layout` with the record's props, and each slot as the
71
108
  * components the record names, in order, as a plain array, which hydrates the markup the server
72
- * wrote with no fragment of its own. `components` must hold every component the record names. */
73
- export declare function createPageApp(layout: Component, record: PageRecord, components: Record<string, Component>, options?: PageOptions): App;
109
+ * wrote with no fragment of its own. With `hydrate: false` the same tree is rendered from scratch,
110
+ * so the first page and every page shown after it are one shape of app. `components` must hold
111
+ * every component the record names. */
112
+ export declare function createPageApp(layout: Component, record: PageRecord, components: Record<string, Component>, options?: PageAppOptions): App;
74
113
  /** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
75
114
  * no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
76
115
  * as the server rendered it, when the container or the record is missing or a component is not
77
116
  * given or does not load. */
78
117
  export declare function mountPage(layout: IslandComponent, components: Record<string, IslandComponent>, options?: PageOptions): Promise<App>;
118
+ export interface RenderPageOptions extends Pick<MountOptions, "pinia" | "router" | "plugins"> {
119
+ /** The element holding the page shown now, or a selector for it: `#app` by default. */
120
+ container?: Element | string;
121
+ /** What shows the page now: the app `mountPage` or `renderPage` returned, or the islands
122
+ * `mountIslands` mounted. It is unmounted once the next page is mounted. */
123
+ previous?: {
124
+ unmount(): void;
125
+ } | undefined;
126
+ /** The document the container is in. */
127
+ doc?: Document;
128
+ }
129
+ /** Show the next page of a navigation from its record, as `readPage` read it out of the fetched
130
+ * document: load the components it names (and no others), render the app `createPageApp` builds
131
+ * with `hydrate: false` into a copy of the container with no children, wait for the router, put the
132
+ * copy in the container's place, mount, and then unmount `previous`. The next app is mounted
133
+ * before the previous one is unmounted, so a router both use stays started. `router` must be at the
134
+ * next page's location: `linkRouter` with the `location` the navigation goes to. Rejects, leaving
135
+ * the page shown as it is, when the container is missing or a component is not given or does not
136
+ * load. */
137
+ export declare function renderPage(layout: IslandComponent, components: Record<string, IslandComponent>, record: PageRecord, options?: RenderPageOptions): Promise<App>;