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 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. A component whose outlets pass a slot scope id (`:slotted()`) takes slot
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`: it holds only scope ids,
34
- * which the caller writes after the rest. Of the rest, `class` and `style` values are merged as an
35
- * array, and any other key keeps the place it first had with the last value given. */
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: the scope ids a parent handed it, already written as Vue
241
- * writes them. They are all `_attrs` holds, since a parent passes a child only the props it
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 isAttrs = (a) => a.type === "Identifier" && a.name === "_attrs";
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
- renderAttrs(s, e, merged);
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
- renderAttrs(s, e, part(true));
359
+ objectAttrs(s, e, part(true), true);
288
360
  inheritedAttrs(s, e);
289
- renderAttrs(s, e, part(false));
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
- renderStyle(s, e, p.value);
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(a, _attrs, b, …)` as one object literal, without `_attrs`: it holds only scope ids,
321
- * which the caller writes after the rest. Of the rest, `class` and `style` values are merged as an
322
- * array, and any other key keeps the place it first had with the last value given. */
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.type === "Identifier" && a.name === "_attrs")
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 && (key === "class" || key === "style")) {
338
- merged.set(key, { ...p, value: { type: "ArrayExpression", elements: [had.value, p.value] } });
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) {