ferrovue 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -4
- package/dist/attrs.d.ts +21 -5
- package/dist/attrs.js +305 -24
- package/dist/cli.js +131 -20
- package/dist/client.d.ts +11 -2
- package/dist/client.js +56 -8
- package/dist/compiler.d.ts +4 -0
- package/dist/compiler.js +17 -5
- package/dist/component.js +13 -0
- package/dist/context.d.ts +2 -2
- package/dist/context.js +23 -5
- package/dist/diff.d.ts +4 -0
- package/dist/diff.js +77 -0
- package/dist/expr.js +39 -1
- package/dist/islands.d.ts +5 -0
- package/dist/islands.js +4 -0
- package/dist/model.d.ts +21 -0
- package/dist/model.js +13 -0
- package/dist/router.js +52 -2
- package/dist/rust.d.ts +3 -0
- package/dist/rust.js +37 -10
- package/dist/scoped.d.ts +15 -1
- package/dist/scoped.js +88 -1
- package/dist/script.js +18 -2
- package/dist/stores.js +2 -0
- package/dist/template.d.ts +8 -5
- package/dist/template.js +131 -58
- package/dist/vite.js +45 -2
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -82,8 +82,11 @@ defineProps<{ name: string; unread: number; note?: string }>();
|
|
|
82
82
|
### 4. Generate and render
|
|
83
83
|
|
|
84
84
|
```sh
|
|
85
|
+
npx ferrovue init # a starter ferrovue.config.json and components/Hello.vue
|
|
85
86
|
npx ferrovue # writes src/generated/{greeting.rs, mod.rs}
|
|
86
87
|
npx ferrovue --check # in CI: exits 1 if the committed modules are stale
|
|
88
|
+
npx ferrovue --check --diff # …and shows what differs, as `diff -u` does
|
|
89
|
+
npx ferrovue --watch # regenerates when components or configs change
|
|
87
90
|
```
|
|
88
91
|
|
|
89
92
|
```rust
|
|
@@ -118,6 +121,21 @@ route.
|
|
|
118
121
|
With the `maud` feature, `ferrovue::Html` implements `maud::Render`, so `(greeting::html(&props))` can go
|
|
119
122
|
straight into a `maud::html!` page.
|
|
120
123
|
|
|
124
|
+
With the `axum` feature, `Html` is an `IntoResponse`, and with `actix-web` a `Responder`: a handler
|
|
125
|
+
responds with the component, as `text/html; charset=utf-8`. `into_html(props)` and
|
|
126
|
+
`into_island(props)` take the props by value, so a handler that builds them returns the page:
|
|
127
|
+
|
|
128
|
+
```rust
|
|
129
|
+
async fn hello(Path(name): Path<String>) -> impl IntoResponse {
|
|
130
|
+
greeting::into_html(greeting::Props::new(name, 0))
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
With the `dioxus` feature, a component goes into a Dioxus 0.7 page (`dioxus-ssr` or fullstack):
|
|
135
|
+
`{greeting::island(&props)}` in `rsx!` is the island element itself, with exactly the markup Vue
|
|
136
|
+
hydrates inside it. See `examples/dioxus` and the crate guide's
|
|
137
|
+
[Dioxus page](crates/ferrovue/docs/guide/dioxus.md).
|
|
138
|
+
|
|
121
139
|
## What a component may use
|
|
122
140
|
|
|
123
141
|
ferrovue compiles `<script setup lang="ts">` components. Props are declared by type, with
|
|
@@ -138,11 +156,12 @@ ferrovue compiles `<script setup lang="ts">` components. Props are declared by t
|
|
|
138
156
|
| `class` | strings, arrays, objects (`{ active: on }`, computed keys), `cond && "x"`, `cond ? "x" : null`, merged with a static `class` |
|
|
139
157
|
| `style` | objects (camelCase or kebab-case keys, `--custom` properties), arrays of objects, strings, merged with a static `style`, and `v-show`; later values override earlier ones as in Vue. A global `<style>` block is allowed |
|
|
140
158
|
| Scoped styles | `<style scoped>`: the id on every element, on child components' roots (a root that is itself a component, fragments, recursion and `inheritAttrs: false` as Vue renders them) and, from a component with `:slotted()` rules, on the slot content it is given, forwarded slots included; inside `<Transition>`, `<KeepAlive>`, `<Teleport>` and `v-if`; on `<RouterLink>` and what it holds, as vue-router renders them |
|
|
141
|
-
| Components | imported child components, `v-bind` of a child's own `Props`, `v-model` on a child's `defineModel`, recursion |
|
|
159
|
+
| Components | imported child components, `v-bind` of a child's own `Props`, `v-model` on a child's `defineModel`, recursion; props named in `kebab-case` or `camelCase` |
|
|
160
|
+
| Fallthrough attributes | what a parent passes a child beyond its props (static and bound attributes, `class`, `style`, `data-*`, `aria-*`, booleans, `undefined`): onto its single root, merged with the root's own class and style and replacing its other attributes where they stand, as Vue's `mergeProps` merges them; none for two roots; on through a root that is a component, or a `<RouterLink>`; with `inheritAttrs: false`, onto the elements or components that bind `v-bind="$attrs"` or a `useAttrs()` binding, before or after their own; beside scope ids. Listeners are dropped, as Vue's server drops them. See the crate's [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#fallthrough-attributes) guide |
|
|
142
161
|
| Slots | default and named slots, fallbacks, `$slots.name` tests, scoped slots (`<slot :item="x">` and `#item="{ item }"` or `v-slot="props"`), whose props a parent can hand to its own children |
|
|
143
162
|
| Forms | `v-model` on text inputs, checkboxes, radios, `<select>` and `<textarea>` (renders the initial state) |
|
|
144
163
|
| Built-ins | `<Transition>`, `<TransitionGroup>`, `<KeepAlive>`, `<Suspense>` (synchronous content), `<Teleport>` (to any target, nested, disabled: see below), `v-text`, `v-once`, `v-pre`, `v-memo`, custom directives listed in `clientDirectives` |
|
|
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 |
|
|
164
|
+
| Vue Router | `<RouterLink>` (resolved by name or imported) with a string `to` or `{ name, params, query, hash }` / `{ path, query, hash }`, `active-class`, `exact-active-class`, `aria-current-value`, `replace`; vue-router's own encoding and active-link matching, nested routes included (a parent link is active on its children's pages, exact only on its own); a history base. `useRoute()` and `$route`: `path`, `fullPath`, `hash`, `name`, `params`, and `query` (a value written once, without `=`, or repeated, exactly as vue-router parses it; `typeof route.query.q === "string"` narrows one to a single string). `<RouterView>`, at the top and in nested route components: each takes the page it shows as a slot |
|
|
146
165
|
| Pinia | option stores with a typed `state`, and setup stores (`defineStore(id, () => { … })`) whose returned refs are typed by `ref<T>()` or their initial literal; read through `useX()` or `storeToRefs`, in the template or in `computed`; getters that are an expression of the state, and a setup store's computeds, which may read each other |
|
|
147
166
|
| vue-i18n | `$t` and `useI18n()`'s `t` and `locale`: named and list values, plurals by vue-i18n's rule, literals, linked messages with `upper`/`lower`/`capitalize`, nested and flat keys, fallback locales, a missing key shown as itself. Messages are parsed at build time by vue-i18n's own compiler |
|
|
148
167
|
| `v-html` | only on a `TrustedHtml` prop (`import type { TrustedHtml } from "ferrovue/types"`) |
|
|
@@ -171,7 +190,18 @@ Refused at compile time, each with an error that names the construct:
|
|
|
171
190
|
- two strings that may each hold half of a surrogate pair compared, searched or joined (see
|
|
172
191
|
[Strings](#strings))
|
|
173
192
|
- `null`
|
|
193
|
+
- an attribute (`class` and `style` aside) bound to a value that may be neither a string, a number
|
|
194
|
+
nor a boolean: a list, an object, or a `route.query` value, which is an array when its key is
|
|
195
|
+
repeated. Vue's server renderer leaves the attribute out and hydration then sets it, silently;
|
|
196
|
+
join a list (`.join(",")`), or narrow a query value with `typeof route.query.q === "string"`
|
|
174
197
|
- any method call without a Rust twin
|
|
198
|
+
- a value read from `$attrs` or `useAttrs()` (`$attrs.title`), which has no type; an attribute a
|
|
199
|
+
component is passed that would reach a prop of the component (or `<RouterLink>`) its root passes it
|
|
200
|
+
on to; `$attrs` in a component that is the root of one that may be handed scope ids, where it would
|
|
201
|
+
hold them; an attribute named by a number, or bound to a `$route.query` value; a class merged by
|
|
202
|
+
`v-bind` with a string that may equal the class before it, which Vue would write once; attributes
|
|
203
|
+
passed to a component whose root is a `<Transition>` or `<KeepAlive>` around a `v-if`, which Vue's
|
|
204
|
+
server drops but its client keeps
|
|
175
205
|
|
|
176
206
|
An object prop handed to a child component is cloned. Its strings are `Cow`s, so borrowed ones
|
|
177
207
|
cost nothing to copy.
|
|
@@ -203,7 +233,8 @@ same in development and production, and do not change, nor change the generated
|
|
|
203
233
|
component's source does.
|
|
204
234
|
|
|
205
235
|
A component a parent may hand ids to has a `render_scoped(…, attrs)` beside `render`, which
|
|
206
|
-
generated parents call
|
|
236
|
+
generated parents call (for one a parent also passes attributes to, `attrs` is an `fv::Attrs`
|
|
237
|
+
holding them and the ids). A component whose outlets pass a slot scope id (`:slotted()`) takes slot
|
|
207
238
|
content that is given it: `Slot::slotted` for a slot, a third `&str` parameter for a scoped slot's
|
|
208
239
|
closure. `render`, and content from Rust, need none of it: markup written from Rust carries no ids.
|
|
209
240
|
|
|
@@ -286,6 +317,37 @@ let row = |out: &mut String, p: &data_list::RowSlotProps<'_>| {
|
|
|
286
317
|
data_list::render(&mut page, &props, data_list::Slots { row: Some(&row), ..Default::default() });
|
|
287
318
|
```
|
|
288
319
|
|
|
320
|
+
### Hydrating islands
|
|
321
|
+
|
|
322
|
+
`mountIslands` from `ferrovue/client` hydrates every `data-island` element on the page with the
|
|
323
|
+
component of that name, from the props the server wrote. With the Vite plugin, `ferrovue/islands`
|
|
324
|
+
gives it every island there is, so nothing has to be listed by hand:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { hydrateState, mountIslands } from "ferrovue/client";
|
|
328
|
+
import islands from "ferrovue/islands"; // written by `ferrovue()` from `ferrovue/vite`
|
|
329
|
+
|
|
330
|
+
hydrateState(pinia);
|
|
331
|
+
await mountIslands(islands, { pinia, router }); // one Pinia and one router for every island
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The islands are exactly the components that have an `island()`: those that render from their props
|
|
335
|
+
alone, as the compiler finds while it generates them. That is the only set the server can write
|
|
336
|
+
`data-island` for, so it needs no folder convention or list to keep in step, and making a component
|
|
337
|
+
an island is a matter of the server calling its `island()`. Each is keyed by its `NAME`, the file
|
|
338
|
+
name without `.vue`, which is what `data-island` carries.
|
|
339
|
+
|
|
340
|
+
Each entry is a lazy `import()`, so the bundler gives every island a chunk of its own and a page
|
|
341
|
+
fetches the code of the islands it holds and no more. `mountIslands` takes such loaders and
|
|
342
|
+
components alike, so a hand-written island can join them: `mountIslands({ ...islands, Chart })`.
|
|
343
|
+
An island whose name is not among them is left as the server rendered it, with a warning naming it
|
|
344
|
+
(`onError` to handle it otherwise), as is one whose component fails to load.
|
|
345
|
+
|
|
346
|
+
An island's `<style>` goes into its chunk too, and Vite links it only once the island's script has
|
|
347
|
+
loaded, after the server's markup is on screen. Link those stylesheets from the page up front: a
|
|
348
|
+
server reading Vite's manifest takes the `css` of the entry's `dynamicImports` as well as its own
|
|
349
|
+
(`examples/fullstack/src/assets.rs`).
|
|
350
|
+
|
|
289
351
|
### Hydrating Pinia state
|
|
290
352
|
|
|
291
353
|
```rust
|
|
@@ -301,7 +363,12 @@ hydrateState(pinia); // before app.mount(): every store starts from what the ser
|
|
|
301
363
|
|
|
302
364
|
`ferrovue::hole()` is a slot whose content you write later. Render a layout with holes,
|
|
303
365
|
`split_holes` the output, and stream the pieces with each hole's content between them, in whatever
|
|
304
|
-
order the content is ready.
|
|
366
|
+
order the content is ready. With the `axum` or `actix-web` feature, `ferrovue::HtmlStream` is that
|
|
367
|
+
streamed response, from the render and a future for each hole's content:
|
|
368
|
+
|
|
369
|
+
```rust
|
|
370
|
+
let body = ferrovue::HtmlStream::new(page).hole(async move { reviews_of(&id).await });
|
|
371
|
+
```
|
|
305
372
|
|
|
306
373
|
## Performance
|
|
307
374
|
|
|
@@ -376,6 +443,7 @@ packages/ferrovue/ the compiler (npm package)
|
|
|
376
443
|
src/rust.ts, emitter.ts the Rust source written out
|
|
377
444
|
src/cli.ts, vite.ts the `ferrovue` command and the Vite plugin
|
|
378
445
|
src/client.ts browser-side helpers: `mountIslands`, `hydrateState`
|
|
446
|
+
src/islands.ts `ferrovue/islands`, which the Vite plugin writes: every island, loaded lazily
|
|
379
447
|
src/testing.ts utilities for a project's own conformance suite
|
|
380
448
|
src/types.ts `ferrovue/types`: `TrustedHtml`, `Float`
|
|
381
449
|
test/ compiler, CLI, router, vector, island, Vite and conformance tests
|
|
@@ -384,6 +452,7 @@ packages/ferrovue/ the compiler (npm package)
|
|
|
384
452
|
fuzz/ the randomised differential tester (`pnpm fuzz`)
|
|
385
453
|
examples/greeting/ the smallest setup: one component rendered from Rust
|
|
386
454
|
examples/fullstack/ axum + Vite: islands, Pinia state, routes and streaming
|
|
455
|
+
examples/dioxus/ a Dioxus page, rendered with dioxus-ssr, with an island in it
|
|
387
456
|
scripts/release.ts the release version bump (see RELEASING.md)
|
|
388
457
|
```
|
|
389
458
|
|
package/dist/attrs.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare function interpolate(e: Emitter, v: Val): void;
|
|
|
5
5
|
/** The value half of `key="value"`, escaped. */
|
|
6
6
|
export declare function attrValue(e: Emitter, v: Val): void;
|
|
7
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;
|
|
8
|
+
export declare function renderAttr(s: Scope, e: Emitter, key: string, v: Val, n: N): void;
|
|
9
9
|
/** `ssrRenderDynamicAttr(key, value)`: as `renderAttr`, but a boolean attribute is present or
|
|
10
10
|
* absent, and an empty string renders the name alone. */
|
|
11
11
|
export declare function renderDynamicAttr(s: Scope, e: Emitter, key: string, v: Val, n: N): void;
|
|
@@ -27,13 +27,29 @@ export declare const IGNORED_PROPS: Set<string>;
|
|
|
27
27
|
/** `ssrGetDirectiveProps(_ctx, dir)`: a custom directive's server props, which are none for a
|
|
28
28
|
* directive the configuration declares client-only. `false` when `n` is not such a call. */
|
|
29
29
|
export declare function directiveProps(s: Scope, n: N): boolean;
|
|
30
|
+
/** Whether `n` is the root's `_attrs`: what a parent passes, then the scope ids. */
|
|
31
|
+
export declare function isAttrs(n: N): boolean;
|
|
32
|
+
/** Whether `n` is `$attrs` — `_ctx.$attrs`, or a `useAttrs()` binding — which a component whose
|
|
33
|
+
* root is another's would find holding scope ids as well. */
|
|
34
|
+
export declare function dollarAttrs(s: Scope, n: N): boolean;
|
|
35
|
+
/** The parts `_mergeProps(…)` merges, or the one object that is not merged. */
|
|
36
|
+
export declare function mergedParts(n: N): N[];
|
|
30
37
|
/** `ssrRenderAttrs(obj)`, key by key in the object's order, and the inherited scope ids where the
|
|
31
|
-
* root's `_attrs` is merged in.
|
|
38
|
+
* root's `_attrs` is merged in. When a parent may pass attributes, which only the run time knows,
|
|
39
|
+
* `$attrs` and the root's `_attrs` are merged with the element's own by `fv::attrs_into` — but only
|
|
40
|
+
* when some were passed: with none, the element is written as it is without. */
|
|
32
41
|
export declare function renderAttrs(s: Scope, e: Emitter, n: N): void;
|
|
33
|
-
/** `mergeProps(a, _attrs, b, …)` as one object literal, without `_attrs
|
|
34
|
-
*
|
|
35
|
-
*
|
|
42
|
+
/** `mergeProps(a, _attrs, b, …)` as one object literal, without `_attrs` and `$attrs`, which hold
|
|
43
|
+
* what only the run time knows. Of the rest, `class` and `style` values are merged as an array —
|
|
44
|
+
* unless a class equals the one so far, which Vue then keeps once — and any other key keeps the
|
|
45
|
+
* place it first had with the last value given. */
|
|
36
46
|
export declare function mergeProps(s: Scope, n: N): N;
|
|
47
|
+
/** The attributes of an object literal as `fv::Attr` entries, `("name", value)`, for a merge at run
|
|
48
|
+
* time: given to a child component (`vnode`), whose virtual node normalises a class array or object
|
|
49
|
+
* to a string, or an element's own (`own`), which `mergeProps` keeps as an object. */
|
|
50
|
+
export declare function attrEntries(s: Scope, obj: N, side: "vnode" | "own"): string[];
|
|
51
|
+
/** One attribute's value as an `fv::Attr`. */
|
|
52
|
+
export declare function attrOf(s: Scope, key: string, n: N, side: "vnode" | "own"): string;
|
|
37
53
|
/** One object of a style binding, and the run-time condition under which it is part of it. */
|
|
38
54
|
export interface StyleItem {
|
|
39
55
|
cond: string | null;
|
package/dist/attrs.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { escapeHtml, hyphenate, isBooleanAttr, isSSRSafeAttrName, parseStringStyle, propsToAttrMap } from "@vue/shared";
|
|
3
3
|
import { fail, GenError, rustStr, STR } from "./model.js";
|
|
4
4
|
import { CONFIG_FILE, ctx } from "./context.js";
|
|
5
|
-
import { cond, expr, known, meet, truthy, unquote } from "./expr.js";
|
|
5
|
+
import { cond, describeTy, expr, known, meet, truthy, unquote } from "./expr.js";
|
|
6
6
|
import { atom, bare, condition, logical, not, receiver, strArg } from "./parens.js";
|
|
7
7
|
import { Emitter } from "./emitter.js";
|
|
8
|
+
import { isDollarAttrs } from "./scoped.js";
|
|
8
9
|
/** What a number written at run time is expected to take: most a page shows are shorter, and the
|
|
9
10
|
* reservation is an estimate. */
|
|
10
11
|
const NUMBER_BYTES = 6;
|
|
@@ -54,16 +55,27 @@ export function attrValue(e, v) {
|
|
|
54
55
|
if (!display(e, v))
|
|
55
56
|
throw new GenError("an attribute value must be a string, a number or a boolean");
|
|
56
57
|
}
|
|
58
|
+
/** Vue's server renderer leaves out an attribute whose value is not a string, a number or a boolean
|
|
59
|
+
* (`isRenderableAttrValue`), but hydration then sets it, to `String(value)`, without reporting a
|
|
60
|
+
* mismatch: the page changes as it hydrates. So a value that may be anything else is refused. */
|
|
61
|
+
function renderable(s, key, v, n) {
|
|
62
|
+
const ty = v.ty.k === "opt" ? v.ty.of : v.ty;
|
|
63
|
+
if (ty.k === "str" || ty.k === "int" || ty.k === "float" || ty.k === "bool" || ty.k === "undef")
|
|
64
|
+
return;
|
|
65
|
+
const what = ty.k === "query" ? "a query value, which is an array when its key is repeated" : describeTy(ty);
|
|
66
|
+
const fix = ty.k === "query" ? 'narrow it to one string, as `typeof route.query.q === "string" ? route.query.q : ""`'
|
|
67
|
+
: ty.k === "list" ? 'join it into one string, as `.join(",")`'
|
|
68
|
+
: "bind a string, a number or a boolean";
|
|
69
|
+
fail(s.comp, `\`${key}\` is bound to ${what}: Vue's server renderer leaves the attribute out, and hydration then sets it, changing the page; ${fix}`, n);
|
|
70
|
+
}
|
|
57
71
|
/** `ssrRenderAttr(key, value)`: absent for null/undefined, `key="value"` for anything else. */
|
|
58
|
-
export function renderAttr(e, key, v) {
|
|
72
|
+
export function renderAttr(s, e, key, v, n) {
|
|
73
|
+
renderable(s, key, v, n);
|
|
59
74
|
if (v.ty.k === "undef")
|
|
60
75
|
return;
|
|
61
|
-
// Only a single query value is an attribute's value; `null` and an array leave it out.
|
|
62
|
-
if (v.ty.k === "query")
|
|
63
|
-
v = { code: `${atom(v.code)}.attr_value()`, ty: { k: "opt", of: STR } };
|
|
64
76
|
if (v.ty.k === "opt") {
|
|
65
77
|
e.open(`if let Some(v) = ${v.code}`);
|
|
66
|
-
renderAttr(e, key, { code: "v", ty: v.ty.of });
|
|
78
|
+
renderAttr(s, e, key, { code: "v", ty: v.ty.of }, n);
|
|
67
79
|
e.close();
|
|
68
80
|
return;
|
|
69
81
|
}
|
|
@@ -77,10 +89,9 @@ export function renderDynamicAttr(s, e, key, v, n) {
|
|
|
77
89
|
const name = propsToAttrMap[key] ?? key.toLowerCase();
|
|
78
90
|
if (!isSSRSafeAttrName(name))
|
|
79
91
|
fail(s.comp, `unsafe attribute name \`${name}\``, n);
|
|
92
|
+
renderable(s, key, v, n);
|
|
80
93
|
if (v.ty.k === "undef")
|
|
81
94
|
return;
|
|
82
|
-
if (v.ty.k === "query")
|
|
83
|
-
v = { code: `${atom(v.code)}.attr_value()`, ty: { k: "opt", of: STR } };
|
|
84
95
|
const boolean = (t) => isBooleanAttr(name) || (name === "hidden" && (t.k === "bool" || t.k === "int" || t.k === "float"));
|
|
85
96
|
if (v.ty.k === "opt" && boolean(v.ty.of)) {
|
|
86
97
|
// Present, or truthy when it is not a string: one test, with nothing to bind.
|
|
@@ -237,9 +248,8 @@ export function renderClass(s, e, n, after = false) {
|
|
|
237
248
|
}
|
|
238
249
|
}
|
|
239
250
|
export const IGNORED_PROPS = new Set(["", "key", "ref", "innerHTML", "textContent", "ref_key", "ref_for"]);
|
|
240
|
-
/** `ssrRenderAttrs(_attrs)` on the root
|
|
241
|
-
*
|
|
242
|
-
* declares and nothing else falls through. */
|
|
251
|
+
/** `ssrRenderAttrs(_attrs)` on the root, when no attribute reaches it at run time: the scope ids a
|
|
252
|
+
* parent handed it, already written as Vue writes them. */
|
|
243
253
|
function inheritedAttrs(s, e) {
|
|
244
254
|
if (s.attrs !== null)
|
|
245
255
|
e.stmt(`out.push_str(${s.attrs});`);
|
|
@@ -263,32 +273,100 @@ export function directiveProps(s, n) {
|
|
|
263
273
|
}
|
|
264
274
|
return true;
|
|
265
275
|
}
|
|
276
|
+
/** Whether `n` is the root's `_attrs`: what a parent passes, then the scope ids. */
|
|
277
|
+
export function isAttrs(n) {
|
|
278
|
+
return n?.type === "Identifier" && n.name === "_attrs";
|
|
279
|
+
}
|
|
280
|
+
/** Whether `n` is `$attrs` — `_ctx.$attrs`, or a `useAttrs()` binding — which a component whose
|
|
281
|
+
* root is another's would find holding scope ids as well. */
|
|
282
|
+
export function dollarAttrs(s, n) {
|
|
283
|
+
if (!isDollarAttrs(n, s.attrsBindings))
|
|
284
|
+
return false;
|
|
285
|
+
if (s.comp.idsInAttrs) {
|
|
286
|
+
fail(s.comp, `\`$attrs\` in ${s.comp.name}, the root of a component that may be handed scope ids: it would hold them as attributes too`, n);
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
/** The parts `_mergeProps(…)` merges, or the one object that is not merged. */
|
|
291
|
+
export function mergedParts(n) {
|
|
292
|
+
return n?.type === "CallExpression" && n.callee.type === "Identifier" && n.callee.name === "_mergeProps" ? n.arguments : [n];
|
|
293
|
+
}
|
|
266
294
|
/** `ssrRenderAttrs(obj)`, key by key in the object's order, and the inherited scope ids where the
|
|
267
|
-
* root's `_attrs` is merged in.
|
|
295
|
+
* root's `_attrs` is merged in. When a parent may pass attributes, which only the run time knows,
|
|
296
|
+
* `$attrs` and the root's `_attrs` are merged with the element's own by `fv::attrs_into` — but only
|
|
297
|
+
* when some were passed: with none, the element is written as it is without. */
|
|
268
298
|
export function renderAttrs(s, e, n) {
|
|
269
|
-
const
|
|
299
|
+
const parts = mergedParts(n);
|
|
300
|
+
const passed = (p) => s.fallthrough !== null && ((isAttrs(p) && s.comp.inheritAttrs) || dollarAttrs(s, p));
|
|
301
|
+
if (!parts.some(passed)) {
|
|
302
|
+
staticAttrs(s, e, n);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
e.open(`if ${s.fallthrough}.is_empty()`);
|
|
306
|
+
const at = e.lines.length;
|
|
307
|
+
staticAttrs(s, e, n);
|
|
308
|
+
e.flush();
|
|
309
|
+
// Nothing to write when nothing is passed: only the other branch is left.
|
|
310
|
+
if (e.lines.length === at)
|
|
311
|
+
e.replace(at - 1, `if ${s.fallthrough}.is_empty()`, `if !${s.fallthrough}.is_empty()`);
|
|
312
|
+
else
|
|
313
|
+
e.close(" else {");
|
|
314
|
+
// `_attrs` or `$attrs` alone is written as it is, with no `mergeProps` to normalise it.
|
|
315
|
+
if (parts.length === 1 && parts[0] === n) {
|
|
316
|
+
e.stmt(`fv::passed_attrs_into(out, ${s.fallthrough}.list(), ${isAttrs(n) && s.attrs !== null ? s.attrs : '""'});`);
|
|
317
|
+
e.close();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const sources = [];
|
|
321
|
+
let idsAt = -1;
|
|
322
|
+
for (const p of parts) {
|
|
323
|
+
if (directiveProps(s, p))
|
|
324
|
+
continue;
|
|
325
|
+
if (isAttrs(p))
|
|
326
|
+
idsAt = sources.length;
|
|
327
|
+
if (passed(p))
|
|
328
|
+
sources.push(`${s.fallthrough}.list()`);
|
|
329
|
+
else if (isAttrs(p))
|
|
330
|
+
sources.push("&[]");
|
|
331
|
+
else if (p.type === "ObjectExpression")
|
|
332
|
+
sources.push(`&[${attrEntries(s, p, "own").join(", ")}]`);
|
|
333
|
+
else
|
|
334
|
+
fail(s.comp, "attributes must be an object literal", p);
|
|
335
|
+
}
|
|
336
|
+
const ids = idsAt >= 0 && s.attrs !== null ? s.attrs : '""';
|
|
337
|
+
e.stmt(`fv::attrs_into(out, &[${sources.join(", ")}], ${idsAt >= 0 ? idsAt : sources.length}, ${ids});`);
|
|
338
|
+
e.close();
|
|
339
|
+
}
|
|
340
|
+
/** `ssrRenderAttrs(obj)` with nothing passed at run time: `$attrs` empty, `_attrs` the scope ids. */
|
|
341
|
+
function staticAttrs(s, e, n) {
|
|
270
342
|
if (isAttrs(n)) {
|
|
271
343
|
inheritedAttrs(s, e);
|
|
272
344
|
return;
|
|
273
345
|
}
|
|
274
|
-
if (directiveProps(s, n))
|
|
346
|
+
if (dollarAttrs(s, n) || directiveProps(s, n))
|
|
275
347
|
return;
|
|
276
348
|
if (n.type === "CallExpression" && n.callee.type === "Identifier" && n.callee.name === "_mergeProps") {
|
|
277
349
|
const merged = mergeProps(s, n);
|
|
278
350
|
const at = n.arguments.findIndex(isAttrs);
|
|
279
351
|
if (at < 0) {
|
|
280
|
-
|
|
352
|
+
objectAttrs(s, e, merged, true);
|
|
281
353
|
return;
|
|
282
354
|
}
|
|
283
355
|
// The inherited ids take their place among the keys: after those given before `_attrs`, before
|
|
284
356
|
// those first given after it, such as `v-show`'s `style` when there is no other.
|
|
285
357
|
const before = new Set(mergeProps(s, { ...n, arguments: n.arguments.slice(0, at) }).properties.map((p) => p.key.name ?? p.key.value));
|
|
286
358
|
const part = (early) => ({ ...merged, properties: merged.properties.filter((p) => before.has(p.key.name ?? p.key.value) === early) });
|
|
287
|
-
|
|
359
|
+
objectAttrs(s, e, part(true), true);
|
|
288
360
|
inheritedAttrs(s, e);
|
|
289
|
-
|
|
361
|
+
objectAttrs(s, e, part(false), true);
|
|
290
362
|
return;
|
|
291
363
|
}
|
|
364
|
+
objectAttrs(s, e, n, false);
|
|
365
|
+
}
|
|
366
|
+
/** The keys of one attribute object. `merged` says `mergeProps` made it, which normalises the class
|
|
367
|
+
* and the style first: a class that is `undefined` is left out, and a style given as text is parsed
|
|
368
|
+
* and written back. */
|
|
369
|
+
function objectAttrs(s, e, n, merged) {
|
|
292
370
|
if (n.type !== "ObjectExpression")
|
|
293
371
|
fail(s.comp, "attributes must be an object literal", n);
|
|
294
372
|
for (const p of n.properties) {
|
|
@@ -303,13 +381,23 @@ export function renderAttrs(s, e, n) {
|
|
|
303
381
|
fail(s.comp, "`className` renders by its own rule in Vue; bind `class`", p);
|
|
304
382
|
}
|
|
305
383
|
else if (key === "class") {
|
|
384
|
+
const present = merged ? classPresent(s, p.value) : "true";
|
|
385
|
+
if (present === "false")
|
|
386
|
+
continue;
|
|
387
|
+
if (present !== "true")
|
|
388
|
+
e.open(`if ${condition(present)}`);
|
|
306
389
|
e.lit(` class="`);
|
|
307
390
|
renderClass(s, e, p.value);
|
|
308
391
|
e.lit(`"`);
|
|
392
|
+
if (present !== "true")
|
|
393
|
+
e.close();
|
|
309
394
|
}
|
|
310
395
|
else if (key === "style") {
|
|
311
396
|
e.lit(` style="`);
|
|
312
|
-
|
|
397
|
+
if (merged)
|
|
398
|
+
mergedStyle(s, e, p.value);
|
|
399
|
+
else
|
|
400
|
+
renderStyle(s, e, p.value);
|
|
313
401
|
e.lit(`"`);
|
|
314
402
|
}
|
|
315
403
|
else {
|
|
@@ -317,13 +405,83 @@ export function renderAttrs(s, e, n) {
|
|
|
317
405
|
}
|
|
318
406
|
}
|
|
319
407
|
}
|
|
320
|
-
/** `mergeProps
|
|
321
|
-
* which
|
|
322
|
-
|
|
408
|
+
/** When `mergeProps` gives an element a `class` at all: unless every value it merges is
|
|
409
|
+
* `undefined`, which `ret.class !== undefined` leaves out. */
|
|
410
|
+
function classPresent(s, n) {
|
|
411
|
+
if (n.fvMerged)
|
|
412
|
+
return n.elements.reduce((acc, el) => logical(acc, "||", classPresent(s, el)), "false");
|
|
413
|
+
switch (n.type) {
|
|
414
|
+
case "StringLiteral":
|
|
415
|
+
case "NullLiteral":
|
|
416
|
+
case "ArrayExpression":
|
|
417
|
+
case "ObjectExpression":
|
|
418
|
+
return "true";
|
|
419
|
+
case "LogicalExpression":
|
|
420
|
+
if (n.operator === "&&")
|
|
421
|
+
return logical(not(cond(s, n.left)), "||", classPresent(s, n.right));
|
|
422
|
+
break;
|
|
423
|
+
case "ConditionalExpression":
|
|
424
|
+
if (n.consequent.type === "NullLiteral")
|
|
425
|
+
return logical(cond(s, n.test), "||", classPresent(s, n.alternate));
|
|
426
|
+
if (n.alternate.type === "NullLiteral")
|
|
427
|
+
return logical(not(cond(s, n.test)), "||", classPresent(s, n.consequent));
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
const v = expr(s, n);
|
|
431
|
+
if (v.ty.k === "undef")
|
|
432
|
+
return "false";
|
|
433
|
+
if (v.ty.k === "opt")
|
|
434
|
+
return `${atom(v.code)}.is_some()`;
|
|
435
|
+
return "true";
|
|
436
|
+
}
|
|
437
|
+
/** A style `mergeProps` merged: normalised, so that text is parsed into properties — by Vue's own
|
|
438
|
+
* parser now when it is literal, by `fv::style_text_into` at run time when it is not. */
|
|
439
|
+
function mergedStyle(s, e, n) {
|
|
440
|
+
if (n.type === "StringLiteral") {
|
|
441
|
+
renderStyle(s, e, { type: "ArrayExpression", elements: [n] });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (n.fvMerged || ["ObjectExpression", "ArrayExpression", "ConditionalExpression", "LogicalExpression", "NullLiteral"].includes(n.type)) {
|
|
445
|
+
renderStyle(s, e, n);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const v = expr(s, n);
|
|
449
|
+
if (v.ty.k === "str")
|
|
450
|
+
e.stmt(`fv::style_text_into(out, ${strArg(v.code)});`);
|
|
451
|
+
else if (v.ty.k === "opt" && v.ty.of.k === "str") {
|
|
452
|
+
e.open(`if let Some(v) = ${v.code}`);
|
|
453
|
+
e.stmt("fv::style_text_into(out, v);");
|
|
454
|
+
e.close();
|
|
455
|
+
}
|
|
456
|
+
else if (v.ty.k !== "undef")
|
|
457
|
+
fail(s.comp, "a style binding is a string, an object or an array", n);
|
|
458
|
+
}
|
|
459
|
+
/** Whether a class value could equal, as a string, the class merged before it: a string. */
|
|
460
|
+
function maybeEqual(s, n) {
|
|
461
|
+
if (n.type === "StringLiteral")
|
|
462
|
+
return true;
|
|
463
|
+
if (n.fvMerged || ["ArrayExpression", "ObjectExpression", "NullLiteral", "LogicalExpression", "ConditionalExpression"].includes(n.type))
|
|
464
|
+
return false;
|
|
465
|
+
const v = expr(s, n);
|
|
466
|
+
return v.ty.k === "str" || (v.ty.k === "opt" && v.ty.of.k === "str");
|
|
467
|
+
}
|
|
468
|
+
/** A class value's names, known now: a literal, or literals merged. */
|
|
469
|
+
function literalClass(n) {
|
|
470
|
+
if (n.type === "StringLiteral")
|
|
471
|
+
return n.value.trim();
|
|
472
|
+
if (!n.fvMerged)
|
|
473
|
+
return null;
|
|
474
|
+
const parts = n.elements.map(literalClass);
|
|
475
|
+
return parts.includes(null) ? null : parts.filter(Boolean).join(" ");
|
|
476
|
+
}
|
|
477
|
+
/** `mergeProps(a, _attrs, b, …)` as one object literal, without `_attrs` and `$attrs`, which hold
|
|
478
|
+
* what only the run time knows. Of the rest, `class` and `style` values are merged as an array —
|
|
479
|
+
* unless a class equals the one so far, which Vue then keeps once — and any other key keeps the
|
|
480
|
+
* place it first had with the last value given. */
|
|
323
481
|
export function mergeProps(s, n) {
|
|
324
482
|
const merged = new Map();
|
|
325
483
|
for (const a of n.arguments) {
|
|
326
|
-
if (a
|
|
484
|
+
if (isAttrs(a) || dollarAttrs(s, a))
|
|
327
485
|
continue;
|
|
328
486
|
if (directiveProps(s, a))
|
|
329
487
|
continue;
|
|
@@ -334,8 +492,17 @@ export function mergeProps(s, n) {
|
|
|
334
492
|
fail(s.comp, "attribute objects hold plain keys", p);
|
|
335
493
|
const key = p.key.type === "Identifier" ? p.key.name : p.key.value;
|
|
336
494
|
const had = merged.get(key);
|
|
337
|
-
if (had &&
|
|
338
|
-
|
|
495
|
+
if (had && key === "class" && maybeEqual(s, p.value)) {
|
|
496
|
+
// `ret.class !== toMerge.class`: a string equal to the class so far is not added again.
|
|
497
|
+
const before = literalClass(had.value);
|
|
498
|
+
if (before === null || p.value.type !== "StringLiteral") {
|
|
499
|
+
fail(s.comp, "a class merged with a string that may equal the class before it, which Vue then writes once: bind an array", p);
|
|
500
|
+
}
|
|
501
|
+
if (before !== p.value.value)
|
|
502
|
+
merged.set(key, { ...p, value: { type: "ArrayExpression", elements: [had.value, p.value], fvMerged: true } });
|
|
503
|
+
}
|
|
504
|
+
else if (had && (key === "class" || key === "style")) {
|
|
505
|
+
merged.set(key, { ...p, value: { type: "ArrayExpression", elements: [had.value, p.value], fvMerged: true } });
|
|
339
506
|
}
|
|
340
507
|
else if (had) {
|
|
341
508
|
// `Map` keeps a key where it was first set, as a JavaScript object does.
|
|
@@ -347,6 +514,120 @@ export function mergeProps(s, n) {
|
|
|
347
514
|
}
|
|
348
515
|
return { type: "ObjectExpression", properties: [...merged.values()] };
|
|
349
516
|
}
|
|
517
|
+
/** The attributes of an object literal as `fv::Attr` entries, `("name", value)`, for a merge at run
|
|
518
|
+
* time: given to a child component (`vnode`), whose virtual node normalises a class array or object
|
|
519
|
+
* to a string, or an element's own (`own`), which `mergeProps` keeps as an object. */
|
|
520
|
+
export function attrEntries(s, obj, side) {
|
|
521
|
+
const out = [];
|
|
522
|
+
for (const p of obj.properties) {
|
|
523
|
+
if (p.type !== "ObjectProperty" || p.computed)
|
|
524
|
+
fail(s.comp, "attribute objects hold plain keys", p);
|
|
525
|
+
const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
|
|
526
|
+
if (IGNORED_PROPS.has(key) || /^on[^a-z]/.test(key) || key.startsWith("."))
|
|
527
|
+
continue;
|
|
528
|
+
out.push(`(${rustStr(key)}, ${attrOf(s, key, p.value, side)})`);
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
/** One attribute's value as an `fv::Attr`. */
|
|
533
|
+
export function attrOf(s, key, n, side) {
|
|
534
|
+
if (key === "className")
|
|
535
|
+
fail(s.comp, "`className` renders by its own rule in Vue; bind `class`", n);
|
|
536
|
+
if (key === "class")
|
|
537
|
+
return classAttr(s, n, side);
|
|
538
|
+
if (key === "style")
|
|
539
|
+
return styleAttr(s, n);
|
|
540
|
+
if (n.type === "NullLiteral")
|
|
541
|
+
return "fv::Attr::Undefined";
|
|
542
|
+
return valueAttr(s, expr(s, n), n);
|
|
543
|
+
}
|
|
544
|
+
/** A string, a number, a boolean or `undefined` as an `fv::Attr`. */
|
|
545
|
+
function valueAttr(s, v, n) {
|
|
546
|
+
switch (v.ty.k) {
|
|
547
|
+
case "str":
|
|
548
|
+
return `fv::Attr::str(${strArg(v.code)})`;
|
|
549
|
+
case "int":
|
|
550
|
+
return `fv::Attr::Int(${bare(v.code)})`;
|
|
551
|
+
case "float":
|
|
552
|
+
return `fv::Attr::Float(${bare(v.code)})`;
|
|
553
|
+
case "bool":
|
|
554
|
+
return `fv::Attr::Bool(${bare(v.code)})`;
|
|
555
|
+
case "undef":
|
|
556
|
+
return "fv::Attr::Undefined";
|
|
557
|
+
case "opt": {
|
|
558
|
+
const of = v.ty.of.k;
|
|
559
|
+
const make = of === "str" ? "fv::Attr::str" : of === "int" ? "fv::Attr::Int" : of === "float" ? "fv::Attr::Float" : of === "bool" ? "fv::Attr::Bool" : null;
|
|
560
|
+
if (make !== null)
|
|
561
|
+
return `${atom(v.code)}.map_or(fv::Attr::Undefined, ${make})`;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return fail(s.comp, "an attribute that may fall through is a string, a number or a boolean", n);
|
|
565
|
+
}
|
|
566
|
+
/** A class as an `fv::Attr`: a string as it is, an array or an object by the names it normalises
|
|
567
|
+
* to, `cond && x` and `cond ? x : y` decided at run time. */
|
|
568
|
+
function classAttr(s, n, side) {
|
|
569
|
+
switch (n.type) {
|
|
570
|
+
case "StringLiteral":
|
|
571
|
+
return `fv::Attr::str(${rustStr(n.value)})`;
|
|
572
|
+
// `null`, like `false`, joins as nothing and equals no class: an empty string does the same.
|
|
573
|
+
case "NullLiteral":
|
|
574
|
+
return 'fv::Attr::str("")';
|
|
575
|
+
case "ArrayExpression":
|
|
576
|
+
case "ObjectExpression": {
|
|
577
|
+
const items = classItems(s, n);
|
|
578
|
+
const names = items.every((it) => "lit" in it)
|
|
579
|
+
? rustStr(items.map((it) => ("lit" in it ? it.lit : "")).join(" "))
|
|
580
|
+
: `fv::class_names(&[${items.map((it) => ("lit" in it ? rustStr(it.lit) : it.code)).join(", ")}])`;
|
|
581
|
+
const literal = names.startsWith('"');
|
|
582
|
+
if (side === "vnode")
|
|
583
|
+
return literal ? `fv::Attr::str(${names})` : `fv::Attr::from(${names})`;
|
|
584
|
+
return `fv::Attr::Names(${literal ? `std::borrow::Cow::Borrowed(${names})` : `${names}.into()`})`;
|
|
585
|
+
}
|
|
586
|
+
case "LogicalExpression":
|
|
587
|
+
if (n.operator === "&&")
|
|
588
|
+
return `if ${condition(cond(s, n.left))} { ${classAttr(s, n.right, side)} } else { fv::Attr::str("") }`;
|
|
589
|
+
break;
|
|
590
|
+
case "ConditionalExpression":
|
|
591
|
+
return `if ${condition(cond(s, n.test))} { ${classAttr(s, n.consequent, side)} } else { ${classAttr(s, n.alternate, side)} }`;
|
|
592
|
+
}
|
|
593
|
+
const v = expr(s, n);
|
|
594
|
+
if (v.ty.k === "str" || v.ty.k === "undef" || (v.ty.k === "opt" && v.ty.of.k === "str"))
|
|
595
|
+
return valueAttr(s, v, n);
|
|
596
|
+
return fail(s.comp, "a class is a string, an array, or an object of conditions", n);
|
|
597
|
+
}
|
|
598
|
+
/** A style as an `fv::Attr`: text as it is, objects by their properties, arrays merged, `cond && x`
|
|
599
|
+
* and `cond ? x : y` decided at run time. */
|
|
600
|
+
function styleAttr(s, n) {
|
|
601
|
+
switch (n.type) {
|
|
602
|
+
case "StringLiteral":
|
|
603
|
+
return `fv::Attr::str(${rustStr(n.value)})`;
|
|
604
|
+
case "NullLiteral":
|
|
605
|
+
return "fv::Attr::Undefined";
|
|
606
|
+
case "ObjectExpression": {
|
|
607
|
+
const entries = n.properties.map((p) => {
|
|
608
|
+
if (p.type !== "ObjectProperty" || p.computed)
|
|
609
|
+
fail(s.comp, "a style object holds plain `property: value` pairs", p);
|
|
610
|
+
const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
|
|
611
|
+
if (/^\d+$/.test(key) || key.startsWith(":"))
|
|
612
|
+
fail(s.comp, `style property \`${key}\``, p);
|
|
613
|
+
return `(${rustStr(key)}, ${valueAttr(s, expr(s, p.value), p.value)})`;
|
|
614
|
+
});
|
|
615
|
+
return `fv::Attr::style([${entries.join(", ")}])`;
|
|
616
|
+
}
|
|
617
|
+
case "ArrayExpression":
|
|
618
|
+
return `fv::Attr::styles([${n.elements.map((el) => (el ? styleAttr(s, el) : "fv::Attr::Undefined")).join(", ")}])`;
|
|
619
|
+
case "LogicalExpression":
|
|
620
|
+
if (n.operator === "&&")
|
|
621
|
+
return `if ${condition(cond(s, n.left))} { ${styleAttr(s, n.right)} } else { fv::Attr::Undefined }`;
|
|
622
|
+
break;
|
|
623
|
+
case "ConditionalExpression":
|
|
624
|
+
return `if ${condition(cond(s, n.test))} { ${styleAttr(s, n.consequent)} } else { ${styleAttr(s, n.alternate)} }`;
|
|
625
|
+
}
|
|
626
|
+
const v = expr(s, n);
|
|
627
|
+
if (v.ty.k === "str" || v.ty.k === "undef" || (v.ty.k === "opt" && v.ty.of.k === "str"))
|
|
628
|
+
return valueAttr(s, v, n);
|
|
629
|
+
return fail(s.comp, "a style binding is a string, an object or an array", n);
|
|
630
|
+
}
|
|
350
631
|
/** The objects a style binding merges, in order: an object literal, a static `style` the compiler
|
|
351
632
|
* parsed, `v-show`'s `cond ? null : { display: "none" }`, and arrays of those. */
|
|
352
633
|
export function styleItems(s, n, when) {
|