ferrovue 0.7.2 → 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 +23 -11
- package/dist/calls.js +5 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +21 -0
- package/dist/expr.js +6 -0
- package/dist/numbers.js +8 -1
- package/dist/parens.js +7 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugins/dates.d.ts +19 -0
- package/dist/plugins/dates.js +148 -0
- package/dist/plugins/i18n.js +16 -14
- package/dist/plugins/index.js +2 -1
- package/dist/plugins/router.js +4 -3
- package/dist/rust.d.ts +1 -1
- package/dist/rust.js +5 -4
- package/dist/script.d.ts +1 -0
- package/dist/script.js +41 -32
- package/dist/strings.js +1 -1
- package/dist/styles.d.ts +3 -0
- package/dist/styles.js +22 -0
- package/package.json +20 -20
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`
|
|
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.
|
|
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.
|
|
787
|
-
| `vue-router` (optional) | `~5.
|
|
788
|
-
| `pinia` (optional) | `~4.0
|
|
789
|
-
| `vue-i18n` (optional) | `~11.4.
|
|
790
|
-
| `@unhead/vue` (optional) | `~3.4.
|
|
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.
|
|
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
|
|
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
|
+
};
|
package/dist/plugins/i18n.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 = "
|
|
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 =
|
|
142
|
+
named = `.with_named(&[${pairs.join(", ")}])`;
|
|
144
143
|
}
|
|
145
144
|
else if (a.type === "ArrayExpression") {
|
|
146
|
-
list =
|
|
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 =
|
|
153
|
+
plural = `.with_plural(${bare(v.code)})`;
|
|
155
154
|
}
|
|
156
155
|
}
|
|
157
|
-
return { code: `&*fv_i18n.t(${strArg(key.code)}, &fv::i18n::Args
|
|
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
|
}
|
package/dist/plugins/index.js
CHANGED
|
@@ -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
|
-
|
|
13
|
+
import { dates } from "./dates.js";
|
|
14
|
+
export const PLUGINS = [router, piniaStores, sharedTypes, i18n, teleport, scoped, clientOnly, twinsPlugin, provideInject, head, cssModules, dates];
|
package/dist/plugins/router.js
CHANGED
|
@@ -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 ? "
|
|
113
|
-
const
|
|
114
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
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.
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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.]
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferrovue",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
81
|
-
"@vue/compiler-sfc": "3.5.
|
|
82
|
-
"@vue/shared": "3.5.
|
|
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.
|
|
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.
|
|
93
|
-
"@vue/language-core": "3.3.
|
|
94
|
-
"@vue/language-service": "3.3.
|
|
95
|
-
"@vue/server-renderer": "3.5.
|
|
96
|
-
"pinia": "^4.0
|
|
97
|
-
"playwright": "1.64.
|
|
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
100
|
"vite": "^8.3.4",
|
|
101
|
-
"vitest": "^5.0.
|
|
101
|
+
"vitest": "^5.0.5",
|
|
102
102
|
"vscode-uri": "3.2.0",
|
|
103
|
-
"vue": "3.5.
|
|
104
|
-
"vue-i18n": "11.4.
|
|
105
|
-
"vue-router": "^5.
|
|
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.
|
|
108
|
+
"@unhead/vue": "~3.4.3",
|
|
109
109
|
"@vue/language-core": "^3.3.0",
|
|
110
|
-
"pinia": "~4.0
|
|
110
|
+
"pinia": "~4.1.0",
|
|
111
111
|
"vite": ">=6",
|
|
112
112
|
"vitest": ">=5",
|
|
113
|
-
"vue": "~3.5.
|
|
114
|
-
"vue-i18n": "~11.4.
|
|
115
|
-
"vue-router": "~5.
|
|
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": {
|