ferrovue 0.4.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 (68) hide show
  1. package/README.md +198 -34
  2. package/dist/attrs.d.ts +1 -1
  3. package/dist/attrs.js +24 -22
  4. package/dist/calls.js +17 -15
  5. package/dist/children.d.ts +9 -2
  6. package/dist/children.js +48 -35
  7. package/dist/classes.js +11 -11
  8. package/dist/cli.js +31 -5
  9. package/dist/client.d.ts +35 -2
  10. package/dist/client.js +77 -2
  11. package/dist/compiler.d.ts +1 -1
  12. package/dist/compiler.js +7 -3
  13. package/dist/component.d.ts +3 -1
  14. package/dist/component.js +114 -19
  15. package/dist/constants.d.ts +42 -0
  16. package/dist/constants.js +292 -0
  17. package/dist/context.d.ts +25 -1
  18. package/dist/context.js +11 -5
  19. package/dist/diagnostics.d.ts +23 -0
  20. package/dist/diagnostics.js +30 -0
  21. package/dist/errors.d.ts +867 -0
  22. package/dist/errors.js +330 -0
  23. package/dist/expr.d.ts +1 -0
  24. package/dist/expr.js +120 -49
  25. package/dist/fixture.d.ts +3 -10
  26. package/dist/fixture.js +2 -6
  27. package/dist/hydration.d.ts +15 -0
  28. package/dist/hydration.js +80 -0
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.js +2 -1
  31. package/dist/link-router.d.ts +24 -0
  32. package/dist/link-router.js +36 -0
  33. package/dist/lists.js +30 -21
  34. package/dist/loops.js +14 -10
  35. package/dist/model.d.ts +32 -1
  36. package/dist/model.js +123 -6
  37. package/dist/narrowing.d.ts +8 -0
  38. package/dist/narrowing.js +40 -9
  39. package/dist/plugins/client-only.d.ts +8 -0
  40. package/dist/plugins/client-only.js +107 -0
  41. package/dist/plugins/i18n.js +14 -14
  42. package/dist/plugins/index.js +3 -1
  43. package/dist/plugins/router-link.js +33 -30
  44. package/dist/plugins/router.js +9 -9
  45. package/dist/plugins/scoped.js +1 -1
  46. package/dist/plugins/shared-types.js +1 -1
  47. package/dist/plugins/stores.js +26 -24
  48. package/dist/plugins/teleport.js +3 -3
  49. package/dist/plugins/twins.d.ts +10 -0
  50. package/dist/plugins/twins.js +164 -0
  51. package/dist/routes.d.ts +10 -0
  52. package/dist/routes.js +6 -0
  53. package/dist/rust.js +18 -14
  54. package/dist/script.d.ts +3 -0
  55. package/dist/script.js +119 -17
  56. package/dist/settle.d.ts +6 -0
  57. package/dist/settle.js +34 -0
  58. package/dist/slots.js +18 -18
  59. package/dist/strings.js +11 -11
  60. package/dist/styles.js +13 -13
  61. package/dist/template.js +11 -11
  62. package/dist/testing.d.ts +1 -0
  63. package/dist/testing.js +3 -2
  64. package/dist/typescript.d.ts +2 -1
  65. package/dist/typescript.js +46 -19
  66. package/dist/vite.d.ts +1 -1
  67. package/dist/vite.js +10 -9
  68. package/package.json +9 -5
package/dist/expr.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BOOL, fail, FLOAT, GenError, INT, opt, rustStr, sameTy, snake, STR, UNDEF } from "./model.js";
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
3
  import { lookupStruct, markHome } from "./typescript.js";
4
4
  import { claim } from "./plugin.js";
