ferrovue 0.7.1 → 0.8.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/README.md CHANGED
@@ -215,10 +215,11 @@ ferrovue compiles `<script setup lang="ts">` components, and components with no
215
215
  | Shared types | Interfaces and type aliases imported from `.ts` files (generated once, into `types.rs`), from a store's file, or from another component's `.vue` file; objects of a shared type can be passed between components. A TypeScript `enum` whose members are literals is the type of its values: a string, or a number |
216
216
  | Constants | Constants imported from `.ts` files and `enum`s (imported, or declared in the component), evaluated when the component is compiled: strings, numbers, booleans, `null`, lists of them, objects of them read field by field (`LABELS.save`, `Tone.Loud`, `Rank[5]`), and lists of objects of them, which become a `const` in `types.rs` (`{ value, label }` items get a type named after the constant, `OptionsItem`, unless the constant is typed with an interface: `OPTIONS: Option[]`; a field that is `null` in some items is `T | null`). See [Constants](#constants) |
217
217
  | Props | `withDefaults`, destructured props with defaults (`const { size = "md" } = defineProps<…>()`), the props object (`const props = defineProps<…>()`, then `props.label` in the template or in script code), optional booleans (Vue casts an absent one to `false`), `defineModel` (named, required, with defaults), components with no props. A generic component (`<script setup generic="T extends Item">`) renders each type parameter as its constraint, which is all the template can rely on |
218
- | Setup | `ref`/`shallowRef`, `computed` (an expression or a single `return`), refs that start empty, typed by their type argument (`ref<Row[]>([])`, `ref<Row>()`, `ref<string \| undefined>(undefined)`), plain `const`/`let`, `.value` in script code, helpers with Rust twins, a plain `<script>` block beside setup. Lifecycle hooks, `watch` (not `immediate`), `defineEmits`, `defineSlots`, `defineOptions`, `defineExpose`, template refs (`ref(null)`, `useTemplateRef`) and functions are client-only. The template may name them only from event handlers, which the server drops |
218
+ | Setup | `ref`/`shallowRef`, `computed` (an expression or a single `return`; one that may throw, through `.repeat()`, `.padStart()`, a date, `$t` or a helper, is evaluated where the render first reads it, once, as Vue does), refs that start empty, typed by their type argument (`ref<Row[]>([])`, `ref<Row>()`, `ref<string \| undefined>(undefined)`), plain `const`/`let`, `.value` in script code, helpers with Rust twins, a plain `<script>` block beside setup. Lifecycle hooks, `watch` (not `immediate`), `defineEmits`, `defineSlots`, `defineOptions`, `defineExpose`, template refs (`ref(null)`, `useTemplateRef`) and functions are client-only. The template may name them only from event handlers, which the server drops |
219
219
  | Provide and inject | `provide` and `inject` resolved through the component tree, slot content seeing the providers of the component that renders it, as Vue does: keys that are string literals or `InjectionKey` symbols exported from `.ts` files; values that are props, refs, `computed`s, constants or objects of them (an interface's fields, `reactive()` unwrapping refs); defaults, factory defaults (`inject(k, () => …, true)`), object defaults built as the key's interface (`inject(k, { size: "md" })`), and keys holding functions, which only event handlers may call. A page rendered from Rust passes the generated `Provides`, empty or holding what the client app gives `app.provide`. See the crate's [`provide_inject`](https://docs.rs/ferrovue/latest/ferrovue/guide/provide_inject/index.html) guide |
220
220
  | Text | `{{ }}` of strings, integers and booleans; `+`, `-`, `*`, `/`, `%`, with integers and fractions as JavaScript computes them; `<`, `>`, `<=`, `>=` (between numbers, or between strings by UTF-16 code unit), `===`, `!==`; unary `-`, `!`; `??`, `\|\|`, `&&`, `?:`; template literals; optional chaining `a?.b`; `.length`; `String()`, `.toString()`, `.toFixed()`, `Math.max`/`min`/`abs`/`round`/`floor`/`ceil`/`trunc`; `Number()`, `parseInt()` (no radix, 10 or 16), `parseFloat()`; `JSON.stringify()` of strings, numbers, booleans and lists of those; array literals |
221
221
  | Strings | `.trim()`, `.trimStart()`, `.trimEnd()`, `.toUpperCase()`, `.toLowerCase()`, `.includes()`, `.startsWith()`, `.endsWith()`, `.indexOf()`, `.lastIndexOf()`, `.slice()`, `.substring()`, `.at()`, `.charAt()`, `.split()`, `.replace()` and `.replaceAll()` with string patterns (`$&`, `` $` ``, `$'`, `$$` read as JavaScript reads them), `.padStart()`, `.padEnd()`, `.repeat()`; indices and lengths count UTF-16 code units, as JavaScript's do (see [Strings](#strings)) |
222
+ | Dates | `new Date(s)` and `Date.parse(s)` of an ISO string (a date alone, or a date and time with a `Z` or an offset, as ECMAScript's date time string format specifies), `new Date(ms)`, `.toISOString()`, `.getTime()`, `.valueOf()` and the `.getUTC…()` methods, invalid dates included, as JavaScript computes them. Prices and formatted dates are written in Rust and passed as strings, or filled into a `$t` message. See the crate's [`dates_and_prices`](https://docs.rs/ferrovue/latest/ferrovue/guide/dates_and_prices/index.html) guide |
222
223
  | Conditions | `v-if` / `v-else-if` / `v-else`, `?:`, `&&` and `||`, with optional and nullable values narrowed as TypeScript narrows them: `v-if="user"`, `user !== undefined`, `user !== null`, `user != null`, and `!user`, `user === undefined` or `user === null` for the `v-else` |
223
224
  | Lists | `v-for` over arrays (of strings, numbers, objects or child props), array literals and number ranges (`n in 5`); with an index; with destructured items (`{ id, name } in rows`); nested; on `<template>`. `.includes()` (a number looked for in a list of numbers whether either is a `number` or a `Float`, as JavaScript compares them), `.join()`, and `.filter()`, `.map()`, `.some()`, `.every()`, `.find()`, `.findIndex()` with an arrow function of the item (a name, or destructured) and its index, whose body is an expression, narrowing inside as outside, or with `Boolean`; `.filter(Boolean)` of a list or an array literal whose items may be `null` or `undefined`, which keeps the ones present and truthy as a list of the values (`[name, title, city].filter(Boolean).join(" · ")`); `Boolean(x)`; `.slice()`; chained and nested; a computed list read by `v-for`, `.join()`, `.length`, `.includes()`, kept by `computed`, or handed to a child |
224
225
  | Dictionaries | `v-for="(value, key, index) in r"` over a `Record<string, T>`, and `([key, value], index) in Object.entries(r)`, in JavaScript's order: keys that are array indices first, in numeric order, then the others as the props give them; `Object.keys(r)`, `Object.values(r)`, `Object.entries(r).length`; handed to a child |
@@ -266,7 +267,13 @@ Refused at compile time, each with an error that names the construct:
266
267
  - the Options API (a `<script>` without `setup`), and a type parameter of a generic component with
267
268
  no constraint (`generic="T"`)
268
269
  - constants that are not literals (`Date.now()`, a function), an object constant read whole or by a
269
- key chosen at run time (`LABELS[key]`), and `new` (`new Date(at)`, `new Intl.NumberFormat()`)
270
+ key chosen at run time (`LABELS[key]`), and `new` of anything but `Date` (`new Map()`)
271
+ - the current time (`new Date()`, `Date.now()`), `new Date(year, month, …)`, a date string outside
272
+ ECMAScript's date time string format or with a time and no `Z` or offset, which browsers may read
273
+ differently (one from the props panics as it renders), the local getters (`.getFullYear()`,
274
+ `.getHours()`), `.toString()` and the other `Date` methods; and, permanently, locale formatting:
275
+ `Intl`, `.toLocaleString()`, `.toLocaleDateString()`, `.toLocaleTimeString()`, `.localeCompare()`
276
+ and vue-i18n's `$d` and `$n`, whose output differs between Node and each browser
270
277
  - `<RouterLink custom>`, slot props that are array literals, defaults in destructured slot props, and outlets of one slot that pass different props
271
278
  - custom directives not listed in `clientDirectives`
272
279
  - `watchEffect`, `watch` with `immediate`, `onServerPrefetch`, top-level `await`, and statements in setup that change state
@@ -773,23 +780,26 @@ pnpm bench:record # rewrite benches/expected/*.html from Vue, after changing a
773
780
  ```
774
781
 
775
782
  The benchmarks are not part of `pnpm test`. CI runs both in a short mode only to check that they
776
- still build, run and agree on the HTML. It never compares their timings.
783
+ still build, run and agree on the HTML. It never compares their timings. What CI does hold to a
784
+ budget is the number of instructions each scenario runs, within 5% of a committed baseline, and
785
+ the time ferrovue and `cargo build` take on a project of 500 components; [TESTING.md](https://github.com/Kinsmir/ferrovue/blob/main/TESTING.md#performance-budgets)
786
+ describes both.
777
787
 
778
788
  ## Versions
779
789
 
780
790
  Byte-identical output depends on Vue's own SSR compiler, so ferrovue compiles with an exact
781
- `@vue/compiler-sfc` (currently 3.5.43), and its conformance fixtures are recorded from one version
791
+ `@vue/compiler-sfc` (currently 3.5.44), and its conformance fixtures are recorded from one version
782
792
  of Vue and of each integration. Your project may use any later patch of the same minor:
783
793
 
784
794
  | Peer | Supported | Recorded from |
785
795
  |---|---|---|
786
- | `vue` | `~3.5.43` (3.5.43 and later 3.5 patches) | 3.5.43 |
787
- | `vue-router` (optional) | `~5.3.1` | 5.3.1 |
788
- | `pinia` (optional) | `~4.0.3` | 4.0.3 |
789
- | `vue-i18n` (optional) | `~11.4.13` | 11.4.13 |
790
- | `@unhead/vue` (optional) | `~3.4.2` | 3.4.2 |
796
+ | `vue` | `~3.5.44` (3.5.44 and later 3.5 patches) | 3.5.44 |
797
+ | `vue-router` (optional) | `~5.4.0` | 5.4.0 |
798
+ | `pinia` (optional) | `~4.1.0` | 4.1.0 |
799
+ | `vue-i18n` (optional) | `~11.4.15` | 11.4.15 |
800
+ | `@unhead/vue` (optional) | `~3.4.3` | 3.4.3 |
791
801
 
792
- A new minor of any of them (Vue 3.6, vue-router 5.4, …) needs a ferrovue release that re-records
802
+ A new minor of any of them (Vue 3.6, vue-router 5.5, …) needs a ferrovue release that re-records
793
803
  the fixtures from it. A weekly CI job re-records them from the newest patch each range allows and
794
804
  hydrates the committed HTML with it, so a patch that changes what Vue writes or hydrates is caught
795
805
  before it reaches you; a ferrovue patch follows when one does.
@@ -819,7 +829,7 @@ crates/ferrovue/ the Rust runtime crate, which generated code calls
819
829
  tests/sanitised/ a component whose `TrustedHtml` is `ferrovue::Sanitised`
820
830
  tests/vectors/ vectors recorded from JavaScript and Vue
821
831
  tests/properties.rs property-based tests of the runtime
822
- benches/ criterion benchmarks of generated renderers (see Performance)
832
+ benches/ criterion and instruction-count benchmarks of generated renderers
823
833
  crates/ferrovue-core/ escaping and JavaScript's numbers, which the other crates share
824
834
  tests/vectors/ vectors recorded from escapeHtml, String(n), Math and toFixed
825
835
  crates/ferrovue-router/ vue-router's matching and links (the `router` feature)
@@ -877,10 +887,12 @@ examples/fullstack/ axum + Vite: islands (some hydrating later), a page
877
887
  Pinia state, routes from a folder of pages, the page head, streaming,
878
888
  <ClientOnly>, a Rust twin, and a conformance suite
879
889
  examples/dioxus/ a Dioxus page, rendered with dioxus-ssr, with an island in it
890
+ fuzz/ cargo-fuzz targets for the runtime's escaping and JSON (see TESTING.md)
880
891
  scripts/ferrovue-in.ts the `ferrovue` command run in a project of this repository (`pnpm conformance:check`, CI)
881
892
  scripts/inspect.ts Vue's SSR code and render of a component beside the Rust ferrovue generates for it
882
893
  scripts/release.ts the release version bump (see RELEASING.md)
883
894
  scripts/api-report.ts the npm package's API report, written and checked (`pnpm api:report`, `pnpm api:check`)
895
+ scripts/traceability.test.ts the README's "What a component may use" rows, traced to conformance components in TESTING.md
884
896
  ```
885
897
 
886
898
  ## Development
package/dist/calls.js CHANGED
@@ -101,6 +101,8 @@ export function call(s, n) {
101
101
  const helper = s.helpers.get(method);
102
102
  if (helper)
103
103
  return helperCall(s, helper, args, n);
104
+ if (s.clientOnly.has(method))
105
+ return fail(comp, "FV0613", `\`${method}\` is set up in a way the server cannot evaluate: ${s.clientOnly.get(method)}`, n);
104
106
  }
105
107
  if (callee.object.type === "Identifier" && callee.object.name === "Math") {
106
108
  const vals = args.map((a) => expr(s, a));
@@ -125,6 +127,9 @@ export function call(s, n) {
125
127
  }
126
128
  const literal = method === "filter" && args.length === 1 && isBoolean(s, args[0]) && callee.object.type === "ArrayExpression";
127
129
  const target = (literal ? presentLiteral(s, callee.object) : null) ?? expr(s, callee.object);
130
+ const typed = claim((p) => p.method?.(s, target, method, args, n));
131
+ if (typed)
132
+ return typed;
128
133
  if (target.ty.k === "str") {
129
134
  const lone = loneOf(target);
130
135
  switch (args.length === 0 ? method : "") {
package/dist/errors.d.ts CHANGED
@@ -1069,6 +1069,26 @@ export declare const ERRORS: {
1069
1069
  readonly FV1711: {
1070
1070
  readonly title: "`innerHTML` or `textContent` on a head tag that has no content";
1071
1071
  };
1072
+ readonly FV1801: {
1073
+ readonly title: "The current time: `new Date()` or `Date.now()`";
1074
+ 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).";
1075
+ };
1076
+ readonly FV1802: {
1077
+ readonly title: "`new Date()` or `Date.parse()` of anything but one present string or number";
1078
+ 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).";
1079
+ };
1080
+ readonly FV1803: {
1081
+ readonly title: "Date string that browsers may read differently";
1082
+ 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).";
1083
+ };
1084
+ readonly FV1804: {
1085
+ 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).";
1087
+ };
1088
+ readonly FV1805: {
1089
+ readonly title: "Locale formatting with `Intl`, `.toLocaleString()`, `.localeCompare()`, `$d` or `$n`";
1090
+ 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
+ };
1072
1092
  };
1073
1093
  /** A stable error code, as `FV0604`. */
1074
1094
  export type Code = keyof typeof ERRORS;
package/dist/errors.js CHANGED
@@ -18,6 +18,7 @@ export const AREAS = {
18
18
  "15": { name: "Raw HTML, client-only content, teleports and Rust twins", guide: "errors_and_limits" },
19
19
  "16": { name: "Provide and inject", guide: "provide_inject" },
20
20
  "17": { name: "Page head", guide: "head" },
21
+ "18": { name: "Dates and locale formatting", guide: "dates_and_prices" },
21
22
  };
22
23
  /** Every code the compiler raises. A code is never reused: one the compiler stops raising is marked
23
24
  * `retired` and stays listed. `pnpm errors:generate` writes the guide's index from this list. */
@@ -448,6 +449,26 @@ export const ERRORS = {
448
449
  detail: "unhead's server renderer throws on a `class` or `style` of `null`. Write `?? undefined`, which leaves the attribute out.",
449
450
  },
450
451
  FV1711: { title: "`innerHTML` or `textContent` on a head tag that has no content" },
452
+ FV1801: {
453
+ title: "The current time: `new Date()` or `Date.now()`",
454
+ 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).",
455
+ },
456
+ FV1802: {
457
+ title: "`new Date()` or `Date.parse()` of anything but one present string or number",
458
+ 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).",
459
+ },
460
+ FV1803: {
461
+ title: "Date string that browsers may read differently",
462
+ 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).",
463
+ },
464
+ FV1804: {
465
+ title: "`Date` method or function ferrovue does not translate",
466
+ 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).",
467
+ },
468
+ FV1805: {
469
+ title: "Locale formatting with `Intl`, `.toLocaleString()`, `.localeCompare()`, `$d` or `$n`",
470
+ 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).",
471
+ },
451
472
  };
