ferrovue 0.4.0 → 0.6.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 +378 -63
- package/dist/attrs.d.ts +3 -2
- package/dist/attrs.js +31 -26
- package/dist/calls.js +17 -15
- package/dist/children.d.ts +11 -4
- package/dist/children.js +110 -44
- package/dist/classes.d.ts +4 -0
- package/dist/classes.js +33 -11
- package/dist/cli.js +31 -5
- package/dist/client.d.ts +44 -6
- package/dist/client.js +182 -18
- package/dist/compiler.d.ts +1 -1
- package/dist/compiler.js +42 -10
- package/dist/component.d.ts +3 -1
- package/dist/component.js +114 -20
- package/dist/conformance.d.ts +64 -0
- package/dist/conformance.js +276 -0
- package/dist/constants.d.ts +42 -0
- package/dist/constants.js +292 -0
- package/dist/context.d.ts +40 -7
- package/dist/context.js +14 -5
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.js +30 -0
- package/dist/dynamic.d.ts +18 -0
- package/dist/dynamic.js +576 -0
- package/dist/errors.d.ts +1034 -0
- package/dist/errors.js +425 -0
- package/dist/expr.d.ts +1 -0
- package/dist/expr.js +120 -49
- package/dist/file-routes.d.ts +33 -0
- package/dist/file-routes.js +224 -0
- package/dist/fixture.d.ts +19 -10
- package/dist/fixture.js +23 -12
- package/dist/hydration.d.ts +15 -0
- package/dist/hydration.js +80 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/link-router.d.ts +24 -0
- package/dist/link-router.js +37 -0
- package/dist/lists.d.ts +4 -0
- package/dist/lists.js +32 -23
- package/dist/loops.js +18 -14
- package/dist/model.d.ts +43 -1
- package/dist/model.js +167 -7
- package/dist/narrowing.d.ts +8 -0
- package/dist/narrowing.js +40 -9
- package/dist/numbers.js +3 -2
- package/dist/page-routes.d.ts +9 -0
- package/dist/page-routes.js +10 -0
- package/dist/plugin.d.ts +13 -1
- package/dist/plugin.js +3 -0
- package/dist/plugins/client-only.d.ts +8 -0
- package/dist/plugins/client-only.js +110 -0
- package/dist/plugins/head.d.ts +15 -0
- package/dist/plugins/head.js +385 -0
- package/dist/plugins/i18n.js +14 -14
- package/dist/plugins/index.js +5 -1
- package/dist/plugins/provide.d.ts +44 -0
- package/dist/plugins/provide.js +528 -0
- package/dist/plugins/router-link.js +39 -32
- package/dist/plugins/router.d.ts +4 -1
- package/dist/plugins/router.js +65 -19
- package/dist/plugins/scoped.js +1 -1
- package/dist/plugins/shared-types.js +1 -1
- package/dist/plugins/stores.js +45 -33
- package/dist/plugins/teleport.js +3 -3
- package/dist/plugins/twins.d.ts +10 -0
- package/dist/plugins/twins.js +164 -0
- package/dist/routes.d.ts +13 -0
- package/dist/routes.js +12 -0
- package/dist/rust.js +64 -32
- package/dist/script.d.ts +3 -0
- package/dist/script.js +138 -20
- package/dist/settle.d.ts +8 -0
- package/dist/settle.js +39 -0
- package/dist/slots.js +36 -22
- package/dist/ssr.d.ts +3 -0
- package/dist/ssr.js +29 -0
- package/dist/strings.d.ts +3 -0
- package/dist/strings.js +27 -13
- package/dist/styles.js +13 -13
- package/dist/template.js +102 -21
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +3 -29
- package/dist/types.d.ts +5 -0
- package/dist/typescript.d.ts +2 -1
- package/dist/typescript.js +52 -21
- package/dist/vhtml.d.ts +4 -0
- package/dist/vhtml.js +63 -0
- package/dist/vite.d.ts +8 -2
- package/dist/vite.js +75 -14
- package/package.json +23 -5
package/README.md
CHANGED
|
@@ -56,15 +56,17 @@ 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", "children" }`. 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" }`. Or `{ "pages": "client/pages" }`: a folder of pages, whose file names give the routes as vue-router's file-based routing reads them (`index.vue`, `[id].vue`, `[[id]].vue`, `[...path].vue`, `(group)` folders, a `name.vue` beside `name/` as its layout), each page compiled as a component. 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
|
-
| `trustedHtml` | no | Rust type of a `TrustedHtml` prop,
|
|
62
|
+
| `trustedHtml` | no | Rust type of a `TrustedHtml` prop (needed for `v-html`): `ferrovue::BasicHtml` (text with a few formatting tags, built in), `ferrovue::InlineHtml` (inline formatting tags only, built in, which makes every `TrustedHtml` prop inline HTML), `ferrovue::Sanitised` (HTML cleaned by ammonia, the `ammonia` feature), or a type of your own such as `crate::html::CleanHtml` |
|
|
63
63
|
| `helpers` | no | `{ module, functions }`: functions a template may call, each mapped to a Rust twin |
|
|
64
|
+
| `twins` | no | Components ferrovue does not compile, each rendered by a Rust function of yours: `{ "VBtn": { "rust": "crate::ui::v_btn", "props": { "label": "string" }, "slots": ["default"] } }`. See [Escape hatches](#escape-hatches) |
|
|
64
65
|
| `i18n` | no | vue-i18n: `{ messages, locale?, fallbackLocale? }`, the directory of locale files (`en.json`, `nl.json`), the default locale and the fallbacks |
|
|
65
66
|
| `clientDirectives` | no | Custom directives with no server output (no `getSSRProps`), by name without `v-`: `["focus"]` |
|
|
66
67
|
| `scopeId` | no | How a `<style scoped>` id is hashed, as `@vitejs/plugin-vue` hashes it: `"filepath-source"` (the default, the plugin's in a production build) or `"filepath"`. See [Scoped styles](#scoped-styles) |
|
|
67
68
|
| `viteRoot` | no | Vite's root, relative to this file's directory, from which a component's path is hashed (default `.`) |
|
|
69
|
+
| `builders` | no | `false` leaves out each props struct's and shared type's `new()` and setters, for an app that builds its props as struct literals (default `true`) |
|
|
68
70
|
|
|
69
71
|
### 3. Write a component
|
|
70
72
|
|
|
@@ -82,13 +84,20 @@ defineProps<{ name: string; unread: number; note?: string }>();
|
|
|
82
84
|
### 4. Generate and render
|
|
83
85
|
|
|
84
86
|
```sh
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
pnpm ferrovue init # a starter ferrovue.config.json and components/Hello.vue
|
|
88
|
+
pnpm ferrovue # writes src/generated/{greeting.rs, mod.rs}
|
|
89
|
+
pnpm ferrovue --check # in CI: exits 1 if the committed modules are stale
|
|
90
|
+
pnpm ferrovue --check --diff # …and shows what differs, as `diff -u` does
|
|
91
|
+
pnpm ferrovue --check --format json # errors as JSON, for an editor or CI annotations
|
|
92
|
+
pnpm ferrovue --watch # regenerates when components or configs change
|
|
90
93
|
```
|
|
91
94
|
|
|
95
|
+
Every error carries a stable code, as `error[FV0602]: components/Card.vue:4:17: …`, documented in
|
|
96
|
+
the crate's [`error_codes`](https://docs.rs/ferrovue/latest/ferrovue/guide/error_codes/index.html)
|
|
97
|
+
guide. `--format json` writes the same errors as JSON with the exit status unchanged, and a VS Code
|
|
98
|
+
problem matcher for `tasks.json` puts them in the Problems panel: both are in the
|
|
99
|
+
[quick start](https://docs.rs/ferrovue/latest/ferrovue/guide/quick_start/index.html#editor-and-ci-diagnostics).
|
|
100
|
+
|
|
92
101
|
```rust
|
|
93
102
|
#[rustfmt::skip]
|
|
94
103
|
mod generated;
|
|
@@ -106,8 +115,8 @@ let html: String = greeting::html(&props).into_string();
|
|
|
106
115
|
let island = greeting::island(&props).into_string();
|
|
107
116
|
|
|
108
117
|
// (`island()` exists for a component that renders from its props alone: the client rebuilds it from
|
|
109
|
-
// `data-props`. One that also takes slots, the route, stores, translations
|
|
110
|
-
// and the page's own app mounts it
|
|
118
|
+
// `data-props`. One that also takes slots, the route, stores, translations, teleports, provided
|
|
119
|
+
// values or the page head has `html()`, and the page's own app mounts it; see examples/fullstack.)
|
|
111
120
|
|
|
112
121
|
// …or straight into a buffer you already hold:
|
|
113
122
|
let mut page = String::from("<!doctype html><body>");
|
|
@@ -120,11 +129,22 @@ route.
|
|
|
120
129
|
|
|
121
130
|
The router and vue-i18n are features of the crate, `router` and `i18n`, both on by default. An
|
|
122
131
|
application with neither routes nor translations can leave them out, and build neither
|
|
123
|
-
`ferrovue-router` nor `ferrovue-i18n`: `ferrovue = { version = "0.
|
|
132
|
+
`ferrovue-router` nor `ferrovue-i18n`: `ferrovue = { version = "0.6", default-features = false }`.
|
|
124
133
|
|
|
125
134
|
With the `maud` feature, `ferrovue::Html` implements `maud::Render`, so `(greeting::html(&props))` can go
|
|
126
135
|
straight into a `maud::html!` page.
|
|
127
136
|
|
|
137
|
+
For `v-html`, `ferrovue::BasicHtml` is built in: `BasicHtml::new(untrusted)` escapes everything but
|
|
138
|
+
`<b>`, `<i>`, `<em>`, `<strong>`, `<code>`, `<br>`, `<p>`, `<ul>`, `<ol>` and `<li>` written with no
|
|
139
|
+
attributes, and balances them; `BasicHtml::from_text(text)` turns plain text into paragraphs.
|
|
140
|
+
`ferrovue::InlineHtml` does the same with `<b>`, `<i>`, `<em>`, `<strong>`, `<code>` and `<br>`
|
|
141
|
+
only: it is what a prop typed `InlineHtml` from `ferrovue/types` is on the server, and the HTML
|
|
142
|
+
`v-html` may write inside a `<p>`.
|
|
143
|
+
With the `ammonia` feature, `ferrovue::Sanitised` is HTML cleaned by [ammonia](https://docs.rs/ammonia):
|
|
144
|
+
name it as `trustedHtml` and build a `v-html` prop with `Sanitised::new(untrusted)`, or
|
|
145
|
+
`Sanitised::with(&builder, untrusted)` for a policy of your own. The server renders the cleaned
|
|
146
|
+
string, and the island's `data-props` carries that same string to the client.
|
|
147
|
+
|
|
128
148
|
With the `axum` feature, `Html` is an `IntoResponse`, and with `actix-web` a `Responder`: a handler
|
|
129
149
|
responds with the component, as `text/html; charset=utf-8`. `into_html(props)` and
|
|
130
150
|
`into_island(props)` take the props by value, so a handler that builds them returns the page:
|
|
@@ -138,37 +158,43 @@ async fn hello(Path(name): Path<String>) -> impl IntoResponse {
|
|
|
138
158
|
With the `dioxus` feature, a component goes into a Dioxus 0.7 page (`dioxus-ssr` or fullstack):
|
|
139
159
|
`{greeting::island(&props)}` in `rsx!` is the island element itself, with exactly the markup Vue
|
|
140
160
|
hydrates inside it. See `examples/dioxus` and the crate guide's
|
|
141
|
-
[Dioxus page](crates/ferrovue/docs/guide/dioxus.md).
|
|
161
|
+
[Dioxus page](https://github.com/Kinsmir/ferrovue/blob/main/crates/ferrovue/docs/guide/dioxus.md).
|
|
142
162
|
|
|
143
163
|
## What a component may use
|
|
144
164
|
|
|
145
|
-
ferrovue compiles `<script setup lang="ts">` components
|
|
165
|
+
ferrovue compiles `<script setup lang="ts">` components, and components with no script at all
|
|
166
|
+
(an icon, a divider), which take no props. Props are declared by type, with
|
|
146
167
|
`defineProps<{ … }>()`, an interface, or a type alias.
|
|
147
168
|
|
|
148
169
|
| Area | Supported |
|
|
149
170
|
|---|---|
|
|
150
|
-
| Prop types | `string`, `number` (integers, `i64`, written and computed as JavaScript does
|
|
151
|
-
| 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 |
|
|
152
|
-
|
|
|
153
|
-
|
|
|
171
|
+
| Prop types | `string`, `number` (integers, `i64`, written and computed as JavaScript does, exact within ±2⁵³), `Float` from `ferrovue/types` (fractions, `f64`), `boolean`, string-literal unions (`"sm" \| "md"`), `T \| undefined`, `T \| null` (an `Option` written as `null`), arrays (`T[]`, `Array<T>`, `readonly T[]`), interfaces and object type aliases (recursive ones too), dictionaries (`Record<string, T>`, `{ [key: string]: T }`, `ferrovue::Record` in Rust, which keeps JavaScript's order of keys), another component's exported `Props`, `TrustedHtml`, `InlineHtml` |
|
|
172
|
+
| Shared types | Interfaces and type aliases imported from `.ts` files (generated once, into `types.rs`), from a store's file, or from another component's `.vue` file; objects of a shared type can be passed between components. A TypeScript `enum` whose members are literals is the type of its values: a string, or a number |
|
|
173
|
+
| Constants | Constants imported from `.ts` files and `enum`s (imported, or declared in the component), evaluated when the component is compiled: strings, numbers, booleans, `null`, lists of them, objects of them read field by field (`LABELS.save`, `Tone.Loud`, `Rank[5]`), and lists of objects of them, which become a `const` in `types.rs` (`{ value, label }` items get a type named after the constant, `OptionsItem`, unless the constant is typed with an interface: `OPTIONS: Option[]`; a field that is `null` in some items is `T | null`). See [Constants](#constants) |
|
|
174
|
+
| Props | `withDefaults`, destructured props with defaults (`const { size = "md" } = defineProps<…>()`), the props object (`const props = defineProps<…>()`, then `props.label` in the template or in script code), optional booleans (Vue casts an absent one to `false`), `defineModel` (named, required, with defaults), components with no props. A generic component (`<script setup generic="T extends Item">`) renders each type parameter as its constraint, which is all the template can rely on |
|
|
175
|
+
| Setup | `ref`/`shallowRef`, `computed` (an expression or a single `return`), refs that start empty, typed by their type argument (`ref<Row[]>([])`, `ref<Row>()`, `ref<string \| undefined>(undefined)`), plain `const`/`let`, `.value` in script code, helpers with Rust twins, a plain `<script>` block beside setup. Lifecycle hooks, `watch` (not `immediate`), `defineEmits`, `defineSlots`, `defineOptions`, `defineExpose`, template refs (`ref(null)`, `useTemplateRef`) and functions are client-only. The template may name them only from event handlers, which the server drops |
|
|
176
|
+
| Provide and inject | `provide` and `inject` resolved through the component tree, slot content seeing the providers of the component that renders it, as Vue does: keys that are string literals or `InjectionKey` symbols exported from `.ts` files; values that are props, refs, `computed`s, constants or objects of them (an interface's fields, `reactive()` unwrapping refs); defaults, factory defaults (`inject(k, () => …, true)`), object defaults built as the key's interface (`inject(k, { size: "md" })`), and keys holding functions, which only event handlers may call. A page rendered from Rust passes the generated `Provides`, empty or holding what the client app gives `app.provide`. See the crate's [`provide_inject`](https://docs.rs/ferrovue/latest/ferrovue/guide/provide_inject/index.html) guide |
|
|
154
177
|
| 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 |
|
|
155
178
|
| 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)) |
|
|
156
|
-
| 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 ===
|
|
157
|
-
| 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 |
|
|
179
|
+
| Conditions | `v-if` / `v-else-if` / `v-else`, `?:`, `&&` and `||`, with optional and nullable values narrowed as TypeScript narrows them: `v-if="user"`, `user !== undefined`, `user !== null`, `user != null`, and `!user`, `user === undefined` or `user === null` for the `v-else` |
|
|
180
|
+
| Lists | `v-for` over arrays (of strings, numbers, objects or child props), array literals and number ranges (`n in 5`); with an index; with destructured items (`{ id, name } in rows`); nested; on `<template>`. `.includes()` (a number looked for in a list of numbers whether either is a `number` or a `Float`, as JavaScript compares them), `.join()`, and `.filter()`, `.map()`, `.some()`, `.every()`, `.find()`, `.findIndex()` with an arrow function of the item (a name, or destructured) and its index, whose body is an expression, narrowing inside as outside; `.slice()`; chained and nested; a computed list read by `v-for`, `.join()`, `.length`, `.includes()`, kept by `computed`, or handed to a child |
|
|
158
181
|
| 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 |
|
|
159
182
|
| Attributes | static and bound attributes, boolean attributes, `:hidden`, `data-*` and `aria-*`, `v-bind` objects |
|
|
160
183
|
| `class` | strings, arrays, objects (`{ active: on }`, computed keys), `cond && "x"`, `cond ? "x" : null`, merged with a static `class` |
|
|
161
184
|
| `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 |
|
|
162
185
|
| 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 |
|
|
163
|
-
| 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` |
|
|
186
|
+
| Components | imported child components, `v-bind` of a child's own `Props`, `v-model` on a child's `defineModel`, recursion; props named in `kebab-case` or `camelCase`; `defineAsyncComponent(() => import("./X.vue"))` (or `{ loader: … }`), rendered as the component it loads, which Vue's server renderer waits for |
|
|
187
|
+
| `<component :is>` | over a closed set of choices, compiled to a `match`: an imported component (`:is="Card"`), an HTML element's name (`is="h2"`), a `computed` or `?:` choosing among them, a prop typed as a union of string literals (`as: "h1" \| "h2"`), and an object of imported components (or element names) declared in setup or imported from a `.ts` file, read by such a prop (`ICONS[name]`); props, fallthrough attributes, slots and scope ids reach each choice as they reach a static child; also inside `<KeepAlive>` and `<Transition>`. See the crate's [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#component-is) guide |
|
|
188
|
+
| Escape hatches | `<ClientOnly>` from `ferrovue/client`, whose default slot the server never renders, so anything may go in it; components listed in `twins`, rendered by Rust functions of yours. See [Escape hatches](#escape-hatches) |
|
|
164
189
|
| 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 |
|
|
165
|
-
| Slots | default and named slots, fallbacks, `$slots.name` tests
|
|
190
|
+
| Slots | default and named slots, fallbacks, `$slots.name` tests and the same through `useSlots()`, scoped slots (`<slot :item="x">` and `#item="{ item }"` or `v-slot="props"`), whose props a parent can hand to its own children |
|
|
166
191
|
| Forms | `v-model` on text inputs, checkboxes, radios, `<select>` and `<textarea>` (renders the initial state) |
|
|
167
192
|
| 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` |
|
|
168
|
-
| 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
|
|
193
|
+
| 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; optional parameters (`:id?`); routes built from a folder of pages, which the Vite plugin also gives the client as `ferrovue/routes`. `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 |
|
|
169
194
|
| 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 |
|
|
170
195
|
| 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 |
|
|
171
|
-
| `v-html` | only on a `TrustedHtml` prop (`import type { TrustedHtml } from "ferrovue/types"`) |
|
|
196
|
+
| `v-html` | only on a `TrustedHtml` prop (`import type { TrustedHtml } from "ferrovue/types"`), or an `InlineHtml` one (inline tags only, `ferrovue::InlineHtml` on the server, with no `trustedHtml` needed), the only HTML it may write inside a `<p>`; never on a table's structure, a `<select>` or `<optgroup>`, or an SVG or MathML element that holds no HTML. See the crate's [`escaping`](https://docs.rs/ferrovue/latest/ferrovue/guide/escaping/index.html#where-v-html-may-go) guide |
|
|
197
|
+
| Page head | `useHead` (and `useServerHead`) from `@unhead/vue` 3: `title`, `titleTemplate`, `base`, `meta`, `link`, `script`, `style`, `noscript`, `htmlAttrs` and `bodyAttrs`, with values the server computes (props, setup bindings, getters such as `() => props.title`, `list.map(x => ({ … }))` for a list of tags), `key`, `tagPosition`, `tagPriority` and `tagDuplicateStrategy`; `useSeoMeta` (and `useServerSeoMeta`) of string, number, boolean and `null` values. Collected into a `ferrovue::Head` in the order Vue's server runs them, and written as unhead's `renderSSRHead` writes them: its tag order, deduplication, title template and escaping. See [Page head](#page-head) |
|
|
172
198
|
|
|
173
199
|
Refused at compile time, each with an error that names the construct:
|
|
174
200
|
|
|
@@ -176,11 +202,31 @@ Refused at compile time, each with an error that names the construct:
|
|
|
176
202
|
- `<RouterView>` in a component with `<style scoped>`, which would give the page that component's
|
|
177
203
|
id; and, since vue-router renders a link from virtual nodes, a `<slot>` inside a `<RouterLink>`
|
|
178
204
|
that takes scope ids, or an element inside one in slot content given a `:slotted()` id
|
|
179
|
-
- `<component :is>`
|
|
205
|
+
- `<component :is>` over an open set (a `string`, a `Component`, a key that is not a literal union),
|
|
206
|
+
over a prop that may be absent, or naming a key its object lacks or a tag that is not an HTML
|
|
207
|
+
element; `v-html` or `v-text` on it; and, inside an element it chooses (which Vue renders from
|
|
208
|
+
virtual nodes), a `<slot>` with fallback content, `v-show`, and `v-model` on a `<select>`
|
|
209
|
+
- in provide and inject: a key that is neither a string literal nor an `InjectionKey` symbol exported
|
|
210
|
+
from a `.ts` file, `inject(key)!`, a value that may be `null` or `undefined`, values of different
|
|
211
|
+
types (or a ref and a plain value) under one key, setup that assigns to an injected value,
|
|
212
|
+
`provide` in a component holding `<RouterView>`, a string key injected inside a Rust twin's slot,
|
|
213
|
+
and `provide(…)` not imported from `vue`
|
|
214
|
+
- `v-show`, and `v-model` on a `<select>`, in the content of a `<RouterLink>` or of a Rust twin's
|
|
215
|
+
slot, which Vue renders from virtual nodes
|
|
216
|
+
- in the page head: options given to `useHead`, `useHeadSafe`, `templateParams`, a `titleTemplate`
|
|
217
|
+
function, event handlers (`onload`), a `class` or `style` that may be `null` (on which unhead's
|
|
218
|
+
renderer throws), and an object or array given to a `useSeoMeta` key
|
|
219
|
+
- the Options API (a `<script>` without `setup`), and a type parameter of a generic component with
|
|
220
|
+
no constraint (`generic="T"`)
|
|
221
|
+
- constants that are not literals (`Date.now()`, a function), an object constant read whole or by a
|
|
222
|
+
key chosen at run time (`LABELS[key]`), and `new` (`new Date(at)`, `new Intl.NumberFormat()`)
|
|
180
223
|
- `<RouterLink custom>`, slot props that are array literals, defaults in destructured slot props, and outlets of one slot that pass different props
|
|
181
224
|
- custom directives not listed in `clientDirectives`
|
|
182
225
|
- `watchEffect`, `watch` with `immediate`, `onServerPrefetch`, top-level `await`, and statements in setup that change state
|
|
183
226
|
- `route.meta` and `route.matched`
|
|
227
|
+
- in a folder of pages: a parameter beside text in one part of a path (`prefix-[id].vue`), repeatable
|
|
228
|
+
and optional catch-all parameters, parameter parsers (`[id=int]`), named views (`index@aside.vue`),
|
|
229
|
+
`definePage()`, a `<route>` block, and two pages, or a page and a component, with one component name
|
|
184
230
|
- Pinia getters that read `this` or return a function
|
|
185
231
|
- ordering comparisons between a string and a number, which JavaScript makes numeric
|
|
186
232
|
- regular expressions (`.replace(/x/g, …)`, `.split(/,/)`), replacement functions, a search's
|
|
@@ -193,7 +239,9 @@ Refused at compile time, each with an error that names the construct:
|
|
|
193
239
|
it is not; `Object.entries()` anywhere but as a `v-for`'s source; dictionaries of optional values
|
|
194
240
|
- two strings that may each hold half of a surrogate pair compared, searched or joined (see
|
|
195
241
|
[Strings](#strings))
|
|
196
|
-
- `null`
|
|
242
|
+
- a type that is both `null` and `undefined` (`T | null | undefined`, `x?: T | null`), a default
|
|
243
|
+
for a nullable prop, and `=== null` or `=== undefined` of a value that may be either (`a?.b` where
|
|
244
|
+
`b` is nullable; `== null` tests both)
|
|
197
245
|
- an attribute (`class` and `style` aside) bound to a value that may be neither a string, a number
|
|
198
246
|
nor a boolean: a list, an object, or a `route.query` value, which is an array when its key is
|
|
199
247
|
repeated. Vue's server renderer leaves the attribute out and hydration then sets it, silently;
|
|
@@ -207,16 +255,50 @@ Refused at compile time, each with an error that names the construct:
|
|
|
207
255
|
passed to a component whose root is a `<Transition>` or `<KeepAlive>` around a `v-if`, which Vue's
|
|
208
256
|
server drops but its client keeps
|
|
209
257
|
|
|
258
|
+
Every error names the file, line and column, with the line quoted and a caret under the construct.
|
|
259
|
+
|
|
210
260
|
An object prop handed to a child component is cloned. Its strings are `Cow`s, so borrowed ones
|
|
211
261
|
cost nothing to copy.
|
|
212
262
|
|
|
263
|
+
### Constants
|
|
264
|
+
|
|
265
|
+
A constant a component imports from a `.ts` file, or an `enum`, is evaluated when the component is
|
|
266
|
+
compiled, as `types.rs` holds the shared types:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
// components/catalog.ts
|
|
270
|
+
export const LABELS = { title: "Catalogue", empty: "Nothing yet" } as const;
|
|
271
|
+
export const SIZES = ["sm", "md", "lg"] as const;
|
|
272
|
+
export const SORTS = [
|
|
273
|
+
{ key: "name", label: "By name" },
|
|
274
|
+
{ key: "price", label: "By price" },
|
|
275
|
+
] as const;
|
|
276
|
+
export enum Tone { Calm = "calm", Loud = "loud" }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`{{ LABELS.title }}` and `v-if="tone === Tone.Loud"` are written into the page as the strings they
|
|
280
|
+
are, and `v-for="s in SIZES"` walks an array literal. A list of objects becomes a `const` beside
|
|
281
|
+
the shared types, with a type for its items (here `SortsItem`, or the interface the constant is
|
|
282
|
+
declared with):
|
|
283
|
+
|
|
284
|
+
```rust
|
|
285
|
+
pub const SORTS: &[SortsItem<'static>] = &[
|
|
286
|
+
SortsItem { key: Cow::Borrowed("name"), label: Cow::Borrowed("By name") },
|
|
287
|
+
SortsItem { key: Cow::Borrowed("price"), label: Cow::Borrowed("By price") },
|
|
288
|
+
];
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
A constant is a literal, a list, an object, or another constant of the same file. One that is
|
|
292
|
+
anything else (`Date.now()`, a function) is refused where the template reads it, naming the file
|
|
293
|
+
and line it is declared on.
|
|
294
|
+
|
|
213
295
|
### Scoped styles
|
|
214
296
|
|
|
215
297
|
A `<style scoped>` component's elements carry `data-v-<id>`, and its CSS is rewritten by the client
|
|
216
298
|
build to select them. The server has to write the id the client build chose, which is not something
|
|
217
299
|
the browser checks when it hydrates: a wrong id hydrates cleanly and leaves the styles unapplied. So
|
|
218
|
-
ferrovue computes it as `@vitejs/plugin-vue` does
|
|
219
|
-
`.vue` file's path from Vite's root, followed by its source unless only the path is hashed
|
|
300
|
+
ferrovue computes it as `@vitejs/plugin-vue` does (the first 8 hex digits of a SHA-256 of the
|
|
301
|
+
`.vue` file's path from Vite's root, followed by its source unless only the path is hashed), and the
|
|
220
302
|
two must be configured alike:
|
|
221
303
|
|
|
222
304
|
| `@vitejs/plugin-vue` | `ferrovue.config.json` |
|
|
@@ -232,7 +314,7 @@ The default is the plugin's production behaviour because the production build is
|
|
|
232
314
|
get: with every option left alone, its styles apply. But plugin-vue hashes the path alone in its dev
|
|
233
315
|
server, so with the default a page rendered during development carries other ids than the dev
|
|
234
316
|
client and shows unstyled. The recommended setup is `componentIdGenerator: "filepath"` with
|
|
235
|
-
`"scopeId": "filepath"`, as [`examples/fullstack`](examples/fullstack) does: the ids are then the
|
|
317
|
+
`"scopeId": "filepath"`, as [`examples/fullstack`](https://github.com/Kinsmir/ferrovue/tree/main/examples/fullstack) does: the ids are then the
|
|
236
318
|
same in development and production, and do not change, nor change the generated Rust, whenever a
|
|
237
319
|
component's source does.
|
|
238
320
|
|
|
@@ -254,8 +336,8 @@ String methods count as JavaScript counts: in UTF-16 code units, so `"🦀".leng
|
|
|
254
336
|
from U+E000 to U+FFFF after one beyond U+FFFF. Each runtime routine is held to vectors recorded from
|
|
255
337
|
JavaScript (`crates/*/tests/vectors/`).
|
|
256
338
|
|
|
257
|
-
A JavaScript string can hold half of a surrogate pair
|
|
258
|
-
`.split("")`
|
|
339
|
+
A JavaScript string can hold half of a surrogate pair (`"🦀".slice(0, 1)`, `.charAt(1)`,
|
|
340
|
+
`.split("")`), and a Rust string cannot. ferrovue writes U+FFFD in its place, which is exactly what
|
|
259
341
|
the page carries anyway: a server sends Vue's string as UTF-8, and UTF-8 writes each half as U+FFFD
|
|
260
342
|
(`res.end`, `Buffer.from` and `TextEncoder` all do). Its length is the same. Where the half itself
|
|
261
343
|
would decide the result, ferrovue refuses at compile time: two strings that may each hold a half
|
|
@@ -277,8 +359,8 @@ Where JavaScript throws, the generated code panics, as Vue's render rejects: a n
|
|
|
277
359
|
|
|
278
360
|
### Dictionaries
|
|
279
361
|
|
|
280
|
-
A `Record<string, T>` prop is a `ferrovue::Record`, built from pairs
|
|
281
|
-
`[("b", 1), ("10", 2)].into_iter().collect()`
|
|
362
|
+
A `Record<string, T>` prop is a `ferrovue::Record`, built from pairs
|
|
363
|
+
(`[("b", 1), ("10", 2)].into_iter().collect()`) or read from JSON. It holds its keys in the order a
|
|
282
364
|
JavaScript object does, array indices (`"0"` to `"4294967294"`) first in numeric order, so `v-for`
|
|
283
365
|
walks them as Vue does, and it is written back as JSON in that order, which the browser reads back
|
|
284
366
|
the same.
|
|
@@ -293,6 +375,64 @@ let i18n = generated::i18n::i18n("nl"); // falls back as configured
|
|
|
293
375
|
page::render(&mut out, &props, &i18n);
|
|
294
376
|
```
|
|
295
377
|
|
|
378
|
+
### Escape hatches
|
|
379
|
+
|
|
380
|
+
Some components will never compile: a component library's render functions, code that measures the
|
|
381
|
+
page in `onMounted`. Two ways keep one of them from blocking the page around it.
|
|
382
|
+
|
|
383
|
+
**`<ClientOnly>`**: the server writes its `#fallback` slot (or a comment, without one), and the
|
|
384
|
+
browser hydrates that same fallback, then swaps in the default slot once mounted. Both sides render
|
|
385
|
+
the fallback first, so this is exact by construction. ferrovue does not compile the default slot at
|
|
386
|
+
all: any component, import or expression may go there.
|
|
387
|
+
|
|
388
|
+
```vue
|
|
389
|
+
<script setup lang="ts">
|
|
390
|
+
import { ClientOnly } from "ferrovue/client";
|
|
391
|
+
import { VDataTable } from "vuetify/components";
|
|
392
|
+
|
|
393
|
+
defineProps<{ rows: { name: string }[] }>();
|
|
394
|
+
</script>
|
|
395
|
+
|
|
396
|
+
<template>
|
|
397
|
+
<ClientOnly>
|
|
398
|
+
<VDataTable :items="rows" />
|
|
399
|
+
<template #fallback><p class="loading">Loading the table…</p></template>
|
|
400
|
+
</ClientOnly>
|
|
401
|
+
</template>
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The swap needs Vue running on that part of the page, so put `<ClientOnly>` inside an island (or an
|
|
405
|
+
app the client hydrates whole); elsewhere the fallback stays. `<ClientOnly>` takes no attributes.
|
|
406
|
+
|
|
407
|
+
**Rust twins**: a component the server should render, written by you in Rust. List it under
|
|
408
|
+
`twins` with the props a template may pass it and the slots it may fill:
|
|
409
|
+
|
|
410
|
+
```json
|
|
411
|
+
{
|
|
412
|
+
"twins": {
|
|
413
|
+
"VBtn": {
|
|
414
|
+
"rust": "crate::ui::v_btn",
|
|
415
|
+
"props": { "label": "string", "size": "int?", "block": "bool" },
|
|
416
|
+
"slots": ["default"]
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
A template then uses `<VBtn>` (or `<v-btn>`), imported from anywhere or registered globally, and the
|
|
423
|
+
generated parent calls `crate::ui::v_btn` with the props, the slots (each a `ferrovue::Slot`) and
|
|
424
|
+
`&ferrovue::Attrs`: the attributes it is given beyond its props and the scope ids its root takes.
|
|
425
|
+
`twins.rs` beside the components holds `VBtnProps`, `VBtnSlots` and the signature your function must
|
|
426
|
+
have. Prop types are those of helpers (`string`, `int?`, …); an absent `bool` is `false`, and a
|
|
427
|
+
bare `block` is `true`, as Vue casts them.
|
|
428
|
+
|
|
429
|
+
**Exactness is your responsibility.** ferrovue cannot check that `v_btn` writes the bytes Vue's
|
|
430
|
+
server renderer writes for `VBtn`: its guarantee rests on your code here, as it does for helpers. Prove it with fixtures of the components that use the twin, held to Vue by the
|
|
431
|
+
conformance suite (see [Testing against Vue](#testing-against-vue)). Slot content reaches the twin as a component's render function sees it
|
|
432
|
+
(`<!--v-if-->` for an absent `v-if`, as Vue writes virtual nodes). The twin decides where it goes and
|
|
433
|
+
whether fragment markers surround it, as the component does. `examples/fullstack` has a
|
|
434
|
+
twin (`src/ui.rs`) and its fixtures (`fixtures/`).
|
|
435
|
+
|
|
296
436
|
### Teleports
|
|
297
437
|
|
|
298
438
|
A component that renders a `<Teleport>` takes a `ferrovue::Teleports`. After rendering the page body,
|
|
@@ -304,9 +444,28 @@ page::render(&mut body, &props, &teleports);
|
|
|
304
444
|
let modals = teleports.get("#modals").unwrap_or_default(); // inside <div id="modals">
|
|
305
445
|
```
|
|
306
446
|
|
|
307
|
-
As Vue recommends, teleport to a dedicated element
|
|
447
|
+
As Vue recommends, teleport to a dedicated element such as `#modals`: the browser hydrates
|
|
308
448
|
a target from its first node, and `body` also holds the app.
|
|
309
449
|
|
|
450
|
+
### Page head
|
|
451
|
+
|
|
452
|
+
A component that calls `useHead` or `useSeoMeta` from `@unhead/vue` takes a `ferrovue::Head`, which
|
|
453
|
+
collects the calls of the whole tree in the order Vue's server runs them. After rendering the body,
|
|
454
|
+
write the head as unhead's server renderer writes it:
|
|
455
|
+
|
|
456
|
+
```rust
|
|
457
|
+
let head = ferrovue::Head::new(); // createHead(): lang="en", charset and viewport; or Head::without_defaults()
|
|
458
|
+
book_page::render(&mut body, &props, &head);
|
|
459
|
+
let tags = head.render(); // head_tags, body_tags_open, body_tags, html_attrs, body_attrs
|
|
460
|
+
let page = format!("<html{}><head>{}</head><body{}>{}{}{}</body></html>",
|
|
461
|
+
tags.html_attrs, tags.head_tags, tags.body_attrs, tags.body_tags_open, body, tags.body_tags);
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
On the client the head is unhead's: give the page's app its client head,
|
|
465
|
+
`mountPage(layout, islands, { plugins: [createHead()] })` with `createHead` from
|
|
466
|
+
`@unhead/vue/client`, and it takes over the tags the server wrote. The crate's
|
|
467
|
+
[`head`](https://docs.rs/ferrovue/latest/ferrovue/guide/head/index.html) guide has the details.
|
|
468
|
+
|
|
310
469
|
### Scoped slots from Rust
|
|
311
470
|
|
|
312
471
|
A scoped slot's `Slots` field takes a closure that receives the props its outlet passes, as a
|
|
@@ -347,11 +506,65 @@ components alike, so a hand-written island can join them: `mountIslands({ ...isl
|
|
|
347
506
|
An island whose name is not among them is left as the server rendered it, with a warning naming it
|
|
348
507
|
(`onError` to handle it otherwise), as is one whose component fails to load.
|
|
349
508
|
|
|
509
|
+
An island can wait to hydrate, and to fetch its code, until it is needed. `hydrate` on the
|
|
510
|
+
server's `island()` writes a `data-hydrate` attribute on the wrapper, which `mountIslands` honours
|
|
511
|
+
with Vue's lazy hydration strategies:
|
|
512
|
+
|
|
513
|
+
```rust
|
|
514
|
+
use ferrovue::Hydrate;
|
|
515
|
+
|
|
516
|
+
reviews::island(&props).hydrate(Hydrate::Visible); // scrolled into view
|
|
517
|
+
footer::island(&props).hydrate(Hydrate::visible_with("200px")); // within 200px of the view
|
|
518
|
+
chart::island(&props).hydrate(Hydrate::Idle); // the browser is idle
|
|
519
|
+
counter::island(&props).hydrate(Hydrate::Interaction); // pointerenter, click or focus
|
|
520
|
+
menu::island(&props).hydrate(Hydrate::InteractionOn(&["keydown"]));
|
|
521
|
+
sidebar::island(&props).hydrate(Hydrate::media("(min-width: 60rem)"));
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
The island's markup is unchanged, so nothing about exactness changes. An event that woke an island
|
|
525
|
+
waiting for interaction is dispatched again once it has hydrated, so the first click is not lost.
|
|
526
|
+
An island without `data-hydrate` hydrates at once, as before.
|
|
527
|
+
|
|
350
528
|
An island's `<style>` goes into its chunk too, and Vite links it only once the island's script has
|
|
351
529
|
loaded, after the server's markup is on screen. Link those stylesheets from the page up front: a
|
|
352
530
|
server reading Vite's manifest takes the `css` of the entry's `dynamicImports` as well as its own
|
|
353
531
|
(`examples/fullstack/src/assets.rs`).
|
|
354
532
|
|
|
533
|
+
An application whose own navigation layer leaves the page can keep `<RouterLink>` for its `href`s
|
|
534
|
+
and active classes with `linkRouter(routes, { navigate })` from `ferrovue/link-router`: a router
|
|
535
|
+
over the routes file (or the default export of `ferrovue/routes`, with a folder of pages) whose
|
|
536
|
+
every navigation after the first goes to `navigate`. The routing guide
|
|
537
|
+
(`ferrovue::guide::routing`) explains each part of it.
|
|
538
|
+
|
|
539
|
+
### Hydrating a whole page
|
|
540
|
+
|
|
541
|
+
A page built from a layout whose slots hold components, each rendered from its own props, can be
|
|
542
|
+
hydrated as one app. `ferrovue::Page` takes each slot's parts as `Part`s, made from the same
|
|
543
|
+
`html()` values that write the markup, renders the layout, and writes what it rendered as a
|
|
544
|
+
`<script type="application/json">`; `mountPage` reads it back, loads the components it names and
|
|
545
|
+
hydrates the layout with each slot's parts:
|
|
546
|
+
|
|
547
|
+
```rust
|
|
548
|
+
let mut page = ferrovue::Page::new();
|
|
549
|
+
let content = page.slot("default", books.iter().map(|b| Part::new(pick::NAME, pick::html(b))));
|
|
550
|
+
let reviews = page.hole("reviews"); // filled later, while the page streams
|
|
551
|
+
let record = page.render_to(&mut out, picks::html(&props, picks::Slots {
|
|
552
|
+
default: Some(content.slot()),
|
|
553
|
+
reviews: Some(reviews.slot()),
|
|
554
|
+
}, &route));
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
import { mountPage } from "ferrovue";
|
|
559
|
+
import islands from "ferrovue/islands";
|
|
560
|
+
|
|
561
|
+
await mountPage(() => import("./Picks.vue"), islands, { pinia, router });
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
The record repeats the text of every prop, so a page hydrated this way is larger than one of
|
|
565
|
+
islands; the guide's `islands_and_hydration` page compares the two, and `examples/fullstack` serves
|
|
566
|
+
a page of each kind.
|
|
567
|
+
|
|
355
568
|
### Hydrating Pinia state
|
|
356
569
|
|
|
357
570
|
```rust
|
|
@@ -363,17 +576,82 @@ import { hydrateState } from "ferrovue";
|
|
|
363
576
|
hydrateState(pinia); // before app.mount(): every store starts from what the server rendered
|
|
364
577
|
```
|
|
365
578
|
|
|
579
|
+
### Fixtures in an application's vitest run
|
|
580
|
+
|
|
581
|
+
`fixtureApp` from `ferrovue/testing` installs Pinia for a fixture's `$stores`, vue-router for its
|
|
582
|
+
routes and vue-i18n for its `i18n` option, importing each itself. A component's `useBasket()`,
|
|
583
|
+
`useRoute()` or `useI18n()` reads the instance its own import of the package gives it, so the two
|
|
584
|
+
must be one module instance. vitest loads `ferrovue/testing` from `node_modules` with Node, and
|
|
585
|
+
runs the application's code through Vite; where a peer goes through Vite too (listed in
|
|
586
|
+
`server.deps.inline`, aliased, linked from elsewhere, or installed twice), the fixture installs a
|
|
587
|
+
different instance from the one the component reads, and the render fails with Pinia's "there was
|
|
588
|
+
no active Pinia" or an undefined route. Pass the application's own modules in the options:
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
const app = await fixtureApp(component, readFixture(json), routes, {
|
|
592
|
+
pinia: await import("pinia"),
|
|
593
|
+
vueRouter: await import("vue-router"),
|
|
594
|
+
vueI18n: await import("vue-i18n"),
|
|
595
|
+
});
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Without them, `fixtureApp` imports the peers itself, as before. Another way is to have vitest run
|
|
599
|
+
ferrovue through Vite as well, which puts it on the application's module graph; this also covers
|
|
600
|
+
`vue` itself, which `fixtureApp` always imports:
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
// vitest.config.ts
|
|
604
|
+
export default defineConfig({ test: { server: { deps: { inline: ["ferrovue"] } } } });
|
|
605
|
+
```
|
|
606
|
+
|
|
366
607
|
### Streaming with holes
|
|
367
608
|
|
|
368
609
|
`ferrovue::hole()` is a slot whose content you write later. Render a layout with holes,
|
|
369
610
|
`split_holes` the output, and stream the pieces with each hole's content between them, in whatever
|
|
370
|
-
order the content is ready. With the `axum`
|
|
371
|
-
streamed
|
|
611
|
+
order the content is ready. With the `stream` feature (which `axum` and `actix-web` turn on),
|
|
612
|
+
`ferrovue::HtmlStream` is that streamed body, from the render and a future for each hole's content:
|
|
372
613
|
|
|
373
614
|
```rust
|
|
374
615
|
let body = ferrovue::HtmlStream::new(page).hole(async move { reviews_of(&id).await });
|
|
375
616
|
```
|
|
376
617
|
|
|
618
|
+
### Testing against Vue
|
|
619
|
+
|
|
620
|
+
An application holds its own components to Vue as ferrovue's suite does, with a fixtures directory
|
|
621
|
+
and two calls. A fixture is a component's props as JSON (plus `$slots`, `$route`, `$stores` and
|
|
622
|
+
`$locale` where it takes them) beside the HTML Vue rendered for them:
|
|
623
|
+
|
|
624
|
+
```text
|
|
625
|
+
fixtures/Reviews/three.json {"reviews":[{"reader":"Ada","stars":5,"text":"The spice must flow."}]}
|
|
626
|
+
fixtures/Reviews/three.html recorded from Vue with FERROVUE_FIXTURES_WRITE=1
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
// test/conformance.test.ts, run by vitest with environment: "happy-dom"
|
|
631
|
+
import { join } from "node:path";
|
|
632
|
+
import type { Component } from "vue";
|
|
633
|
+
import { conformanceSuite } from "ferrovue/testing";
|
|
634
|
+
|
|
635
|
+
await conformanceSuite({
|
|
636
|
+
config: join(import.meta.dirname, "../ferrovue.config.json"),
|
|
637
|
+
components: import.meta.glob<Component>("../client/components/*.vue", { eager: true, import: "default" }),
|
|
638
|
+
pinia: await import("pinia"),
|
|
639
|
+
vueRouter: await import("vue-router"),
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
```rust
|
|
644
|
+
ferrovue::conformance!("fixtures", generated::render_json, at_least = 24);
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
The Vue half fails on a component with no fixtures, a fixture naming no component, a project with
|
|
648
|
+
no components, generated Rust that differs from what `ferrovue` writes now, a fixture Vue renders
|
|
649
|
+
differently from its `.html`, and a recorded `.html` that hydrates with any warning;
|
|
650
|
+
`FERROVUE_FIXTURES_WRITE=1` records the `.html` files instead. The Rust half renders every fixture through the generated `render_json`,
|
|
651
|
+
compares the bytes, and fails when fewer than `at_least` fixtures are found. The crate's
|
|
652
|
+
[`testing`](https://docs.rs/ferrovue/latest/ferrovue/guide/testing/index.html) guide has the
|
|
653
|
+
details; `examples/fullstack` uses both.
|
|
654
|
+
|
|
377
655
|
## Performance
|
|
378
656
|
|
|
379
657
|
Time to render one component to an HTML string. On the left is Vue's `renderToString` on Node, and
|
|
@@ -383,10 +661,10 @@ and both write the same bytes: each side checks its output against
|
|
|
383
661
|
|
|
384
662
|
| Scenario | What renders | Output | Vue `renderToString` | ferrovue | Speed-up |
|
|
385
663
|
|---|---|---|---|---|---|
|
|
386
|
-
| `small` | `Nav`: two `<RouterLink>`s resolved against the current route | 206 B |
|
|
387
|
-
| `list` | `Lists`: 1,000 words, 1,000 numbers, 100 groups of 10 members | 102 KiB |
|
|
388
|
-
| `tree` | `Tree`: a recursive component, binary tree 8 levels deep (255 nodes) | 9.5 KiB |
|
|
389
|
-
| `page` | `Dashboard`: 22 `Panel`s with named slots, a `Text`, 20 `Frame`s holding loops | 8.0 KiB | 181.
|
|
664
|
+
| `small` | `Nav`: two `<RouterLink>`s resolved against the current route | 206 B | 36.41 µs | 0.989 µs | 36.8× |
|
|
665
|
+
| `list` | `Lists`: 1,000 words, 1,000 numbers, 100 groups of 10 members | 102 KiB | 355.8 µs | 46.16 µs | 7.7× |
|
|
666
|
+
| `tree` | `Tree`: a recursive component, binary tree 8 levels deep (255 nodes) | 9.5 KiB | 522.2 µs | 3.86 µs | 135.2× |
|
|
667
|
+
| `page` | `Dashboard`: 22 `Panel`s with named slots, a `Text`, 20 `Frame`s holding loops | 8.0 KiB | 181.3 µs | 5.06 µs | 35.8× |
|
|
390
668
|
|
|
391
669
|
These are mean times per render. The Vue column is tinybench's mean, with 5 s per scenario after
|
|
392
670
|
a 1 s warm-up. The ferrovue column is criterion's mean point estimate, using its defaults of a 3 s
|
|
@@ -424,20 +702,37 @@ still build, run and agree on the HTML. It never compares their timings.
|
|
|
424
702
|
|
|
425
703
|
## Versions
|
|
426
704
|
|
|
427
|
-
Byte-identical output depends on Vue's own SSR compiler, so
|
|
428
|
-
`@vue/compiler-sfc`
|
|
429
|
-
Vue
|
|
430
|
-
|
|
705
|
+
Byte-identical output depends on Vue's own SSR compiler, so ferrovue compiles with an exact
|
|
706
|
+
`@vue/compiler-sfc` (currently 3.5.43), and its conformance fixtures are recorded from one version
|
|
707
|
+
of Vue and of each integration. Your project may use any later patch of the same minor:
|
|
708
|
+
|
|
709
|
+
| Peer | Supported | Recorded from |
|
|
710
|
+
|---|---|---|
|
|
711
|
+
| `vue` | `~3.5.43` (3.5.43 and later 3.5 patches) | 3.5.43 |
|
|
712
|
+
| `vue-router` (optional) | `~5.3.1` | 5.3.1 |
|
|
713
|
+
| `pinia` (optional) | `~4.0.3` | 4.0.3 |
|
|
714
|
+
| `vue-i18n` (optional) | `~11.4.13` | 11.4.13 |
|
|
715
|
+
| `@unhead/vue` (optional) | `~3.4.2` | 3.4.2 |
|
|
716
|
+
|
|
717
|
+
A new minor of any of them (Vue 3.6, vue-router 5.4, …) needs a ferrovue release that re-records
|
|
718
|
+
the fixtures from it. A weekly CI job re-records them from the newest patch each range allows and
|
|
719
|
+
hydrates the committed HTML with it, so a patch that changes what Vue writes or hydrates is caught
|
|
720
|
+
before it reaches you; a ferrovue patch follows when one does.
|
|
431
721
|
|
|
432
722
|
## Repository layout
|
|
433
723
|
|
|
434
724
|
```text
|
|
435
725
|
crates/ferrovue/ the Rust runtime crate, which generated code calls
|
|
436
726
|
src/ a module per part (`Html`, slots, class and style, the state script,
|
|
437
|
-
strings, teleports, fallthrough attributes
|
|
438
|
-
|
|
439
|
-
|
|
727
|
+
strings, teleports, the page head, fallthrough attributes, `BasicHtml`
|
|
728
|
+
and `InlineHtml`,
|
|
729
|
+
`Sanitised`, the fixture check `conformance!` writes), each module's
|
|
730
|
+
unit tests beside it in <module>/tests.rs; lib.rs re-exports them all,
|
|
731
|
+
and the three crates below
|
|
732
|
+
docs/guide/ the crate guide, `ferrovue::guide` on docs.rs
|
|
440
733
|
tests/conformance/ components, fixtures, recorded HTML, generated Rust
|
|
734
|
+
tests/literal_props/ components compiled with `"builders": false`
|
|
735
|
+
tests/sanitised/ a component whose `TrustedHtml` is `ferrovue::Sanitised`
|
|
441
736
|
tests/vectors/ vectors recorded from JavaScript and Vue
|
|
442
737
|
tests/properties.rs property-based tests of the runtime
|
|
443
738
|
benches/ criterion benchmarks of generated renderers (see Performance)
|
|
@@ -447,31 +742,51 @@ crates/ferrovue-router/ vue-router's matching and links (the `router` featu
|
|
|
447
742
|
tests/vectors/ vectors recorded from vue-router
|
|
448
743
|
crates/ferrovue-i18n/ vue-i18n's t() (the `i18n` feature)
|
|
449
744
|
packages/ferrovue/ the compiler (npm package)
|
|
450
|
-
src/index.ts `ferrovue`: the browser API, `mountIslands`, `hydrateState` and the types
|
|
745
|
+
src/index.ts `ferrovue`: the browser API, `mountIslands`, `mountPage`, `hydrateState` and the types
|
|
451
746
|
src/compiler.ts `ferrovue/compiler`: `generate`, `write`
|
|
452
|
-
src/
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
747
|
+
src/context.ts, model.ts `ferrovue.config.json`, and the types and values the compiler passes around
|
|
748
|
+
src/component.ts, script.ts, typescript.ts, constants.ts
|
|
749
|
+
a `.vue` file read, <script setup>, TypeScript types, constants and enums
|
|
750
|
+
src/template.ts, children.ts, slots.ts, loops.ts, vhtml.ts
|
|
751
|
+
the compiled template: statements, child components, slots, v-for,
|
|
752
|
+
where `v-html` may go
|
|
753
|
+
src/expr.ts, strings.ts, numbers.ts, narrowing.ts, calls.ts, lists.ts, parens.ts
|
|
754
|
+
expressions: operators, strings, numbers, narrowing, calls, lists,
|
|
755
|
+
the parentheses Rust needs
|
|
458
756
|
src/attrs.ts, classes.ts, styles.ts, fallthrough.ts
|
|
459
757
|
attributes, class and style, attributes a parent passes on
|
|
758
|
+
src/dynamic.ts `<component :is>` over a closed set of choices
|
|
460
759
|
src/plugin.ts the plugin interface (see CONTRIBUTING.md)
|
|
461
|
-
src/plugins/ vue-router, Pinia, vue-i18n, scoped styles, <Teleport>, shared types
|
|
760
|
+
src/plugins/ vue-router, Pinia, vue-i18n, scoped styles, <Teleport>, shared types,
|
|
761
|
+
<ClientOnly>, Rust twins, provide and inject, the page head
|
|
462
762
|
src/rust.ts, emitter.ts the Rust source written out
|
|
463
763
|
src/cli.ts, vite.ts the `ferrovue` command and the Vite plugin
|
|
464
|
-
src/
|
|
764
|
+
src/diff.ts the diff `ferrovue --check --diff` prints for a stale file
|
|
765
|
+
src/errors.ts, diagnostics.ts
|
|
766
|
+
every error code with its title, and errors as the CLI, JSON and Vite show them
|
|
767
|
+
src/client.ts browser-side helpers: `mountIslands`, `mountPage`, `hydrateState`, `<ClientOnly>`
|
|
465
768
|
src/islands.ts `ferrovue/islands`, which the Vite plugin writes: every island, loaded lazily
|
|
466
|
-
src/
|
|
467
|
-
src/
|
|
769
|
+
src/link-router.ts `ferrovue/link-router`: `<RouterLink>` while the application navigates on its own
|
|
770
|
+
src/routes.ts a routes file as vue-router's route records
|
|
771
|
+
src/file-routes.ts the routes vue-router's file-based routing builds from a folder of pages
|
|
772
|
+
src/page-routes.ts `ferrovue/routes`, which the Vite plugin writes from the pages
|
|
773
|
+
src/testing.ts `ferrovue/testing`: utilities for a project's own conformance suite
|
|
774
|
+
src/fixture.ts, settle.ts `fixtureApp` and `readFixture`, and waiting for async components to settle
|
|
775
|
+
src/conformance.ts `conformanceSuite`, which `ferrovue/testing` exports
|
|
776
|
+
src/ssr.ts `attachSsrRender`, which `ferrovue/testing` exports
|
|
777
|
+
src/hydration.ts `hydrateRecordedPage`, which `ferrovue/testing` exports
|
|
778
|
+
src/types.ts `ferrovue/types`: `TrustedHtml`, `InlineHtml`, `Float`
|
|
468
779
|
test/ compiler, CLI, router, vector, island, Vite and conformance tests
|
|
469
|
-
browser/ conformance fixtures hydrated in real browsers (`pnpm test:browser`)
|
|
780
|
+
browser/ conformance fixtures and recorded pages hydrated in real browsers (`pnpm test:browser`)
|
|
470
781
|
bench/ Vue renderToString benchmarks, the other half of Performance
|
|
471
782
|
fuzz/ the randomised differential tester (`pnpm fuzz`)
|
|
472
783
|
examples/greeting/ the smallest setup: one component rendered from Rust
|
|
473
|
-
examples/fullstack/ axum + Vite: islands
|
|
784
|
+
examples/fullstack/ axum + Vite: islands (some hydrating later), a page hydrated whole,
|
|
785
|
+
Pinia state, routes from a folder of pages, the page head, streaming,
|
|
786
|
+
<ClientOnly>, a Rust twin, and a conformance suite
|
|
474
787
|
examples/dioxus/ a Dioxus page, rendered with dioxus-ssr, with an island in it
|
|
788
|
+
scripts/ferrovue-in.ts the `ferrovue` command run in a project of this repository (`pnpm conformance:check`, CI)
|
|
789
|
+
scripts/inspect.ts Vue's SSR code and render of a component beside the Rust ferrovue generates for it
|
|
475
790
|
scripts/release.ts the release version bump (see RELEASING.md)
|
|
476
791
|
```
|
|
477
792
|
|
|
@@ -484,15 +799,15 @@ pnpm typecheck
|
|
|
484
799
|
cargo test --all-features
|
|
485
800
|
```
|
|
486
801
|
|
|
487
|
-
[TESTING.md](TESTING.md) explains how the suite fits together and how to add a case,
|
|
488
|
-
[CONTRIBUTING.md](CONTRIBUTING.md) how to add support for a Vue construct, and
|
|
489
|
-
[RELEASING.md](RELEASING.md) how a release is cut.
|
|
802
|
+
[TESTING.md](https://github.com/Kinsmir/ferrovue/blob/main/TESTING.md) explains how the suite fits together and how to add a case,
|
|
803
|
+
[CONTRIBUTING.md](https://github.com/Kinsmir/ferrovue/blob/main/CONTRIBUTING.md) how to add support for a Vue construct, and
|
|
804
|
+
[RELEASING.md](https://github.com/Kinsmir/ferrovue/blob/main/RELEASING.md) how a release is cut.
|
|
490
805
|
|
|
491
|
-
[`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.
|
|
806
|
+
[`examples/fullstack`](https://github.com/Kinsmir/ferrovue/blob/main/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.
|
|
492
807
|
|
|
493
808
|
## Licence
|
|
494
809
|
|
|
495
|
-
Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT licence](LICENSE-MIT),
|
|
810
|
+
Licensed under either of [Apache License, Version 2.0](https://github.com/Kinsmir/ferrovue/blob/main/LICENSE-APACHE) or [MIT licence](https://github.com/Kinsmir/ferrovue/blob/main/LICENSE-MIT),
|
|
496
811
|
at your option. Unless you explicitly state otherwise, any contribution you intentionally submit for
|
|
497
812
|
inclusion in this work, as defined in the Apache-2.0 licence, is dual-licensed as above, without any
|
|
498
813
|
additional terms or conditions.
|