ferrovue 0.1.0 → 0.2.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 +206 -20
- package/dist/attrs.d.ts +51 -0
- package/dist/attrs.js +488 -0
- package/dist/cli.js +68 -10
- package/dist/client.d.ts +27 -0
- package/dist/client.js +44 -0
- package/dist/compiler.d.ts +16 -53
- package/dist/compiler.js +87 -3178
- package/dist/component.d.ts +6 -0
- package/dist/component.js +251 -0
- package/dist/context.d.ts +151 -0
- package/dist/context.js +84 -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 +1267 -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/lists.d.ts +19 -0
- package/dist/lists.js +253 -0
- package/dist/model.d.ts +248 -0
- package/dist/model.js +102 -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 +272 -0
- package/dist/rust.d.ts +14 -0
- package/dist/rust.js +496 -0
- package/dist/scoped.d.ts +10 -0
- package/dist/scoped.js +90 -0
- package/dist/script.d.ts +16 -0
- package/dist/script.js +316 -0
- package/dist/stores.d.ts +6 -0
- package/dist/stores.js +227 -0
- package/dist/template.d.ts +45 -0
- package/dist/template.js +939 -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 +114 -0
- package/package.json +26 -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
|
|
|
@@ -89,7 +92,8 @@ mod generated;
|
|
|
89
92
|
|
|
90
93
|
use generated::greeting::{self, Props};
|
|
91
94
|
|
|
92
|
-
|
|
95
|
+
// `new` takes the required props; each optional one has a setter.
|
|
96
|
+
let props = Props::new("Ada", 3).note("welcome back");
|
|
93
97
|
|
|
94
98
|
// The markup alone:
|
|
95
99
|
let html: String = greeting::html(&props).into_string();
|
|
@@ -98,6 +102,10 @@ let html: String = greeting::html(&props).into_string();
|
|
|
98
102
|
// <div data-island="Greeting" data-props="{…}">…</div>
|
|
99
103
|
let island = greeting::island(&props).into_string();
|
|
100
104
|
|
|
105
|
+
// (`island()` exists for a component that renders from its props alone: the client rebuilds it from
|
|
106
|
+
// `data-props`. One that also takes slots, the route, stores, translations or teleports has `html()`,
|
|
107
|
+
// and the page's own app mounts it — see examples/fullstack.)
|
|
108
|
+
|
|
101
109
|
// …or straight into a buffer you already hold:
|
|
102
110
|
let mut page = String::from("<!doctype html><body>");
|
|
103
111
|
greeting::render(&mut page, &props);
|
|
@@ -117,40 +125,153 @@ ferrovue compiles `<script setup lang="ts">` components. Props are declared by t
|
|
|
117
125
|
|
|
118
126
|
| Area | Supported |
|
|
119
127
|
|---|---|
|
|
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` |
|
|
128
|
+
| 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
129
|
| 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
130
|
| 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
131
|
| 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
|
-
|
|
|
132
|
+
| 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 |
|
|
133
|
+
| 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)) |
|
|
134
|
+
| 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` |
|
|
135
|
+
| 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 |
|
|
136
|
+
| 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
137
|
| Attributes | static and bound attributes, boolean attributes, `:hidden`, `data-*` and `aria-*`, `v-bind` objects |
|
|
128
138
|
| `class` | strings, arrays, objects (`{ active: on }`, computed keys), `cond && "x"`, `cond ? "x" : null`, merged with a static `class` |
|
|
129
139
|
| `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 |
|
|
140
|
+
| 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 |
|
|
130
141
|
| Components | imported child components, `v-bind` of a child's own `Props`, `v-model` on a child's `defineModel`, recursion |
|
|
131
142
|
| 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
143
|
| 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 |
|
|
144
|
+
| 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` |
|
|
145
|
+
| 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). `<RouterView>`, at the top and in nested route components: each takes the page it shows as a slot |
|
|
146
|
+
| 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 |
|
|
147
|
+
| 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
148
|
| `v-html` | only on a `TrustedHtml` prop (`import type { TrustedHtml } from "ferrovue/types"`) |
|
|
137
149
|
|
|
138
150
|
Refused at compile time, each with an error that names the construct:
|
|
139
151
|
|
|
140
|
-
- `<style
|
|
141
|
-
- `<
|
|
152
|
+
- `<style module>`, and `v-bind()` in CSS
|
|
153
|
+
- `<RouterView>` in a component with `<style scoped>`, which would give the page that component's
|
|
154
|
+
id; and, since vue-router renders a link from virtual nodes, a `<slot>` inside a `<RouterLink>`
|
|
155
|
+
that takes scope ids, or an element inside one in slot content given a `:slotted()` id
|
|
156
|
+
- `<component :is>`
|
|
142
157
|
- `<RouterLink custom>`, slot props that are array literals, defaults in destructured slot props, and outlets of one slot that pass different props
|
|
143
158
|
- custom directives not listed in `clientDirectives`
|
|
144
159
|
- `watchEffect`, `watch` with `immediate`, `onServerPrefetch`, top-level `await`, and statements in setup that change state
|
|
145
|
-
- `route.
|
|
160
|
+
- `route.meta` and `route.matched`
|
|
146
161
|
- Pinia getters that read `this` or return a function
|
|
147
|
-
-
|
|
162
|
+
- ordering comparisons between a string and a number, which JavaScript makes numeric
|
|
163
|
+
- regular expressions (`.replace(/x/g, …)`, `.split(/,/)`), replacement functions, a search's
|
|
164
|
+
starting position or a split's limit (`.includes(x, 3)`, `.split(",", 2)`), `.toLocaleUpperCase()`
|
|
165
|
+
and `.toLocaleLowerCase()` (the server's locale is not the browser's), `parseInt` with another radix
|
|
166
|
+
or of a number, `.repeat()` by a negative literal
|
|
167
|
+
- array methods given anything but an arrow function whose body is an expression (`.filter(Boolean)`,
|
|
168
|
+
`x => { return … }`), `.map()` to optional values, a computed list as a slot prop
|
|
169
|
+
- a dictionary's field read by name (`r.key`, `r[key]`), which may be absent although TypeScript says
|
|
170
|
+
it is not; `Object.entries()` anywhere but as a `v-for`'s source; dictionaries of optional values
|
|
171
|
+
- two strings that may each hold half of a surrogate pair compared, searched or joined (see
|
|
172
|
+
[Strings](#strings))
|
|
148
173
|
- `null`
|
|
149
174
|
- any method call without a Rust twin
|
|
150
175
|
|
|
151
176
|
An object prop handed to a child component is cloned. Its strings are `Cow`s, so borrowed ones
|
|
152
177
|
cost nothing to copy.
|
|
153
178
|
|
|
179
|
+
### Scoped styles
|
|
180
|
+
|
|
181
|
+
A `<style scoped>` component's elements carry `data-v-<id>`, and its CSS is rewritten by the client
|
|
182
|
+
build to select them. The server has to write the id the client build chose, which is not something
|
|
183
|
+
the browser checks when it hydrates: a wrong id hydrates cleanly and leaves the styles unapplied. So
|
|
184
|
+
ferrovue computes it as `@vitejs/plugin-vue` does — the first 8 hex digits of a SHA-256 of the
|
|
185
|
+
`.vue` file's path from Vite's root, followed by its source unless only the path is hashed — and the
|
|
186
|
+
two must be configured alike:
|
|
187
|
+
|
|
188
|
+
| `@vitejs/plugin-vue` | `ferrovue.config.json` |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `vite build` with the default options | `"scopeId": "filepath-source"` (the default) |
|
|
191
|
+
| `features: { componentIdGenerator: "filepath" }` (any mode), or the dev server | `"scopeId": "filepath"` |
|
|
192
|
+
|
|
193
|
+
Set `viteRoot` when Vite's root is not the directory holding `ferrovue.config.json`. The Vite
|
|
194
|
+
plugin (`ferrovue/vite`) compares the two when a component has scoped styles: a build in which they
|
|
195
|
+
differ fails, and the dev server warns.
|
|
196
|
+
|
|
197
|
+
The default is the plugin's production behaviour because the production build is the one readers
|
|
198
|
+
get: with every option left alone, its styles apply. But plugin-vue hashes the path alone in its dev
|
|
199
|
+
server, so with the default a page rendered during development carries other ids than the dev
|
|
200
|
+
client and shows unstyled. The recommended setup is `componentIdGenerator: "filepath"` with
|
|
201
|
+
`"scopeId": "filepath"`, as [`examples/fullstack`](examples/fullstack) does: the ids are then the
|
|
202
|
+
same in development and production, and do not change, nor change the generated Rust, whenever a
|
|
203
|
+
component's source does.
|
|
204
|
+
|
|
205
|
+
A component a parent may hand ids to has a `render_scoped(…, attrs)` beside `render`, which
|
|
206
|
+
generated parents call. A component whose outlets pass a slot scope id (`:slotted()`) takes slot
|
|
207
|
+
content that is given it: `Slot::slotted` for a slot, a third `&str` parameter for a scoped slot's
|
|
208
|
+
closure. `render`, and content from Rust, need none of it: markup written from Rust carries no ids.
|
|
209
|
+
|
|
210
|
+
Where Vue's own server render gives other ids than its client render, ferrovue writes the server's:
|
|
211
|
+
a `:slotted()` component's slot fallback, which only the client gives the slot scope id, and a
|
|
212
|
+
component with `inheritAttrs: false` that is another component's root, whose root only the client
|
|
213
|
+
gives the ids that other component inherits.
|
|
214
|
+
|
|
215
|
+
### Strings
|
|
216
|
+
|
|
217
|
+
String methods count as JavaScript counts: in UTF-16 code units, so `"🦀".length` is 2 and
|
|
218
|
+
`"🦀 crab".slice(3)` is `"crab"`. Strings are ordered by code unit too, which puts every character
|
|
219
|
+
from U+E000 to U+FFFF after one beyond U+FFFF. Each runtime routine is held to vectors recorded from
|
|
220
|
+
JavaScript (`crates/ferrovue/tests/vectors/`).
|
|
221
|
+
|
|
222
|
+
A JavaScript string can hold half of a surrogate pair — `"🦀".slice(0, 1)`, `.charAt(1)`,
|
|
223
|
+
`.split("")` — and a Rust string cannot. ferrovue writes U+FFFD in its place, which is exactly what
|
|
224
|
+
the page carries anyway: a server sends Vue's string as UTF-8, and UTF-8 writes each half as U+FFFD
|
|
225
|
+
(`res.end`, `Buffer.from` and `TextEncoder` all do). Its length is the same. Where the half itself
|
|
226
|
+
would decide the result, ferrovue refuses at compile time: two strings that may each hold a half
|
|
227
|
+
compared or ordered, side by side (`a.slice(0, 1) + b.slice(1)`, a `.join("")`, a class object's
|
|
228
|
+
names), one searched for in another (`.includes(a.charAt(0))`), repeated or padded where halves
|
|
229
|
+
would join, ordered against anything but a literal below U+D800, or written by `JSON.stringify`
|
|
230
|
+
(which escapes it) or into a `<RouterLink>` (whose encoding throws). Three corners remain, by design:
|
|
231
|
+
|
|
232
|
+
- a string holding U+FFFD in the props compares equal to a half, or finds one, where JavaScript
|
|
233
|
+
would tell them apart;
|
|
234
|
+
- a half handed to a child component or a helper is U+FFFD there, which matters only if that
|
|
235
|
+
component then compares or joins it as above;
|
|
236
|
+
- Vue itself cannot hydrate such text cleanly: the browser reads U+FFFD where its own render holds
|
|
237
|
+
the half, and reports a mismatch.
|
|
238
|
+
|
|
239
|
+
Where JavaScript throws, the generated code panics, as Vue's render rejects: a negative or infinite
|
|
240
|
+
`.repeat()` count, and a `.repeat()`, `.padStart()` or `.padEnd()` past the longest string V8 makes
|
|
241
|
+
(2²⁹ − 24 code units).
|
|
242
|
+
|
|
243
|
+
### Dictionaries
|
|
244
|
+
|
|
245
|
+
A `Record<string, T>` prop is a `ferrovue::Record`, built from pairs —
|
|
246
|
+
`[("b", 1), ("10", 2)].into_iter().collect()` — or read from JSON. It holds its keys in the order a
|
|
247
|
+
JavaScript object does, array indices (`"0"` to `"4294967294"`) first in numeric order, so `v-for`
|
|
248
|
+
walks them as Vue does, and it is written back as JSON in that order, which the browser reads back
|
|
249
|
+
the same.
|
|
250
|
+
|
|
251
|
+
### Translating
|
|
252
|
+
|
|
253
|
+
With `i18n` configured, the generated `i18n` module holds every locale's messages. Build an `I18n` per
|
|
254
|
+
request, in the reader's locale, and pass it to the components that translate:
|
|
255
|
+
|
|
256
|
+
```rust
|
|
257
|
+
let i18n = generated::i18n::i18n("nl"); // falls back as configured
|
|
258
|
+
page::render(&mut out, &props, &i18n);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Teleports
|
|
262
|
+
|
|
263
|
+
A component that renders a `<Teleport>` takes a `ferrovue::Teleports`. After rendering the page body,
|
|
264
|
+
write what was teleported where each target is, as Vue's `ssrContext.teleports` is written:
|
|
265
|
+
|
|
266
|
+
```rust
|
|
267
|
+
let teleports = ferrovue::Teleports::new();
|
|
268
|
+
page::render(&mut body, &props, &teleports);
|
|
269
|
+
let modals = teleports.get("#modals").unwrap_or_default(); // inside <div id="modals">
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
As Vue recommends, teleport to a dedicated element (`#modals`) rather than `body`: the browser hydrates
|
|
273
|
+
a target from its first node, and `body` also holds the app.
|
|
274
|
+
|
|
154
275
|
### Scoped slots from Rust
|
|
155
276
|
|
|
156
277
|
A scoped slot's `Slots` field takes a closure that receives the props its outlet passes, as a
|
|
@@ -182,6 +303,54 @@ hydrateState(pinia); // before app.mount(): every store starts from what the ser
|
|
|
182
303
|
`split_holes` the output, and stream the pieces with each hole's content between them, in whatever
|
|
183
304
|
order the content is ready.
|
|
184
305
|
|
|
306
|
+
## Performance
|
|
307
|
+
|
|
308
|
+
Time to render one component to an HTML string. On the left is Vue's `renderToString` on Node, and
|
|
309
|
+
on the right is the Rust that ferrovue generated from the same `.vue` file. Both get the same props,
|
|
310
|
+
and both write the same bytes: each side checks its output against
|
|
311
|
+
`crates/ferrovue/benches/expected/<scenario>.html` before anything is timed.
|
|
312
|
+
|
|
313
|
+
| Scenario | What renders | Output | Vue `renderToString` | ferrovue | Speed-up |
|
|
314
|
+
|---|---|---|---|---|---|
|
|
315
|
+
| `small` | `Nav`: two `<RouterLink>`s resolved against the current route | 206 B | 34.57 µs | 0.950 µs | 36.4× |
|
|
316
|
+
| `list` | `Lists`: 1,000 words, 1,000 numbers, 100 groups of 10 members | 102 KiB | 368.1 µs | 45.95 µs | 8.0× |
|
|
317
|
+
| `tree` | `Tree`: a recursive component, binary tree 8 levels deep (255 nodes) | 9.5 KiB | 484.6 µs | 3.72 µs | 130.2× |
|
|
318
|
+
| `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× |
|
|
319
|
+
|
|
320
|
+
These are mean times per render. The Vue column is tinybench's mean, with 5 s per scenario after
|
|
321
|
+
a 1 s warm-up. The ferrovue column is criterion's mean point estimate, using its defaults of a 3 s
|
|
322
|
+
warm-up and 100 samples.
|
|
323
|
+
|
|
324
|
+
What each side measures:
|
|
325
|
+
|
|
326
|
+
- **Vue**: one awaited `renderToString(app)`. Vue is the production build (`NODE_ENV=production`).
|
|
327
|
+
Each component uses the `ssrRender` its SSR build would have. The app is created once outside
|
|
328
|
+
the loop, along with its Pinia instance and, for `small`, a vue-router already at `/users/me`.
|
|
329
|
+
- **ferrovue**: a new `String` plus one call of the generated `render`, built with the release
|
|
330
|
+
profile. The props, the router and the `Route` are built once outside the loop.
|
|
331
|
+
|
|
332
|
+
Measured on an AMD Ryzen 5 3600XT (6 cores / 12 threads, up to 3.8 GHz) with 62 GiB of RAM, running
|
|
333
|
+
Linux 7.2.8-2-cachyos (CachyOS, x86_64). Software: Node 26.10.0, Vue 3.5.43, rustc 1.99.0. The
|
|
334
|
+
machine was lightly loaded (a load average of about 1) but not idle, so treat the numbers as
|
|
335
|
+
indicative.
|
|
336
|
+
|
|
337
|
+
The speed-up says as much about Vue as about ferrovue. Most of Vue's time goes on component
|
|
338
|
+
instances and their virtual nodes, of which `tree` has 255 and `list` one: per byte written, Vue
|
|
339
|
+
renders `list` some fourteen times faster than `tree`, while ferrovue writes the two at a similar
|
|
340
|
+
rate.
|
|
341
|
+
|
|
342
|
+
To reproduce:
|
|
343
|
+
|
|
344
|
+
```sh
|
|
345
|
+
pnpm bench # both halves: pnpm bench:rust, then pnpm bench:js
|
|
346
|
+
pnpm bench:rust # cargo bench -p ferrovue --bench render (crates/ferrovue/benches/render.rs)
|
|
347
|
+
pnpm bench:js # Vue's renderToString with tinybench (packages/ferrovue/bench/ssr.bench.ts)
|
|
348
|
+
pnpm bench:record # rewrite benches/expected/*.html from Vue, after changing a scenario
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The benchmarks are not part of `pnpm test`. CI runs both in a short mode only to check that they
|
|
352
|
+
still build, run and agree on the HTML. It never compares their timings.
|
|
353
|
+
|
|
185
354
|
## Versions
|
|
186
355
|
|
|
187
356
|
Byte-identical output depends on Vue's own SSR compiler, so the package pins **`vue`,
|
|
@@ -193,16 +362,29 @@ ferrovue version.
|
|
|
193
362
|
|
|
194
363
|
```text
|
|
195
364
|
crates/ferrovue/ the Rust runtime crate
|
|
196
|
-
src/ runtime + unit tests
|
|
365
|
+
src/ runtime (escaping, JS numbers, router, i18n, teleports) + unit tests
|
|
197
366
|
tests/conformance/ components, fixtures, recorded HTML, generated Rust
|
|
198
|
-
tests/vectors/
|
|
367
|
+
tests/vectors/ vectors recorded from JavaScript, vue-router and vue-i18n
|
|
199
368
|
tests/properties.rs property-based tests of the runtime
|
|
369
|
+
benches/ criterion benchmarks of generated renderers (see Performance)
|
|
200
370
|
packages/ferrovue/ the compiler (npm package)
|
|
201
|
-
src/compiler.ts
|
|
202
|
-
src/
|
|
203
|
-
|
|
371
|
+
src/compiler.ts the API: `generate`, `write`
|
|
372
|
+
src/script.ts, template.ts, expr.ts, attrs.ts
|
|
373
|
+
<script setup>, the compiled template, expressions, class/style
|
|
374
|
+
src/router.ts, stores.ts, i18n.ts
|
|
375
|
+
vue-router, Pinia and vue-i18n
|
|
376
|
+
src/rust.ts, emitter.ts the Rust source written out
|
|
377
|
+
src/cli.ts, vite.ts the `ferrovue` command and the Vite plugin
|
|
378
|
+
src/client.ts browser-side helpers: `mountIslands`, `hydrateState`
|
|
204
379
|
src/testing.ts utilities for a project's own conformance suite
|
|
205
|
-
|
|
380
|
+
src/types.ts `ferrovue/types`: `TrustedHtml`, `Float`
|
|
381
|
+
test/ compiler, CLI, router, vector, island, Vite and conformance tests
|
|
382
|
+
browser/ conformance fixtures hydrated in real browsers (`pnpm test:browser`)
|
|
383
|
+
bench/ Vue renderToString benchmarks, the other half of Performance
|
|
384
|
+
fuzz/ the randomised differential tester (`pnpm fuzz`)
|
|
385
|
+
examples/greeting/ the smallest setup: one component rendered from Rust
|
|
386
|
+
examples/fullstack/ axum + Vite: islands, Pinia state, routes and streaming
|
|
387
|
+
scripts/release.ts the release version bump (see RELEASING.md)
|
|
206
388
|
```
|
|
207
389
|
|
|
208
390
|
## Development
|
|
@@ -214,7 +396,11 @@ pnpm typecheck
|
|
|
214
396
|
cargo test --all-features
|
|
215
397
|
```
|
|
216
398
|
|
|
217
|
-
[TESTING.md](TESTING.md) explains how the suite fits together and how to add a case
|
|
399
|
+
[TESTING.md](TESTING.md) explains how the suite fits together and how to add a case,
|
|
400
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) how to add support for a Vue construct, and
|
|
401
|
+
[RELEASING.md](RELEASING.md) how a release is cut.
|
|
402
|
+
|
|
403
|
+
[`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
404
|
|
|
219
405
|
## Licence
|
|
220
406
|
|
package/dist/attrs.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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(e: Emitter, key: string, v: Val): 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
|
+
/** `ssrRenderAttrs(obj)`, key by key in the object's order, and the inherited scope ids where the
|
|
31
|
+
* root's `_attrs` is merged in. */
|
|
32
|
+
export declare function renderAttrs(s: Scope, e: Emitter, n: N): void;
|
|
33
|
+
/** `mergeProps(a, _attrs, b, …)` as one object literal, without `_attrs`: it holds only scope ids,
|
|
34
|
+
* which the caller writes after the rest. Of the rest, `class` and `style` values are merged as an
|
|
35
|
+
* array, and any other key keeps the place it first had with the last value given. */
|
|
36
|
+
export declare function mergeProps(s: Scope, n: N): N;
|
|
37
|
+
/** One object of a style binding, and the run-time condition under which it is part of it. */
|
|
38
|
+
export interface StyleItem {
|
|
39
|
+
cond: string | null;
|
|
40
|
+
entries: {
|
|
41
|
+
key: string;
|
|
42
|
+
css: string;
|
|
43
|
+
value: N;
|
|
44
|
+
}[];
|
|
45
|
+
}
|
|
46
|
+
/** The objects a style binding merges, in order: an object literal, a static `style` the compiler
|
|
47
|
+
* parsed, `v-show`'s `cond ? null : { display: "none" }`, and arrays of those. */
|
|
48
|
+
export declare function styleItems(s: Scope, n: N, when: string | null): StyleItem[];
|
|
49
|
+
/** `ssrRenderStyle(value)`: a string as it is, or the objects merged — each property where it first
|
|
50
|
+
* appears, with the last value given — and written `name:value;`, all escaped. */
|
|
51
|
+
export declare function renderStyle(s: Scope, e: Emitter, n: N): void;
|