452
473
  /** Where a code is documented. */
453
474
  export function docsUrl(code) {
package/dist/expr.js CHANGED
@@ -9,6 +9,7 @@ import { arithmetic, asF64, compare, intFromF64, isNumber, negatedOrder, numberV
9
9
  import { boolOf, checkNullTest, choice, known, narrowing, narrowTo, nullTest, pathOf, presence, truthy } from "./narrowing.js";
10
10
  import { call, isObjectCall } from "./calls.js";
11
11
  import { constOf, constVal } from "./constants.js";
12
+ import { localeRefusal } from "./plugins/dates.js";
12
13
  export function describeTy(ty) {
13
14
  switch (ty.k) {
14
15
  case "str":
@@ -295,6 +296,8 @@ export function expr(s, n) {
295
296
  }
296
297
  if (n.name === s.propsIdent)
297
298
  return { code: "props", ty: { k: "struct", name: "Props" } };
299
+ if (n.name === "Intl")
300
+ return localeRefusal(s, "`Intl`", n);
298
301
  return fail(comp, "FV0601", `\`${n.name}\` is not available when rendering on the server`, n);
299
302
  }
300
303
  case "MemberExpression": {
@@ -583,6 +586,9 @@ export function expr(s, n) {
583
586
  case "TSSatisfiesExpression":
584
587
  return stated(s, n);
585
588
  case "NewExpression": {
589
+ const own = claim((p) => p.construct?.(s, n));
590
+ if (own)
591
+ return own;
586
592
  const made = n.callee.type === "Identifier" ? n.callee.name : n.callee.type === "MemberExpression" && !n.callee.computed ? `${n.callee.object.name ?? "…"}.${n.callee.property.name}` : "…";
587
593
  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);
588
594
  }
package/dist/numbers.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BOOL, FLOAT, INT } from "./model.js";
2
- import { AS, binary, FLIPPED, operand, UNARY } from "./parens.js";
2
+ import { AS, atom, binary, FLIPPED, operand, UNARY } from "./parens.js";
3
3
  import { expr } from "./expr.js";
4
4
  export function isNumber(ty) {
5
5
  return ty.k === "int" || ty.k === "float";
@@ -63,6 +63,13 @@ export function compare(a, op, b) {
63
63
  const konst = fold(a.num, op, b.num);
64
64
  return { code: String(konst), ty: BOOL, konst };
65
65
  }
66
+ if (asF64(a) === asF64(b)) {
67
+ if (op === "<" || op === ">")
68
+ return { code: "false", ty: BOOL, konst: false };
69
+ if (a.ty.k === "int")
70
+ return { code: "true", ty: BOOL, konst: true };
71
+ return { code: `!${atom(asF64(a))}.is_nan()`, ty: BOOL };
72
+ }
66
73
  return { code: binary(asF64(a), op, asF64(b)), ty: BOOL };
67
74
  }
68
75
  export function negatedOrder(s, n) {
package/dist/parens.js CHANGED
@@ -178,7 +178,14 @@ export function strArg(code) {
178
178
  const branches = ifElse(code);
179
179
  if (branches)
180
180
  return `if ${branches.test} { ${strArg(branches.yes)} } else { ${strArg(branches.no)} }`;
181
+ if (/^\(\*\w+\)$/.test(code))
182
+ return bare(code);
181
183
  const m = /^&\*(\**)(.*)$/s.exec(code);
184
+ if (m && !m[1]) {
185
+ const lazy = /^\(\*(\w+)\)$/.exec(m[2])?.[1];
186
+ if (lazy !== undefined)
187
+ return `&${lazy}`;
188
+ }
182
189
  if (!m || (binding(m[2]) < ATOM && !enclosed(m[2])))
183
190
  return code;
184
191
  return m[1] ? m[2] : `&${m[2]}`;
package/dist/plugin.d.ts CHANGED
@@ -75,6 +75,8 @@ export interface Plugin<Run = unknown, Local = unknown> {
75
75
  };
76
76
  global?(s: Scope, name: string, n: N): Val | null;
77
77
  call?(s: Scope, n: N): Val | null;
78
+ construct?(s: Scope, n: N): Val | null;
79
+ method?(s: Scope, target: Val, method: string, args: N[], n: N): Val | null;
78
80
  member?(s: Scope, base: Val, prop: string, n: N, computed: boolean): Val | null;
79
81
  equality?(s: Scope, n: N): Val | null;
80
82
  presence?(s: Scope, n: N): Presence | null | undefined;
@@ -0,0 +1,19 @@
1
+ import { type N, type Scope } from "../model.ts";
2
+ import { type Plugin } from "../plugin.ts";
3
+ declare module "../model.ts" {
4
+ interface PluginTys {
5
+ date: {
6
+ k: "date";
7
+ };
8
+ }
9
+ }
10
+ /** The time value `Date.parse(s)` gives a string every JavaScript engine reads alike: one in
11
+ * ECMAScript's date time string format, a date alone or a date and time with a `Z` or an offset, its
12
+ * fields within their ranges. `undefined` for any other string, which an engine may read by rules of
13
+ * its own, or in the time zone of the machine running it, and for a date and time whose fields lie
14
+ * beyond ±8.64 × 10¹⁵ ms read as UTC while its offset brings it within, which Safari reads as an
15
+ * invalid date and the other engines as a valid one. */
16
+ export declare function portableTime(s: string): number | undefined;
17
+ /** The refusal of anything that formats by locale, which `Intl` decides from data each engine ships. */
18
+ export declare function localeRefusal(s: Scope, what: string, n: N): never;
19
+ export declare const dates: Plugin;
@@ -0,0 +1,148 @@
1
+ import { fail, FLOAT, STR } from "../model.js";
2
+ import {} from "../plugin.js";
3
+ import { bare, strArg } from "../parens.js";
4
+ import { asF64, numberVal } from "../numbers.js";
5
+ import { expr } from "../expr.js";
6
+ const GUIDE = "see the `dates_and_prices` guide";
7
+ const PORTABLE = /^([+-]\d{6}|\d{4})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{3})?)?(Z|[+-](\d{2}):(\d{2}))?)?$/;
8
+ function daysInMonth(year, month) {
9
+ if (month === 2)
10
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
11
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
12
+ }
13
+ /** The time value `Date.parse(s)` gives a string every JavaScript engine reads alike: one in
14
+ * ECMAScript's date time string format, a date alone or a date and time with a `Z` or an offset, its
15
+ * fields within their ranges. `undefined` for any other string, which an engine may read by rules of
16
+ * its own, or in the time zone of the machine running it, and for a date and time whose fields lie
17
+ * beyond ±8.64 × 10¹⁵ ms read as UTC while its offset brings it within, which Safari reads as an
18
+ * invalid date and the other engines as a valid one. */
19
+ export function portableTime(s) {
20
+ const m = PORTABLE.exec(s);
21
+ if (!m || m[1] === "-000000" || (m[4] !== undefined && m[7] === undefined))
22
+ return undefined;
23
+ const [year, month, day, hours, minutes, seconds, , zoneHours, zoneMinutes] = m.slice(1).map((x) => (x === undefined ? undefined : Number(x)));
24
+ const within = (x, low, high) => x === undefined || (x >= low && x <= high);
25
+ const fields = [within(month, 1, 12), within(day, 1, daysInMonth(year, month ?? 1)), within(hours, 0, 23), within(minutes, 0, 59), within(seconds, 0, 59), within(zoneHours, 0, 23), within(zoneMinutes, 0, 59)];
26
+ if (!fields.every(Boolean))
27
+ return undefined;
28
+ const t = Date.parse(s);
29
+ const zone = m[7];
30
+ if (zone !== undefined && zone !== "Z" && Number.isNaN(Date.parse(s.slice(0, -zone.length) + "Z")) && !Number.isNaN(t))
31
+ return undefined;
32
+ return t;
33
+ }
34
+ const LOCAL_GETTERS = new Map([
35
+ ["getFullYear", "getUTCFullYear"],
36
+ ["getYear", "getUTCFullYear"],
37
+ ["getMonth", "getUTCMonth"],
38
+ ["getDate", "getUTCDate"],
39
+ ["getDay", "getUTCDay"],
40
+ ["getHours", "getUTCHours"],
41
+ ["getMinutes", "getUTCMinutes"],
42
+ ["getSeconds", "getUTCSeconds"],
43
+ ["getMilliseconds", "getUTCMilliseconds"],
44
+ ]);
45
+ const UTC_GETTERS = new Map([
46
+ ["getUTCFullYear", "full_year"],
47
+ ["getUTCMonth", "month"],
48
+ ["getUTCDate", "date"],
49
+ ["getUTCDay", "day"],
50
+ ["getUTCHours", "hours"],
51
+ ["getUTCMinutes", "minutes"],
52
+ ["getUTCSeconds", "seconds"],
53
+ ["getUTCMilliseconds", "milliseconds"],
54
+ ]);
55
+ const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString", "localeCompare"]);
56
+ const DATE = { k: "date" };
57
+ function global(s, n, name) {
58
+ return n?.type === "Identifier" && n.name === name && !s.locals.has(name) && !s.setup.has(name);
59
+ }
60
+ function rootOf(n) {
61
+ while (n?.type === "MemberExpression" || n?.type === "CallExpression" || n?.type === "NewExpression")
62
+ n = n.type === "MemberExpression" ? n.object : n.callee;
63
+ return n;
64
+ }
65
+ /** The refusal of anything that formats by locale, which `Intl` decides from data each engine ships. */
66
+ export function localeRefusal(s, what, n) {
67
+ return fail(s.comp, "FV1805", `${what} formats by locale with \`Intl\`, whose output differs between Node, Chromium, Firefox and Safari: format the value in Rust and pass a string, or translate it with \`$t\` (${GUIDE})`, n);
68
+ }
69
+ function parsed(s, arg, what) {
70
+ if (arg.type === "StringLiteral") {
71
+ const t = portableTime(arg.value);
72
+ if (t === undefined) {
73
+ fail(s.comp, "FV1803", `${what} of ${JSON.stringify(arg.value)}, which is not a date alone nor a date and time with a \`Z\` or an offset in ECMAScript's date time string format, so each browser may read it differently (${GUIDE})`, arg);
74
+ }
75
+ return numberVal(t, false);
76
+ }
77
+ const v = expr(s, arg);
78
+ if (v.ty.k !== "str")
79
+ return fail(s.comp, "FV1802", `${what} takes a string that is present`, arg);
80
+ return { code: `fv::js_date_parse(${strArg(v.code)})`, ty: FLOAT };
81
+ }
82
+ function constructed(s, n) {
83
+ const args = n.arguments;
84
+ if (args.length === 0)
85
+ return fail(s.comp, "FV1801", `\`new Date()\` is the time it runs, which differs between the server's render and the browser's hydration: pass the time as a prop (${GUIDE})`, n);
86
+ if (args.length > 1) {
87
+ return fail(s.comp, "FV1802", `\`new Date(year, month, …)\` reads its parts in the time zone of the machine running it: pass an ISO string with a \`Z\` or an offset, or a number of milliseconds (${GUIDE})`, n);
88
+ }
89
+ if (args[0].type === "StringLiteral")
90
+ return { code: parsed(s, args[0], "`new Date()`").code, ty: DATE };
91
+ const v = expr(s, args[0]);
92
+ if (v.ty.k === "date")
93
+ return v;
94
+ if (v.ty.k === "str")
95
+ return { code: `fv::js_date_parse(${strArg(v.code)})`, ty: DATE };
96
+ if (v.ty.k === "int" || v.ty.k === "float")
97
+ return { code: `fv::js_time_clip(${bare(asF64(v))})`, ty: DATE };
98
+ return fail(s.comp, "FV1802", `\`new Date()\` takes one string or one number that is present (${GUIDE})`, args[0]);
99
+ }
100
+ export const dates = {
101
+ name: "dates",
102
+ construct(s, n) {
103
+ if (global(s, rootOf(n.callee), "Intl"))
104
+ return localeRefusal(s, "`Intl`", n);
105
+ return global(s, n.callee, "Date") ? constructed(s, n) : null;
106
+ },
107
+ call(s, n) {
108
+ const callee = n.callee;
109
+ if (callee.type !== "MemberExpression" || callee.computed)
110
+ return null;
111
+ const method = callee.property.name;
112
+ const object = callee.object;
113
+ if (object.type === "Identifier" && object.name === "_ctx" && (method === "$d" || method === "$n"))
114
+ return localeRefusal(s, `vue-i18n's \`${method}\``, n);
115
+ const own = object.type === "Identifier" && (object.name === "_ctx" || object.name === "$setup");
116
+ if (!own && LOCALE_METHODS.has(method))
117
+ return localeRefusal(s, `\`.${method}()\``, n);
118
+ if (!global(s, object, "Date"))
119
+ return null;
120
+ if (method === "now")
121
+ return fail(s.comp, "FV1801", `\`Date.now()\` is the time it runs, which differs between the server's render and the browser's hydration: pass the time as a prop (${GUIDE})`, n);
122
+ if (method === "parse" && n.arguments.length === 1)
123
+ return parsed(s, n.arguments[0], "`Date.parse()`");
124
+ return fail(s.comp, "FV1804", `\`Date.${method}()\` is not supported: build a date with \`new Date()\` of an ISO string or a number of milliseconds (${GUIDE})`, n);
125
+ },
126
+ method(s, target, method, args, n) {
127
+ if (target.ty.k !== "date")
128
+ return null;
129
+ const t = bare(target.code);
130
+ const plain = args.length === 0;
131
+ if (plain && method === "toISOString")
132
+ return { code: `&*fv::js_to_iso_string(${t})`, ty: STR };
133
+ if (plain && (method === "getTime" || method === "valueOf"))
134
+ return { code: target.code, ty: FLOAT };
135
+ const utc = UTC_GETTERS.get(method);
136
+ if (plain && utc !== undefined)
137
+ return { code: `fv::js_utc_${utc}(${t})`, ty: FLOAT };
138
+ if (LOCAL_GETTERS.has(method) || method === "getTimezoneOffset") {
139
+ const instead = LOCAL_GETTERS.get(method);
140
+ return fail(s.comp, "FV1804", `\`.${method}()\` reads the date in the time zone of the machine running it, which differs between the server and the reader's browser${instead ? `: use \`.${instead}()\`` : ""} (${GUIDE})`, n);
141
+ }
142
+ return fail(s.comp, "FV1804", `\`.${method}(${plain ? "" : "…"})\` on a \`Date\` is not supported: a date is read with \`.toISOString()\`, \`.getTime()\` and the \`.getUTC…()\` methods (${GUIDE})`, n);
143
+ },
144
+ values: {
145
+ describe: (ty) => (ty.k === "date" ? "a `Date`" : undefined),
146
+ unbindable: (ty) => ty.k === "date" ? { what: "a `Date`", fix: "bind `.toISOString()`, or format it in Rust and pass a string" } : undefined,
147
+ },
148
+ };
@@ -67,7 +67,7 @@ function messageSource(file, key, ast) {
67
67
  }
68
68
  };
69
69
  const items = (c) => c.items ?? (c.static !== undefined ? [{ type: 3, value: c.static }] : []);
70
- return `Message { cases: &[${cases.map((c) => `&[${items(c).map(part).join(", ")}]`).join(", ")}] }`;
70
+ return `Message::new(&[${cases.map((c) => `&[${items(c).map(part).join(", ")}]`).join(", ")}])`;
71
71
  }
