ferrovue 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +225 -42
  2. package/dist/attrs.d.ts +3 -53
  3. package/dist/attrs.js +41 -469
  4. package/dist/calls.d.ts +4 -0
  5. package/dist/calls.js +221 -0
  6. package/dist/children.d.ts +16 -0
  7. package/dist/children.js +286 -0
  8. package/dist/classes.d.ts +13 -0
  9. package/dist/classes.js +176 -0
  10. package/dist/cli.js +31 -22
  11. package/dist/client.d.ts +35 -2
  12. package/dist/client.js +77 -14
  13. package/dist/compiler.d.ts +1 -1
  14. package/dist/compiler.js +31 -92
  15. package/dist/component.d.ts +3 -1
  16. package/dist/component.js +123 -104
  17. package/dist/constants.d.ts +42 -0
  18. package/dist/constants.js +292 -0
  19. package/dist/context.d.ts +29 -67
  20. package/dist/context.js +13 -38
  21. package/dist/diagnostics.d.ts +23 -0
  22. package/dist/diagnostics.js +30 -0
  23. package/dist/diff.d.ts +0 -3
  24. package/dist/diff.js +0 -9
  25. package/dist/emitter.d.ts +0 -8
  26. package/dist/emitter.js +0 -12
  27. package/dist/errors.d.ts +867 -0
  28. package/dist/errors.js +330 -0
  29. package/dist/expr.d.ts +1 -70
  30. package/dist/expr.js +142 -934
  31. package/dist/fallthrough.d.ts +9 -0
  32. package/dist/fallthrough.js +78 -0
  33. package/dist/fixture.d.ts +4 -10
  34. package/dist/fixture.js +38 -27
  35. package/dist/hydration.d.ts +15 -0
  36. package/dist/hydration.js +80 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.js +2 -0
  39. package/dist/islands.d.ts +1 -0
  40. package/dist/islands.js +1 -0
  41. package/dist/link-router.d.ts +24 -0
  42. package/dist/link-router.js +36 -0
  43. package/dist/lists.d.ts +0 -11
  44. package/dist/lists.js +34 -60
  45. package/dist/loops.d.ts +3 -0
  46. package/dist/loops.js +168 -0
  47. package/dist/model.d.ts +46 -144
  48. package/dist/model.js +150 -18
  49. package/dist/narrowing.d.ts +27 -0
  50. package/dist/narrowing.js +157 -0
  51. package/dist/numbers.d.ts +8 -0
  52. package/dist/numbers.js +73 -0
  53. package/dist/parens.d.ts +0 -27
  54. package/dist/parens.js +0 -44
  55. package/dist/plugin.d.ts +84 -0
  56. package/dist/plugin.js +26 -0
  57. package/dist/plugins/client-only.d.ts +8 -0
  58. package/dist/plugins/client-only.js +107 -0
  59. package/dist/plugins/i18n.d.ts +23 -0
  60. package/dist/{i18n.js → plugins/i18n.js} +88 -31
  61. package/dist/plugins/index.d.ts +2 -0
  62. package/dist/plugins/index.js +10 -0
  63. package/dist/plugins/router-link.d.ts +8 -0
  64. package/dist/{router.js → plugins/router-link.js} +52 -91
  65. package/dist/plugins/router.d.ts +42 -0
  66. package/dist/plugins/router.js +256 -0
  67. package/dist/plugins/scoped.d.ts +12 -0
  68. package/dist/plugins/scoped.js +125 -0
  69. package/dist/plugins/shared-types.d.ts +2 -0
  70. package/dist/plugins/shared-types.js +7 -0
  71. package/dist/plugins/stores.d.ts +37 -0
  72. package/dist/plugins/stores.js +379 -0
  73. package/dist/plugins/teleport.d.ts +5 -0
  74. package/dist/plugins/teleport.js +55 -0
  75. package/dist/plugins/twins.d.ts +10 -0
  76. package/dist/plugins/twins.js +164 -0
  77. package/dist/routes.d.ts +10 -0
  78. package/dist/routes.js +6 -0
  79. package/dist/rust.d.ts +1 -7
  80. package/dist/rust.js +56 -174
  81. package/dist/script.d.ts +3 -6
  82. package/dist/script.js +127 -140
  83. package/dist/settle.d.ts +6 -0
  84. package/dist/settle.js +34 -0
  85. package/dist/slots.d.ts +13 -0
  86. package/dist/slots.js +160 -0
  87. package/dist/strings.d.ts +13 -0
  88. package/dist/strings.js +208 -0
  89. package/dist/styles.d.ts +14 -0
  90. package/dist/styles.js +181 -0
  91. package/dist/template.d.ts +1 -42
  92. package/dist/template.js +25 -749
  93. package/dist/testing.d.ts +2 -0
  94. package/dist/testing.js +4 -10
  95. package/dist/types.d.ts +2 -0
  96. package/dist/typescript.d.ts +2 -14
  97. package/dist/typescript.js +53 -59
  98. package/dist/vite.d.ts +2 -1
  99. package/dist/vite.js +11 -32
  100. package/package.json +15 -6
  101. package/dist/i18n.d.ts +0 -27
  102. package/dist/router.d.ts +0 -17
  103. package/dist/scoped.d.ts +0 -24
  104. package/dist/scoped.js +0 -177
  105. package/dist/stores.d.ts +0 -6
  106. package/dist/stores.js +0 -229
package/README.md CHANGED
@@ -26,7 +26,7 @@ The project has two halves:
26
26
  | | What it is | Published as |
27
27
  |---|---|---|
