ferrovue 0.1.0 → 0.3.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 (50) hide show
  1. package/README.md +277 -22
  2. package/dist/attrs.d.ts +67 -0
  3. package/dist/attrs.js +769 -0
  4. package/dist/cli.js +183 -14
  5. package/dist/client.d.ts +36 -0
  6. package/dist/client.js +93 -1
  7. package/dist/compiler.d.ts +20 -53
  8. package/dist/compiler.js +99 -3178
  9. package/dist/component.d.ts +6 -0
  10. package/dist/component.js +264 -0
  11. package/dist/context.d.ts +151 -0
  12. package/dist/context.js +102 -0
  13. package/dist/diff.d.ts +4 -0
  14. package/dist/diff.js +77 -0
  15. package/dist/emitter.d.ts +23 -0
  16. package/dist/emitter.js +58 -0
  17. package/dist/expr.d.ts +76 -0
  18. package/dist/expr.js +1305 -0
  19. package/dist/fixture.d.ts +38 -0
  20. package/dist/fixture.js +78 -0
  21. package/dist/i18n.d.ts +27 -0
  22. package/dist/i18n.js +175 -0
  23. package/dist/islands.d.ts +5 -0
  24. package/dist/islands.js +4 -0
  25. package/dist/lists.d.ts +19 -0
  26. package/dist/lists.js +253 -0
  27. package/dist/model.d.ts +269 -0
  28. package/dist/model.js +115 -0
  29. package/dist/parens.d.ts +51 -0
  30. package/dist/parens.js +239 -0
  31. package/dist/router.d.ts +17 -0
  32. package/dist/router.js +322 -0
  33. package/dist/rust.d.ts +17 -0
  34. package/dist/rust.js +523 -0
  35. package/dist/scoped.d.ts +24 -0
  36. package/dist/scoped.js +177 -0
  37. package/dist/script.d.ts +16 -0
  38. package/dist/script.js +332 -0
  39. package/dist/stores.d.ts +6 -0
  40. package/dist/stores.js +229 -0
  41. package/dist/template.d.ts +48 -0
  42. package/dist/template.js +1012 -0
  43. package/dist/testing.d.ts +2 -22
  44. package/dist/testing.js +11 -55
  45. package/dist/types.d.ts +1 -0
  46. package/dist/typescript.d.ts +31 -0
  47. package/dist/typescript.js +310 -0
  48. package/dist/vite.d.ts +23 -0
  49. package/dist/vite.js +157 -0
  50. package/package.json +30 -8
package/README.md CHANGED
@@ -56,12 +56,15 @@ crate that includes it needs **edition 2024**.
56
56
  |---|---|---|
57
57
  | `components` | yes | Directory of `.vue` files to compile |
58
58
  | `out` | yes | Directory the Rust modules are written to. **Everything in it is replaced.** |
59
- | `routes` | no | JSON file listing the app's routes: each a vue-router path, or `{ "path", "name" }`. Needed for `<RouterLink>`, `<RouterView>` and `useRoute()` |
59
+ | `routes` | no | JSON file listing the app's routes: each a vue-router path, or `{ "path", "name", "children" }`. Needed for `<RouterLink>`, `<RouterView>` and `useRoute()` |
60
60
  | `router` | no | Instead of `routes`: `{ routes, base?, linkActiveClass?, linkExactActiveClass? }`, matching `createWebHistory(base)` and `createRouter`'s options |
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
+ | `i18n` | no | vue-i18n: `{ messages, locale?, fallbackLocale? }`, the directory of locale files (`en.json`, `nl.json`), the default locale and the fallbacks |
64
65
  | `clientDirectives` | no | Custom directives with no server output (no `getSSRProps`), by name without `v-`: `["focus"]` |
66
+ | `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
+ | `viteRoot` | no | Vite's root, relative to this file's directory, from which a component's path is hashed (default `.`) |
65
68
 
66
69
  ### 3. Write a component
67
70
 
@@ -79,8 +82,11 @@ defineProps<{ name: string; unread: number; note?: string }>();
79
82
  ### 4. Generate and render
80
83
 
81
84
  ```sh