72
72
  function byteOrder(a, b) {
73
73
  return Buffer.compare(Buffer.from(a), Buffer.from(b));
@@ -76,13 +76,12 @@ function i18nSource(setup) {
76
76
  const locales = setup.locales
77
77
  .map((l) => {
78
78
  const keys = [...l.messages.keys()].toSorted(byteOrder);
79
- const entries = keys.map((k) => ` (${rustStr(k)}, ${messageSource(l.file, k, l.messages.get(k))}),`).join("\n");
80
- return ` Locale {
81
- name: ${rustStr(l.name)},
82
- messages: &[
79
+ if (keys.length === 0)
80
+ return ` Locale::new(${rustStr(l.name)}, &[]),`;
81
+ const entries = keys.map((k) => ` (${rustStr(k)}, ${messageSource(l.file, k, l.messages.get(k))}),`).join("\n");
82
+ return ` Locale::new(${rustStr(l.name)}, &[
83
83
  ${entries}
84
- ],
85
- },`;
84
+ ]),`;
86
85
  })
87
86
  .join("\n");
88
87
  return `${header(setup.dir.replace(/\/?$/, "/"), "the locale files")}
@@ -130,9 +129,9 @@ function translate(s, args, n) {
130
129
  const key = expr(s, args[0]);
131
130
  if (key.ty.k !== "str")
132
131
  fail(s.comp, "FV1409", "the key given to `t()` is a string", args[0]);
133
- let named = "&[]";
134
- let list = "&[]";
135
- let plural = "None";
132
+ let named = "";
133
+ let list = "";
134
+ let plural = "";
136
135
  for (const a of args.slice(1)) {
137
136
  if (a.type === "ObjectExpression") {
138
137
  const pairs = a.properties.map((p) => {
@@ -140,10 +139,10 @@ function translate(s, args, n) {
140
139
  fail(s.comp, "FV1410", "named values hold plain keys", p);
141
140
  return `(${rustStr(String(p.key.name ?? p.key.value))}, ${i18nValue(s, p.value)})`;
142
141
  });
143
- named = `&[${pairs.join(", ")}]`;
142
+ named = `.with_named(&[${pairs.join(", ")}])`;
144
143
  }
145
144
  else if (a.type === "ArrayExpression") {
146
- list = `&[${a.elements.map((el) => i18nValue(s, el)).join(", ")}]`;
145
+ list = `.with_list(&[${a.elements.map((el) => i18nValue(s, el)).join(", ")}])`;
147
146
  }
148
147
  else {
149
148
  const v = expr(s, a);
@@ -151,10 +150,10 @@ function translate(s, args, n) {
151
150
  fail(s.comp, "FV1411", "a default message given to `t()` is not supported: add the message to the locale files", a);
152
151
  if (v.ty.k !== "int")
153
152
  fail(s.comp, "FV1412", "the plural number given to `t()` is an integer", a);
154
- plural = `Some(${bare(v.code)})`;
153
+ plural = `.with_plural(${bare(v.code)})`;
155
154
  }
156
155
  }
157
- return { code: `&*fv_i18n.t(${strArg(key.code)}, &fv::i18n::Args { named: ${named}, list: ${list}, plural: ${plural} })`, ty: STR };
156
+ return { code: `&*fv_i18n.t(${strArg(key.code)}, &fv::i18n::Args::new()${named}${list}${plural})`, ty: STR };
158
157
  }
159
158
  export const i18n = {
160
159
  name: "i18n",
@@ -193,6 +192,9 @@ export const i18n = {
193
192
  s.setup.set(p.value.name, { code: "fv_i18n.locale()", ty: STR });
194
193
  s.refs.add(p.value.name);
195
194
  }
195
+ else if (key === "d" || key === "n") {
196
+ s.clientOnly.set(p.value.name, `\`${key}\` from \`useI18n()\` formats by locale with \`Intl\`, whose output differs between Node and each browser (see the \`dates_and_prices\` guide)`);
197
+ }
196
198
  else