28
28
  | `packages/ferrovue` | The compiler and the `ferrovue` command (TypeScript, runs on Node) | npm: [`ferrovue`](https://www.npmjs.com/package/ferrovue) |
29
- | `crates/ferrovue` | The runtime the generated Rust calls: escaping, slots, islands, `<RouterLink>` matching | crates.io: [`ferrovue`](https://crates.io/crates/ferrovue) ([API docs](https://docs.rs/ferrovue)) |
29
+ | `crates/ferrovue` | The runtime the generated Rust calls: escaping, slots, islands, `<RouterLink>` matching. The router, vue-i18n and the escaping and number primitives they share are crates of their own (`crates/ferrovue-router`, `ferrovue-i18n`, `ferrovue-core`), which `ferrovue` re-exports | crates.io: [`ferrovue`](https://crates.io/crates/ferrovue) ([API docs](https://docs.rs/ferrovue)) |
30
30
 
31
31
  ## Quick start
32
32
 
@@ -61,10 +61,12 @@ crate that includes it needs **edition 2024**.
61
61
  | `stores` | no | Directory of Pinia option stores whose state components may read |
62
62
  | `trustedHtml` | no | Rust type of a `TrustedHtml` prop, e.g. `crate::html::Sanitised` (needed for `v-html`) |
63
63
  | `helpers` | no | `{ module, functions }`: functions a template may call, each mapped to a Rust twin |
64
+ | `twins` | no | Components ferrovue does not compile, each rendered by a Rust function of yours: `{ "VBtn": { "rust": "crate::ui::v_btn", "props": { "label": "string" }, "slots": ["default"] } }`. See [Escape hatches](#escape-hatches) |
64
65
  | `i18n` | no | vue-i18n: `{ messages, locale?, fallbackLocale? }`, the directory of locale files (`en.json`, `nl.json`), the default locale and the fallbacks |
65
66
  | `clientDirectives` | no | Custom directives with no server output (no `getSSRProps`), by name without `v-`: `["focus"]` |
66
67
  | `scopeId` | no | How a `<style scoped>` id is hashed, as `@vitejs/plugin-vue` hashes it: `"filepath-source"` (the default, the plugin's in a production build) or `"filepath"`. See [Scoped styles](#scoped-styles) |
67
68
  | `viteRoot` | no | Vite's root, relative to this file's directory, from which a component's path is hashed (default `.`) |
69
+ | `builders` | no | `false` leaves out each props struct's and shared type's `new()` and setters, for an app that builds its props as struct literals (default `true`) |
68
70
 
69
71
  ### 3. Write a component
70
72
 
@@ -86,9 +88,16 @@ npx ferrovue init # a starter ferrovue.config.json and components/Hello.vu
86
88
  npx ferrovue # writes src/generated/{greeting.rs, mod.rs}
87
89
  npx ferrovue --check # in CI: exits 1 if the committed modules are stale
88
90
  npx ferrovue --check --diff # …and shows what differs, as `diff -u` does
91
+ npx ferrovue --check --format json # errors as JSON, for an editor or CI annotations
89
92
  npx ferrovue --watch # regenerates when components or configs change
90
93
  ```
91
94
 
95
+ Every error carries a stable code, as `error[FV0602]: components/Card.vue:4:17: …`, documented in
96
+ the crate's [`error_codes`](https://docs.rs/ferrovue/latest/ferrovue/guide/error_codes/index.html)
97
+ guide. `--format json` writes the same errors as JSON with the exit status unchanged, and a VS Code
98
+ problem matcher for `tasks.json` puts them in the Problems panel: both are in the
99
+ [quick start](https://docs.rs/ferrovue/latest/ferrovue/guide/quick_start/index.html#editor-and-ci-diagnostics).
100
+
92
101
  ```rust
93
102
  #[rustfmt::skip]
94
103
  mod generated;
@@ -107,7 +116,7 @@ let island = greeting::island(&props).into_string();
107
116
 
108
117
  // (`island()` exists for a component that renders from its props alone: the client rebuilds it from
109
118
  // `data-props`. One that also takes slots, the route, stores, translations or teleports has `html()`,
110
- // and the page's own app mounts it — see examples/fullstack.)
119
+ // and the page's own app mounts it; see examples/fullstack.)
111
120
 
112
121
  // …or straight into a buffer you already hold:
113
122
  let mut page = String::from("<!doctype html><body>");
@@ -118,6 +127,10 @@ With `routes` configured, the generated `route_table::router()` builds the route
118
127
  each request's location with `router.at(path)` and pass the result to the components that take a
119
128
  route.
120
129
 
130
+ The router and vue-i18n are features of the crate, `router` and `i18n`, both on by default. An
131
+ application with neither routes nor translations can leave them out, and build neither
132
+ `ferrovue-router` nor `ferrovue-i18n`: `ferrovue = { version = "0.5", default-features = false }`.
133
+
121
134
  With the `maud` feature, `ferrovue::Html` implements `maud::Render`, so `(greeting::html(&props))` can go
122
135
  straight into a `maud::html!` page.
123
136
 
@@ -138,27 +151,30 @@ hydrates inside it. See `examples/dioxus` and the crate guide's
138
151
 
139
152
  ## What a component may use
140
153
 
141
- ferrovue compiles `<script setup lang="ts">` components. Props are declared by type, with
154
+ ferrovue compiles `<script setup lang="ts">` components, and components with no script at all
155
+ (an icon, a divider), which take no props. Props are declared by type, with
142
156
  `defineProps<{ … }>()`, an interface, or a type alias.
143
157
 
144
158
  | Area | Supported |
145
159
  |---|---|
146
- | Prop types | `string`, `number` (integers, `i64`, written and computed as JavaScript does — exact within ±2⁵³), `Float` from `ferrovue/types` (fractions, `f64`), `boolean`, string-literal unions (`"sm" \| "md"`), `T \| undefined`, arrays (`T[]`, `Array<T>`, `readonly T[]`), interfaces and object type aliases (recursive ones too), dictionaries (`Record<string, T>`, `{ [key: string]: T }`, `ferrovue::Record` in Rust, which keeps JavaScript's order of keys), another component's exported `Props`, `TrustedHtml` |
147
- | 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 |
148
- | Props | `withDefaults`, destructured props with defaults (`const { size = "md" } = defineProps<…>()`), optional booleans (Vue casts an absent one to `false`), `defineModel` (named, required, with defaults), components with no props |
149
- | Setup | `ref`/`shallowRef`, `computed` (an expression or a single `return`), 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`, `provide`, template refs (`ref(null)`, `useTemplateRef`) and functions are client-only. The template may name them only from event handlers, which the server drops |
160
+ | Prop types | `string`, `number` (integers, `i64`, written and computed as JavaScript does, exact within ±2⁵³), `Float` from `ferrovue/types` (fractions, `f64`), `boolean`, string-literal unions (`"sm" \| "md"`), `T \| undefined`, `T \| null` (an `Option` written as `null`), arrays (`T[]`, `Array<T>`, `readonly T[]`), interfaces and object type aliases (recursive ones too), dictionaries (`Record<string, T>`, `{ [key: string]: T }`, `ferrovue::Record` in Rust, which keeps JavaScript's order of keys), another component's exported `Props`, `TrustedHtml` |
161
+ | 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 |
162
+ | 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) |
163
+ | 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 |
164
+ | 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`, `provide`, template refs (`ref(null)`, `useTemplateRef`) and functions are client-only. The template may name them only from event handlers, which the server drops |
150
165
  | 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 |
151
166
  | 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)) |
152
- | Conditions | `v-if` / `v-else-if` / `v-else`, `?:`, `&&` and `||`, with optional values narrowed as TypeScript narrows them: `v-if="user"`, `user !== undefined`, and `!user` or `user === undefined` for the `v-else` |
153
- | 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()`, `.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; `.slice()`; chained and nested; a computed list read by `v-for`, `.join()`, `.length`, `.includes()`, kept by `computed`, or handed to a child |
167
+ | 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` |
168
+ | 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; `.slice()`; chained and nested; a computed list read by `v-for`, `.join()`, `.length`, `.includes()`, kept by `computed`, or handed to a child |
154
169
  | 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 |
155
170
  | Attributes | static and bound attributes, boolean attributes, `:hidden`, `data-*` and `aria-*`, `v-bind` objects |
156
171
  | `class` | strings, arrays, objects (`{ active: on }`, computed keys), `cond && "x"`, `cond ? "x" : null`, merged with a static `class` |
157
172
  | `style` | objects (camelCase or kebab-case keys, `--custom` properties), arrays of objects, strings, merged with a static `style`, and `v-show`; later values override earlier ones as in Vue. A global `<style>` block is allowed |
158
173
  | Scoped styles | `<style scoped>`: the id on every element, on child components' roots (a root that is itself a component, fragments, recursion and `inheritAttrs: false` as Vue renders them) and, from a component with `:slotted()` rules, on the slot content it is given, forwarded slots included; inside `<Transition>`, `<KeepAlive>`, `<Teleport>` and `v-if`; on `<RouterLink>` and what it holds, as vue-router renders them |
159
- | Components | imported child components, `v-bind` of a child's own `Props`, `v-model` on a child's `defineModel`, recursion; props named in `kebab-case` or `camelCase` |
174
+ | Components | imported child components, `v-bind` of a child's own `Props`, `v-model` on a child's `defineModel`, recursion; props named in `kebab-case` or `camelCase`; `defineAsyncComponent(() => import("./X.vue"))` (or `{ loader: … }`), rendered as the component it loads, which Vue's server renderer waits for |
175
+ | Escape hatches | `<ClientOnly>` from `ferrovue/client`, whose default slot the server never renders, so anything may go in it; components listed in `twins`, rendered by Rust functions of yours. See [Escape hatches](#escape-hatches) |
160
176
  | Fallthrough attributes | what a parent passes a child beyond its props (static and bound attributes, `class`, `style`, `data-*`, `aria-*`, booleans, `undefined`): onto its single root, merged with the root's own class and style and replacing its other attributes where they stand, as Vue's `mergeProps` merges them; none for two roots; on through a root that is a component, or a `<RouterLink>`; with `inheritAttrs: false`, onto the elements or components that bind `v-bind="$attrs"` or a `useAttrs()` binding, before or after their own; beside scope ids. Listeners are dropped, as Vue's server drops them. See the crate's [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#fallthrough-attributes) guide |
161
- | Slots | default and named slots, fallbacks, `$slots.name` tests, scoped slots (`<slot :item="x">` and `#item="{ item }"` or `v-slot="props"`), whose props a parent can hand to its own children |
177
+ | Slots | default and named slots, fallbacks, `$slots.name` tests and the same through `useSlots()`, scoped slots (`<slot :item="x">` and `#item="{ item }"` or `v-slot="props"`), whose props a parent can hand to its own children |
162
178
  | Forms | `v-model` on text inputs, checkboxes, radios, `<select>` and `<textarea>` (renders the initial state) |
163
179
  | Built-ins | `<Transition>`, `<TransitionGroup>`, `<KeepAlive>`, `<Suspense>` (synchronous content), `<Teleport>` (to any target, nested, disabled: see below), `v-text`, `v-once`, `v-pre`, `v-memo`, custom directives listed in `clientDirectives` |
164
180
  | Vue Router | `<RouterLink>` (resolved by name or imported) with a string `to` or `{ name, params, query, hash }` / `{ path, query, hash }`, `active-class`, `exact-active-class`, `aria-current-value`, `replace`; vue-router's own encoding and active-link matching, nested routes included (a parent link is active on its children's pages, exact only on its own); a history base. `useRoute()` and `$route`: `path`, `fullPath`, `hash`, `name`, `params`, and `query` (a value written once, without `=`, or repeated, exactly as vue-router parses it; `typeof route.query.q === "string"` narrows one to a single string). `<RouterView>`, at the top and in nested route components: each takes the page it shows as a slot |
@@ -173,6 +189,10 @@ Refused at compile time, each with an error that names the construct:
173
189
  id; and, since vue-router renders a link from virtual nodes, a `<slot>` inside a `<RouterLink>`
174
190
  that takes scope ids, or an element inside one in slot content given a `:slotted()` id
175
191
  - `<component :is>`
192
+ - the Options API (a `<script>` without `setup`), and a type parameter of a generic component with
193
+ no constraint (`generic="T"`)
194
+ - constants that are not literals (`Date.now()`, a function), an object constant read whole or by a
195
+ key chosen at run time (`LABELS[key]`), and `new` (`new Date(at)`, `new Intl.NumberFormat()`)
176
196
  - `<RouterLink custom>`, slot props that are array literals, defaults in destructured slot props, and outlets of one slot that pass different props
177
197
  - custom directives not listed in `clientDirectives`
178
198
  - `watchEffect`, `watch` with `immediate`, `onServerPrefetch`, top-level `await`, and statements in setup that change state
@@ -189,7 +209,9 @@ Refused at compile time, each with an error that names the construct:
189
209
  it is not; `Object.entries()` anywhere but as a `v-for`'s source; dictionaries of optional values
190
210
  - two strings that may each hold half of a surrogate pair compared, searched or joined (see
191
211
  [Strings](#strings))
192
- - `null`
212
+ - a type that is both `null` and `undefined` (`T | null | undefined`, `x?: T | null`), a default
213
+ for a nullable prop, and `=== null` or `=== undefined` of a value that may be either (`a?.b` where
214
+ `b` is nullable; `== null` tests both)
193
215
  - an attribute (`class` and `style` aside) bound to a value that may be neither a string, a number
194
216
  nor a boolean: a list, an object, or a `route.query` value, which is an array when its key is
195
217
  repeated. Vue's server renderer leaves the attribute out and hydration then sets it, silently;
@@ -203,16 +225,50 @@ Refused at compile time, each with an error that names the construct:
203
225
  passed to a component whose root is a `<Transition>` or `<KeepAlive>` around a `v-if`, which Vue's
204
226
  server drops but its client keeps
205
227
 
228
+ Every error names the file, line and column, with the line quoted and a caret under the construct.
229
+
206
230
  An object prop handed to a child component is cloned. Its strings are `Cow`s, so borrowed ones
207
231
  cost nothing to copy.
208
232
 
233
+ ### Constants
234
+
235
+ A constant a component imports from a `.ts` file, or an `enum`, is evaluated when the component is
236
+ compiled, as `types.rs` holds the shared types:
237
+
238
+ ```ts
239
+ // components/catalog.ts
240
+ export const LABELS = { title: "Catalogue", empty: "Nothing yet" } as const;
241
+ export const SIZES = ["sm", "md", "lg"] as const;
242
+ export const SORTS = [
243
+ { key: "name", label: "By name" },
244
+ { key: "price", label: "By price" },
245
+ ] as const;
246
+ export enum Tone { Calm = "calm", Loud = "loud" }
247
+ ```
248
+
249
+ `{{ LABELS.title }}` and `v-if="tone === Tone.Loud"` are written into the page as the strings they
250
+ are, and `v-for="s in SIZES"` walks an array literal. A list of objects becomes a `const` beside
251
+ the shared types, with a type for its items (here `SortsItem`, or the interface the constant is
252
+ declared with):
253
+
254
+ ```rust,ignore
255
+ pub const SORTS: &[SortsItem<'static>] = &[
256
+ SortsItem { key: Cow::Borrowed("name"), label: Cow::Borrowed("By name") },
257
+ SortsItem { key: Cow::Borrowed("price"), label: Cow::Borrowed("By price") },
258
+ ];
259
+ ```
260
+
261
+ A constant is a literal, a list, an object, or another constant of the same file. One that is
262
+ anything else (`Date.now()`, a function) is refused where the template reads it, naming the file
263
+ and line it is declared on.
264
+
209
265
  ### Scoped styles
210
266
 
211
267
  A `<style scoped>` component's elements carry `data-v-<id>`, and its CSS is rewritten by the client
212
268
  build to select them. The server has to write the id the client build chose, which is not something
213
269
  the browser checks when it hydrates: a wrong id hydrates cleanly and leaves the styles unapplied. So
214
- ferrovue computes it as `@vitejs/plugin-vue` does — the first 8 hex digits of a SHA-256 of the
215
- `.vue` file's path from Vite's root, followed by its source unless only the path is hashed — and the
270
+ ferrovue computes it as `@vitejs/plugin-vue` does (the first 8 hex digits of a SHA-256 of the
271
+ `.vue` file's path from Vite's root, followed by its source unless only the path is hashed), and the
216
272
  two must be configured alike:
217
273
 
218
274
  | `@vitejs/plugin-vue` | `ferrovue.config.json` |
@@ -248,10 +304,10 @@ gives the ids that other component inherits.
248
304
  String methods count as JavaScript counts: in UTF-16 code units, so `"🦀".length` is 2 and
249
305
  `"🦀 crab".slice(3)` is `"crab"`. Strings are ordered by code unit too, which puts every character
250
306
  from U+E000 to U+FFFF after one beyond U+FFFF. Each runtime routine is held to vectors recorded from
251
- JavaScript (`crates/ferrovue/tests/vectors/`).
307
+ JavaScript (`crates/*/tests/vectors/`).
252
308
 
253
- A JavaScript string can hold half of a surrogate pair — `"🦀".slice(0, 1)`, `.charAt(1)`,
254
- `.split("")` — and a Rust string cannot. ferrovue writes U+FFFD in its place, which is exactly what
309
+ A JavaScript string can hold half of a surrogate pair (`"🦀".slice(0, 1)`, `.charAt(1)`,
310
+ `.split("")`), and a Rust string cannot. ferrovue writes U+FFFD in its place, which is exactly what
255
311
  the page carries anyway: a server sends Vue's string as UTF-8, and UTF-8 writes each half as U+FFFD
256
312
  (`res.end`, `Buffer.from` and `TextEncoder` all do). Its length is the same. Where the half itself
257
313
  would decide the result, ferrovue refuses at compile time: two strings that may each hold a half
@@ -273,8 +329,8 @@ Where JavaScript throws, the generated code panics, as Vue's render rejects: a n
273
329
 
274
330
  ### Dictionaries
275
331
 
276
- A `Record<string, T>` prop is a `ferrovue::Record`, built from pairs —
277
- `[("b", 1), ("10", 2)].into_iter().collect()` — or read from JSON. It holds its keys in the order a
332
+ A `Record<string, T>` prop is a `ferrovue::Record`, built from pairs
333
+ (`[("b", 1), ("10", 2)].into_iter().collect()`) or read from JSON. It holds its keys in the order a
278
334
  JavaScript object does, array indices (`"0"` to `"4294967294"`) first in numeric order, so `v-for`
279
335
  walks them as Vue does, and it is written back as JSON in that order, which the browser reads back
280
336
  the same.
@@ -289,6 +345,66 @@ let i18n = generated::i18n::i18n("nl"); // falls back as configured
289
345
  page::render(&mut out, &props, &i18n);
290
346
  ```
291
347
 
348
+ ### Escape hatches
349
+
350
+ Some components will never compile: a component library's render functions, `inject` chains, code
351
+ that measures the page in `onMounted`. Two ways keep one of them from blocking the page around it.
352
+
353
+ **`<ClientOnly>`**: the server writes its `#fallback` slot (or a comment, without one), and the
354
+ browser hydrates that same fallback, then swaps in the default slot once mounted. Both sides render
355
+ the fallback first, so this is exact by construction. ferrovue does not compile the default slot at
356
+ all: any component, import or expression may go there.
357
+
358
+ ```vue
359
+ <script setup lang="ts">
360
+ import { ClientOnly } from "ferrovue/client";
361
+ import { VDataTable } from "vuetify/components";
362
+
363
+ defineProps<{ rows: { name: string }[] }>();
364
+ </script>
365
+
366
+ <template>
367
+ <ClientOnly>
368
+ <VDataTable :items="rows" />
369
+ <template #fallback><p class="loading">Loading the table…</p></template>
370
+ </ClientOnly>
371
+ </template>
372
+ ```
373
+
374
+ The swap needs Vue running on that part of the page, so put `<ClientOnly>` inside an island (or an
375
+ app the client hydrates whole); elsewhere the fallback stays. `<ClientOnly>` takes no attributes.
376
+
377
+ **Rust twins**: a component the server should render, written by you in Rust. List it under
378
+ `twins` with the props a template may pass it and the slots it may fill:
379
+
380
+ ```json
381
+ {
382
+ "twins": {
383
+ "VBtn": {
384
+ "rust": "crate::ui::v_btn",
385
+ "props": { "label": "string", "size": "int?", "block": "bool" },
386
+ "slots": ["default"]
387
+ }
388
+ }
389
+ }
390
+ ```
391
+
392
+ A template then uses `<VBtn>` (or `<v-btn>`), imported from anywhere or registered globally, and the
393
+ generated parent calls `crate::ui::v_btn` with the props, the slots (each a `ferrovue::Slot`) and
394
+ `&ferrovue::Attrs`: the attributes it is given beyond its props and the scope ids its root takes.
395
+ `twins.rs` beside the components holds `VBtnProps`, `VBtnSlots` and the signature your function must
396
+ have. Prop types are those of helpers (`string`, `int?`, …); an absent `bool` is `false`, and a
397
+ bare `block` is `true`, as Vue casts them.
398
+
399
+ **Exactness is your responsibility.** ferrovue cannot check that `v_btn` writes the bytes Vue's
400
+ server renderer writes for `VBtn`: its guarantee rests on your code here, as it does for helpers. Prove it with fixtures: render the components that use the twin with Vue through
401
+ `ferrovue/testing`'s `fixtureApp` and `attachSsrRender`, and with Rust through the generated
402
+ `render_json`, and compare. Slot content reaches the twin as a component's render function sees it
403
+ (`<!--v-if-->` for an absent `v-if`, as Vue writes virtual nodes). The twin decides where it goes and
404
+ whether fragment markers surround it, as the component does. `examples/fullstack` has a
405
+ twin (`src/ui.rs`), its fixtures (`fixtures/`), the Vue half (`test/fixtures.test.ts`) and the Rust
406
+ half (`src/fixtures.rs`).
407
+
292
408
  ### Teleports
293
409
 
294
410
  A component that renders a `<Teleport>` takes a `ferrovue::Teleports`. After rendering the page body,
@@ -300,7 +416,7 @@ page::render(&mut body, &props, &teleports);
300
416
  let modals = teleports.get("#modals").unwrap_or_default(); // inside <div id="modals">
301
417
  ```
302
418
 
303
- As Vue recommends, teleport to a dedicated element (`#modals`) rather than `body`: the browser hydrates
419
+ As Vue recommends, teleport to a dedicated element such as `#modals`: the browser hydrates
304
420
  a target from its first node, and `body` also holds the app.
305
421
 
306
422
  ### Scoped slots from Rust
@@ -319,12 +435,12 @@ data_list::render(&mut page, &props, data_list::Slots { row: Some(&row), ..Defau
319
435
 
320
436
  ### Hydrating islands
321
437
 
322
- `mountIslands` from `ferrovue/client` hydrates every `data-island` element on the page with the
438
+ `mountIslands` from `ferrovue` hydrates every `data-island` element on the page with the
323
439
  component of that name, from the props the server wrote. With the Vite plugin, `ferrovue/islands`
324
440
  gives it every island there is, so nothing has to be listed by hand:
325
441
 
326
442
  ```ts
327
- import { hydrateState, mountIslands } from "ferrovue/client";
443
+ import { hydrateState, mountIslands } from "ferrovue";
328
444
  import islands from "ferrovue/islands"; // written by `ferrovue()` from `ferrovue/vite`
329
445
 
330
446
  hydrateState(pinia);
@@ -348,6 +464,40 @@ loaded, after the server's markup is on screen. Link those stylesheets from the
348
464
  server reading Vite's manifest takes the `css` of the entry's `dynamicImports` as well as its own
349
465
  (`examples/fullstack/src/assets.rs`).
350
466
 
467
+ An application whose own navigation layer leaves the page can keep `<RouterLink>` for its `href`s
468
+ and active classes with `linkRouter(routes, { navigate })` from `ferrovue/link-router`: a router
469
+ over the routes file whose every navigation after the first goes to `navigate`. The routing guide
470
+ (`ferrovue::guide::routing`) explains each part of it.
471
+
472
+ ### Hydrating a whole page
473
+
474
+ A page built from a layout whose slots hold components, each rendered from its own props, can be
475
+ hydrated as one app. `ferrovue::Page` takes each slot's parts as `Part`s, made from the same
476
+ `html()` values that write the markup, renders the layout, and writes what it rendered as a
477
+ `<script type="application/json">`; `mountPage` reads it back, loads the components it names and
478
+ hydrates the layout with each slot's parts:
479
+
480
+ ```rust
481
+ let mut page = ferrovue::Page::new();
482
+ let content = page.slot("default", books.iter().map(|b| Part::new(pick::NAME, pick::html(b))));
483
+ let reviews = page.hole("reviews"); // filled later, while the page streams
484
+ let record = page.render_to(&mut out, picks::html(&props, picks::Slots {
485
+ default: Some(content.slot()),
486
+ reviews: Some(reviews.slot()),
487
+ }, &route));
488
+ ```
489
+
490
+ ```ts
491
+ import { mountPage } from "ferrovue";
492
+ import islands from "ferrovue/islands";
493
+
494
+ await mountPage(() => import("./Picks.vue"), islands, { pinia, router });
495
+ ```
496
+
497
+ The record repeats the text of every prop, so a page hydrated this way is larger than one of
498
+ islands; the guide's `islands_and_hydration` page compares the two, and `examples/fullstack` serves
499
+ a page of each kind.
500
+
351
501
  ### Hydrating Pinia state
352
502
 
353
503
  ```rust
@@ -355,7 +505,7 @@ ferrovue::state_script_into(&mut page, "__pinia", &stores); // a <script type=
355
505
  ```
356
506
 
357
507
  ```ts
358
- import { hydrateState } from "ferrovue/client";
508
+ import { hydrateState } from "ferrovue";
359
509
  hydrateState(pinia); // before app.mount(): every store starts from what the server rendered
360
510
  ```
361
511
 
@@ -379,10 +529,10 @@ and both write the same bytes: each side checks its output against
379
529
 
380
530
  | Scenario | What renders | Output | Vue `renderToString` | ferrovue | Speed-up |
381
531
  |---|---|---|---|---|---|
382
- | `small` | `Nav`: two `<RouterLink>`s resolved against the current route | 206 B | 34.57 µs | 0.950 µs | 36.4× |
383
- | `list` | `Lists`: 1,000 words, 1,000 numbers, 100 groups of 10 members | 102 KiB | 368.1 µs | 45.95 µs | 8.0× |
384
- | `tree` | `Tree`: a recursive component, binary tree 8 levels deep (255 nodes) | 9.5 KiB | 484.6 µs | 3.72 µs | 130.2× |
385
- | `page` | `Dashboard`: 22 `Panel`s with named slots, a `Text`, 20 `Frame`s holding loops | 8.0 KiB | 181.6 µs | 4.45 µs | 40.9× |
532
+ | `small` | `Nav`: two `<RouterLink>`s resolved against the current route | 206 B | 41.39 µs | 0.962 µs | 43.0× |
533
+ | `list` | `Lists`: 1,000 words, 1,000 numbers, 100 groups of 10 members | 102 KiB | 363.9 µs | 49.00 µs | 7.4× |
534
+ | `tree` | `Tree`: a recursive component, binary tree 8 levels deep (255 nodes) | 9.5 KiB | 539.5 µs | 3.89 µs | 138.5× |
535
+ | `page` | `Dashboard`: 22 `Panel`s with named slots, a `Text`, 20 `Frame`s holding loops | 8.0 KiB | 192.4 µs | 4.84 µs | 39.7× |
386
536
 
387
537
  These are mean times per render. The Vue column is tinybench's mean, with 5 s per scenario after
388
538
  a 1 s warm-up. The ferrovue column is criterion's mean point estimate, using its defaults of a 3 s
@@ -420,38 +570,71 @@ still build, run and agree on the HTML. It never compares their timings.
420
570
 
421
571
  ## Versions
422
572
 
423
- Byte-identical output depends on Vue's own SSR compiler, so the package pins **`vue`,
424
- `@vue/compiler-sfc` and `@vue/server-renderer` to an exact version** (currently 3.5.43). Upgrading
425
- Vue means re-running the conformance suite (see [TESTING.md](TESTING.md)) and releasing a new
426
- ferrovue version.
573
+ Byte-identical output depends on Vue's own SSR compiler, so ferrovue compiles with an exact
574
+ `@vue/compiler-sfc` (currently 3.5.43), and its conformance fixtures are recorded from one version
575
+ of Vue and of each integration. Your project may use any later patch of the same minor:
576
+
577
+ | Peer | Supported | Recorded from |
578
+ |---|---|---|
579
+ | `vue` | `~3.5.43` (3.5.43 and later 3.5 patches) | 3.5.43 |
580
+ | `vue-router` (optional) | `~5.3.1` | 5.3.1 |
581
+ | `pinia` (optional) | `~4.0.3` | 4.0.3 |
582
+ | `vue-i18n` (optional) | `~11.4.13` | 11.4.13 |
583
+
584
+ A new minor of any of them (Vue 3.6, vue-router 5.4, …) needs a ferrovue release that re-records
585
+ the fixtures from it. A weekly CI job re-records them from the newest patch each range allows and
586
+ hydrates the committed HTML with it, so a patch that changes what Vue writes or hydrates is caught
587
+ before it reaches you; a ferrovue patch follows when one does.
427
588
 
428
589
  ## Repository layout
429
590
 
430
591
  ```text
431
- crates/ferrovue/ the Rust runtime crate
432
- src/ runtime (escaping, JS numbers, router, i18n, teleports) + unit tests
592
+ crates/ferrovue/ the Rust runtime crate, which generated code calls
593
+ src/ a module per part (`Html`, slots, class and style, the state script,
594
+ strings, teleports, fallthrough attributes), each module's unit tests
595
+ beside it in <module>/tests.rs; lib.rs re-exports them all, and the
596
+ three crates below
433
597
  tests/conformance/ components, fixtures, recorded HTML, generated Rust
434
- tests/vectors/ vectors recorded from JavaScript, vue-router and vue-i18n
598
+ tests/vectors/ vectors recorded from JavaScript and Vue
435
599
  tests/properties.rs property-based tests of the runtime
436
600
  benches/ criterion benchmarks of generated renderers (see Performance)
601
+ crates/ferrovue-core/ escaping and JavaScript's numbers, which the other crates share
602
+ tests/vectors/ vectors recorded from escapeHtml, String(n), Math and toFixed
603
+ crates/ferrovue-router/ vue-router's matching and links (the `router` feature)
604
+ tests/vectors/ vectors recorded from vue-router
605
+ crates/ferrovue-i18n/ vue-i18n's t() (the `i18n` feature)
437
606
  packages/ferrovue/ the compiler (npm package)
438
- src/compiler.ts the API: `generate`, `write`
439
- src/script.ts, template.ts, expr.ts, attrs.ts
440
- <script setup>, the compiled template, expressions, class/style
441
- src/router.ts, stores.ts, i18n.ts
442
- vue-router, Pinia and vue-i18n
607
+ src/index.ts `ferrovue`: the browser API, `mountIslands`, `mountPage`, `hydrateState` and the types
608
+ src/compiler.ts `ferrovue/compiler`: `generate`, `write`
609
+ src/component.ts, script.ts, typescript.ts, constants.ts
610
+ a `.vue` file read, <script setup>, TypeScript types, constants and enums
611
+ src/template.ts, children.ts, slots.ts, loops.ts
612
+ the compiled template: statements, child components, slots, v-for
613
+ src/expr.ts, strings.ts, numbers.ts, narrowing.ts, calls.ts, lists.ts
614
+ expressions: operators, strings, numbers, narrowing, calls, lists
615
+ src/attrs.ts, classes.ts, styles.ts, fallthrough.ts
616
+ attributes, class and style, attributes a parent passes on
617
+ src/plugin.ts the plugin interface (see CONTRIBUTING.md)
618
+ src/plugins/ vue-router, Pinia, vue-i18n, scoped styles, <Teleport>, shared types,
619
+ <ClientOnly>, Rust twins
443
620
  src/rust.ts, emitter.ts the Rust source written out
444
621
  src/cli.ts, vite.ts the `ferrovue` command and the Vite plugin
445
- src/client.ts browser-side helpers: `mountIslands`, `hydrateState`
622
+ src/errors.ts, diagnostics.ts
623
+ every error code with its title, and errors as the CLI, JSON and Vite show them
624
+ src/client.ts browser-side helpers: `mountIslands`, `mountPage`, `hydrateState`, `<ClientOnly>`
446
625
  src/islands.ts `ferrovue/islands`, which the Vite plugin writes: every island, loaded lazily
626
+ src/link-router.ts `ferrovue/link-router`: `<RouterLink>` while the application navigates on its own
627
+ src/routes.ts a routes file as vue-router's route records
447
628
  src/testing.ts utilities for a project's own conformance suite
629
+ src/hydration.ts `hydrateRecordedPage`, which `ferrovue/testing` exports
448
630
  src/types.ts `ferrovue/types`: `TrustedHtml`, `Float`
449
631
  test/ compiler, CLI, router, vector, island, Vite and conformance tests
450
- browser/ conformance fixtures hydrated in real browsers (`pnpm test:browser`)
632
+ browser/ conformance fixtures and recorded pages hydrated in real browsers (`pnpm test:browser`)
451
633
  bench/ Vue renderToString benchmarks, the other half of Performance
452
634
  fuzz/ the randomised differential tester (`pnpm fuzz`)
453
635
  examples/greeting/ the smallest setup: one component rendered from Rust
454
- examples/fullstack/ axum + Vite: islands, Pinia state, routes and streaming
636
+ examples/fullstack/ axum + Vite: islands, a page hydrated whole, Pinia state, routes and
637
+ streaming, <ClientOnly>, a Rust twin and its fixtures
455
638
  examples/dioxus/ a Dioxus page, rendered with dioxus-ssr, with an island in it
456
639
  scripts/release.ts the release version bump (see RELEASING.md)
457
640
  ```
package/dist/attrs.d.ts CHANGED
@@ -1,67 +1,17 @@
1
1
  import { type N, type Scope, type Val } from "./model.ts";
2
2
  import { Emitter } from "./emitter.ts";
3
- /** `toDisplayString`, escaped. */
4
- export declare function interpolate(e: Emitter, v: Val): void;
5
- /** The value half of `key="value"`, escaped. */
3
+ export declare function display(e: Emitter, v: Val): boolean;
4
+ export declare function interpolate(s: Scope, e: Emitter, v: Val, n: N): void;
6
5
  export declare function attrValue(e: Emitter, v: Val): void;
7
- /** `ssrRenderAttr(key, value)`: absent for null/undefined, `key="value"` for anything else. */
8
6
  export declare function renderAttr(s: Scope, e: Emitter, key: string, v: Val, n: N): void;
9
- /** `ssrRenderDynamicAttr(key, value)`: as `renderAttr`, but a boolean attribute is present or
10
- * absent, and an empty string renders the name alone. */
11
7
  export declare function renderDynamicAttr(s: Scope, e: Emitter, key: string, v: Val, n: N): void;
12
- /** One item of a class list once it is flattened: literal text, known now, or a Rust `&str`
13
- * expression evaluated at run time — empty when the item contributes nothing. */
14
- export type ClassItem = {
15
- lit: string;
16
- } | {
17
- code: string;
18
- };
19
- /** `normalizeClass`'s view of a class binding, flattened: a string, an array of items, an object
20
- * of `name: condition`, `cond && "name"`, `cond ? "a" : null`. Every item is trimmed and the empty
21
- * ones dropped before they are joined with one space, which is what `class_into` does too, so an
22
- * object's keys become items of their own. */
23
- export declare function classItems(s: Scope, n: N): ClassItem[];
24
- /** `ssrRenderClass(value)`: normalised, then escaped. `after` says a class was already written. */
25
- export declare function renderClass(s: Scope, e: Emitter, n: N, after?: boolean): void;
26
8
  export declare const IGNORED_PROPS: Set<string>;
27
- /** `ssrGetDirectiveProps(_ctx, dir)`: a custom directive's server props, which are none for a
28
- * directive the configuration declares client-only. `false` when `n` is not such a call. */
29
9
  export declare function directiveProps(s: Scope, n: N): boolean;
30
- /** Whether `n` is the root's `_attrs`: what a parent passes, then the scope ids. */
31
10
  export declare function isAttrs(n: N): boolean;
32
- /** Whether `n` is `$attrs` — `_ctx.$attrs`, or a `useAttrs()` binding — which a component whose
33
- * root is another's would find holding scope ids as well. */
34
11
  export declare function dollarAttrs(s: Scope, n: N): boolean;
35
- /** The parts `_mergeProps(…)` merges, or the one object that is not merged. */
36
12
  export declare function mergedParts(n: N): N[];
37
- /** `ssrRenderAttrs(obj)`, key by key in the object's order, and the inherited scope ids where the
38
- * root's `_attrs` is merged in. When a parent may pass attributes, which only the run time knows,
39
- * `$attrs` and the root's `_attrs` are merged with the element's own by `fv::attrs_into` — but only
40
- * when some were passed: with none, the element is written as it is without. */
41
13
  export declare function renderAttrs(s: Scope, e: Emitter, n: N): void;
42
- /** `mergeProps(a, _attrs, b, …)` as one object literal, without `_attrs` and `$attrs`, which hold
43
- * what only the run time knows. Of the rest, `class` and `style` values are merged as an array —
44
- * unless a class equals the one so far, which Vue then keeps once — and any other key keeps the
45
- * place it first had with the last value given. */
46
14
  export declare function mergeProps(s: Scope, n: N): N;
47
- /** The attributes of an object literal as `fv::Attr` entries, `("name", value)`, for a merge at run
48
- * time: given to a child component (`vnode`), whose virtual node normalises a class array or object
49
- * to a string, or an element's own (`own`), which `mergeProps` keeps as an object. */
50
15
  export declare function attrEntries(s: Scope, obj: N, side: "vnode" | "own"): string[];
51
- /** One attribute's value as an `fv::Attr`. */
52
16
  export declare function attrOf(s: Scope, key: string, n: N, side: "vnode" | "own"): string;
53
- /** One object of a style binding, and the run-time condition under which it is part of it. */
54
- export interface StyleItem {
55
- cond: string | null;
56
- entries: {
57
- key: string;
58
- css: string;
59
- value: N;
60
- }[];
61
- }
62
- /** The objects a style binding merges, in order: an object literal, a static `style` the compiler
63
- * parsed, `v-show`'s `cond ? null : { display: "none" }`, and arrays of those. */
64
- export declare function styleItems(s: Scope, n: N, when: string | null): StyleItem[];
65
- /** `ssrRenderStyle(value)`: a string as it is, or the objects merged — each property where it first
66
- * appears, with the last value given — and written `name:value;`, all escaped. */
67
- export declare function renderStyle(s: Scope, e: Emitter, n: N): void;
17
+ export declare function valueAttr(s: Scope, v: Val, n: N): string;