@@ -6,8 +6,9 @@ import { atom, bare, binary, enclosed, logical, negate, not, occurrences, strArg
6
6
  import { collected } 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
- import { boolOf, choice, known, narrowing, narrowTo, pathOf, presence, truthy } from "./narrowing.js";
9
+ import { boolOf, checkNullTest, choice, known, narrowing, narrowTo, nullTest, pathOf, presence, truthy } from "./narrowing.js";
10
10
  import { call, isObjectCall } from "./calls.js";
11
+ import { constOf, constVal } from "./constants.js";
11
12
  export function describeTy(ty) {
12
13
  switch (ty.k) {
13
14
  case "str":
@@ -26,20 +27,26 @@ export function describeTy(ty) {
26
27
  case "child":
27
28
  return "an object";
28
29
  case "opt":
30
+ if (ty.none === "null")
31
+ return `${describeTy(ty.of)} or \`null\``;
32
+ if (ty.none === "either")
33
+ return `${describeTy(ty.of)}, \`null\` or \`undefined\``;
29
34
  return `an optional ${describeTy(ty.of).replace(/^an? /, "")}`;
30
35
  case "undef":
31
36
  return "`undefined`";
37
+ case "null":
38
+ return "`null`";
32
39
  default:
33
40
  return claim((p) => p.values?.describe?.(ty)) ?? "a value of another kind";
34
41
  }
35
42
  }
36
43
  export function fieldVal(comp, base, ty, js, node) {
37
44
  if (ty.k !== "struct")
38
- fail(comp, `\`.${js}\` on a value that is not an object`, node);
45
+ fail(comp, "FV0604", `\`.${js}\` on a value that is not an object`, node);
39
46
  const { st, owner } = lookupStruct(comp, ty);
40
47
  const found = st?.fields.find((x) => x.js === js);
41
48
  if (!found)
42
- fail(comp, `\`${ty.name}\` has no field \`${js}\``, node);
49
+ fail(comp, "FV0605", `\`${ty.name}\` has no field \`${js}\``, node);
43
50
  const f = owner === comp ? found : { ...found, ty: markHome(found.ty, owner.name) };
44
51
  if (st.slot)
45
52
  return { code: `${base}.${f.rust}`, ty: f.ty };
@@ -70,25 +77,49 @@ export function fieldVal(comp, base, ty, js, node) {
70
77
  return { code: place, ty: f.ty };
71
78
  }
72
79
  }
80
+ export function holdsNothing(comp, v, want, node, where) {
81
+ const has = absence(v.ty);
82
+ if (has === null)
83
+ return;
84
+ const takes = absence(want);
85
+ if (takes === has || takes === "either")
86
+ return;
87
+ const is = has === "either" ? "may be `null` or `undefined`" : has === "null" ? "may be `null`" : "may be `undefined`";
88
+ const fix = takes === null ? "narrow it with `v-if` first" : takes === "null" ? "write `?? null` after it" : "write `?? undefined` after it";
89
+ fail(comp, "FV0606", `a value that ${is} where ${where} takes ${describeTy(want)}, which Vue would hand it as it is: ${fix}`, node);
90
+ }
73
91
  export function coerce(comp, v, want, node) {
74
92
  if (sameTy(v.ty, want))
75
93
  return v.code;
76
- if (want.k === "opt" && v.ty.k === "undef")
94
+ holdsNothing(comp, v, want, node, "the function");
95
+ if (want.k === "opt" && nothing(v.ty))
77
96
  return "None";
97
+ if (want.k === "opt" && v.ty.k === "opt" && sameTy(v.ty.of, want.of))
98
+ return v.code;
78
99
  if (want.k === "opt" && sameTy(v.ty, want.of))
79
100
  return `Some(${bare(v.code)})`;
80
101
  if (want.k === "float" && v.ty.k === "int")
81
102
  return asF64(v);
82
103
  if (want.k === "opt" && want.of.k === "float" && v.ty.k === "int")
83
104
  return `Some(${asF64(v)})`;
84
- return fail(comp, `a ${JSON.stringify(v.ty)} where ${JSON.stringify(want)} is expected`, node);
105
+ return fail(comp, "FV0607", `a ${JSON.stringify(v.ty)} where ${JSON.stringify(want)} is expected`, node);
85
106
  }
86
107
  export function childOf(name) {
87
108
  const c = ctx.components.get(name);
88
109
  if (!c)
89
- throw new GenError(`\`Props\` is imported from ${name}.vue, which is not among the components compiled`);
110
+ throw new GenError("FV0512", `\`Props\` is imported from ${name}.vue, which is not among the components compiled`);
90
111
  return c;
91
112
  }
113
+ function slotsObject(s, n) {
114
+ if (n.type === "Identifier")
115
+ return n.name === "$slots" || (s.slotsBindings.has(n.name) && !s.locals.has(n.name)) ? n.name : null;
116
+ if (n.type !== "MemberExpression" || n.computed || n.object.type !== "Identifier")
117
+ return null;
118
+ const [owner, name] = [n.object.name, n.property.name];
119
+ if (owner === "_ctx" && name === "$slots")
120
+ return "$slots";
121
+ return (owner === "$setup" || owner === "_ctx") && s.slotsBindings.has(name) ? name : null;
122
+ }
92
123
  export function expr(s, n) {
93
124
  const comp = s.comp;
94
125
  const path = pathOf(n);
@@ -97,6 +128,9 @@ export function expr(s, n) {
97
128
  if (narrowed)
98
129
  return narrowed;
99
130
  }
131
+ const named = constOf(s, n);
132
+ if (named)
133
+ return constVal(named.c, { comp, node: n, what: named.what });
100
134
  if (n.type === "MemberExpression" && !n.computed && n.property.type === "Identifier" && n.property.name === "length") {
101
135
  if (isObjectCall(n.object, "entries") && n.object.arguments.length === 1) {
102
136
  const r = expr(s, n.object.arguments[0]);
@@ -129,7 +163,7 @@ export function expr(s, n) {
129
163
  }
130
164
  if (isNumber(a.ty) && isNumber(b.ty))
131
165
  return arithmetic(a, "+", b, a.ty.k === "int" && b.ty.k === "int");
132
- return fail(comp, "`+` joins a string to a string or a number, or adds two numbers", n);
166
+ return fail(comp, "FV0608", "`+` joins a string to a string or a number, or adds two numbers", n);
133
167
  }
134
168
  if (n.type === "BinaryExpression" && ["-", "*", "%", "/", "<", ">", "<=", ">="].includes(n.operator)) {
135
169
  const a = expr(s, n.left);
@@ -145,7 +179,7 @@ export function expr(s, n) {
145
179
  ? "narrow an optional one with `v-if` first"
146
180
  : `the other is ${describeTy(isNumber(a.ty) ? b.ty : a.ty)}`;
147
181
  const between = comparison ? "two numbers, or two strings," : "two numbers";
148
- return fail(comp, `\`${n.operator}\` is supported between ${between} that are present: ${why}`, n);
182
+ return fail(comp, "FV0609", `\`${n.operator}\` is supported between ${between} that are present: ${why}`, n);
149
183
  }
150
184
  if (["<", ">", "<=", ">="].includes(n.operator))
151
185
  return compare(a, n.operator, b);
@@ -168,24 +202,26 @@ export function expr(s, n) {
168
202
  return { code: "[]", ty: { k: "list", of: UNDEF } };
169
203
  const values = n.elements.map((el) => {
170
204
  if (!el || el.type === "SpreadElement")
171
- fail(comp, "an array literal holds plain values", n);
205
+ fail(comp, "FV0610", "an array literal holds plain values", n);
172
206
  return expr(s, el);
173
207
  });
174
208
  const of = values[0].ty;
175
209
  if (!(of.k === "str" || of.k === "int" || of.k === "float" || of.k === "bool") || values.some((v) => !sameTy(v.ty, of))) {
176
- fail(comp, "an array literal holds strings, numbers or booleans, all of one type", n);
210
+ fail(comp, "FV0611", "an array literal holds strings, numbers or booleans, all of one type", n);
177
211
  }
178
212
  return { code: `[${values.map((v) => bare(v.code)).join(", ")}]`, ty: { k: "list", of }, ...loneOf(...values) };
179
213
  }
180
214
  case "OptionalMemberExpression": {
181
215
  if (n.computed)
182
- return fail(comp, "computed member access", n);
216
+ return fail(comp, "FV0612", "computed member access", n);
183
217
  const base = expr(s, n.object);
218
+ if (nothing(base.ty))
219
+ return { code: "None", ty: UNDEF };
184
220
  if (base.ty.k !== "opt")
185
221
  return fieldVal(comp, base.code, base.ty, n.property.name, n);
186
222
  const f = fieldVal(comp, "v", base.ty.of, n.property.name, n);
187
223
  if (f.ty.k === "opt")
188
- return { code: `${atom(base.code)}.and_then(|v| ${f.code})`, ty: f.ty };
224
+ return { code: `${atom(base.code)}.and_then(|v| ${f.code})`, ty: opt(f.ty) };
189
225
  return { code: `${atom(base.code)}.map(|v| ${f.code})`, ty: opt(f.ty) };
190
226
  }
191
227
  case "StringLiteral":
@@ -195,7 +231,7 @@ export function expr(s, n) {
195
231
  case "BooleanLiteral":
196
232
  return { code: String(n.value), ty: BOOL, konst: n.value };
197
233
  case "NullLiteral":
198
- return fail(comp, "`null`: compare with `undefined`, which is what an absent prop is", n);
234
+ return { code: "None", ty: NULL };
199
235
  case "Identifier": {
200
236
  if (n.name === "undefined")
201
237
  return { code: "None", ty: UNDEF };
@@ -203,11 +239,11 @@ export function expr(s, n) {
203
239
  if (local)
204
240
  return local;
205
241
  if (s.clientOnly.has(n.name)) {
206
- return fail(comp, `\`${n.name}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(n.name)}`, n);
242
+ return fail(comp, "FV0613", `\`${n.name}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(n.name)}`, n);
207
243
  }
208
244
  if (n.name === s.propsIdent)
209
245
  return { code: "props", ty: { k: "struct", name: "Props" } };
210
- return fail(comp, `\`${n.name}\` is not available when rendering on the server`, n);
246
+ return fail(comp, "FV0601", `\`${n.name}\` is not available when rendering on the server`, n);
211
247
  }
212
248
  case "MemberExpression": {
213
249
  if (!n.computed && n.property.name === "value" && n.object.type === "Identifier" && s.refs.has(n.object.name) && !s.locals.has(n.object.name)) {
@@ -223,15 +259,13 @@ export function expr(s, n) {
223
259
  if (own)
224
260
  return own;
225
261
  }
226
- return fail(comp, "computed member access", n);
262
+ return fail(comp, "FV0612", "computed member access", n);
227
263
  }
228
264
  const prop = n.property.name;
229
- const slotsObject = (n.object.type === "Identifier" && n.object.name === "$slots") ||
230
- (n.object.type === "MemberExpression" && !n.object.computed && n.object.object.type === "Identifier" &&
231
- n.object.object.name === "_ctx" && n.object.property.name === "$slots");
232
- if (slotsObject) {
265
+ const slots = slotsObject(s, n.object);
266
+ if (slots !== null) {
233
267
  if (!comp.slotNames.includes(prop))
234
- fail(comp, `\`$slots.${prop}\` names a slot this template does not render`, n);
268
+ fail(comp, "FV0907", `\`${slots}.${prop}\` names a slot this template does not render`, n);
235
269
  return { code: `fv_slots.${snake(prop)}.is_some()`, ty: BOOL };
236
270
  }
237
271
  if (n.object.type === "Identifier") {
@@ -244,18 +278,20 @@ export function expr(s, n) {
244
278
  if (global)
245
279
  return global;
246
280
  if (n.object.name === "_ctx" && prop === "$attrs") {
247
- return fail(comp, "`$attrs` is bound whole, with `v-bind=\"$attrs\"`; a value read from it has no type: declare it as a prop", n);
281
+ return fail(comp, "FV0415", "`$attrs` is bound whole, with `v-bind=\"$attrs\"`; a value read from it has no type: declare it as a prop", n);
248
282
  }
249
283
  const v = s.setup.get(prop);
250
284
  if (v)
251
285
  return v;
286
+ if (prop === s.propsIdent)
287
+ return { code: "props", ty: { k: "struct", name: "Props" } };
252
288
  if (s.clientOnly.has(prop)) {
253
- return fail(comp, `\`${prop}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(prop)}`, n);
289
+ return fail(comp, "FV0613", `\`${prop}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(prop)}`, n);
254
290
  }
255
291
  if (n.object.name === "_ctx" && comp.props.fields.some((f) => f.js === prop)) {
256
292
  return fieldVal(comp, "props", { k: "struct", name: "Props" }, prop, n);
257
293
  }
258
- return fail(comp, `\`${prop}\` is not available when rendering on the server`, n);
294
+ return fail(comp, "FV0601", `\`${prop}\` is not available when rendering on the server`, n);
259
295
  }
260
296
  }
261
297
  }
@@ -272,7 +308,7 @@ export function expr(s, n) {
272
308
  const code = narrowing(s, p, (inner) => (right = expr(inner, n.right)).code, () => String(n.operator === "||"));
273
309
  if (right?.ty.k === "bool")
274
310
  return { code, ty: BOOL };
275
- return fail(comp, `\`${n.operator}\` is supported between booleans only`, n);
311
+ return fail(comp, "FV0614", `\`${n.operator}\` is supported between booleans only`, n);
276
312
  }
277
313
  }
278
314
  const a = expr(s, n.left);
@@ -281,12 +317,14 @@ export function expr(s, n) {
281
317
  const own = claim((p) => p.values?.nullish?.(s, a, b, n));
282
318
  if (own)
283
319
  return own;
320
+ if (nothing(a.ty))
321
+ return b;
284
322
  if (a.ty.k !== "opt")
285
323
  return a;
286
- if (b.ty.k === "undef")
287
- return a;
324
+ if (nothing(b.ty))
325
+ return { ...a, ty: withAbsence(a.ty.of, absence(b.ty)) };
288
326
  if (b.iter !== undefined)
289
- fail(comp, "`??` falling back to a computed list", n);
327
+ fail(comp, "FV0615", "`??` falling back to a computed list", n);
290
328
  if (a.held !== undefined && b.ty.k === "str")
291
329
  return { code: `&*${atom(a.held)}.unwrap_or(${asCow(b)})`, ty: STR, ...loneOf(a, b) };
292
330
  if (a.ty.of.k === "str" && b.ty.k === "str" && isTemporary(b)) {
@@ -294,13 +332,13 @@ export function expr(s, n) {
294
332
  }
295
333
  if (sameTy(a.ty.of, b.ty))
296
334
  return { code: `${atom(a.code)}.unwrap_or(${bare(b.code)})`, ty: b.ty, ...loneOf(a, b) };
297
- if (sameTy(a.ty, b.ty))
298
- return { code: `${atom(a.code)}.or(${bare(b.code)})`, ty: a.ty, ...loneOf(a, b) };
335
+ if (b.ty.k === "opt" && sameTy(a.ty.of, b.ty.of))
336
+ return { code: `${atom(a.code)}.or(${bare(b.code)})`, ty: b.ty, ...loneOf(a, b) };
299
337
  if (a.ty.of.k === "float" && b.ty.k === "int")
300
338
  return { code: `${atom(a.code)}.unwrap_or(${bare(asF64(b))})`, ty: FLOAT };
301
339
  if (a.ty.of.k === "int" && b.ty.k === "float")
302
340
  return { code: `${atom(a.code)}.map(|v| v as f64).unwrap_or(${bare(b.code)})`, ty: FLOAT };
303
- return fail(comp, "`??` between different types", n);
341
+ return fail(comp, "FV0616", "`??` between different types", n);
304
342
  }
305
343
  if (n.operator === "||") {
306
344
  if (a.ty.k === "bool" && b.ty.k === "bool")
@@ -322,11 +360,11 @@ export function expr(s, n) {
322
360
  const otherwise = a.ty.k === "str" ? strArg(b.code) : bare(b.code);
323
361
  return { code: `{ let a = ${bare(a.code)}; if ${truthy({ code: "a", ty: a.ty })} { a } else { ${otherwise} } }`, ty: a.ty, ...loneOf(a, b) };
324
362
  }
325
- return fail(comp, "`||` between these types", n);
363
+ return fail(comp, "FV0617", "`||` between these types", n);
326
364
  }
327
365
  if (n.operator === "&&" && a.ty.k === "bool" && b.ty.k === "bool")
328
366
  return boolOf(logical(a.code, "&&", b.code));
329
- return fail(comp, `\`${n.operator}\` is supported between booleans only`, n);
367
+ return fail(comp, "FV0614", `\`${n.operator}\` is supported between booleans only`, n);
330
368
  }
331
369
  case "UnaryExpression":
332
370
  if (n.operator === "!") {
@@ -341,13 +379,32 @@ export function expr(s, n) {
341
379
  if (a.ty.k === "float")
342
380
  return { code: negate(a.code), ty: FLOAT };
343
381
  }
344
- return fail(comp, `unary \`${n.operator}\``, n);
382
+ return fail(comp, "FV0618", `unary \`${n.operator}\``, n);
345
383
  case "BinaryExpression": {
346
- if (n.operator !== "===" && n.operator !== "!==")
347
- fail(comp, `\`${n.operator}\``, n);
384
+ const test = nullTest(n);
385
+ if (n.operator !== "===" && n.operator !== "!==" && !test)
386
+ fail(comp, "FV0619", `\`${n.operator}\`: \`==\` and \`!=\` compare with \`null\` or \`undefined\` only; use \`===\``, n);
348
387
  const whole = claim((p) => p.equality?.(s, n));
349
388
  if (whole)
350
389
  return whole;
390
+ if (test) {
391
+ const v = expr(s, test.target);
392
+ const none = absence(v.ty);
393
+ if (v.ty.k === "opt") {
394
+ checkNullTest(s, test, v);
395
+ const is = `${atom(v.code)}.is_none()`;
396
+ return boolOf(test.is ? is : not(is));
397
+ }
398
+ if (none !== null) {
399
+ const konst = (!test.strict || none === test.literal) === test.is;
400
+ return { code: String(konst), ty: BOOL, konst };
401
+ }
402
+ if (!test.strict && claim((p) => p.values?.describe?.(v.ty)) === undefined) {
403
+ return { code: String(!test.is), ty: BOOL, konst: !test.is };
404
+ }
405
+ if (!test.strict)
406
+ fail(comp, "FV0620", `\`${n.operator}\` of ${describeTy(v.ty)}: compare with \`${test.is ? "===" : "!=="} undefined\``, n);
407
+ }
351
408
  const a = expr(s, n.left);
352
409
  const b = expr(s, n.right);
353
410
  let eq;
@@ -358,11 +415,16 @@ export function expr(s, n) {
358
415
  const own = claim((p) => p.values?.equals?.(a, b));
359
416
  if (own !== undefined)
360
417
  eq = own;
361
- else if (b.ty.k === "undef" && a.ty.k === "opt")
362
- eq = `${atom(a.code)}.is_none()`;
363
- else if (a.ty.k === "undef" && b.ty.k === "opt")
364
- eq = `${atom(b.code)}.is_none()`;
365
- else if (a.ty.k === "undef" || b.ty.k === "undef") {
418
+ else if ((nothing(b.ty) && a.ty.k === "opt") || (nothing(a.ty) && b.ty.k === "opt")) {
419
+ const [v, literal] = a.ty.k === "opt" ? [a, b] : [b, a];
420
+ checkNullTest(s, { target: n, literal: literal.ty.k === "null" ? "null" : "undefined", strict: true, is: n.operator === "===" }, v);
421
+ eq = `${atom(v.code)}.is_none()`;
422
+ }
423
+ else if (nothing(a.ty) || nothing(b.ty)) {
424
+ const other = nothing(a.ty) ? b.ty : a.ty;
425
+ const opaque = nothing(other) ? undefined : claim((p) => p.values?.describe?.(other));
426
+ if (opaque !== undefined)
427
+ fail(comp, "FV0621", `\`${n.operator}\` between ${opaque} and ${describeTy(nothing(a.ty) ? a.ty : b.ty)}`, n);
366
428
  const same = a.ty.k === b.ty.k;
367
429
  const konst = n.operator === "===" ? same : !same;
368
430
  return { code: String(konst), ty: BOOL, konst };
@@ -383,7 +445,7 @@ export function expr(s, n) {
383
445
  else if (b.ty.k === "opt" && sameTy(b.ty.of, a.ty) && scalar(a.ty))
384
446
  eq = binary(`Some(${bare(a.code)})`, "==", b.code);
385
447
  else
386
- return fail(comp, "`===` between these types", n);
448
+ return fail(comp, "FV0622", "`===` between these types", n);
387
449
  return boolOf(n.operator === "===" ? eq : not(eq));
388
450
  }
389
451
  case "ConditionalExpression": {
@@ -406,6 +468,8 @@ export function expr(s, n) {
406
468
  return p.option;
407
469
  if (present === name)
408
470
  return `${atom(p.option)}.unwrap_or(${absent})`;
471
+ if (absent === "None" && present.startsWith("Some(") && enclosed(present.slice(4)))
472
+ return `${atom(p.option)}.map(|${name}| ${bare(present.slice(5, -1))})`;
409
473
  if (occurrences(present, name))
410
474
  return `if ${p.pattern(name)} { ${present} } else { ${absent} }`;
411
475
  return choice(p.present, present, absent);
@@ -418,15 +482,18 @@ export function expr(s, n) {
418
482
  const both = `(${choose(collected(a), collected(b))})`;
419
483
  return { code: both, ty: a.ty, iter: `${both}.into_iter()`, ...lone };
420
484
  }
421
- const optStr = (v) => v.ty.k === "undef" || (v.ty.k === "opt" && v.ty.of.k === "str") || v.ty.k === "str";
485
+ const optStr = (v) => nothing(v.ty) || (v.ty.k === "opt" && v.ty.of.k === "str") || v.ty.k === "str";
422
486
  if (sameTy(a.ty, b.ty) && a.ty.k !== "opt")
423
487
  return { code: choose(a.code, b.code), ty: a.ty, ...lone };
424
488
  if (isNumber(a.ty) && isNumber(b.ty))
425
489
  return { code: choose(asF64(a), asF64(b)), ty: FLOAT };
426
- if (sameTy(a.ty, b.ty) || a.ty.k === "undef" || b.ty.k === "undef" || (a.ty.k === "opt" && sameTy(a.ty.of, b.ty)) || (b.ty.k === "opt" && sameTy(b.ty.of, a.ty))) {
427
- const ty = opt(a.ty.k === "undef" || b.ty.k === "opt" ? b.ty : a.ty);
490
+ const base = (ty) => (ty.k === "opt" ? ty.of : nothing(ty) ? null : ty);
491
+ const [baseA, baseB] = [base(a.ty), base(b.ty)];
492
+ const of = baseA ?? baseB;
493
+ if (of !== null && (baseA === null || baseB === null || sameTy(baseA, baseB))) {
494
+ const ty = withAbsence(of, joinAbsence(absence(a.ty), absence(b.ty)));
428
495
  if (optStr(a) && optStr(b) && (isTemporary(a) || isTemporary(b) || a.held !== undefined || b.held !== undefined)) {
429
- const held = (v) => v.held ?? (v.ty.k === "undef" ? "None" : v.ty.k === "opt" ? `${atom(v.code)}.map(std::borrow::Cow::<str>::Borrowed)` : `Some(${asCow(v)})`);
496
+ const held = (v) => v.held ?? (nothing(v.ty) ? "None" : v.ty.k === "opt" ? `${atom(v.code)}.map(std::borrow::Cow::<str>::Borrowed)` : `Some(${asCow(v)})`);
430
497
  const both = atom(choose(held(a), held(b)));
431
498
  return { code: `${both}.as_deref()`, ty, held: both, ...lone };
432
499
  }
@@ -434,9 +501,13 @@ export function expr(s, n) {
434
501
  return { code: choose(a.code, b.code), ty: a.ty, ...lone };
435
502
  return { code: choose(coerce(comp, a, ty, n), coerce(comp, b, ty, n)), ty, ...lone };
436
503
  }
437
- return fail(comp, "the two branches of `?:` differ in type", n);
504
+ return fail(comp, "FV0623", "the two branches of `?:` differ in type", n);
505
+ }
506
+ case "NewExpression": {
507
+ 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
+ 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);
438
509
  }
439
510
  default:
440
- return fail(comp, `\`${n.type}\` is not supported in an island template`, n);
511
+ return fail(comp, "FV0625", `\`${n.type}\` is not supported in a template`, n);
441
512
  }
442
513
  }
package/dist/fixture.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type App, type Component } from "vue";
2
- import type { RouteRecordRaw } from "vue-router";
2
+ import { type RouteEntry } from "./routes.ts";
3
+ export { routeRecords, type RouteEntry } from "./routes.ts";
3
4
  /** A conformance fixture: the props, and `$slots`, `$route`, `$stores` and `$locale`. */
4
5
  export interface Fixture {
5
6
  props: Record<string, unknown>;
@@ -11,12 +12,6 @@ export interface Fixture {
11
12
  }
12
13
  /** A fixture's JSON, split into the props and the rest. */
13
14
  export declare function readFixture(json: Record<string, unknown>): Fixture;
14
- /** A route as a routes file lists it: a path, or a path and a name. */
15
- export type RouteEntry = string | {
16
- path: string;
17
- name?: string;
18
- children?: RouteEntry[];
19
- };
20
15
  /** The router options ferrovue reproduces, as the project's configuration gives them. */
21
16
  export interface RouterOptions {
22
17
  base?: string;
@@ -29,11 +24,9 @@ export interface RouterOptions {
29
24
  locale: string;
30
25
  fallbackLocale?: string | string[];
31
26
  };
32
- /** Render on the client with `createApp`, rather than hydrate what the server rendered: the scope
27
+ /** Render on the client from scratch with `createApp`: the scope
33
28
  * ids a fresh client render writes, which the server's must equal for scoped styles to apply. */
34
29
  client?: boolean;
35
30
  }
36
- /** Route records for vue-router, every route — nested ones too — given the fixture's view. */
37
- export declare function routeRecords(routes: RouteEntry[], View: Component): RouteRecordRaw[];
38
31
  /** An app rendering one fixture of `component`: with a router over `routes` when there are any. */
39
32
  export declare function fixtureApp(component: Component, fixture: Fixture, routes: RouteEntry[] | null, options?: RouterOptions): Promise<App>;
package/dist/fixture.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { createApp, createSSRApp, createStaticVNode, defineComponent, h } from "vue";
2
+ import { routeRecords } from "./routes.js";
3
+ export { routeRecords } from "./routes.js";
2
4
  /** A fixture's JSON, split into the props and the rest. */
3
5
  export function readFixture(json) {
4
6
  const { $slots, $route, $stores, $locale, ...props } = json;
@@ -45,12 +47,6 @@ async function optionalPinia(stores) {
45
47
  return null;
46
48
  }
47
49
  }
48
- /** Route records for vue-router, every route — nested ones too — given the fixture's view. */
49
- export function routeRecords(routes, View) {
50
- return routes.map((r) => typeof r === "string"
51
- ? { path: r, component: View }
52
- : { path: r.path, component: View, ...(r.name ? { name: r.name } : {}), ...(r.children ? { children: routeRecords(r.children, View) } : {}) });
53
- }
54
50
  /** An app rendering one fixture of `component`: with a router over `routes` when there are any. */
55
51
  export async function fixtureApp(component, fixture, routes, options = {}) {
56
52
  const slots = Object.fromEntries(Object.entries(fixture.slots)
@@ -0,0 +1,15 @@
1
+ import type { App, Component } from "vue";
2
+ import { type IslandComponent, type PageOptions, type PageRecord } from "./client.ts";
3
+ /** A page as the server wrote it: `html` holds the container and, unless `record` is given, the
4
+ * record's script. */
5
+ export interface RecordedPage {
6
+ html: string;
7
+ record?: PageRecord;
8
+ }
9
+ /** Put a recorded page into the document's body and hydrate it with `mountPage`, throwing on a
10
+ * node Vue replaced, on any change hydrating made to the markup (a `style` attribute is compared by
11
+ * the declarations it holds), and on anything Vue warns or logs as an error until the page has
12
+ * settled. The markup is compared as hydrating leaves it; what changes once the page is mounted,
13
+ * such as a `<ClientOnly>` showing its content or an async component loading, is the app's to
14
+ * change. Resolves to the app, mounted and settled. */
15
+ export declare function hydrateRecordedPage(page: RecordedPage, layout: Component, components: Record<string, IslandComponent>, options?: PageOptions): Promise<App>;
@@ -0,0 +1,80 @@
1
+ import { mountPage } from "./client.js";
2
+ import { settled } from "./settle.js";
3
+ function recordScript(id, record) {
4
+ const json = JSON.stringify(record).replace(/[<>&\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
5
+ return `<script type="application/json" id="${id}">${json}</script>`;
6
+ }
7
+ function shape(node, scratch) {
8
+ if (node.nodeType === 3)
9
+ return node.textContent ?? "";
10
+ if (node.nodeType === 8)
11
+ return `<!--${node.textContent ?? ""}-->`;
12
+ if (node.nodeType !== 1)
13
+ return "";
14
+ const el = node;
15
+ const attrs = el
16
+ .getAttributeNames()
17
+ .filter((name) => name !== "style")
18
+ .toSorted()
19
+ .map((name) => ` ${name}=${JSON.stringify(el.getAttribute(name))}`);
20
+ scratch.setAttribute("style", el.getAttribute("style") ?? "");
21
+ if (scratch.style.cssText)
22
+ attrs.push(` style=${JSON.stringify(scratch.style.cssText)}`);
23
+ return `<${el.tagName}${attrs.join("")}>${[...el.childNodes].map((child) => shape(child, scratch)).join("")}</${el.tagName}>`;
24
+ }
25
+ /** Put a recorded page into the document's body and hydrate it with `mountPage`, throwing on a
26
+ * node Vue replaced, on any change hydrating made to the markup (a `style` attribute is compared by
27
+ * the declarations it holds), and on anything Vue warns or logs as an error until the page has
28
+ * settled. The markup is compared as hydrating leaves it; what changes once the page is mounted,
29
+ * such as a `<ClientOnly>` showing its content or an async component loading, is the app's to
30
+ * change. Resolves to the app, mounted and settled. */
31
+ export async function hydrateRecordedPage(page, layout, components, options = {}) {
32
+ const doc = options.doc ?? document;
33
+ const id = options.record ?? "__fv_page";
34
+ doc.body.innerHTML = page.html + (page.record ? recordScript(id, page.record) : "");
35
+ const selector = options.container ?? "#app";
36
+ const container = typeof selector === "string" ? doc.querySelector(selector) : selector;
37
+ if (!container)
38
+ throw new Error(`the recorded page has no ${selector}`);
39
+ const scratch = doc.createElement("div");
40
+ const before = container.innerHTML;
41
+ const shapeBefore = [...container.childNodes].map((child) => shape(child, scratch)).join("");
42
+ const first = container.firstChild;
43
+ const problems = [];
44
+ const shapeOf = () => [...container.childNodes].map((child) => shape(child, scratch)).join("");
45
+ let hydrated = "";
46
+ let shapeHydrated = "";
47
+ const watch = {
48
+ install(app) {
49
+ app.config.warnHandler = (message) => void problems.push(`warning: ${message}`);
50
+ const mount = app.mount.bind(app);
51
+ app.mount = (...args) => {
52
+ const root = mount(...args);
53
+ hydrated = container.innerHTML;
54
+ shapeHydrated = shapeOf();
55
+ return root;
56
+ };
57
+ },
58
+ };
59
+ const { warn, error } = console;
60
+ console.warn = (...args) => void problems.push(`console.warn: ${args.map(String).join(" ")}`);
61
+ console.error = (...args) => void problems.push(`console.error: ${args.map(String).join(" ")}`);
62
+ let app;
63
+ try {
64
+ app = await mountPage(layout, components, { ...options, doc, container, plugins: [...(options.plugins ?? []), watch] });
65
+ await settled(app);
66
+ }
67
+ finally {
68
+ console.warn = warn;
69
+ console.error = error;
70
+ }
71
+ if (container.firstChild !== first)
72
+ problems.push("Vue replaced the server's first node");
73
+ if (shapeHydrated !== shapeBefore)
74
+ problems.push(`hydrating changed the markup:\n server: ${before}\n hydrated: ${hydrated}`);
75
+ if (problems.length) {
76
+ app.unmount();
77
+ throw new Error(`the page did not hydrate exactly:\n${problems.join("\n")}`);
78
+ }
79
+ return app;
80
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- export { hydrateState, mountIslands, type IslandComponent, type Islands, type MountOptions } from "./client.ts";
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";
2
3
  export type { Float, TrustedHtml } from "./types.ts";
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
- export { hydrateState, mountIslands } from "./client.js";
1
+ export { ClientOnly, hydrateState, mountIslands } from "./client.js";
2
+ export { createPageApp, mountPage } from "./client.js";
@@ -0,0 +1,24 @@
1
+ import { type RouteLocationNormalized, type Router, type RouterHistory } from "vue-router";
2
+ import { type RouteEntry } from "./routes.ts";
3
+ export type { RouteEntry } from "./routes.ts";
4
+ /** What `linkRouter` hands navigations to, and the router options of `ferrovue.config.json`. */
5
+ export interface LinkRouterOptions {
6
+ /** The application's own navigation, called with the `href` of every navigation after the first
7
+ * (the base included, as the link's `href` attribute has it) and the route it resolves to. */
8
+ navigate: (href: string, to: RouteLocationNormalized) => void;
9
+ /** The history's base, as `router.base` in `ferrovue.config.json` gives it. */
10
+ base?: string;
11
+ /** As `router.linkActiveClass` in `ferrovue.config.json`. */
12
+ linkActiveClass?: string;
13
+ /** As `router.linkExactActiveClass` in `ferrovue.config.json`. */
14
+ linkExactActiveClass?: string;
15
+ /** The history the router reads its first location from. By default a memory history at the
16
+ * page's location: the router neither writes to the browser's history nor listens to it. */
17
+ history?: RouterHistory;
18
+ }
19
+ /** A router for `<RouterLink>` and `useRoute()` alone, for an application whose own navigation
20
+ * layer leaves the page: built from the routes file the compiler reads, every route rendering
21
+ * nothing. Its first navigation resolves the page the server rendered; every later one, a link's
22
+ * click included, is handed to `navigate` and aborted, so the router stays on that page. Build one
23
+ * for each page the application shows. */
24
+ export declare function linkRouter(routes: RouteEntry[], options: LinkRouterOptions): Router;
@@ -0,0 +1,36 @@
1
+ import { createMemoryHistory, createRouter, isNavigationFailure, NavigationFailureType, START_LOCATION } from "vue-router";
2
+ import { routeRecords } from "./routes.js";
3
+ const Empty = { render: () => null };
4
+ function pageHistory(base) {
5
+ const history = createMemoryHistory(base);
6
+ const { pathname, search, hash } = window.location;
7
+ const prefix = history.base;
8
+ const path = prefix && pathname.toLowerCase().startsWith(prefix.toLowerCase()) ? pathname.slice(prefix.length) || "/" : pathname;
9
+ history.replace(path + search + hash);
10
+ return history;
11
+ }
12
+ /** A router for `<RouterLink>` and `useRoute()` alone, for an application whose own navigation
13
+ * layer leaves the page: built from the routes file the compiler reads, every route rendering
14
+ * nothing. Its first navigation resolves the page the server rendered; every later one, a link's
15
+ * click included, is handed to `navigate` and aborted, so the router stays on that page. Build one
16
+ * for each page the application shows. */
17
+ export function linkRouter(routes, options) {
18
+ const history = options.history ?? pageHistory(options.base);
19
+ const router = createRouter({
20
+ history,
21
+ routes: routeRecords(routes, Empty),
22
+ ...(options.linkActiveClass !== undefined ? { linkActiveClass: options.linkActiveClass } : {}),
23
+ ...(options.linkExactActiveClass !== undefined ? { linkExactActiveClass: options.linkExactActiveClass } : {}),
24
+ });
25
+ router.beforeEach((to, from) => {
26
+ if (from === START_LOCATION)
27
+ return true;
28
+ options.navigate(history.createHref(to.fullPath), to);
29
+ return false;
30
+ });
31
+ router.afterEach((to, _from, failure) => {
32
+ if (isNavigationFailure(failure, NavigationFailureType.duplicated))
33
+ options.navigate(history.createHref(to.fullPath), to);
34
+ });
35
+ return router;
36
+ }