197
199
  s.clientOnly.set(p.value.name, `\`${key}\` from \`useI18n()\` does not run on the server`);
198
200
  }
@@ -10,4 +10,5 @@ import { twinsPlugin } from "./twins.js";
10
10
  import { provideInject } from "./provide.js";
11
11
  import { head } from "./head.js";
12
12
  import { cssModules } from "./css-modules.js";
13
- export const PLUGINS = [router, piniaStores, sharedTypes, i18n, teleport, scoped, clientOnly, twinsPlugin, provideInject, head, cssModules];
13
+ import { dates } from "./dates.js";
14
+ export const PLUGINS = [router, piniaStores, sharedTypes, i18n, teleport, scoped, clientOnly, twinsPlugin, provideInject, head, cssModules, dates];
@@ -109,9 +109,10 @@ function routeDefs(routes, depth) {
109
109
  const pad = " ".repeat(depth);
110
110
  return routes
111
111
  .map((r) => {
112
- const name = r.name === undefined ? "None" : `Some(${rustStr(r.name)})`;
113
- const children = r.children?.length ? `&[\n${routeDefs(r.children, depth + 1)}\n${pad}]` : "&[]";
114
- return `${pad}ferrovue::RouteDef { path: ${rustStr(r.path)}, name: ${name}, view: ${r.view !== false}, children: ${children} },`;
112
+ const name = r.name === undefined ? "" : `.with_name(${rustStr(r.name)})`;
113
+ const view = r.view === false ? ".with_view(false)" : "";
114
+ const children = r.children?.length ? `.with_children(&[\n${routeDefs(r.children, depth + 1)}\n${pad}])` : "";
115
+ return `${pad}ferrovue::RouteDef::new(${rustStr(r.path)})${name}${view}${children},`;
115
116
  })
116
117
  .join("\n");
117
118
  }
