ferrovue 0.8.0 → 0.9.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.
package/dist/calls.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import { type N, type Scope, type Val } from "./model.ts";
2
2
  export declare function call(s: Scope, n: N): Val;
3
+ /** The globals of JavaScript a template may name, which the server translates only some of. */
4
+ export declare const BUILTINS: Set<string>;
5
+ /** Refuses `what`, a use of one of JavaScript's globals the server does not translate. */
6
+ export declare function builtin(s: Scope, what: string, n: N): never;
3
7
  export declare function isObjectCall(n: N, method: string): boolean;
4
8
  export declare function helperCall(s: Scope, name: string, args: N[], n: N): Val;
package/dist/calls.js CHANGED
@@ -2,11 +2,16 @@ import { BOOL, fail, FLOAT, INT, sameTy, STR } from "./model.js";
2
2
  import { ctx } from "./context.js";
3
3
  import { claim } from "./plugin.js";
4
4
  import { atom, bare, binary, condition, operand, receiver, strArg, UNARY } from "./parens.js";
5
- import { computedListMethod, isBoolean, items, listMethod, objectCall, presentLiteral } from "./lists.js";
6
- import { lonely, loneOf, meet, stringMethod, stringsEqual } from "./strings.js";
7
- import { asF64, intFromF64, isNumber } from "./numbers.js";
5
+ import { computedListMethod, isBoolean, items, listMethod, objectCall, presentLiteral, spreadExtreme } from "./lists.js";
6
+ import { formatted, lonely, loneOf, meet, stringMethod, stringOf, stringsEqual } from "./strings.js";
7
+ import { asF64, intFromF64, isNumber, numberVal, power } from "./numbers.js";
8
8
  import { boolOf, truthy } from "./narrowing.js";
