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.
- package/README.md +277 -22
- package/dist/attrs.d.ts +67 -0
- package/dist/attrs.js +769 -0
- package/dist/cli.js +183 -14
- package/dist/client.d.ts +36 -0
- package/dist/client.js +93 -1
- package/dist/compiler.d.ts +20 -53
- package/dist/compiler.js +99 -3178
- package/dist/component.d.ts +6 -0
- package/dist/component.js +264 -0
- package/dist/context.d.ts +151 -0
- package/dist/context.js +102 -0
- package/dist/diff.d.ts +4 -0
- package/dist/diff.js +77 -0
- package/dist/emitter.d.ts +23 -0
- package/dist/emitter.js +58 -0
- package/dist/expr.d.ts +76 -0
- package/dist/expr.js +1305 -0
- package/dist/fixture.d.ts +38 -0
- package/dist/fixture.js +78 -0
- package/dist/i18n.d.ts +27 -0
- package/dist/i18n.js +175 -0
- package/dist/islands.d.ts +5 -0
- package/dist/islands.js +4 -0
- package/dist/lists.d.ts +19 -0
- package/dist/lists.js +253 -0
- package/dist/model.d.ts +269 -0
- package/dist/model.js +115 -0
- package/dist/parens.d.ts +51 -0
- package/dist/parens.js +239 -0
- package/dist/router.d.ts +17 -0
- package/dist/router.js +322 -0
- package/dist/rust.d.ts +17 -0
- package/dist/rust.js +523 -0
- package/dist/scoped.d.ts +24 -0
- package/dist/scoped.js +177 -0
- package/dist/script.d.ts +16 -0
- package/dist/script.js +332 -0
- package/dist/stores.d.ts +6 -0
- package/dist/stores.js +229 -0
- package/dist/template.d.ts +48 -0
- package/dist/template.js +1012 -0
- package/dist/testing.d.ts +2 -22
- package/dist/testing.js +11 -55
- package/dist/types.d.ts +1 -0
- package/dist/typescript.d.ts +31 -0
- package/dist/typescript.js +310 -0
- package/dist/vite.d.ts +23 -0
- package/dist/vite.js +157 -0
- 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
|
-
|
|
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; `+`, `-`, `*`,
|
|
125
|
-
|
|
|
126
|
-
|
|
|
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
|
-
|
|
|
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
|
|
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
|
|
141
|
-
- `<
|
|
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.
|
|
179
|
+
- `route.meta` and `route.matched`
|
|
146
180
|
- Pinia getters that read `this` or return a function
|
|
147
|
-
-
|
|
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/
|
|
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
|
|
202
|
-
src/
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/attrs.d.ts
ADDED
|
@@ -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;
|