package/dist/rust.d.ts CHANGED
@@ -7,7 +7,7 @@ export declare function structSource(st: Struct, comp: Component, doc: string):
7
7
  /** The version of generated code this compiler writes, which `mod.rs` hands `ferrovue::__compat!`:
8
8
  * the highest version the `ferrovue` crate of the same release supports. RELEASING.md says when
9
9
  * it changes. */
10
- export declare const GENERATED_VERSION = 1;
10
+ export declare const GENERATED_VERSION = 2;
11
11
  /** How every file ferrovue writes begins. */
12
12
  export declare const GENERATED = "// @generated by ferrovue";
13
13
  export declare function header(source: string, edit?: string): string;
package/dist/rust.js CHANGED
@@ -7,7 +7,7 @@ import { childOf } from "./expr.js";
7
7
  import { Emitter } from "./emitter.js";
8
8
  import { occurrences } from "./parens.js";
9
9
  import { statements } from "./template.js";
10
- import { inlineCssVars } from "./styles.js";
10
+ import { cssVarsReads, inlineCssVars } from "./styles.js";
11
11
  import { slotFieldBorrows, slotFieldTy, slotTypeName } from "./slots.js";
12
12
  import { extraParams, fieldInit, takesSlots } from "./children.js";
13
13
  import { paramsOf, renderParams, slotContextOf, slotFieldsOf } from "./plugin.js";
