ferrovue 0.7.2 → 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;
@@ -101,8 +129,17 @@ export function call(s, n) {
101
129
  const helper = s.helpers.get(method);
102
130
  if (helper)
103
131
  return helperCall(s, helper, args, n);
132
+ if (s.clientOnly.has(method))
133
+ return fail(comp, "FV0613", `\`${method}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(method)}`, n);
104
134
  }
105
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
+ }
106
143
  const vals = args.map((a) => expr(s, a));
107
144
  if (vals.length && vals.every((v) => isNumber(v.ty))) {
108
145
  const ints = vals.every((v) => v.ty.k === "int");
@@ -119,12 +156,31 @@ export function call(s, n) {
119
156
  return { code: `fv::js_round(${bare(asF64(x))})`, ty: FLOAT };
120
157
  if (method === "floor" || method === "ceil" || method === "trunc")
121
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
+ }
122
167
  }
123
168
  }
124
- 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);
125
178
  }
126
179
  const literal = method === "filter" && args.length === 1 && isBoolean(s, args[0]) && callee.object.type === "ArrayExpression";
127
180
  const target = (literal ? presentLiteral(s, callee.object) : null) ?? expr(s, callee.object);
181
+ const typed = claim((p) => p.method?.(s, target, method, args, n));
182
+ if (typed)
183
+ return typed;
128
184
  if (target.ty.k === "str") {
129
185
  const lone = loneOf(target);
130
186
  switch (args.length === 0 ? method : "") {
@@ -133,7 +189,10 @@ export function call(s, n) {
133
189
  case "trimStart":
134
190
  return { code: `fv::js_trim_start(${strArg(target.code)})`, ty: STR, ...lone };
135
191
  case "trimEnd":
192
+ case "trimRight":
136
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 };
137
196
  case "toUpperCase":
138
197
  return { code: `&*${receiver(target.code)}.to_uppercase()`, ty: STR, ...lone };
139
198
  case "toLowerCase":
@@ -145,12 +204,21 @@ export function call(s, n) {
145
204
  if (m)
146
205
  return m;
147
206
  }
148
- if (isNumber(target.ty) && method === "toFixed" && args.length <= 1) {
149
- const d = args.length ? args[0] : { type: "NumericLiteral", value: 0 };
150
- if (d.type !== "NumericLiteral" || !Number.isInteger(d.value) || d.value < 0 || d.value > 100) {
151
- fail(comp, "FV0705", "`.toFixed()` takes a literal number of digits, from 0 to 100", n);
152
- }
153
- 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 };
154
222
  }
155
223
  if (isNumber(target.ty) && method === "toString" && args.length === 0) {
156
224
  return { code: `&*fv::Js(${bare(target.code)}).to_string()`, ty: STR };
@@ -186,6 +254,71 @@ export function call(s, n) {
186
254
  }
187
255
  return fail(comp, "FV0603", "this call is not supported", n);
188
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
+ }
189
322
  export function isObjectCall(n, method) {
190
323
  return (n?.type === "CallExpression" && n.callee.type === "MemberExpression" && !n.callee.computed &&
191
324
  n.callee.object.type === "Identifier" && n.callee.object.name === "Object" && n.callee.property.name === method);
@@ -204,7 +337,7 @@ function json(s, v, n) {
204
337
  case "bool":
205
338
  return `(if ${condition(code)} { "true" } else { "false" }).to_owned()`;
206
339
  default:
207
- 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);
208
341
  }
209
342
  };
210
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";
@@ -1069,6 +1144,42 @@ export declare const ERRORS: {
1069
1144
  readonly FV1711: {
1070
1145
  readonly title: "`innerHTML` or `textContent` on a head tag that has no content";
1071
1146
  };
1147
+ readonly FV1801: {
1148
+ readonly title: "The current time: `new Date()` or `Date.now()`";
1149
+ readonly detail: "The server renders at one moment and the browser hydrates at another, so the text would differ. Read the time in Rust and pass it as a prop: an ISO string, or a number of milliseconds. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1150
+ };
1151
+ readonly FV1802: {
1152
+ readonly title: "`new Date()` or `Date.parse()` of anything but one present string or number";
1153
+ readonly detail: "`new Date(year, month, …)` reads its parts in the time zone of the machine running it. A value that may be absent gives an invalid date for `undefined` and the epoch for `null`. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1154
+ };
1155
+ readonly FV1803: {
1156
+ readonly title: "Date string that browsers may read differently";
1157
+ readonly detail: "ECMAScript specifies how a date alone (`2024-05-01`, `2024-05`, `2024`) and a date and time with a `Z` or an offset (`2024-05-01T09:30:00.000Z`, `2024-05-01T09:30+02:00`) are read. A date and time with neither is read in the time zone of the machine running it, and every other string by each engine's own rules, as are fields out of range (`2024-02-30`, `T24:00`) and fields beyond the range of dates that an offset brings back within it (`+275760-09-13T01:00+01:00`), which Safari reads as invalid. A literal is checked when the component is compiled; a string from the props is checked as it renders, and one outside the format panics. See [`dates_and_prices`](crate::guide::dates_and_prices).";
1158
+ };
1159
+ readonly FV1804: {
1160
+ readonly title: "`Date` method or function ferrovue does not translate";
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).";
1162
+ };
1163
+ readonly FV1805: {
1164
+ readonly title: "Locale formatting with `Intl`, `.toLocaleString()`, `.localeCompare()`, `$d` or `$n`";
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).";
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
+ };
1072
1183
  };
1073
1184
  /** A stable error code, as `FV0604`. */
1074
1185
  export type Code = keyof typeof ERRORS;