9
- import { coerce, expr } from "./expr.js";
9
+ import { coerce, describeTy, expr } from "./expr.js";
10
+ const DIGITS = new Map([
11
+ ["toFixed", ["FV0705", 0, "number of digits"]],
12
+ ["toPrecision", ["FV0716", 1, "precision"]],
13
+ ["toExponential", ["FV0717", 0, "number of digits"]],
14
+ ]);
10
15
  export function call(s, n) {
11
16
  const comp = s.comp;
12
17
  const callee = n.callee;
@@ -43,7 +48,10 @@ export function call(s, n) {
43
48
  return helperCall(s, helper, args, n);
44
49
  if (isBoolean(s, callee) && args.length === 1)
45
50
  return boolOf(truthy(expr(s, args[0])));
46
- if (callee.name === "String" && args.length === 1) {
51
+ const global = !s.locals.has(callee.name) && !s.setup.has(callee.name);
52
+ if (callee.name === "String" && global) {
53
+ if (args.length !== 1)
54
+ return fail(comp, "FV0723", "`String()` takes one value", n);
47
55
  const a = expr(s, args[0]);
48
56
  if (a.ty.k === "str")
49
57
  return a;
@@ -51,6 +59,17 @@ export function call(s, n) {
51
59
  return { code: `&*fv::Js(${bare(a.code)}).to_string()`, ty: STR };
52
60
  if (a.ty.k === "bool")
53
61
  return { code: `if ${condition(a.code)} { "true" } else { "false" }`, ty: STR };
62
+ return formatted(comp, [stringOf(comp, a, args[0], "`String()`", "FV0723")], n, "`String()`");
63
+ }
64
+ if ((callee.name === "encodeURIComponent" || callee.name === "encodeURI") && global) {
65
+ const name = `\`${callee.name}()\``;
66
+ if (args.length !== 1)
67
+ return fail(comp, "FV0723", `${name} takes one value`, n);
68
+ const a = expr(s, args[0]);
69
+ if (a.lone)
70
+ fail(comp, "FV0706", `${lonely(name)}; it throws a \`URIError\` on a half`, n);
71
+ const text = a.ty.k === "str" ? a : formatted(comp, [stringOf(comp, a, args[0], name, "FV0723")], n, name);
72
+ return { code: `&*fv::js_${callee.name === "encodeURI" ? "encode_uri" : "encode_uri_component"}(${strArg(text.code)})`, ty: STR };
54
73
  }
55
74
  if (callee.name === "Number" && args.length === 1) {
56
75
  const a = expr(s, args[0]);
@@ -74,6 +93,10 @@ export function call(s, n) {
74
93
  }
75
94
  return { code: `fv::js_parse_int(${strArg(a.code)}, ${radix ? radix.value : 0})`, ty: FLOAT };
76
95
  }
96
+ if ((callee.name === "isNaN" || callee.name === "isFinite") && global)
97
+ return numberTest(s, callee.name, args, n, true);
98
+ if (global && BUILTINS.has(callee.name))
99
+ return builtin(s, `${callee.name}()`, n);
77
100
  return fail(comp, "FV0601", `\`${callee.name}()\` is not available when rendering on the server`, n);
78
101
  }
79
102
  if (callee.type === "MemberExpression" && !callee.computed) {
@@ -88,12 +111,17 @@ export function call(s, n) {
88
111
  }
89
112
  if (callee.object.type === "Identifier" && callee.object.name === "Object")
90
113
  return objectCall(s, method, args, n);
114
+ if (callee.object.type === "Identifier" && callee.object.name === "Number" && NUMBER_TESTS.has(method))
115
+ return numberTest(s, method, args, n, false);
91
116
  if (callee.object.type === "Identifier" && callee.object.name === "JSON" && method === "stringify" && args.length === 1) {
92
117
  const v = expr(s, args[0]);
93
118
  if (v.ty.k === "bool")
94
119
  return { code: `if ${condition(v.code)} { "true" } else { "false" }`, ty: STR };
95
120
  return { code: `&*${json(s, v, args[0])}`, ty: STR };
96
121
  }
122
+ if (callee.object.type === "Identifier" && callee.object.name === "JSON" && method === "stringify") {
123
+ return fail(comp, "FV0633", "`JSON.stringify()` with a replacer or an indentation, which the server does not write: give it one value, `JSON.stringify(x)`", n);
124
+ }
97
125
  const own = claim((p) => p.call?.(s, n));
98
126
  if (own)
99
127
  return own;
@@ -105,6 +133,13 @@ export function call(s, n) {
105
133
  return fail(comp, "FV0613", `\`${method}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(method)}`, n);
106
134
  }
107
135
  if (callee.object.type === "Identifier" && callee.object.name === "Math") {
136
+ if ((method === "max" || method === "min") && args.some((a) => a?.type === "SpreadElement"))
137
+ return spreadExtreme(s, method, args);
138
+ if (method === "pow" && args.length === 2)
139
+ return power(s, args[0], args[1], n, "`Math.pow()`");
140
+ if (ROUNDED_BY_EACH.has(method)) {
141
+ return fail(comp, "FV0719", `\`Math.${method}()\` is computed by each JavaScript engine's own routine, which need not round its result as another engine or Rust does, so the page could show digits the browser's render does not: compute it in Rust and pass it as a prop`, n);
142
+ }
108
143
  const vals = args.map((a) => expr(s, a));
109
144
  if (vals.length && vals.every((v) => isNumber(v.ty))) {
110
145
  const ints = vals.every((v) => v.ty.k === "int");
@@ -121,9 +156,25 @@ export function call(s, n) {
121
156
  return { code: `fv::js_round(${bare(asF64(x))})`, ty: FLOAT };
122
157
  if (method === "floor" || method === "ceil" || method === "trunc")
123
158
  return { code: `${receiver(asF64(x))}.${method}()`, ty: FLOAT };
159
+ if (method === "sqrt")
160
+ return x.num !== undefined ? numberVal(Math.sqrt(x.num), false) : { code: `${receiver(asF64(x))}.sqrt()`, ty: FLOAT };
161
+ if (method === "sign") {
162
+ if (x.num !== undefined)
163
+ return numberVal(Math.sign(x.num), ints);
164
+ const sign = `fv::js_sign(${bare(asF64(x))})`;
165
+ return ints ? intFromF64(sign) : { code: sign, ty: FLOAT };
166
+ }
124
167
  }
125
168
  }
126
- return fail(comp, "FV0704", `\`Math.${method}()\` is supported on numbers as \`max\`, \`min\`, \`abs\`, \`round\`, \`floor\`, \`ceil\` and \`trunc\``, n);
169
+ return fail(comp, "FV0704", `\`Math.${method}()\` is supported on numbers as \`max\`, \`min\`, \`abs\`, \`round\`, \`floor\`, \`ceil\`, \`trunc\`, \`sqrt\`, \`sign\` and \`pow\``, n);
170
+ }
171
+ if (callee.object.type === "Identifier" && BUILTINS.has(callee.object.name) && !s.locals.has(callee.object.name) && !s.setup.has(callee.object.name)) {
172
+ return builtin(s, `${callee.object.name}.${method}()`, n);
173
+ }
174
+ if ((method === "sort" || method === "reverse") && callee.object.type === "ArrayExpression") {
175
+ const copy = expr(s, callee.object);
176
+ if (copy.ty.k === "list")
177
+ return listMethod(s, copy, method === "sort" ? "toSorted" : "toReversed", args, n);
127
178
  }
128
179
  const literal = method === "filter" && args.length === 1 && isBoolean(s, args[0]) && callee.object.type === "ArrayExpression";
129
180
  const target = (literal ? presentLiteral(s, callee.object) : null) ?? expr(s, callee.object);
@@ -138,7 +189,10 @@ export function call(s, n) {
138
189
  case "trimStart":
139
190
  return { code: `fv::js_trim_start(${strArg(target.code)})`, ty: STR, ...lone };
140
191
  case "trimEnd":
192
+ case "trimRight":
141
193
  return { code: `fv::js_trim_end(${strArg(target.code)})`, ty: STR, ...lone };
194
+ case "trimLeft":
195
+ return { code: `fv::js_trim_start(${strArg(target.code)})`, ty: STR, ...lone };
142
196
  case "toUpperCase":
143
197
  return { code: `&*${receiver(target.code)}.to_uppercase()`, ty: STR, ...lone };
144
198
  case "toLowerCase":
@@ -150,12 +204,21 @@ export function call(s, n) {
150
204
  if (m)
151
205
  return m;
152
206
  }
153
- if (isNumber(target.ty) && method === "toFixed" && args.length <= 1) {
154
- const d = args.length ? args[0] : { type: "NumericLiteral", value: 0 };
155
- if (d.type !== "NumericLiteral" || !Number.isInteger(d.value) || d.value < 0 || d.value > 100) {
156
- fail(comp, "FV0705", "`.toFixed()` takes a literal number of digits, from 0 to 100", n);
157
- }
158
- return { code: `&*fv::js_to_fixed(${bare(asF64(target))}, ${d.value})`, ty: STR };
207
+ const digits = isNumber(target.ty) && args.length <= 1 ? DIGITS.get(method) : undefined;
208
+ if (digits) {
209
+ const [code, low, what] = digits;
210
+ const a = args[0];
211
+ const given = a === undefined || (a.type === "Identifier" && a.name === "undefined") ? undefined : a.type === "NumericLiteral" ? Math.trunc(a.value) : NaN;
212
+ if (given !== undefined && !(given >= low && given <= 100))
213
+ fail(comp, code, `\`.${method}()\` takes a literal ${what}, from ${low} to 100`, n);
214
+ const x = bare(asF64(target));
215
+ if (method === "toFixed")
216
+ return { code: `&*fv::js_to_fixed(${x}, ${given ?? 0})`, ty: STR };
217
+ if (method === "toExponential")
218
+ return { code: `&*fv::js_to_exponential(${x}, ${given === undefined ? "None" : `Some(${given})`})`, ty: STR };
219
+ if (given === undefined)
220
+ return { code: `&*fv::Js(${bare(target.code)}).to_string()`, ty: STR };
221
+ return { code: `&*fv::js_to_precision(${x}, ${given})`, ty: STR };
159
222
  }
160
223
  if (isNumber(target.ty) && method === "toString" && args.length === 0) {
161
224
  return { code: `&*fv::Js(${bare(target.code)}).to_string()`, ty: STR };
@@ -191,6 +254,71 @@ export function call(s, n) {
191
254
  }
192
255
  return fail(comp, "FV0603", "this call is not supported", n);
193
256
  }
257
+ const NUMBER_TESTS = new Set(["isNaN", "isFinite", "isInteger", "isSafeInteger"]);
258
+ const ROUNDED_BY_EACH = new Set("acos acosh asin asinh atan atan2 atanh cbrt cos cosh exp expm1 hypot log log10 log1p log2 sin sinh tan tanh".split(" "));
259
+ /** The globals of JavaScript a template may name, which the server translates only some of. */
260
+ export const BUILTINS = new Set("Array BigInt Boolean JSON Map Math Number Object RegExp Set String Symbol console decodeURI decodeURIComponent encodeURI encodeURIComponent isFinite isNaN parseFloat parseInt".split(" "));
261
+ const OF_BUILTINS = {
262
+ Array: "`Array.isArray(x)`",
263
+ Boolean: "`Boolean(x)` and `.filter(Boolean)`",
264
+ JSON: "`JSON.stringify(x)`",
265
+ Math: "`Math.max`, `min`, `abs`, `round`, `floor`, `ceil`, `trunc`, `sqrt`, `sign` and `pow`, and its constants",
266
+ Number: "`Number(x)`, `Number.isNaN`, `isFinite`, `isInteger` and `isSafeInteger`, and its constants",
267
+ Object: "`Object.keys`, `Object.values` and `Object.entries` of a record",
268
+ String: "`String(x)`",
269
+ encodeURI: "`encodeURI(s)`",
270
+ encodeURIComponent: "`encodeURIComponent(s)`",
271
+ isFinite: "`isFinite(n)`",
272
+ isNaN: "`isNaN(n)`",
273
+ parseFloat: "`parseFloat(s)`",
274
+ parseInt: "`parseInt(s)` and `parseInt(s, radix)`",
275
+ };
276
+ /** Refuses `what`, a use of one of JavaScript's globals the server does not translate. */
277
+ export function builtin(s, what, n) {
278
+ const name = /^\w+/.exec(what)[0];
279
+ const of = OF_BUILTINS[name];
280
+ return fail(s.comp, "FV0633", `\`${what}\` is not supported: ${of ? `of \`${name}\`, the server translates ${of}` : `the server does not translate \`${name}\``}`, n);
281
+ }
282
+ function floatTest(method, x) {
283
+ switch (method) {
284
+ case "isNaN":
285
+ return `${receiver(x)}.is_nan()`;
286
+ case "isFinite":
287
+ return `${receiver(x)}.is_finite()`;
288
+ case "isInteger":
289
+ return `${receiver(x)}.fract() == 0.0`;
290
+ default:
291
+ return /^[\w.]+$/.test(x) ? `${x}.fract() == 0.0 && ${x}.abs() <= 9_007_199_254_740_991.0` : `matches!(${bare(x)}, v if v.fract() == 0.0 && v.abs() <= 9_007_199_254_740_991.0)`;
292
+ }
293
+ }
294
+ function intTest(method, x) {
295
+ if (method === "isSafeInteger")
296
+ return `(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&${operand(x, UNARY)})`;
297
+ return String(method !== "isNaN");
298
+ }
299
+ function numberTest(s, method, args, n, global) {
300
+ const name = global ? `\`${method}()\`` : `\`Number.${method}()\``;
301
+ if (args.length !== 1)
302
+ return fail(s.comp, "FV0718", `${name} takes one number`, n);
303
+ const v = expr(s, args[0]);
304
+ if (isNumber(v.ty)) {
305
+ if (v.num !== undefined) {
306
+ const konst = Number[method](v.num);
307
+ return { code: String(konst), ty: BOOL, konst };
308
+ }
309
+ return boolOf(v.ty.k === "int" ? intTest(method, v.code) : floatTest(method, v.code));
310
+ }
311
+ if (!global && v.ty.k === "opt" && isNumber(v.ty.of)) {
312
+ const each = v.ty.of.k === "int" ? intTest(method, "v") : floatTest(method, "v");
313
+ if (each === "false")
314
+ return { code: "false", ty: BOOL, konst: false };
315
+ return boolOf(each === "true" ? `${atom(v.code)}.is_some()` : `${atom(v.code)}.is_some_and(|v| ${each})`);
316
+ }
317
+ if (global) {
318
+ return fail(s.comp, "FV0718", `${name} converts its argument as \`Number()\` does, which the server does for a number only: write \`Number.${method}(Number(x))\` for a string, or pass a present number`, args[0]);
319
+ }
320
+ return fail(s.comp, "FV0718", `${name} of ${describeTy(v.ty)}, for which it is \`false\` whatever it holds, since it does not convert its argument: pass a number, present or optional`, args[0]);
321
+ }
194
322
  export function isObjectCall(n, method) {
195
323
  return (n?.type === "CallExpression" && n.callee.type === "MemberExpression" && !n.callee.computed &&
196
324
  n.callee.object.type === "Identifier" && n.callee.object.name === "Object" && n.callee.property.name === method);
@@ -209,7 +337,7 @@ function json(s, v, n) {
209
337
  case "bool":
210
338
  return `(if ${condition(code)} { "true" } else { "false" }).to_owned()`;
211
339
  default:
212
- return fail(s.comp, "FV0707", "`JSON.stringify()` of a string, a number, a boolean or a list of those, present", n);
340
+ return claim((p) => p.values?.json?.({ code, ty })) ?? fail(s.comp, "FV0707", "`JSON.stringify()` of a string, a number, a boolean, a date or a list of those, present", n);
213
341
  }
214
342
  };
215
343
  if (v.ty.k === "list") {
package/dist/children.js CHANGED
@@ -321,6 +321,14 @@ export function ownInto(comp, v, want, node) {
321
321
  if (v.ty.k === "opt" && v.ty.of.k === "str")
322
322
  return `${atom(v.code)}.map(std::borrow::Cow::Borrowed)`;
323
323
  }
324
+ if (want.k === "date" && v.ty.k === "date")
325
+ return `fv::js_date_prop(${bare(v.code)})`;
326
+ if (want.k === "opt" && want.of.k === "date") {
327
+ if (v.ty.k === "date")
328
+ return `Some(fv::js_date_prop(${bare(v.code)}))`;
329
+ if (v.ty.k === "opt" && v.ty.of.k === "date")
330
+ return `${atom(v.code)}.map(fv::js_date_prop)`;
331
+ }
324
332
  if (sameTy(v.ty, want) && (want.k === "int" || want.k === "float" || want.k === "bool"))
325
333
  return v.code;
326
334
  if (want.k === "opt" && sameTy(v.ty, want.of) && (v.ty.k === "int" || v.ty.k === "float" || v.ty.k === "bool"))
package/dist/client.d.ts CHANGED
@@ -1,6 +1,13 @@
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 props a component is given, with each prop it declares a `Date` turned from what JSON
5
+ * carries, the ISO string `toISOString()` writes or a number of milliseconds (`NaN` for an invalid
6
+ * date), back into a `Date`, as the server rendered it. Vue's compiler declares a prop typed `Date`
7
+ * as `{ type: Date }`, and one typed `Date | null` as `{ type: [Date, null] }`. `mountIslands`,
8
+ * `mountPage` and `renderPage` call it; an app that hands a component props it read from JSON
9
+ * itself calls it too. */
10
+ export declare function reviveDates<P extends Record<string, unknown> | null>(component: Component, props: P): P;
4
11
  /** The state of a page already read: the text of its state's script, as `readPage` gives it. */
5
12
  export interface StateText {
6
13
  text: string | undefined;
package/dist/client.js CHANGED
@@ -11,6 +11,28 @@ function parseJson(text) {
11
11
  const quoted = text.replace(BARE, (match) => (match.startsWith('"') ? match : `"${escaped}${match}"`));
12
12
  return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
13
13
  }
14
+ function declaresDate(declared) {
15
+ const type = declared !== null && typeof declared === "object" && "type" in declared ? declared.type : declared;
16
+ return type === Date || (Array.isArray(type) && type.includes(Date));
17
+ }
18
+ /** The props a component is given, with each prop it declares a `Date` turned from what JSON
19
+ * carries, the ISO string `toISOString()` writes or a number of milliseconds (`NaN` for an invalid
20
+ * date), back into a `Date`, as the server rendered it. Vue's compiler declares a prop typed `Date`
21
+ * as `{ type: Date }`, and one typed `Date | null` as `{ type: [Date, null] }`. `mountIslands`,
22
+ * `mountPage` and `renderPage` call it; an app that hands a component props it read from JSON
23
+ * itself calls it too. */
24
+ export function reviveDates(component, props) {
25
+ const declared = component.props;
26
+ if (!props || !declared || typeof declared !== "object" || Array.isArray(declared))
27
+ return props;
28
+ let revived = null;
29
+ for (const [key, type] of Object.entries(declared)) {
30
+ const value = props[key];
31
+ if ((typeof value === "string" || typeof value === "number") && declaresDate(type))
32
+ (revived ??= { ...props })[key] = new Date(value);
33
+ }
34
+ return (revived ?? props);
35
+ }
14
36
  /** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
15
37
  * the app mounts, so every store starts from it and the markup agrees with the server's. The state
16
38
  * is read from the script with the `id` given (`__pinia` by default) in `doc`, or given as
@@ -303,7 +325,7 @@ export async function mountIslands(components, options = {}) {
303
325
  // One island that throws as it mounts (its setup, a plugin) is reported, and leaves the others
304
326
  // to hydrate.
305
327
  try {
306
- const app = createSSRApp(component, props);
328
+ const app = createSSRApp(component, reviveDates(component, props));
307
329
  if (options.pinia)
308
330
  app.use(options.pinia);
309
331
  if (options.router)
@@ -415,9 +437,9 @@ async function load(component) {
415
437
  export function createPageApp(layout, record, components, options = {}) {
416
438
  const slots = Object.fromEntries(Object.entries(record.slots).map(([name, parts]) => [
417
439
  name,
418
- () => (parts.length ? parts.map((part) => h(components[part.c], part.p)) : [createTextVNode("")]),
440
+ () => (parts.length ? parts.map((part) => h(components[part.c], reviveDates(components[part.c], part.p))) : [createTextVNode("")]),
419
441
  ]));
420
- const app = (options.hydrate === false ? createApp : createSSRApp)({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
442
+ const app = (options.hydrate === false ? createApp : createSSRApp)({ name: "FerrovuePage", render: () => h(layout, reviveDates(layout, record.props), slots) });
421
443
  if (options.pinia)
422
444
  app.use(options.pinia);
423
445
  if (options.router)
package/dist/compiler.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import { relativePath } from "./paths.js";
4
- import { ctx, loadConfig, tyOfName } from "./context.js";
4
+ import { ctx, JS_DATE, loadConfig, tyOfName } from "./context.js";
5
5
  import { failIn, snake } from "./model.js";
6
6
  import { readComponent, refuseOptionsApi } from "./component.js";
7
7
  import { listDir } from "./files.js";
@@ -9,7 +9,7 @@ import { scopeFor } from "./script.js";
9
9
  import { attrsFlow } from "./fallthrough.js";
10
10
  import { prepareDynamic } from "./dynamic.js";
11
11
  import { componentSource, GENERATED, isIsland, modSource } from "./rust.js";
12
- import { requireWhole } from "./typescript.js";
12
+ import { refuseNestedDates, requireWhole } from "./typescript.js";
13
13
  import { renderParams } from "./plugin.js";
14
14
  import { PLUGINS } from "./plugins/index.js";
15
15
  /** Every generated file, keyed by its name in the output directory. `sources` holds the text of
@@ -19,6 +19,7 @@ export function generate(root, config = loadConfig(root), sources = new Map()) {
19
19
  ctx.componentsDir = config.components.replace(/\/?$/, "/");
20
20
  ctx.helperModule = config.helpers?.module ?? null;
21
21
  ctx.trustedHtml = config.trustedHtml ?? null;
22
+ ctx.dateType = config.dateType ?? JS_DATE;
22
23
  ctx.builders = config.builders ?? true;
23
24
  ctx.clientDirectives = new Set(config.clientDirectives ?? []);
24
25
  ctx.rootDir = root;
@@ -74,6 +75,8 @@ export function generate(root, config = loadConfig(root), sources = new Map()) {
74
75
  for (const c of components.values())
75
76
  for (const f of c.props.fields)
76
77
  requireWhole(c, f.ty);
78
+ for (const c of components.values())
79
+ refuseNestedDates(c, c.props.fields, c.propsAt);
77
80
  const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
78
81
  const analysed = prepareDynamic(read.map((r, i) => ({ ...r, scope: scopes[i] })));
79
82
  const all = read.map((r, i) => ({ comp: r.comp, ssr: analysed[i], children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
package/dist/component.js CHANGED
@@ -213,10 +213,10 @@ export function readComponent(file, root, name = basename(file, ".vue")) {
213
213
  if (!propsTy) {
214
214
  }
215
215
  else if (propsTy.type === "TSTypeLiteral") {
216
- comp.props = structOf(comp, "Props", propsTy.members, comp.structs);
216
+ comp.props = structOf(comp, "Props", propsTy.members, comp.structs, false, true);
217
217
  }
218
218
  else if (propsTy.type === "TSTypeReference" && comp.structs.has(propsTy.typeName.name)) {
219
- reachLocal(comp, propsTy.typeName.name);
219
+ reachLocal(comp, propsTy.typeName.name, true);
220
220
  const st = comp.structs.get(propsTy.typeName.name);
221
221
  const refused = st.refused?.values().next().value;
222
222
  if (refused !== undefined)
@@ -246,6 +246,7 @@ export function readComponent(file, root, name = basename(file, ".vue")) {
246
246
  comp.models.set(d.id.name, named);
247
247
  }
248
248
  }
249
+ comp.propsAt = propsTy;
249
250
  const defaults = runtimeDefaults(comp, script?.content ?? "");
250
251
  for (const f of comp.props.fields) {
251
252
  if (f.ty.k !== "opt")
package/dist/context.d.ts CHANGED
@@ -49,6 +49,10 @@ export interface Config {
49
49
  * `crate::render::Trusted<'a>`. `ferrovue::InlineHtml` makes every `TrustedHtml` prop inline HTML.
50
50
  * Without it, only an `InlineHtml` prop may reach `v-html`. */
51
51
  trustedHtml?: string;
52
+ /** The Rust type a `Date` prop is, which must implement `ferrovue::JsTime`:
53
+ * `ferrovue::JsDate` by default, or `chrono::DateTime<chrono::Utc>` with the crate's `chrono`
54
+ * feature. */
55
+ dateType?: string;
52
56
  /** A JSON file listing the app's routes as vue-router paths (`/users/:id`), which `<RouterLink>`
53
57
  * resolves against, or `{ pages }`: a folder of pages whose file names give the routes, as
54
58
  * vue-router's file-based routing reads them. Without it, a component cannot use `<RouterLink>`. */
@@ -126,6 +130,8 @@ export declare function checkConfig(raw: unknown, file: string, warn: (warning:
126
130
  export declare function tyOfName(name: TypeName): Ty;
127
131
  /** The Rust type of an `InlineHtml` prop, and of a `TrustedHtml` one when `trustedHtml` names it. */
128
132
  export declare const INLINE_HTML = "ferrovue::InlineHtml";
133
+ /** The Rust type of a `Date` prop when `dateType` names none. */
134
+ export declare const JS_DATE = "ferrovue::JsDate";
129
135
  /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
130
136
  export declare const TYPES_MODULE = "ferrovue/types";
131
137
  export declare const ctx: {
@@ -137,6 +143,7 @@ export declare const ctx: {
137
143
  }>;
138
144
  helperModule: string | null;
139
145
  trustedHtml: string | null;
146
+ dateType: string;
140
147
  builders: boolean;
141
148
  clientDirectives: Set<string>;
142
149
  plugins: readonly Plugin[];
package/dist/context.js CHANGED
@@ -100,7 +100,7 @@ export function checkConfig(raw, file, warn, schema = CONFIG_SCHEMA) {
100
100
  if (raw.builders !== undefined && typeof raw.builders !== "boolean") {
101
101
  throw new GenError("FV1106", `\`builders\` in ${file} is \`true\` or \`false\`: whether each props struct gets \`new()\` and a setter per optional field`, { file });
102
102
  }
103
- for (const key of ["$schema", "stores", "trustedHtml", "viteRoot"])
103
+ for (const key of ["$schema", "stores", "trustedHtml", "dateType", "viteRoot"])
104
104
  string(raw, key, key);
105
105
  if (raw.isProduction !== undefined && typeof raw.isProduction !== "boolean")
106
106
  wrong("isProduction", "`true` or `false`: whether `@vitejs/plugin-vue` compiles for production");
@@ -216,12 +216,15 @@ export function tyOfName(name) {
216
216
  }
217
217
  /** The Rust type of an `InlineHtml` prop, and of a `TrustedHtml` one when `trustedHtml` names it. */
218
218
  export const INLINE_HTML = "ferrovue::InlineHtml";
219
+ /** The Rust type of a `Date` prop when `dateType` names none. */
220
+ export const JS_DATE = "ferrovue::JsDate";
219
221
  /** Where `TrustedHtml` comes from. Only that import names the type `v-html` will write raw. */
220
222
  export const TYPES_MODULE = "ferrovue/types";
221
223
  export const ctx = {
222
224
  helpers: {},
223
225
  helperModule: null,
224
226
  trustedHtml: null,
227
+ dateType: JS_DATE,
225
228
  builders: true,
226
229
  clientDirectives: new Set(),
227
230
  plugins: [],
package/dist/errors.d.ts CHANGED
@@ -325,7 +325,7 @@ export declare const ERRORS: {
325
325
  readonly title: "Unsupported call";
326
326
  };
327
327
  readonly FV0604: {
328
- readonly title: "Field read on a value that is not an object";
328
+ readonly title: "Field read on a value that is not an object, or that may be absent";
329
329
  };
330
330
  readonly FV0605: {
331
331
  readonly title: "Field the type does not have";
@@ -343,7 +343,7 @@ export declare const ERRORS: {
343
343
  readonly title: "Comparison between unsupported or absent values";
344
344
  };
345
345
  readonly FV0610: {
346
- readonly title: "Spread or hole in an array literal";
346
+ readonly title: "Hole in an array literal";
347
347
  };
348
348
  readonly FV0611: {
349
349
  readonly title: "Array literal of different types";
@@ -399,6 +399,23 @@ export declare const ERRORS: {
399
399
  readonly FV0628: {
400
400
  readonly title: "`as` or `satisfies` stating a type the value does not have";
401
401
  };
402
+ readonly FV0629: {
403
+ readonly title: "Optional call `?.()`";
404
+ };
405
+ readonly FV0630: {
406
+ readonly title: "`??` between a list an optional chain computes and another list";
407
+ };
408
+ readonly FV0631: {
409
+ readonly title: "`in` or `instanceof`";
410
+ readonly detail: "`in` and `instanceof` read an object's keys and prototype as the page renders, which the server's typed values do not carry. Test a record's key with `Object.keys(r).includes(key)`.";
411
+ };
412
+ readonly FV0632: {
413
+ readonly title: "Bitwise or shift operator";
414
+ };
415
+ readonly FV0633: {
416
+ readonly title: "Part of JavaScript's own globals the server does not translate";
417
+ readonly detail: "`JSON`, `Math`, `Number`, `String` and the other globals a template may name are translated only in part: `JSON.stringify(x)` of one value, `String(x)`, `Number(x)` and the `Number.is…()` tests, the `Math` methods and constants the [`numbers`](crate::guide::numbers) page lists, `encodeURI` and `encodeURIComponent`. The message names what the server translates of the one used.";
418
+ };
402
419
  readonly FV0701: {
403
420
  readonly title: "`Number()` of an unsupported value";
404
421
  };
@@ -415,14 +432,14 @@ export declare const ERRORS: {
415
432
  readonly title: "`.toFixed()` without a literal number of digits from 0 to 100";
416
433
  };
417
434
  readonly FV0706: {
418
- readonly title: "String that may hold half of a surrogate pair compared, searched or joined";
419
- readonly detail: "`slice`, `substring`, `at`, `charAt` and `split(\"\")` can cut a surrogate pair in half. JavaScript keeps the half where ferrovue holds U+FFFD, so two such strings may compare, search or join differently. See [`strings`](crate::guide::strings#halves-of-surrogate-pairs).";
435
+ readonly title: "String that may hold half of a surrogate pair compared, searched, joined or encoded";
436
+ readonly detail: "`slice`, `substring`, `substr`, `at`, `charAt` and `split(\"\")` can cut a surrogate pair in half. JavaScript keeps the half where ferrovue holds U+FFFD, so two such strings may compare, search or join differently, `charCodeAt` and `codePointAt` would read U+FFFD's code, and `encodeURI` and `encodeURIComponent`, which throw a `URIError` on a half, would encode U+FFFD. See [`strings`](crate::guide::strings#halves-of-surrogate-pairs).";
420
437
  };
421
438
  readonly FV0707: {
422
439
  readonly title: "`JSON.stringify()` of an unsupported value";
423
440
  };
424
441
  readonly FV0708: {
425
- readonly title: "Template literal of values that are not present strings, numbers or booleans";
442
+ readonly title: "Template literal of a value it cannot write";
426
443
  };
427
444
  readonly FV0709: {
428
445
  readonly title: "String method with the wrong number of arguments";
@@ -445,6 +462,33 @@ export declare const ERRORS: {
445
462
  readonly FV0715: {
446
463
  readonly title: "Case mapping by the server's locale";
447
464
  };
465
+ readonly FV0716: {
466
+ readonly title: "`.toPrecision()` without a literal precision from 1 to 100";
467
+ };
468
+ readonly FV0717: {
469
+ readonly title: "`.toExponential()` without a literal number of digits from 0 to 100";
470
+ };
471
+ readonly FV0718: {
472
+ readonly title: "`isNaN()`, `isFinite()` or a `Number.is…()` test of a value that is not a number";
473
+ readonly detail: "`Number.isNaN()`, `Number.isFinite()`, `Number.isInteger()` and `Number.isSafeInteger()` take a number, present or optional, and are `false` for any other value, which they do not convert; the global `isNaN()` and `isFinite()` convert theirs as `Number()` does and take a present number. Test a date with `Number.isNaN(d.getTime())`, and a string with `Number.isNaN(Number(s))`.";
474
+ };
475
+ readonly FV0719: {
476
+ readonly title: "`Math` function engines round differently";
477
+ readonly detail: "`Math.cbrt`, `Math.hypot`, `Math.exp`, `Math.log`, the trigonometric functions and their kin are computed by each JavaScript engine's own routine, which the specification leaves free to round differently, and Rust's do not promise V8's digits either. Compute the value in Rust and pass it as a prop.";
478
+ };
479
+ readonly FV0720: {
480
+ readonly title: "`**` or `Math.pow()` other than an exact integer power known when compiling";
481
+ readonly detail: "Engines compute powers by different routines: Node 22's V8 writes `10 ** -20` as `1.0000000000000001e-20`, Node 24's and Firefox's as `1e-20`, and above 2⁵³ they round in their own ways. The compiler works out `a ** b` and `Math.pow(a, b)` when both are known, `a` an integer and `b` a whole number from 0, with a result within ±2⁵³, which every engine gives exactly.";
482
+ };
483
+ readonly FV0721: {
484
+ readonly title: "Value that may be `null` or `undefined` written as a string";
485
+ };
486
+ readonly FV0722: {
487
+ readonly title: "`typeof` the compiler cannot work out";
488
+ };
489
+ readonly FV0723: {
490
+ readonly title: "`String()`, `.concat()` or `encodeURI…()` of a value it cannot write";
491
+ };
448
492
  readonly FV0801: {
449
493
  readonly title: "`.includes()` of a value of another type than the list's";
450
494
  };
@@ -514,6 +558,36 @@ export declare const ERRORS: {
514
558
  readonly FV0823: {
515
559
  readonly title: "`v-for` key or index that is not a plain name";
516
560
  };
561
+ readonly FV0824: {
562
+ readonly title: "List item read by an index that is not an integer";
563
+ };
564
+ readonly FV0825: {
565
+ readonly title: "`.at()` of a list given anything but one number";
566
+ };
567
+ readonly FV0826: {
568
+ readonly title: "`.indexOf()` or `.lastIndexOf()` given anything but one value of the list's type";
569
+ };
570
+ readonly FV0827: {
571
+ readonly title: "`.concat()` of anything but lists of the same item type";
572
+ };
573
+ readonly FV0828: {
574
+ readonly title: "`.reduce()` or `.reduceRight()` without an initial value";
575
+ };
576
+ readonly FV0829: {
577
+ readonly title: "`.reduce()` or `.reduceRight()` whose accumulator changes type or is not a string, number or boolean";
578
+ };
579
+ readonly FV0830: {
580
+ readonly title: "`.toSorted()` with a comparator of a shape the server does not order by";
581
+ };
582
+ readonly FV0831: {
583
+ readonly title: "`.toSorted()` without a comparator of a list of objects";
584
+ };
585
+ readonly FV0832: {
586
+ readonly title: "`.sort()` or `.reverse()` of a list in place";
587
+ };
588
+ readonly FV0833: {
589
+ readonly title: "Array spread of anything but lists of the same item type";
590
+ };
517
591
  readonly FV0901: {
518
592
  readonly title: "Slots that are not an object literal";
519
593
  };
@@ -782,6 +856,7 @@ export declare const ERRORS: {
782
856
  };
783
857
  readonly FV1234: {
784
858
  readonly title: "`typeof` of anything but a query value";
859
+ readonly retired: "0.9.0";
785
860
  };
786
861
  readonly FV1235: {
787
862
  readonly title: "`??` after a query value with a fallback that is not a string";
@@ -1083,12 +1158,28 @@ export declare const ERRORS: {
1083
1158
  };
1084
1159
  readonly FV1804: {
1085
1160
  readonly title: "`Date` method or function ferrovue does not translate";
1086
- readonly detail: "A date is read with `.toISOString()`, `.getTime()`, `.valueOf()` and the `.getUTC…()` methods, which do not depend on the time zone. The local getters (`.getFullYear()`, `.getHours()`), `.getTimezoneOffset()` and `.toString()` read the time zone of the machine running them, which differs between the server and the reader's browser. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1161
+ readonly detail: "A date is read with `.toISOString()`, `.toUTCString()`, `.toJSON()`, `.getTime()`, `.valueOf()` and the `.getUTC…()` methods, which do not depend on the time zone. The local getters (`.getFullYear()`, `.getHours()`), `.getTimezoneOffset()` and `.toString()` read the time zone of the machine running them, which differs between the server and the reader's browser. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1087
1162
  };
1088
1163
  readonly FV1805: {
1089
1164
  readonly title: "Locale formatting with `Intl`, `.toLocaleString()`, `.localeCompare()`, `$d` or `$n`";
1090
1165
  readonly detail: "`Intl` formats from locale data each JavaScript engine ships, and Node, Chromium, Firefox and Safari write some dates, times and numbers differently (spaces, separators, abbreviations), so no server can write what every reader's browser writes. This refusal is permanent. Format the value in Rust and pass it as a string prop, or write it with `.toFixed()` and `.toISOString()` into a message `$t` translates. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1091
1166
  };
1167
+ readonly FV1806: {
1168
+ readonly title: "`Date` anywhere but a component's own prop";
1169
+ readonly detail: "A prop typed `Date` (optional or nullable too) is a `ferrovue::JsDate`, or the type `dateType` names, and an island's `data-props` carries it as its ISO string, which the client turns back into a `Date` because the prop's declared type says it is one. A `Date` inside a list, a dictionary or an object prop, and one in an interface the props do not declare, in Pinia state, `inject`, `ref` or `defineModel`, have no declared type the client could read, so they would reach the browser as strings. Pass the ISO string or the number of milliseconds and build the date with `new Date()`. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1170
+ };
1171
+ readonly FV1807: {
1172
+ readonly title: "`Date.UTC()` of anything but one to seven numbers that are present";
1173
+ readonly detail: "`Date.UTC(year, month, date, hours, minutes, seconds, ms)` takes numbers, each truncated toward zero, the month counted from 0. A value that may be absent would give `NaN` for `undefined` and `0` for `null`: narrow it with `v-if` or give it a default. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1174
+ };
1175
+ readonly FV1808: {
1176
+ readonly title: "`Date.UTC()` of parts engines compute differently";
1177
+ readonly detail: "V8 computes a year beyond ±1,000,000, a month beyond ±10,000,000 and a year before −399,999 by rules of its own, which Firefox and Safari do not share, so a day or hours that bring the date back within the range give another date in each; and Node rounds a part worth more than 2⁵³ ms that the others bring back within the range otherwise than the browsers. Literal parts are checked when the component is compiled; parts from the props are checked as it renders, and `js_date_utc` panics on them. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1178
+ };
1179
+ readonly FV1809: {
1180
+ readonly title: "`===`, `!==`, `==` or `!=` with a `Date`";
1181
+ readonly detail: "Two `Date`s are equal in JavaScript only when they are one object, so two dates of the same time are unequal, and `==` with a string compares the date's `.toString()`, in the time zone of the machine running it. Compare time values: `a.getTime() === b.getTime()`. `<`, `<=`, `>` and `>=` between two dates compare their time values, and are false when either is invalid. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1182
+ };
1092
1183
  };
1093
1184
  /** A stable error code, as `FV0604`. */
1094
1185
  export type Code = keyof typeof ERRORS;