@@ -141,7 +141,7 @@ ${ctx.builders ? `\n${builderSource(st, comp, life)}\n` : ""}`;
141
141
  /** The version of generated code this compiler writes, which `mod.rs` hands `ferrovue::__compat!`:
142
142
  * the highest version the `ferrovue` crate of the same release supports. RELEASING.md says when
143
143
  * it changes. */
144
- export const GENERATED_VERSION = 1;
144
+ export const GENERATED_VERSION = 2;
145
145
  /** How every file ferrovue writes begins. */
146
146
  export const GENERATED = "// @generated by ferrovue";
147
147
  export function header(source, edit = "the `.vue` file") {
@@ -222,7 +222,7 @@ export function componentSource(comp, ast, ssr, components) {
222
222
  if (takesAttrs(comp) && comp.inheritAttrs && comp.attrsDropped) {
223
223
  fail(comp, "FV0416", `a root \`<Transition>\` or \`<KeepAlive>\` around a \`v-if\` in ${comp.name}, which a parent passes attributes: Vue's server drops them, where its client puts them on the element; set \`inheritAttrs: false\` and bind \`$attrs\` on the element`, comp.attrsDropped);
224
224
  }
225
- const { scope, lets } = scopeFor(comp, ast, components);
225
+ const { scope, lets, lazies } = scopeFor(comp, ast, components);
226
226
  const program = parseJs(ssr, { sourceType: "module" }).program;
227
227
  tagAst(program, "template");
228
228
  const fn = program.body.find((s) => s.type === "ExportNamedDeclaration" && s.declaration?.id?.name === "ssrRender")?.declaration;
@@ -230,7 +230,8 @@ export function componentSource(comp, ast, ssr, components) {
230
230
  fail(comp, "FV0006", "the compiled template has no `ssrRender`");
231
231
  const e = new Emitter();
232
232
  const preludes = ctx.plugins.flatMap((p) => (p.prelude ? [p.prelude(scope)] : []));
233
- const opening = [...preludes.flatMap((p) => p.before), ...lets, ...preludes.flatMap((p) => p.after)];
233
+ const forced = [...cssVarsReads(fn.body.body)].flatMap((name) => (lazies.has(name) ? [`std::cell::LazyCell::force(&${lazies.get(name)});`] : []));
234
+ const opening = [...preludes.flatMap((p) => p.before), ...lets, ...forced, ...preludes.flatMap((p) => p.after)];
234
235
  for (const l of opening)
235
236
  e.stmt(l);
236
237
  statements(scope, e, inlineCssVars(comp, fn.body.body));
package/dist/script.d.ts CHANGED
@@ -10,4 +10,5 @@ export declare function asyncChildren(comp: Component, ast: N[]): Map<string, st
10
10
  export declare function scopeFor(comp: Component, ast: N[], components: Map<string, Component>): {
11
11
  scope: Scope;
12
12
  lets: string[];
13
+ lazies: Map<string, string>;
13
14
  };
package/dist/script.js CHANGED
@@ -6,7 +6,7 @@ import { constOfDecl, declareConsts } from "./constants.js";
6
6
  import { localTy } from "./rust.js";
7
7
  import { expr, fieldVal, typedBy } from "./expr.js";
8
8
  import { collected, heldList } from "./lists.js";
9
- import { bare, operand, UNARY } from "./parens.js";
9
+ import { bare, occurrences, operand, UNARY } from "./parens.js";
10
10
  import { known } from "./narrowing.js";
11
11
  import { heldStr, isTemporary } from "./strings.js";
12
12
  import { intFromF64 } from "./numbers.js";
@@ -126,6 +126,36 @@ export function asyncChildren(comp, ast) {
126
126
  }
127
127
  return found;
128
128
  }