85
+ npx ferrovue init # a starter ferrovue.config.json and components/Hello.vue
82
86
  npx ferrovue # writes src/generated/{greeting.rs, mod.rs}
83
87
  npx ferrovue --check # in CI: exits 1 if the committed modules are stale
88
+ npx ferrovue --check --diff # …and shows what differs, as `diff -u` does
89
+ npx ferrovue --watch # regenerates when components or configs change
84
90
  ```
85
91
 
86
92
  ```rust
@@ -89,7 +95,8 @@ mod generated;
89
95
 
90
96
  use generated::greeting::{self, Props};
91
97
 
92
- let props = Props { name: "Ada".into(), unread: 3, note: None };
98
+ // `new` takes the required props; each optional one has a setter.
99
+ let props = Props::new("Ada", 3).note("welcome back");
93
100
 
94
101
  // The markup alone:
95
102
  let html: String = greeting::html(&props).into_string();
@@ -98,6 +105,10 @@ let html: String = greeting::html(&props).into_string();
98
105
  // <div data-island="Greeting" data-props="{…}">…</div>
99
106
  let island = greeting::island(&props).into_string();
100
107
 
108
+ // (`island()` exists for a component that renders from its props alone: the client rebuilds it from
109
+ // `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.)
111
+
101
112
  // …or straight into a buffer you already hold:
102
113
  let mut page = String::from("<!doctype html><body>");
103
114
  greeting::render(&mut page, &props);
@@ -110,6 +121,21 @@ route.
110
121
  With the `maud` feature, `ferrovue::Html` implements `maud::Render`, so `(greeting::html(&props))` can go
111
122
  straight into a `maud::html!` page.
112
123
 
124
+ With the `axum` feature, `Html` is an `IntoResponse`, and with `actix-web` a `Responder`: a handler
125
+ responds with the component, as `text/html; charset=utf-8`. `into_html(props)` and
126
+ `into_island(props)` take the props by value, so a handler that builds them returns the page:
127
+
128
+ ```rust
129
+ async fn hello(Path(name): Path<String>) -> impl IntoResponse {
130
+ greeting::into_html(greeting::Props::new(name, 0))
131
+ }
132
+ ```
133
+
134
+ With the `dioxus` feature, a component goes into a Dioxus 0.7 page (`dioxus-ssr` or fullstack):
135
+ `{greeting::island(&props)}` in `rsx!` is the island element itself, with exactly the markup Vue
136
+ hydrates inside it. See `examples/dioxus` and the crate guide's
137
+ [Dioxus page](crates/ferrovue/docs/guide/dioxus.md).
138
+
113
139
  ## What a component may use
114
140
 
115
141
  ferrovue compiles `<script setup lang="ts">` components. Props are declared by type, with
@@ -117,40 +143,166 @@ ferrovue compiles `<script setup lang="ts">` components. Props are declared by t
117
143
 
118
144
  | Area | Supported |
119
145
  |---|---|
120
- | Prop types | `string`, `number` (integers, `i64`), `boolean`, string-literal unions (`"sm" \| "md"`), `T \| undefined`, arrays (`T[]`, `Array<T>`, `readonly T[]`), interfaces and object type aliases (recursive ones too), another component's exported `Props`, `TrustedHtml` |
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` |
121
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 |
122
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 |
123
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 |
124
- | Text | `{{ }}` of strings, integers and booleans; `+`, `-`, `*`, `%` (literal divisor); `<`, `>`, `<=`, `>=`, `===`, `!==`; unary `-`, `!`; `??`, `\|\|`, `&&`, `?:`; template literals; optional chaining `a?.b`; `.length`; string `.trim()`, `.trimStart()`, `.trimEnd()`, `.toUpperCase()`, `.toLowerCase()`, `.includes()`, `.startsWith()`, `.endsWith()`; list `.includes()`, `.join()`; `String()`, `.toString()`, `Math.max`/`min`/`abs`; array literals |
125
- | Conditions | `v-if` / `v-else-if` / `v-else`, with optional values narrowed inside the branch as TypeScript narrows them |
126
- | 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>` |
150
+ | 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
+ | 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 |
154
+ | 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 |
127
155
  | Attributes | static and bound attributes, boolean attributes, `:hidden`, `data-*` and `aria-*`, `v-bind` objects |
