ferrovue 0.6.0 → 0.7.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 (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 +23 -0
  9. package/dist/client-render.js +143 -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/editor.js ADDED
@@ -0,0 +1,59 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { CONFIG_FILE, generate, loadConfig } from "./compiler.js";
4
+ import { diagnose, isRefusal } from "./diagnostics.js";
5
+ const FRESH_MS = 1000;
6
+ const recent = new Map();
7
+ /** The directory holding `config` that is `dir` or the nearest above it. */
8
+ export function projectRoot(dir, config = CONFIG_FILE) {
9
+ for (let at = resolve(dir);; at = dirname(at)) {
10
+ if (existsSync(join(at, config)))
11
+ return at;
12
+ if (dirname(at) === at)
13
+ return null;
14
+ }
15
+ }
16
+ function offsetOf(text, line, column) {
17
+ let at = 0;
18
+ for (let l = 1; l < line; l++) {
19
+ const next = text.indexOf("\n", at);
20
+ if (next < 0)
21
+ return text.length;
22
+ at = next + 1;
23
+ }
24
+ return Math.min(at + column - 1, text.length);
25
+ }
26
+ function check(file, text, options) {
27
+ const root = projectRoot(dirname(file), options.config);
28
+ if (!root)
29
+ return [];
30
+ try {
31
+ generate(root, loadConfig(root, options.config), new Map([[file, text]]));
32
+ return [];
33
+ }
34
+ catch (e) {
35
+ if (!isRefusal(e)) {
36
+ console.error(e);
37
+ return [];
38
+ }
39
+ const d = diagnose(e);
40
+ if (d.file === null || resolve(root, d.file) !== file)
41
+ return [];
42
+ const start = d.line === null ? 0 : offsetOf(text, d.line, d.column ?? 1);
43
+ const end = d.endLine === null ? null : offsetOf(text, d.endLine, d.endColumn ?? 1);
44
+ return [{ ...d, start, end }];
45
+ }
46
+ }
47
+ /** The refusals in a component of a ferrovue project, compiling the project with `text` in place of
48
+ * the file. A project is the nearest directory above the file holding the configuration; a file in
49
+ * none has no refusals. The compiler stops at the first refusal, so a refusal in another file hides
50
+ * those in this one. */
51
+ export function refusalsIn(file, text, options = {}) {
52
+ const path = resolve(file);
53
+ const hit = recent.get(path);
54
+ if (hit && hit.text === text && Date.now() - hit.at < FRESH_MS)
55
+ return hit.found;
56
+ const found = check(path, text, options);
57
+ recent.set(path, { text, at: Date.now(), found });
58
+ return found;
59
+ }
package/dist/errors.d.ts CHANGED
@@ -312,6 +312,9 @@ export declare const ERRORS: {
312
312
  readonly FV0513: {
313
313
  readonly title: "Component resolved by name that is not imported";
314
314
  };
315
+ readonly FV0514: {
316
+ readonly title: "Listener of unknown name given to a child with a prop named like a listener";
317
+ };
315
318
  readonly FV0601: {
316
319
  readonly title: "Name or function not available on the server";
317
320
  };
@@ -393,6 +396,9 @@ export declare const ERRORS: {
393
396
  readonly FV0627: {
394
397
  readonly title: "Strict test against the absence a value cannot have";
395
398
  };
399
+ readonly FV0628: {
400
+ readonly title: "`as` or `satisfies` stating a type the value does not have";
401
+ };
396
402
  readonly FV0701: {
397
403
  readonly title: "`Number()` of an unsupported value";
398
404
  };
@@ -452,7 +458,7 @@ export declare const ERRORS: {
452
458
  readonly title: "List method over a list of unsupported items";
453
459
  };
454
460
  readonly FV0805: {
455
- readonly title: "List method given anything but an arrow function";
461
+ readonly title: "List method given anything but an arrow function or `Boolean`";
456
462
  };
457
463
  readonly FV0806: {
458
464
  readonly title: "Arrow function with a block body";
@@ -582,10 +588,12 @@ export declare const ERRORS: {
582
588
  readonly title: "Class binding of an unsupported type";
583
589
  };
584
590
  readonly FV1005: {
585
- readonly title: "`<style module>`";
591
+ readonly title: "`<style module>` without `cssModules` in the configuration";
592
+ readonly detail: "Vite names a CSS module's classes by `css.modules.generateScopedName`. ferrovue computes the same names when `cssModules` in `ferrovue.config.json` is set as that option is; without it, it cannot know them.";
586
593
  };
587
594
  readonly FV1006: {
588
- readonly title: "`v-bind()` in CSS";
595
+ readonly title: "`v-bind()` in CSS of a value that is not a string or a number";
596
+ readonly detail: "Vue's server writes a variable `v-bind()` sets from a string or a number, `initial` for `null` and `undefined`, and warns of anything else.";
589
597
  };
590
598
  readonly FV1007: {
591
599
  readonly title: "Style binding of an unsupported type";
@@ -609,6 +617,13 @@ export declare const ERRORS: {
609
617
  readonly title: "`class` written apart from a later `:class` in content rendered from virtual nodes";
610
618
  readonly detail: "Inside an element `<component :is>` chooses, in the slot content such an element renders, and inside a `<RouterLink>`, Vue's server renders from virtual nodes, where a static `class` written before `:class` keeps its place and its names come first. ferrovue writes them so when nothing stands between the two; with attributes between them, write the two next to each other, or `:class` first.";
611
619
  };
620
+ readonly FV1014: {
621
+ readonly title: "`<style module>` whose class names ferrovue cannot compute";
622
+ readonly detail: "ferrovue names the classes of a CSS module written in the component in plain CSS, as postcss-modules does in Vite. A module in another file (`src`), one a preprocessor compiles (`lang`), and one that imports names from another file (`composes: x from \"./a.css\"`, `@value x from \"./a.css\"`) are refused.";
623
+ };
624
+ readonly FV1015: {
625
+ readonly title: "`useCssModule` of a module the component does not declare";
626
+ };
612
627
  readonly FV1101: {
613
628
  readonly title: "Helper called with the wrong number of arguments";
614
629
  };
@@ -645,6 +660,27 @@ export declare const ERRORS: {
645
660
  readonly FV1112: {
646
661
  readonly title: "Twin slot name that is not a plain name";
647
662
  };
663
+ readonly FV1113: {
664
+ readonly title: "Configuration that is not a JSON object";
665
+ };
666
+ readonly FV1114: {
667
+ readonly title: "Unknown configuration key";
668
+ readonly detail: "A key ferrovue does not read is refused, as it is most often a misspelt one (`component` for `components`). The message names the key it most likely means.";
669
+ };
670
+ readonly FV1115: {
671
+ readonly title: "Configuration value of the wrong type";
672
+ };
673
+ readonly FV1116: {
674
+ readonly title: "Directory the configuration names that cannot be read";
675
+ };
676
+ readonly FV1117: {
677
+ readonly title: "Deprecated configuration key";
678
+ readonly detail: "A warning: ferrovue still reads the key, and stops reading it in the next major release. The message names what to write instead. A key is deprecated for at least one minor release before it is removed.";
679
+ };
680
+ readonly FV1118: {
681
+ readonly title: "Deprecated command-line option";
682
+ readonly detail: "A warning: the option still works, and is removed in the next major release. The message names what to use instead. An option is deprecated for at least one minor release before it is removed.";
683
+ };
648
684
  readonly FV1201: {
649
685
  readonly title: "Route parameter that is not a present string or number";
650
686
  };
@@ -782,6 +818,12 @@ export declare const ERRORS: {
782
818
  readonly FV1245: {
783
819
  readonly title: "Page whose component name is taken or not a Rust name";
784
820
  };
821
+ readonly FV1246: {
822
+ readonly title: "Routes file that cannot be read";
823
+ };
824
+ readonly FV1247: {
825
+ readonly title: "Routes file that is not valid JSON";
826
+ };
785
827
  readonly FV1301: {
786
828
  readonly title: "Type declared by two stores";
787
829
  };
package/dist/errors.js CHANGED
@@ -145,6 +145,7 @@ export const ERRORS = {
145
145
  FV0511: { title: "Prop given a value of another type" },
146
146
  FV0512: { title: "`Props` imported from a component that is not compiled" },
147
147
  FV0513: { title: "Component resolved by name that is not imported" },
148
+ FV0514: { title: "Listener of unknown name given to a child with a prop named like a listener" },
148
149
  FV0601: { title: "Name or function not available on the server" },
149
150
  FV0602: { title: "Unsupported method" },
150
151
  FV0603: { title: "Unsupported call" },
@@ -172,6 +173,7 @@ export const ERRORS = {
172
173
  FV0625: { title: "Unsupported expression" },
173
174
  FV0626: { title: "Strict test of a value that may be `null` or `undefined`" },
174
175
  FV0627: { title: "Strict test against the absence a value cannot have" },
176
+ FV0628: { title: "`as` or `satisfies` stating a type the value does not have" },
175
177
  FV0701: { title: "`Number()` of an unsupported value" },
176
178
  FV0702: { title: "`parseInt()` or `parseFloat()` of a value that is not a string" },
177
179
  FV0703: { title: "`parseInt()` with a radix other than a literal 10 or 16" },
@@ -194,7 +196,7 @@ export const ERRORS = {
194
196
  FV0802: { title: "`.join()` with a separator that is not a string" },
195
197
  FV0803: { title: "List method on a value that is not a list" },
196
198
  FV0804: { title: "List method over a list of unsupported items" },
197
- FV0805: { title: "List method given anything but an arrow function" },
199
+ FV0805: { title: "List method given anything but an arrow function or `Boolean`" },
198
200
  FV0806: { title: "Arrow function with a block body" },
199
201
  FV0807: { title: "Arrow function index that is not a plain name" },
200
202
  FV0808: { title: "Defaults or nested patterns in an arrow function's item" },
@@ -240,8 +242,14 @@ export const ERRORS = {
240
242
  FV1002: { title: "Computed class name that is not a string" },
241
243
  FV1003: { title: "Class name with spaces around it" },
242
244
  FV1004: { title: "Class binding of an unsupported type" },
243
- FV1005: { title: "`<style module>`" },
244
- FV1006: { title: "`v-bind()` in CSS" },
245
+ FV1005: {
246
+ title: "`<style module>` without `cssModules` in the configuration",
247
+ detail: "Vite names a CSS module's classes by `css.modules.generateScopedName`. ferrovue computes the same names when `cssModules` in `ferrovue.config.json` is set as that option is; without it, it cannot know them.",
248
+ },
249
+ FV1006: {
250
+ title: "`v-bind()` in CSS of a value that is not a string or a number",
251
+ detail: "Vue's server writes a variable `v-bind()` sets from a string or a number, `initial` for `null` and `undefined`, and warns of anything else.",
252
+ },
245
253
  FV1007: { title: "Style binding of an unsupported type" },
246
254
  FV1008: { title: "Computed or spread key in a style object" },
247
255
  FV1009: { title: "Style property named by a number or starting with `:`" },
@@ -252,6 +260,11 @@ export const ERRORS = {
252
260
  title: "`class` written apart from a later `:class` in content rendered from virtual nodes",
253
261
  detail: "Inside an element `<component :is>` chooses, in the slot content such an element renders, and inside a `<RouterLink>`, Vue's server renders from virtual nodes, where a static `class` written before `:class` keeps its place and its names come first. ferrovue writes them so when nothing stands between the two; with attributes between them, write the two next to each other, or `:class` first.",
254
262
  },
263
+ FV1014: {
264
+ title: "`<style module>` whose class names ferrovue cannot compute",
265
+ detail: "ferrovue names the classes of a CSS module written in the component in plain CSS, as postcss-modules does in Vite. A module in another file (`src`), one a preprocessor compiles (`lang`), and one that imports names from another file (`composes: x from \"./a.css\"`, `@value x from \"./a.css\"`) are refused.",
266
+ },
267
+ FV1015: { title: "`useCssModule` of a module the component does not declare" },
255
268
  FV1101: { title: "Helper called with the wrong number of arguments" },
256
269
  FV1102: { title: "Configuration file not found" },
257
270
  FV1103: { title: "Configuration file that is not valid JSON" },
@@ -264,6 +277,21 @@ export const ERRORS = {
264
277
  FV1110: { title: "Twin without `rust`" },
265
278
  FV1111: { title: "Twin prop name that is not camelCase" },
266
279
  FV1112: { title: "Twin slot name that is not a plain name" },
280
+ FV1113: { title: "Configuration that is not a JSON object" },
281
+ FV1114: {
282
+ title: "Unknown configuration key",
283
+ detail: "A key ferrovue does not read is refused, as it is most often a misspelt one (`component` for `components`). The message names the key it most likely means.",
284
+ },
285
+ FV1115: { title: "Configuration value of the wrong type" },
286
+ FV1116: { title: "Directory the configuration names that cannot be read" },
287
+ FV1117: {
288
+ title: "Deprecated configuration key",
289
+ detail: "A warning: ferrovue still reads the key, and stops reading it in the next major release. The message names what to write instead. A key is deprecated for at least one minor release before it is removed.",
290
+ },
291
+ FV1118: {
292
+ title: "Deprecated command-line option",
293
+ detail: "A warning: the option still works, and is removed in the next major release. The message names what to use instead. An option is deprecated for at least one minor release before it is removed.",
294
+ },
267
295
  FV1201: { title: "Route parameter that is not a present string or number" },
268
296
  FV1202: { title: "`to` that is not a string or an object literal" },
269
297
  FV1203: { title: "Computed or spread key in a `to` object" },
@@ -315,6 +343,8 @@ export const ERRORS = {
315
343
  FV1243: { title: "`<route>` block in a page" },
316
344
  FV1244: { title: "`_parent.vue` with no pages to hold" },
317
345
  FV1245: { title: "Page whose component name is taken or not a Rust name" },
346
+ FV1246: { title: "Routes file that cannot be read" },
347
+ FV1247: { title: "Routes file that is not valid JSON" },
318
348
  FV1301: { title: "Type declared by two stores" },
319
349
  FV1302: { title: "Store id that is not a string literal" },
320
350
  FV1303: { title: "Store of an unsupported shape" },
package/dist/expr.d.ts CHANGED
@@ -4,4 +4,6 @@ export declare function fieldVal(comp: Component, base: string, ty: Ty, js: stri
4
4
  export declare function holdsNothing(comp: Component, v: Val, want: Ty, node: N, where: string): void;
5
5
  export declare function coerce(comp: Component, v: Val, want: Ty, node: N): string;
6
6
  export declare function childOf(name: string): Component;
7
+ /** `n` with the type `t` given to each empty array literal it may evaluate to, as an `as T[]` on it. */
8
+ export declare function typedBy(n: N, t: N): N;
7
9
  export declare function expr(s: Scope, n: N): Val;
package/dist/expr.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { absence, BOOL, fail, FLOAT, GenError, INT, joinAbsence, nothing, NULL, opt, rustStr, sameTy, snake, STR, UNDEF, withAbsence } from "./model.js";
2
2
  import { ctx } from "./context.js";
3
- import { lookupStruct, markHome } from "./typescript.js";
3
+ import { lookupStruct, markHome, tyOfTs } from "./typescript.js";
4
4
  import { claim } from "./plugin.js";
5
5
  import { atom, bare, binary, enclosed, logical, negate, not, occurrences, strArg } from "./parens.js";
6
- import { collected } from "./lists.js";
6
+ import { collected, sliced, typedEmpty, untypedEmpty } from "./lists.js";
7
7
  import { asCow, formatted, isTemporary, loneOf, meet, stringsEqual } from "./strings.js";
8
8
  import { arithmetic, asF64, compare, intFromF64, isNumber, negatedOrder, numberVal } from "./numbers.js";
9
9
  import { boolOf, checkNullTest, choice, known, narrowing, narrowTo, nullTest, pathOf, presence, truthy } from "./narrowing.js";
@@ -45,8 +45,12 @@ export function fieldVal(comp, base, ty, js, node) {
45
45
  fail(comp, "FV0604", `\`.${js}\` on a value that is not an object`, node);
46
46
  const { st, owner } = lookupStruct(comp, ty);
47
47
  const found = st?.fields.find((x) => x.js === js);
48
- if (!found)
48
+ if (!found) {
49
+ const refused = st?.refused?.get(js) ?? st?.refused?.get("");
50
+ if (refused !== undefined)
51
+ throw refused;
49
52
  fail(comp, "FV0605", `\`${ty.name}\` has no field \`${js}\``, node);
53
+ }
50
54
  const f = owner === comp ? found : { ...found, ty: markHome(found.ty, owner.name) };
51
55
  if (st.slot)
52
56
  return { code: `${base}.${f.rust}`, ty: f.ty };
@@ -56,7 +60,7 @@ export function fieldVal(comp, base, ty, js, node) {
56
60
  if (of.k === "str")
57
61
  return { code: `${place}.as_deref().unwrap_or(${f.dflt})`, ty: STR };
58
62
  if (of.k === "list")
59
- return { code: `${place}.as_deref().unwrap_or(${f.dflt})`, ty: of };
63
+ return { code: `${place}.as_deref().unwrap_or(${f.dflt})`, ty: of, slice: true };
60
64
  return { code: `${place}.unwrap_or(${f.dflt})`, ty: of };
61
65
  }
62
66
  switch (f.ty.k) {
@@ -120,6 +124,52 @@ function slotsObject(s, n) {
120
124
  return "$slots";
121
125
  return (owner === "$setup" || owner === "_ctx") && s.slotsBindings.has(name) ? name : null;
122
126
  }
127
+ /** `n` with the type `t` given to each empty array literal it may evaluate to, as an `as T[]` on it. */
128
+ export function typedBy(n, t) {
129
+ switch (n.type) {
130
+ case "ArrayExpression":
131
+ return n.elements.length === 0 ? { ...n, type: "TSAsExpression", expression: n, typeAnnotation: t, fvGiven: true } : n;
132
+ case "ParenthesizedExpression":
133
+ return { ...n, expression: typedBy(n.expression, t) };
134
+ case "ConditionalExpression":
135
+ return { ...n, consequent: typedBy(n.consequent, t), alternate: typedBy(n.alternate, t) };
136
+ case "LogicalExpression":
137
+ return n.operator === "&&" ? n : { ...n, right: typedBy(n.right, t) };
138
+ default:
139
+ return n;
140
+ }
141
+ }
142
+ function stated(s, n) {
143
+ const comp = s.comp;
144
+ const keyword = n.type === "TSAsExpression" ? "as" : "satisfies";
145
+ const t = n.typeAnnotation;
146
+ if (t.type === "TSTypeReference" && t.typeName.type === "Identifier" && t.typeName.name === "const" && !t.typeParameters)
147
+ return expr(s, n.expression);
148
+ let want;
149
+ try {
150
+ want = tyOfTs(comp, t, comp.structs);
151
+ }
152
+ catch (e) {
153
+ if (!(e instanceof GenError) || !n.fvGiven)
154
+ throw e;
155
+ return expr(s, n.expression);
156
+ }
157
+ const list = want.k === "opt" ? want.of : want;
158
+ let inner = n.expression;
159
+ while (inner.type === "ParenthesizedExpression")
160
+ inner = inner.expression;
161
+ if (inner.type === "ArrayExpression" && inner.elements.length === 0) {
162
+ if (list.k === "list")
163
+ return typedEmpty(comp, list.of);
164
+ if (n.fvGiven)
165
+ return expr(s, n.expression);
166
+ return fail(comp, "FV0628", `\`${keyword}\` gives an empty array literal ${describeTy(want)}, which it is not`, n);
167
+ }
168
+ const v = expr(s, typedBy(n.expression, t));
169
+ if (sameTy(v.ty, want) || (isNumber(v.ty) && isNumber(want)) || (want.k === "opt" && sameTy(v.ty, want.of)))
170
+ return v;
171
+ return fail(comp, "FV0628", `\`${keyword}\` states ${describeTy(want)} of ${describeTy(v.ty)}, which the server keeps as it is: write the value with the type it has`, n);
172
+ }
123
173
  export function expr(s, n) {
124
174
  const comp = s.comp;
125
175
  const path = pathOf(n);
@@ -219,7 +269,9 @@ export function expr(s, n) {
219
269
  return { code: "None", ty: UNDEF };
220
270
  if (base.ty.k !== "opt")
221
271
  return fieldVal(comp, base.code, base.ty, n.property.name, n);
222
- const f = fieldVal(comp, "v", base.ty.of, n.property.name, n);
272
+ const field = fieldVal(comp, "v", base.ty.of, n.property.name, n);
273
+ const place = ["list", "record", "struct"].includes(field.ty.k) && /^v(?:\.[\w#]+)+$/.test(field.code);
274
+ const f = place ? { ...field, code: `&${field.code}` } : field;
223
275
  if (f.ty.k === "opt")
224
276
  return { code: `${atom(base.code)}.and_then(|v| ${f.code})`, ty: opt(f.ty) };
225
277
  return { code: `${atom(base.code)}.map(|v| ${f.code})`, ty: opt(f.ty) };
@@ -325,6 +377,9 @@ export function expr(s, n) {
325
377
  return { ...a, ty: withAbsence(a.ty.of, absence(b.ty)) };
326
378
  if (b.iter !== undefined)
327
379
  fail(comp, "FV0615", "`??` falling back to a computed list", n);
380
+ if (a.ty.of.k === "list" && b.ty.k === "list" && (untypedEmpty(b) || (sameTy(a.ty.of, b.ty) && !b.code.startsWith("[")))) {
381
+ return { code: `${atom(a.code)}.map(|v| &v[..]).unwrap_or(${untypedEmpty(b) ? "&[]" : sliced(b)})`, ty: a.ty.of, slice: true, ...loneOf(a, b) };
382
+ }
328
383
  if (a.held !== undefined && b.ty.k === "str")
329
384
  return { code: `&*${atom(a.held)}.unwrap_or(${asCow(b)})`, ty: STR, ...loneOf(a, b) };
330
385
  if (a.ty.of.k === "str" && b.ty.k === "str" && isTemporary(b)) {
@@ -451,14 +506,31 @@ export function expr(s, n) {
451
506
  case "ConditionalExpression": {
452
507
  const t = expr(s, n.test);
453
508
  const k = known(t);
454
- if (k !== undefined)
455
- return expr(s, k ? n.consequent : n.alternate);
509
+ if (k !== undefined) {
510
+ const chosen = expr(s, k ? n.consequent : n.alternate);
511
+ if (!untypedEmpty(chosen))
512
+ return chosen;
513
+ try {
514
+ const other = expr(s, k ? n.alternate : n.consequent);
515
+ return other.ty.k === "list" && other.ty.of.k !== "undef" ? typedEmpty(comp, other.ty.of) : chosen;
516
+ }
517
+ catch (e) {
518
+ if (!(e instanceof GenError))
519
+ throw e;
520
+ return chosen;
521
+ }
522
+ }
456
523
  const p = presence(s, n.test);
457
524
  const name = p ? `n${++ctx.narrowCount}` : "";
458
- const a = expr(p && !p.negated ? narrowTo(s, p, name) : s, n.consequent);
459
- const b = expr(p && p.negated ? narrowTo(s, p, name) : s, n.alternate);
525
+ let a = expr(p && !p.negated ? narrowTo(s, p, name) : s, n.consequent);
526
+ let b = expr(p && p.negated ? narrowTo(s, p, name) : s, n.alternate);
460
527
  if (sameTy(a.ty, b.ty) && a.code === b.code && (!name || !occurrences(a.code, name)))
461
528
  return a;
529
+ const [emptyA, emptyB] = [untypedEmpty(a), untypedEmpty(b)];
530
+ if (emptyA && b.ty.k === "list")
531
+ a = { ...a, ty: b.ty };
532
+ if (emptyB && a.ty.k === "list")
533
+ b = { ...b, ty: a.ty };
462
534
  const second = (code) => (a.ty.k === "str" ? strArg(code) : bare(code));
463
535
  const choose = (yes, no) => {
464
536
  if (!p)
@@ -478,9 +550,13 @@ export function expr(s, n) {
478
550
  if (sameTy(a.ty, b.ty) && a.ty.k === "str" && (isTemporary(a) || isTemporary(b))) {
479
551
  return { code: `&*(${choose(asCow(a), asCow(b))})`, ty: STR, ...lone };
480
552
  }
481
- if (sameTy(a.ty, b.ty) && a.ty.k === "list" && (a.iter !== undefined || b.iter !== undefined)) {
482
- const both = `(${choose(collected(a), collected(b))})`;
483
- return { code: both, ty: a.ty, iter: `${both}.into_iter()`, ...lone };
553
+ if (sameTy(a.ty, b.ty) && a.ty.k === "list" && a.ty.of.k !== "undef") {
554
+ const owned = (v, empty) => !empty && (v.iter !== undefined || v.code.startsWith("["));
555
+ if (owned(a, emptyA) || owned(b, emptyB)) {
556
+ const both = `(${choose(emptyA ? "Vec::new()" : collected(a), emptyB ? "Vec::new()" : collected(b))})`;
557
+ return { code: both, ty: a.ty, iter: `${both}.into_iter()`, ...lone };
558
+ }
559
+ return { code: choose(emptyA ? "&[]" : sliced(a), emptyB ? "&[]" : sliced(b)), ty: a.ty, slice: true, ...lone };
484
560
  }
485
561
  const optStr = (v) => nothing(v.ty) || (v.ty.k === "opt" && v.ty.of.k === "str") || v.ty.k === "str";
486
562
  if (sameTy(a.ty, b.ty) && a.ty.k !== "opt")
@@ -503,6 +579,9 @@ export function expr(s, n) {
503
579
  }
504
580
  return fail(comp, "FV0623", "the two branches of `?:` differ in type", n);
505
581
  }
582
+ case "TSAsExpression":
583
+ case "TSSatisfiesExpression":
584
+ return stated(s, n);
506
585
  case "NewExpression": {
507
586
  const made = n.callee.type === "Identifier" ? n.callee.name : n.callee.type === "MemberExpression" && !n.callee.computed ? `${n.callee.object.name ?? "…"}.${n.callee.property.name}` : "…";
508
587
  return fail(comp, "FV0624", `\`new ${made}(…)\` builds an object the server has no twin for: compute the value in Rust and pass it as a prop`, n);
@@ -1,6 +1,7 @@
1
1
  import { type Component, type N } from "./model.ts";
2
2
  export declare function isDollarAttrs(n: N, bindings: Set<string>): boolean;
3
3
  export declare function passedKey(child: Component, key: string): boolean;
4
+ export declare function isListener(key: string): boolean;
4
5
  export declare function attrsFlow(read: {
5
6
  comp: Component;
6
7
  ssr: string;
@@ -9,10 +9,15 @@ export function isDollarAttrs(n, bindings) {
9
9
  return n.object.name === "$setup" && prop !== undefined && bindings.has(prop);
10
10
  }
11
11
  export function passedKey(child, key) {
12
- if (["", "key", "ref", "ref_for", "ref_key", "innerHTML", "textContent"].includes(key) || /^on[^a-z]/.test(key))
12
+ if (["", "key", "ref", "ref_for", "ref_key", "innerHTML", "textContent"].includes(key))
13
13
  return false;
14
+ if (isListener(key))
15
+ return declares(child, key);
14
16
  return !(key.endsWith("Modifiers") && declares(child, key === "modelModifiers" ? "modelValue" : key.slice(0, -"Modifiers".length)));
15
17
  }
18
+ export function isListener(key) {
19
+ return /^on[^a-z]/.test(key);
20
+ }
16
21
  export function attrsFlow(read) {
17
22
  const byName = new Map(read.map((r) => [r.comp.name, r.comp]));
18
23
  const flows = read.map((r) => {
@@ -41,7 +46,10 @@ export function attrsFlow(read) {
41
46
  if (child) {
42
47
  calls.push({
43
48
  child,
44
- keys: parts.filter((p) => p.type === "ObjectExpression").flatMap((p) => p.properties.filter((q) => q.type === "ObjectProperty" && !q.computed).map((q) => q.key.name ?? String(q.key.value))),
49
+ keys: [
50
+ ...parts.filter((p) => p.type === "ObjectExpression").flatMap((p) => p.properties.filter((q) => q.type === "ObjectProperty" && !q.computed).map((q) => q.key.name ?? String(q.key.value))),
51
+ ...(parts.some((p) => p.type === "Identifier" && p.name === "_cssVars") ? ["style"] : []),
52
+ ],
45
53
  passesAttrs: parts.some((p) => p.type === "Identifier" && p.name === "_attrs"),
46
54
  passesDollar: parts.some((p) => isDollarAttrs(p, r.attrsBindings)),
47
55
  });
@@ -0,0 +1,13 @@
1
+ import type { Code } from "./errors.ts";
2
+ import { type Component, type N } from "./model.ts";
3
+ /** The entries of a directory the configuration names as `key`, refusing one that cannot be read. */
4
+ export declare function listDir(root: string, dir: string, key: string): string[];
5
+ /** A JSON file of the project's, parsed; one that cannot be read is refused with `missing`, and one
6
+ * that does not parse with `invalid`. */
7
+ export declare function readJsonFile(root: string, file: string, codes: {
8
+ missing?: Code;
9
+ invalid: Code;
10
+ }): unknown;
11
+ /** The statements of a TypeScript file of the project's, whose text `home.source` holds, refusing
12
+ * one that does not parse with where the parser stopped, as a component's script is. */
13
+ export declare function parseTs(home: Component): N[];
package/dist/files.js ADDED
@@ -0,0 +1,49 @@
1
+ import { parse as parseJs } from "@babel/parser";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fail, failIn, GenError } from "./model.js";
5
+ function why(e) {
6
+ const code = e.code;
7
+ return code === "ENOENT" ? "it does not exist" : code === "ENOTDIR" ? "it is not a directory" : code === "EISDIR" ? "it is a directory" : e.message;
8
+ }
9
+ /** The entries of a directory the configuration names as `key`, refusing one that cannot be read. */
10
+ export function listDir(root, dir, key) {
11
+ try {
12
+ return readdirSync(join(root, dir));
13
+ }
14
+ catch (e) {
15
+ throw new GenError("FV1116", `cannot read the \`${key}\` directory \`${dir}\`: ${why(e)}`, { file: dir });
16
+ }
17
+ }
18
+ /** A JSON file of the project's, parsed; one that cannot be read is refused with `missing`, and one
19
+ * that does not parse with `invalid`. */
20
+ export function readJsonFile(root, file, codes) {
21
+ let text;
22
+ try {
23
+ text = readFileSync(join(root, file), "utf8");
24
+ }
25
+ catch (e) {
26
+ if (!codes.missing)
27
+ throw e;
28
+ return failIn(file, codes.missing, `cannot read the file: ${why(e)}`);
29
+ }
30
+ try {
31
+ return JSON.parse(text);
32
+ }
33
+ catch (e) {
34
+ return failIn(file, codes.invalid, e.message);
35
+ }
36
+ }
37
+ /** The statements of a TypeScript file of the project's, whose text `home.source` holds, refusing
38
+ * one that does not parse with where the parser stopped, as a component's script is. */
39
+ export function parseTs(home) {
40
+ try {
41
+ return parseJs(home.source ?? "", { sourceType: "module", plugins: ["typescript"] }).program.body;
42
+ }
43
+ catch (e) {
44
+ if (!(e instanceof SyntaxError))
45
+ throw e;
46
+ const at = e.loc;
47
+ return fail(home, "FV0001", e.message.replace(/ \(\d+:\d+\)$/, ""), at ? { type: "ParseError", loc: { start: at }, __fv: "source" } : undefined);
48
+ }
49
+ }
package/dist/fixture.d.ts CHANGED
@@ -36,13 +36,13 @@ export interface RouterOptions {
36
36
  /** The application's own `pinia` module, used in place of the one `ferrovue/testing` would
37
37
  * import: under vitest the application's stores use the module Vite loads, which must be the
38
38
  * one that installs the fixture's state. */
39
- pinia?: typeof import("pinia");
39
+ pinia?: typeof import("pinia") | undefined;
40
40
  /** The application's own `vue-router` module, used in place of the one `ferrovue/testing`
41
41
  * would import. */
42
- vueRouter?: typeof import("vue-router");
42
+ vueRouter?: typeof import("vue-router") | undefined;
43
43
  /** The application's own `vue-i18n` module, used in place of the one `ferrovue/testing` would
44
44
  * import. */
45
- vueI18n?: typeof import("vue-i18n");
45
+ vueI18n?: typeof import("vue-i18n") | undefined;
46
46
  }
47
47
  /** An app rendering one fixture of `component`: with a router over `routes` when there are any. */
48
48
  export declare function fixtureApp(component: Component, fixture: Fixture, routes: RouteEntry[] | null, options?: RouterOptions): Promise<App>;
@@ -1,5 +1,5 @@
1
1
  import type { App, Component } from "vue";
2
- import { type IslandComponent, type PageOptions, type PageRecord } from "./client.ts";
2
+ import { type IslandComponent, type PageOptions, type PageRecord, type RenderPageOptions } from "./client.ts";
3
3
  /** A page as the server wrote it: `html` holds the container and, unless `record` is given, the
4
4
  * record's script. */
5
5
  export interface RecordedPage {
@@ -13,3 +13,14 @@ export interface RecordedPage {
13
13
  * such as a `<ClientOnly>` showing its content or an async component loading, is the app's to
14
14
  * change. Resolves to the app, mounted and settled. */
15
15
  export declare function hydrateRecordedPage(page: RecordedPage, layout: Component, components: Record<string, IslandComponent>, options?: PageOptions): Promise<App>;
16
+ /** Put a recorded page into the document's body and show it again from its record with
17
+ * `renderPage`, as a navigation shows the next page, throwing where the client's render differs
18
+ * from the markup the browser parsed, as `renderedShape` compares them (comments and empty text
19
+ * aside, classes, styles and form controls by what they hold), and on anything Vue warns or logs as
20
+ * an error. The render is compared as mounting leaves it, where `<ClientOnly>` still shows its
21
+ * fallback as the server wrote it, and, where that differs while async components are loading,
22
+ * once they have loaded. What is teleported out of the page is not compared. Resolves to the app,
23
+ * mounted. */
24
+ export declare function renderRecordedPage(page: RecordedPage, layout: Component, components: Record<string, IslandComponent>, options?: RenderPageOptions & {
25
+ record?: string;
26
+ }): Promise<App>;
package/dist/hydration.js CHANGED
@@ -1,4 +1,5 @@
1
- import { mountPage } from "./client.js";
1
+ import { mountPage, readPage, renderPage } from "./client.js";
2
+ import { renderDifference, renderedShape, shapeAtMount } from "./client-render.js";
2
3
  import { settled } from "./settle.js";
3
4
  function recordScript(id, record) {
4
5
  const json = JSON.stringify(record).replace(/[<>&\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
@@ -78,3 +79,48 @@ export async function hydrateRecordedPage(page, layout, components, options = {}
78
79
  }
79
80
  return app;
80
81
  }
82
+ /** Put a recorded page into the document's body and show it again from its record with
83
+ * `renderPage`, as a navigation shows the next page, throwing where the client's render differs
84
+ * from the markup the browser parsed, as `renderedShape` compares them (comments and empty text
85
+ * aside, classes, styles and form controls by what they hold), and on anything Vue warns or logs as
86
+ * an error. The render is compared as mounting leaves it, where `<ClientOnly>` still shows its
87
+ * fallback as the server wrote it, and, where that differs while async components are loading,
88
+ * once they have loaded. What is teleported out of the page is not compared. Resolves to the app,
89
+ * mounted. */
90
+ export async function renderRecordedPage(page, layout, components, options = {}) {
91
+ const doc = options.doc ?? document;
92
+ const id = options.record ?? "__fv_page";
93
+ doc.body.innerHTML = page.html + (page.record ? recordScript(id, page.record) : "");
94
+ const record = page.record ?? readPage(page.html, { record: id }).record;
95
+ const selector = options.container ?? "#app";
96
+ const container = typeof selector === "string" ? doc.querySelector(selector) : selector;
97
+ if (!container)
98
+ throw new Error(`the recorded page has no ${selector}`);
99
+ const expected = renderedShape(container);
100
+ const problems = [];
101
+ const watch = {
102
+ install(app) {
103
+ app.config.warnHandler = (message) => void problems.push(`warning: ${message}`);
104
+ },
105
+ };
106
+ const mounted = shapeAtMount();
107
+ const { warn, error } = console;
108
+ console.warn = (...args) => void problems.push(`console.warn: ${args.map(String).join(" ")}`);
109
+ console.error = (...args) => void problems.push(`console.error: ${args.map(String).join(" ")}`);
110
+ let app;
111
+ try {
112
+ app = await renderPage(layout, components, record, { ...options, doc, container, plugins: [...(options.plugins ?? []), watch, mounted.plugin] });
113
+ const difference = await renderDifference(app, mounted.container() ?? container, mounted.shape() ?? "", expected);
114
+ if (difference)
115
+ problems.push(difference);
116
+ }
117
+ finally {
118
+ console.warn = warn;
119
+ console.error = error;
120
+ }
121
+ if (problems.length) {
122
+ app.unmount();
123
+ throw new Error(`the page did not render from its record exactly:\n${problems.join("\n")}`);
124
+ }
125
+ return app;
126
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { ClientOnly, hydrateState, mountIslands, type IslandComponent, type Islands, type MountOptions } from "./client.ts";
2
- export { createPageApp, mountPage, type PageOptions, type PagePart, type PageRecord } from "./client.ts";
1
+ export { ClientOnly, hydrateState, mountIslands, type IslandComponent, type Islands, type MountOptions, type StateText } from "./client.ts";
2
+ export { createPageApp, mountPage, readPage, renderPage, type PageAppOptions, type PageOptions, type PagePart, type PageRecord, type ReadPage, type ReadPageOptions, type RenderPageOptions, } from "./client.ts";
3
3
  export type { Float, InlineHtml, TrustedHtml } from "./types.ts";
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { ClientOnly, hydrateState, mountIslands } from "./client.js";
2
- export { createPageApp, mountPage } from "./client.js";
2
+ export { createPageApp, mountPage, readPage, renderPage, } from "./client.js";