129
+ const PANICS = /\bfv::js_(?:repeat|pad_start|pad_end|date_parse|to_iso_string)\(|\bfv_i18n\.t\(/;
130
+ function mayPanic(scope, lazies, init) {
131
+ if (PANICS.test(init))
132
+ return true;
133
+ if ([...scope.helpers.values()].some((h) => init.includes(`${ctx.helpers[h].rust}(`)))
134
+ return true;
135
+ return [...lazies.values()].some((name) => occurrences(init, name) > 0);
136
+ }
137
+ function binding(v) {
138
+ const lone = v.lone ? { lone: true } : {};
139
+ if (v.slice)
140
+ return { init: bare(v.code), read: (at) => ({ code: at, ty: v.ty, slice: true, ...lone }) };
141
+ if (v.ty.k === "list" && v.iter !== undefined)
142
+ return { init: collected(v), read: (at) => heldList(at, v.ty.k === "list" ? v.ty.of : v.ty, v.lone) };
143
+ if (v.held !== undefined)
144
+ return { init: v.held, read: (at) => ({ code: `${at}.as_deref()`, ty: v.ty, ...lone }) };
145
+ if (v.ty.k === "int" && v.f64 !== undefined && (v.code.endsWith(" as i64") || Object.is(v.num, -0))) {
146
+ return {
147
+ init: bare(v.f64),
148
+ read: (at) => ({ ...intFromF64(at), ...lone, ...(v.num !== undefined ? { num: v.num } : {}), ...(v.konst !== undefined ? { konst: v.konst } : {}) }),
149
+ };
150
+ }
151
+ if (isTemporary(v))
152
+ return { init: heldStr(v), read: (at) => ({ code: `&*${at}`, ty: v.ty, ...lone }) };
153
+ const place = v.ty.k === "list" || v.ty.k === "record" || v.ty.k === "struct" || v.ty.k === "child";
154
+ return {
155
+ init: place ? `&${operand(v.code, UNARY)}` : bare(v.code),
156
+ read: (at) => ({ code: at, ty: v.ty, ...lone, ...(v.konst !== undefined ? { konst: v.konst } : {}) }),
157
+ };
158
+ }
129
159
  export function scopeFor(comp, ast, components) {
130
160
  const scope = {
131
161
  comp,
@@ -168,6 +198,7 @@ export function scopeFor(comp, ast, components) {
168
198
  };
169
199
  const locals = declareConsts(comp, ast);
170
200
  const lets = [];
201
+ const lazies = new Map();
171
202
  const asyncs = asyncChildren(comp, ast);
172
203
  let useAttrsName = null;
173
204
  let useSlotsName = null;
@@ -284,6 +315,7 @@ export function scopeFor(comp, ast, components) {
284
315
  const isRef = init0.type === "CallExpression" && init0.callee.type === "Identifier" && ["ref", "shallowRef", "computed"].includes(init0.callee.name);
285
316
  if (isRef)
286
317
  scope.refs.add(local);
318
+ const isComputed = isRef && init0.callee.name === "computed";
287
319
  const typed = isRef ? init0.typeParameters?.params?.[0] : (d.id.typeAnnotation?.typeAnnotation ?? statedTy(init0));
288
320
  const untyped = setupSource(init0);
289
321
  const source = untyped && typed ? typedBy(untyped, typed) : untyped;
@@ -299,7 +331,7 @@ export function scopeFor(comp, ast, components) {
299
331
  catch (e) {
300
332
  if (!(e instanceof GenError))
301
333
  throw e;
302
- scope.clientOnly.set(local, e.message.replace(/^[^:]*: /, ""));
334
+ scope.clientOnly.set(local, e.what);
303
335
  continue;
304
336
  }
305
337
  if (nothing(v.ty) || (v.ty.k === "str" && known(v) !== undefined)) {
@@ -307,36 +339,13 @@ export function scopeFor(comp, ast, components) {
307
339
  continue;
308
340
  }
309
341
  const name = `s_${snake(local).replace(/^r#/, "")}`;
310
- const lone = v.lone ? { lone: true } : {};
311
- if (v.slice) {
312
- lets.push(`let ${name} = ${bare(v.code)};`);
313
- scope.setup.set(local, { code: name, ty: v.ty, slice: true, ...lone });
314
- continue;
315
- }
316
- if (v.ty.k === "list" && v.iter !== undefined) {
317
- lets.push(`let ${name} = ${collected(v)};`);
318
- scope.setup.set(local, heldList(name, v.ty.of, v.lone));
319
- continue;
320
- }
321
- if (v.held !== undefined) {
322
- lets.push(`let ${name} = ${v.held};`);
323
- scope.setup.set(local, { code: `${name}.as_deref()`, ty: v.ty, ...lone });
324
- continue;
325
- }
326
- if (v.ty.k === "int" && v.f64 !== undefined && (v.code.endsWith(" as i64") || Object.is(v.num, -0))) {
327
- lets.push(`let ${name} = ${bare(v.f64)};`);
328
- scope.setup.set(local, { ...intFromF64(name), ...lone, ...(v.num !== undefined ? { num: v.num } : {}), ...(v.konst !== undefined ? { konst: v.konst } : {}) });
329
- continue;
330
- }
331
- if (isTemporary(v)) {
332
- lets.push(`let ${name} = ${heldStr(v)};`);
333
- scope.setup.set(local, { code: `&*${name}`, ty: v.ty, ...lone });
334
- continue;
335
- }
336
- const place = v.ty.k === "list" || v.ty.k === "record" || v.ty.k === "struct" || v.ty.k === "child";
337
- lets.push(`let ${name} = ${place ? `&${operand(v.code, UNARY)}` : bare(v.code)};`);
338
- scope.setup.set(local, { code: name, ty: v.ty, ...lone, ...(v.konst !== undefined ? { konst: v.konst } : {}) });
342
+ const { init, read } = binding(v);
343
+ const lazy = isComputed && mayPanic(scope, lazies, init);
344
+ lets.push(lazy ? `let ${name} = std::cell::LazyCell::new(|| ${init});` : `let ${name} = ${init};`);
345
+ scope.setup.set(local, read(lazy ? `(*${name})` : name));
346
+ if (lazy)
347
+ lazies.set(local, name);
339
348
  }
340
349
  }
341
- return { scope, lets };
350
+ return { scope, lets, lazies };
342
351
  }
package/dist/strings.js CHANGED
@@ -30,7 +30,7 @@ export function isTemporary(v) {
30
30
  const trimmed = /^fv::js_trim(?:_start|_end)?\(([^]*)\)$/.exec(v.code);
31
31
  if (trimmed?.[1] !== undefined)
32
32
  return isTemporary({ code: trimmed[1].replace(/^&(?!\*)/, "&*"), ty: v.ty });
33
- return /^&\*(?!\**[\w.]+$)/.test(v.code) || /\.unwrap_or\(&\*(?!\**[\w.]+\)$)/.test(v.code);
33
+ return /^&\*(?!\**[\w.]+$|\(\*\w+\)$)/.test(v.code) || /\.unwrap_or\(&\*(?!\**[\w.]+\)$)/.test(v.code);
34
34
  }
35
35
  export function asCow(v) {
36
36
  if (!isTemporary(v))
package/dist/styles.d.ts CHANGED
@@ -4,6 +4,9 @@ import { Emitter } from "./emitter.ts";
4
4
  * `<style>`, in place of each use of it in the compiled template's statements, with each value
5
5
  * marked to be written as `ssrRenderStyle` writes a variable. */
6
6
  export declare function inlineCssVars(comp: Component, body: N[]): N[];
7
+ /** The setup bindings the `_cssVars` object reads, which Vue's server evaluates as its render
8
+ * starts. */
9
+ export declare function cssVarsReads(body: N[]): Set<string>;
7
10
  export declare function mergedStyle(s: Scope, e: Emitter, n: N): void;
8
11
  export declare function styleAttr(s: Scope, n: N): string;
9
12
  export interface StyleItem {
package/dist/styles.js CHANGED
@@ -72,6 +72,28 @@ export function inlineCssVars(comp, body) {
72
72
  swap(rest);
73
73
  return rest;
74
74
  }
75
+ /** The setup bindings the `_cssVars` object reads, which Vue's server evaluates as its render
76
+ * starts. */
77
+ export function cssVarsReads(body) {
78
+ const names = new Set();
79
+ const declared = body.find((st) => st.type === "VariableDeclaration" && st.declarations[0]?.id?.name === "_cssVars");
80
+ const walk = (n) => {
81
+ if (!n || typeof n !== "object")
82
+ return;
83
+ if (Array.isArray(n))
84
+ return n.forEach(walk);
85
+ if (n.type === "MemberExpression" && n.object.type === "Identifier" && (n.object.name === "$setup" || n.object.name === "_ctx")) {
86
+ const name = n.computed ? n.property.value : n.property.name;
87
+ if (typeof name === "string")
88
+ names.add(name);
89
+ }
90
+ for (const [k, v] of Object.entries(n))
91
+ if (k !== "loc" && v && typeof v === "object")
92
+ walk(v);
93
+ };
94
+ walk(declared?.declarations[0].init);
95
+ return names;
96
+ }
75
97
  function cssVarOf(s, n) {
76
98
  const v = expr(s, n.value);
77
99
  const of = v.ty.k === "opt" ? v.ty.of : v.ty;
package/dist/vite.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readdirSync, readFileSync } from "node:fs";
2
- import { join, resolve, sep } from "node:path";
2
+ import { join, posix, resolve, sep } from "node:path";
3
3
  import { CONFIG_FILE, loadConfig, write } from "./compiler.js";
4
4
  import { formatRefusal, formatWarning, isRefusal } from "./diagnostics.js";
5
5
  import { allPages, fileRoutes, pagesFolder } from "./file-routes.js";
@@ -130,6 +130,28 @@ ${routesTree(root, routes, " ", false)}
130
130
  ];
131
131
  `;
132
132
  }
133
+ function islandsFile({ consumer, build }) {
134
+ const { manifest } = build;
135
+ if (!manifest || consumer !== "client")
136
+ return null;
137
+ const written = typeof manifest === "string" ? manifest : ".vite/manifest.json";
138
+ return posix.join(posix.dirname(written), "ferrovue-islands.json");
139
+ }
140
+ function islandsJson(root, islands, bundle) {
141
+ const names = new Map(Object.entries(islands).map(([name, file]) => [resolve(root, file), name]));
142
+ const files = {};
143
+ for (const chunk of bundle) {
144
+ if (chunk.type !== "chunk")
145
+ continue;
146
+ for (const id of chunk.moduleIds ?? []) {
147
+ const name = names.get(resolve(id));
148
+ if (name !== undefined)
149
+ files[name] = chunk.fileName;
150
+ }
151
+ }
152
+ const sorted = Object.fromEntries(Object.entries(files).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
153
+ return `${JSON.stringify(sorted, null, 2)}\n`;
154
+ }
133
155
  /** The Rust renderers regenerated by a Vite build and its dev server, and the `ferrovue/islands`
134
156
  * and `ferrovue/routes` modules. */
135
157
  export default function ferrovue(options = {}) {
@@ -199,6 +221,12 @@ export default function ferrovue(options = {}) {
199
221
  throw e;
200
222
  }
201
223
  },
224
+ generateBundle(_, bundle) {
225
+ const manifest = islandsFile(this.environment.config);
226
+ if (manifest === null || islands === null)
227
+ return;
228
+ this.emitFile({ type: "asset", fileName: manifest, source: islandsJson(root, islands, Object.values(bundle)) });
229
+ },
202
230
  configureServer(server) {
203
231
  if (!inputs) {
204
232
  let config = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferrovue",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Vue templates compiled to Rust render functions: server-side rendering with no JavaScript at run time",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -77,11 +77,11 @@
77
77
  },
78
78
  "dependencies": {
79
79
  "@babel/parser": "^7.29.9",
80
- "@intlify/message-compiler": "11.4.13",
81
- "@vue/compiler-sfc": "3.5.43",
82
- "@vue/shared": "3.5.43",
80
+ "@intlify/message-compiler": "11.4.15",
81
+ "@vue/compiler-sfc": "3.5.44",
82
+ "@vue/shared": "3.5.44",
83
83
  "generic-names": "^4.0.0",
84
- "postcss": "^8.5.28",
84
+ "postcss": "^8.5.30",
85
85
  "postcss-modules-extract-imports": "^3.1.0",
86
86
  "postcss-modules-local-by-default": "^4.2.0",
87
87
  "postcss-modules-scope": "^3.2.1",
@@ -89,30 +89,30 @@
89
89
  "source-map-js": "^1.2.2"
90
90
  },
91
91
  "devDependencies": {
92
- "@unhead/vue": "^3.4.2",
93
- "@vue/language-core": "3.3.12",
94
- "@vue/language-service": "3.3.12",
95
- "@vue/server-renderer": "3.5.43",
96
- "pinia": "^4.0.3",
97
- "playwright": "1.63.0",
92
+ "@unhead/vue": "^3.4.3",
93
+ "@vue/language-core": "3.3.13",
94
+ "@vue/language-service": "3.3.13",
95
+ "@vue/server-renderer": "3.5.44",
96
+ "pinia": "^4.1.0",
97
+ "playwright": "1.64.1",
98
98
  "typescript": "^7.0.2",
99
99
  "typescript-js": "npm:typescript@6.0.3",
100
- "vite": "^8.3.0",
101
- "vitest": "^5.0.1",
102
- "vscode-uri": "3.1.0",
103
- "vue": "3.5.43",
104
- "vue-i18n": "11.4.13",
105
- "vue-router": "^5.3.1"
100
+ "vite": "^8.3.4",
101
+ "vitest": "^5.0.5",
102
+ "vscode-uri": "3.2.0",
103
+ "vue": "3.5.44",
104
+ "vue-i18n": "11.4.15",
105
+ "vue-router": "^5.4.0"
106
106
  },
107
107
  "peerDependencies": {
108
- "@unhead/vue": "~3.4.2",
108
+ "@unhead/vue": "~3.4.3",
109
109
  "@vue/language-core": "^3.3.0",
110
- "pinia": "~4.0.3",
110
+ "pinia": "~4.1.0",
111
111
  "vite": ">=6",
112
112
  "vitest": ">=5",
113
- "vue": "~3.5.43",
114
- "vue-i18n": "~11.4.13",
115
- "vue-router": "~5.3.1"
113
+ "vue": "~3.5.44",
114
+ "vue-i18n": "~11.4.15",
115
+ "vue-router": "~5.4.0"
116
116
  },
117
117
  "peerDependenciesMeta": {
118
118
  "@unhead/vue": {