128
156
  | `class` | strings, arrays, objects (`{ active: on }`, computed keys), `cond && "x"`, `cond ? "x" : null`, merged with a static `class` |
129
157
  | `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 |
130
- | Components | imported child components, `v-bind` of a child's own `Props`, `v-model` on a child's `defineModel`, recursion |
158
+ | 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` |
160
+ | 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 |
131
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 |
132
162
  | Forms | `v-model` on text inputs, checkboxes, radios, `<select>` and `<textarea>` (renders the initial state) |
133
- | Built-ins | `<Transition>`, `<TransitionGroup>`, `<KeepAlive>`, `<Suspense>` (synchronous content), `v-text`, `v-once`, `v-pre`, `v-memo`, custom directives listed in `clientDirectives` |
134
- | 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; a history base. `useRoute()` and `$route`: `path`, `hash`, `name`, `params`. `<RouterView>` at the top level |
135
- | Pinia | option stores with a typed `state`, read through `useX()` or `storeToRefs`, in the template or in `computed`; getters that are an expression of the state |
163
+ | 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
+ | 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 |
165
+ | Pinia | option stores with a typed `state`, and setup stores (`defineStore(id, () => { … })`) whose returned refs are typed by `ref<T>()` or their initial literal; read through `useX()` or `storeToRefs`, in the template or in `computed`; getters that are an expression of the state, and a setup store's computeds, which may read each other |
166
+ | vue-i18n | `$t` and `useI18n()`'s `t` and `locale`: named and list values, plurals by vue-i18n's rule, literals, linked messages with `upper`/`lower`/`capitalize`, nested and flat keys, fallback locales, a missing key shown as itself. Messages are parsed at build time by vue-i18n's own compiler |
136
167
  | `v-html` | only on a `TrustedHtml` prop (`import type { TrustedHtml } from "ferrovue/types"`) |
137
168
 
138
169
  Refused at compile time, each with an error that names the construct:
139
170
 
140
- - `<style scoped>`, `<style module>`, and `v-bind()` in CSS
141
- - `<Teleport>` and `<component :is>`
171
+ - `<style module>`, and `v-bind()` in CSS
172
+ - `<RouterView>` in a component with `<style scoped>`, which would give the page that component's
173
+ id; and, since vue-router renders a link from virtual nodes, a `<slot>` inside a `<RouterLink>`
174
+ that takes scope ids, or an element inside one in slot content given a `:slotted()` id
175
+ - `<component :is>`
142
176
  - `<RouterLink custom>`, slot props that are array literals, defaults in destructured slot props, and outlets of one slot that pass different props
143
177
  - custom directives not listed in `clientDirectives`
144
178
  - `watchEffect`, `watch` with `immediate`, `onServerPrefetch`, top-level `await`, and statements in setup that change state
145
- - `route.query` and `route.fullPath`, and setup stores
179
+ - `route.meta` and `route.matched`
146
180
  - Pinia getters that read `this` or return a function
147
- - `/` and fractions, ordering comparisons of strings
181
+ - ordering comparisons between a string and a number, which JavaScript makes numeric
182
+ - regular expressions (`.replace(/x/g, …)`, `.split(/,/)`), replacement functions, a search's
183
+ starting position or a split's limit (`.includes(x, 3)`, `.split(",", 2)`), `.toLocaleUpperCase()`
184
+ and `.toLocaleLowerCase()` (the server's locale is not the browser's), `parseInt` with another radix
185
+ or of a number, `.repeat()` by a negative literal
186
+ - array methods given anything but an arrow function whose body is an expression (`.filter(Boolean)`,
187
+ `x => { return … }`), `.map()` to optional values, a computed list as a slot prop
188
+ - a dictionary's field read by name (`r.key`, `r[key]`), which may be absent although TypeScript says
189
+ it is not; `Object.entries()` anywhere but as a `v-for`'s source; dictionaries of optional values
190
+ - two strings that may each hold half of a surrogate pair compared, searched or joined (see
191
+ [Strings](#strings))
148
192
  - `null`
193
+ - an attribute (`class` and `style` aside) bound to a value that may be neither a string, a number
194
+ nor a boolean: a list, an object, or a `route.query` value, which is an array when its key is
195
+ repeated. Vue's server renderer leaves the attribute out and hydration then sets it, silently;
196
+ join a list (`.join(",")`), or narrow a query value with `typeof route.query.q === "string"`
149
197
  - any method call without a Rust twin
198
+ - a value read from `$attrs` or `useAttrs()` (`$attrs.title`), which has no type; an attribute a
199
+ component is passed that would reach a prop of the component (or `<RouterLink>`) its root passes it
200
+ on to; `$attrs` in a component that is the root of one that may be handed scope ids, where it would
201
+ hold them; an attribute named by a number, or bound to a `$route.query` value; a class merged by
202
+ `v-bind` with a string that may equal the class before it, which Vue would write once; attributes
203
+ passed to a component whose root is a `<Transition>` or `<KeepAlive>` around a `v-if`, which Vue's
204
+ server drops but its client keeps
150
205
 
151
206
  An object prop handed to a child component is cloned. Its strings are `Cow`s, so borrowed ones
152
207
  cost nothing to copy.
153
208
 
209
+ ### Scoped styles
210
+
211
+ A `<style scoped>` component's elements carry `data-v-<id>`, and its CSS is rewritten by the client
212
+ build to select them. The server has to write the id the client build chose, which is not something
213
+ 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
216
+ two must be configured alike:
217
+
218
+ | `@vitejs/plugin-vue` | `ferrovue.config.json` |
219
+ |---|---|
220
+ | `vite build` with the default options | `"scopeId": "filepath-source"` (the default) |
221
+ | `features: { componentIdGenerator: "filepath" }` (any mode), or the dev server | `"scopeId": "filepath"` |
222
+
223
+ Set `viteRoot` when Vite's root is not the directory holding `ferrovue.config.json`. The Vite
224
+ plugin (`ferrovue/vite`) compares the two when a component has scoped styles: a build in which they
225
+ differ fails, and the dev server warns.
226
+
227
+ The default is the plugin's production behaviour because the production build is the one readers
228
+ get: with every option left alone, its styles apply. But plugin-vue hashes the path alone in its dev
229
+ server, so with the default a page rendered during development carries other ids than the dev
230
+ client and shows unstyled. The recommended setup is `componentIdGenerator: "filepath"` with
231
+ `"scopeId": "filepath"`, as [`examples/fullstack`](examples/fullstack) does: the ids are then the
232
+ same in development and production, and do not change, nor change the generated Rust, whenever a
233
+ component's source does.
234
+
235
+ A component a parent may hand ids to has a `render_scoped(…, attrs)` beside `render`, which
236
+ generated parents call (for one a parent also passes attributes to, `attrs` is an `fv::Attrs`
237
+ holding them and the ids). A component whose outlets pass a slot scope id (`:slotted()`) takes slot
238
+ content that is given it: `Slot::slotted` for a slot, a third `&str` parameter for a scoped slot's
239
+ closure. `render`, and content from Rust, need none of it: markup written from Rust carries no ids.
240
+
241
+ Where Vue's own server render gives other ids than its client render, ferrovue writes the server's:
242
+ a `:slotted()` component's slot fallback, which only the client gives the slot scope id, and a
243
+ component with `inheritAttrs: false` that is another component's root, whose root only the client
244
+ gives the ids that other component inherits.
245
+
246
+ ### Strings
247
+
248
+ String methods count as JavaScript counts: in UTF-16 code units, so `"🦀".length` is 2 and
249
+ `"🦀 crab".slice(3)` is `"crab"`. Strings are ordered by code unit too, which puts every character
250
+ 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/`).
252
+
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
255
+ the page carries anyway: a server sends Vue's string as UTF-8, and UTF-8 writes each half as U+FFFD
256
+ (`res.end`, `Buffer.from` and `TextEncoder` all do). Its length is the same. Where the half itself
257
+ would decide the result, ferrovue refuses at compile time: two strings that may each hold a half
258
+ compared or ordered, side by side (`a.slice(0, 1) + b.slice(1)`, a `.join("")`, a class object's
259
+ names), one searched for in another (`.includes(a.charAt(0))`), repeated or padded where halves
260
+ would join, ordered against anything but a literal below U+D800, or written by `JSON.stringify`
261
+ (which escapes it) or into a `<RouterLink>` (whose encoding throws). Three corners remain, by design:
262
+
263
+ - a string holding U+FFFD in the props compares equal to a half, or finds one, where JavaScript
264
+ would tell them apart;
265
+ - a half handed to a child component or a helper is U+FFFD there, which matters only if that
266
+ component then compares or joins it as above;
267
+ - Vue itself cannot hydrate such text cleanly: the browser reads U+FFFD where its own render holds
268
+ the half, and reports a mismatch.
269
+
270
+ Where JavaScript throws, the generated code panics, as Vue's render rejects: a negative or infinite
271
+ `.repeat()` count, and a `.repeat()`, `.padStart()` or `.padEnd()` past the longest string V8 makes
272
+ (2²⁹ − 24 code units).
273
+
274
+ ### Dictionaries
275
+
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
278
+ JavaScript object does, array indices (`"0"` to `"4294967294"`) first in numeric order, so `v-for`
279
+ walks them as Vue does, and it is written back as JSON in that order, which the browser reads back
280
+ the same.
281
+
282
+ ### Translating
283
+
284
+ With `i18n` configured, the generated `i18n` module holds every locale's messages. Build an `I18n` per
285
+ request, in the reader's locale, and pass it to the components that translate:
286
+
287
+ ```rust
288
+ let i18n = generated::i18n::i18n("nl"); // falls back as configured
289
+ page::render(&mut out, &props, &i18n);
290
+ ```
291
+
292
+ ### Teleports
293
+
294
+ A component that renders a `<Teleport>` takes a `ferrovue::Teleports`. After rendering the page body,
295
+ write what was teleported where each target is, as Vue's `ssrContext.teleports` is written:
296
+
297
+ ```rust
298
+ let teleports = ferrovue::Teleports::new();
299
+ page::render(&mut body, &props, &teleports);
300
+ let modals = teleports.get("#modals").unwrap_or_default(); // inside <div id="modals">
301
+ ```
302
+
303
+ As Vue recommends, teleport to a dedicated element (`#modals`) rather than `body`: the browser hydrates
304
+ a target from its first node, and `body` also holds the app.
305
+
154
306
  ### Scoped slots from Rust
155
307
 
156
308
  A scoped slot's `Slots` field takes a closure that receives the props its outlet passes, as a
@@ -165,6 +317,37 @@ let row = |out: &mut String, p: &data_list::RowSlotProps<'_>| {
165
317
  data_list::render(&mut page, &props, data_list::Slots { row: Some(&row), ..Default::default() });
166
318
  ```
167
319
 
320
+ ### Hydrating islands
321
+
322
+ `mountIslands` from `ferrovue/client` hydrates every `data-island` element on the page with the
323
+ component of that name, from the props the server wrote. With the Vite plugin, `ferrovue/islands`
324
+ gives it every island there is, so nothing has to be listed by hand:
325
+
326
+ ```ts
327
+ import { hydrateState, mountIslands } from "ferrovue/client";
328
+ import islands from "ferrovue/islands"; // written by `ferrovue()` from `ferrovue/vite`
329
+
330
+ hydrateState(pinia);
331
+ await mountIslands(islands, { pinia, router }); // one Pinia and one router for every island
332
+ ```
333
+
334
+ The islands are exactly the components that have an `island()`: those that render from their props
335
+ alone, as the compiler finds while it generates them. That is the only set the server can write
336
+ `data-island` for, so it needs no folder convention or list to keep in step, and making a component
337
+ an island is a matter of the server calling its `island()`. Each is keyed by its `NAME`, the file
338
+ name without `.vue`, which is what `data-island` carries.
339
+
340
+ Each entry is a lazy `import()`, so the bundler gives every island a chunk of its own and a page
341
+ fetches the code of the islands it holds and no more. `mountIslands` takes such loaders and
342
+ components alike, so a hand-written island can join them: `mountIslands({ ...islands, Chart })`.
343
+ An island whose name is not among them is left as the server rendered it, with a warning naming it
344
+ (`onError` to handle it otherwise), as is one whose component fails to load.
345
+
346
+ An island's `<style>` goes into its chunk too, and Vite links it only once the island's script has
347
+ loaded, after the server's markup is on screen. Link those stylesheets from the page up front: a
348
+ server reading Vite's manifest takes the `css` of the entry's `dynamicImports` as well as its own
349
+ (`examples/fullstack/src/assets.rs`).
350
+
168
351
  ### Hydrating Pinia state
169
352
 
170
353
  ```rust
@@ -180,7 +363,60 @@ hydrateState(pinia); // before app.mount(): every store starts from what the ser
180
363
 
181
364
  `ferrovue::hole()` is a slot whose content you write later. Render a layout with holes,
182
365
  `split_holes` the output, and stream the pieces with each hole's content between them, in whatever
183
- order the content is ready.
366
+ order the content is ready. With the `axum` or `actix-web` feature, `ferrovue::HtmlStream` is that
367
+ streamed response, from the render and a future for each hole's content:
368
+
369
+ ```rust
370
+ let body = ferrovue::HtmlStream::new(page).hole(async move { reviews_of(&id).await });
371
+ ```
372
+
373
+ ## Performance
374
+
375
+ Time to render one component to an HTML string. On the left is Vue's `renderToString` on Node, and
376
+ on the right is the Rust that ferrovue generated from the same `.vue` file. Both get the same props,
377
+ and both write the same bytes: each side checks its output against
378
+ `crates/ferrovue/benches/expected/<scenario>.html` before anything is timed.
379
+
380
+ | Scenario | What renders | Output | Vue `renderToString` | ferrovue | Speed-up |
381
+ |---|---|---|---|---|---|
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× |
386
+
387
+ These are mean times per render. The Vue column is tinybench's mean, with 5 s per scenario after
388
+ a 1 s warm-up. The ferrovue column is criterion's mean point estimate, using its defaults of a 3 s
389
+ warm-up and 100 samples.
390
+
391
+ What each side measures:
392
+
393
+ - **Vue**: one awaited `renderToString(app)`. Vue is the production build (`NODE_ENV=production`).
394
+ Each component uses the `ssrRender` its SSR build would have. The app is created once outside
395
+ the loop, along with its Pinia instance and, for `small`, a vue-router already at `/users/me`.
396
+ - **ferrovue**: a new `String` plus one call of the generated `render`, built with the release
397
+ profile. The props, the router and the `Route` are built once outside the loop.
398
+
399
+ Measured on an AMD Ryzen 5 3600XT (6 cores / 12 threads, up to 3.8 GHz) with 62 GiB of RAM, running
400
+ Linux 7.2.8-2-cachyos (CachyOS, x86_64). Software: Node 26.10.0, Vue 3.5.43, rustc 1.99.0. The
401
+ machine was lightly loaded (a load average of about 1) but not idle, so treat the numbers as
402
+ indicative.
403
+
404
+ The speed-up says as much about Vue as about ferrovue. Most of Vue's time goes on component
405
+ instances and their virtual nodes, of which `tree` has 255 and `list` one: per byte written, Vue
406
+ renders `list` some fourteen times faster than `tree`, while ferrovue writes the two at a similar
407
+ rate.
408
+
409
+ To reproduce:
410
+
411
+ ```sh
412
+ pnpm bench # both halves: pnpm bench:rust, then pnpm bench:js
413
+ pnpm bench:rust # cargo bench -p ferrovue --bench render (crates/ferrovue/benches/render.rs)
414
+ pnpm bench:js # Vue's renderToString with tinybench (packages/ferrovue/bench/ssr.bench.ts)
415
+ pnpm bench:record # rewrite benches/expected/*.html from Vue, after changing a scenario
416
+ ```
417
+
418
+ The benchmarks are not part of `pnpm test`. CI runs both in a short mode only to check that they
419
+ still build, run and agree on the HTML. It never compares their timings.
184
420
 
185
421
  ## Versions
186
422
 
@@ -193,16 +429,31 @@ ferrovue version.
193
429
 
194
430
  ```text
195
431
  crates/ferrovue/ the Rust runtime crate
196
- src/ runtime + unit tests
432
+ src/ runtime (escaping, JS numbers, router, i18n, teleports) + unit tests
197
433
  tests/conformance/ components, fixtures, recorded HTML, generated Rust
198
- tests/vectors/ string and router vectors shared with the TypeScript tests
434
+ tests/vectors/ vectors recorded from JavaScript, vue-router and vue-i18n
199
435
  tests/properties.rs property-based tests of the runtime
436
+ benches/ criterion benchmarks of generated renderers (see Performance)
200
437
  packages/ferrovue/ the compiler (npm package)
201
- src/compiler.ts .vue → Rust
202
- src/cli.ts the `ferrovue` command
203
- src/client.ts browser-side helpers
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
443
+ src/rust.ts, emitter.ts the Rust source written out
444
+ src/cli.ts, vite.ts the `ferrovue` command and the Vite plugin
445
+ src/client.ts browser-side helpers: `mountIslands`, `hydrateState`
446
+ src/islands.ts `ferrovue/islands`, which the Vite plugin writes: every island, loaded lazily
204
447
  src/testing.ts utilities for a project's own conformance suite
205
- test/ compiler, CLI, router, vector and conformance tests
448
+ src/types.ts `ferrovue/types`: `TrustedHtml`, `Float`
449
+ test/ compiler, CLI, router, vector, island, Vite and conformance tests
450
+ browser/ conformance fixtures hydrated in real browsers (`pnpm test:browser`)
451
+ bench/ Vue renderToString benchmarks, the other half of Performance
452
+ fuzz/ the randomised differential tester (`pnpm fuzz`)
453
+ examples/greeting/ the smallest setup: one component rendered from Rust
454
+ examples/fullstack/ axum + Vite: islands, Pinia state, routes and streaming
455
+ examples/dioxus/ a Dioxus page, rendered with dioxus-ssr, with an island in it
456
+ scripts/release.ts the release version bump (see RELEASING.md)
206
457
  ```
207
458
 
208
459
  ## Development
@@ -214,7 +465,11 @@ pnpm typecheck
214
465
  cargo test --all-features
215
466
  ```
216
467
 
217
- [TESTING.md](TESTING.md) explains how the suite fits together and how to add a case.
468
+ [TESTING.md](TESTING.md) explains how the suite fits together and how to add a case,
469
+ [CONTRIBUTING.md](CONTRIBUTING.md) how to add support for a Vue construct, and
470
+ [RELEASING.md](RELEASING.md) how a release is cut.
471
+
472
+ [`examples/fullstack`](examples/fullstack/README.md) is a complete app to copy: an axum server, a Vite client, islands, Pinia, vue-router and streaming, with a test that it hydrates.
218
473
 
219
474
  ## Licence
220
475
 
@@ -0,0 +1,67 @@
1
+ import { type N, type Scope, type Val } from "./model.ts";
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. */
6
+ export declare function attrValue(e: Emitter, v: Val): void;
7
+ /** `ssrRenderAttr(key, value)`: absent for null/undefined, `key="value"` for anything else. */
8
+ 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
+ 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
+ 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
+ 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
+ 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
+ export declare function dollarAttrs(s: Scope, n: N): boolean;
35
+ /** The parts `_mergeProps(…)` merges, or the one object that is not merged. */
36
+ 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
+ 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
+ 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
+ export declare function attrEntries(s: Scope, obj: N, side: "vnode" | "own"): string[];
51
+ /** One attribute's value as an `fv::Attr`. */
52
+ 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;