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/dist/model.d.ts CHANGED
@@ -109,6 +109,12 @@ export declare const RUST_KEYWORDS: Set<string>;
109
109
  /** Types the generated code names, which an interface of the same name would shadow. */
110
110
  export declare const RUST_PRELUDE: Set<string>;
111
111
  export declare function snake(js: string): string;
112
+ /** `camelize` in `@vue/shared`: how Vue matches an attribute a parent passes to a declared prop. */
113
+ export declare function camelize(key: string): string;
114
+ /** Whether a component declares the prop a parent passes as `key`, as `setFullProps` decides. */
115
+ export declare function declares(comp: Component, key: string): boolean;
116
+ /** Whether a component may be passed attributes beyond its props, and so takes `fv::Attrs`. */
117
+ export declare function takesAttrs(comp: Component): boolean;
112
118
  export declare function rustStr(s: string): string;
113
119
  /** One character as a Rust `char` literal. */
114
120
  export declare function rustChar(ch: string): string;
@@ -129,6 +135,9 @@ export interface Component {
129
135
  line: number;
130
136
  column: number;
131
137
  };
138
+ /** A root `<Transition>` or `<KeepAlive>` around anything but one plain element — a `v-if`, a
139
+ * `<template>` — whose content Vue's server gives no attributes and its client does. */
140
+ attrsDropped?: N;
132
141
  props: Struct;
133
142
  structs: Map<string, Struct>;
134
143
  /** The local name `TrustedHtml` was imported under from `ferrovue/types`, if it was. */
@@ -183,6 +192,13 @@ export interface Component {
183
192
  /** Whether its outlets may pass slot content a slot scope id, which the content then takes as
184
193
  * `fv_sid`. Known once every component is read, as `inherits` is. */
185
194
  passesSlotIds: boolean;
195
+ /** The attributes a parent may pass it beyond its props, by name: what falls through to its root,
196
+ * or to the element that binds `$attrs`. With any, its `render_scoped` takes them as
197
+ * `fv::Attrs`. Known once every component is read (`attrsFlow`). */
198
+ attrNames: Set<string>;
199
+ /** Whether its `$attrs` may hold scope ids: it is the root of a component that may be handed
200
+ * some, which passes them on as attributes. */
201
+ idsInAttrs: boolean;
186
202
  }
187
203
  export interface Scope {
188
204
  comp: Component;
@@ -229,6 +245,11 @@ export interface Scope {
229
245
  /** The Rust value of `_attrs`, the scope ids this render's root inherits, as `ssrRenderAttrs`
230
246
  * writes them; `null` when the component inherits none. */
231
247
  attrs: string | null;
248
+ /** The Rust `fv::Attrs` a parent passes this render beyond its props — `$attrs`, and what
249
+ * `_attrs` holds before the scope ids — or `null` when no parent passes any. */
250
+ fallthrough: string | null;
251
+ /** Setup bindings holding `useAttrs()`, which the template may bind as `$attrs`. */
252
+ attrsBindings: Set<string>;
232
253
  /** The Rust value of `_scopeId` inside slot content: the slot scope id the content is given, or
233
254
  * `null` where it is always empty. */
234
255
  sid: string | null;
package/dist/model.js CHANGED
@@ -12,6 +12,19 @@ export function snake(js) {
12
12
  const s = js.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase()).replace(/^_/, "");
13
13
  return RUST_KEYWORDS.has(s) ? `r#${s}` : s;
14
14
  }
15
+ /** `camelize` in `@vue/shared`: how Vue matches an attribute a parent passes to a declared prop. */
16
+ export function camelize(key) {
17
+ return key.replace(/-(\w)/g, (_, c) => c.toUpperCase());
18
+ }
19
+ /** Whether a component declares the prop a parent passes as `key`, as `setFullProps` decides. */
20
+ export function declares(comp, key) {
21
+ const camel = camelize(key);
22
+ return comp.props.fields.some((f) => camelize(f.js) === camel);
23
+ }
24
+ /** Whether a component may be passed attributes beyond its props, and so takes `fv::Attrs`. */
25
+ export function takesAttrs(comp) {
26
+ return comp.attrNames.size > 0;
27
+ }
15
28
  export function rustStr(s) {
16
29
  let out = '"';
17
30
  for (const ch of s) {
package/dist/router.js CHANGED
@@ -2,12 +2,12 @@
2
2
  import { escapeHtml } from "@vue/shared";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { fail, GenError, rustStr } from "./model.js";
5
+ import { camelize, fail, GenError, rustStr } from "./model.js";
6
6
  import { allRoutes, CONFIG_FILE, ctx } from "./context.js";
7
7
  import { expr, lonely } from "./expr.js";
8
8
  import { bare, condition, strArg } from "./parens.js";
9
9
  import { Emitter } from "./emitter.js";
10
- import { classItems, IGNORED_PROPS, mergeProps, renderDynamicAttr, renderStyle } from "./attrs.js";
10
+ import { attrOf, classItems, dollarAttrs, IGNORED_PROPS, isAttrs, mergedParts, mergeProps, renderDynamicAttr, renderStyle } from "./attrs.js";
11
11
  import { slotBody, statements } from "./template.js";
12
12
  export const ROUTER_LINK_PROPS = new Set(["to", "class", "activeClass", "exactActiveClass", "ariaCurrentValue"]);
13
13
  /** `<RouterLink>` props that change only what a click does, which the server does not render. */
@@ -202,6 +202,22 @@ export function routerLink(s, e, n) {
202
202
  e.open("");
203
203
  resolveLink(s, e, to);
204
204
  e.lit("<a");
205
+ /* Attributes this component is passed fall through to the link: merged into its virtual node's
206
+ * props with the template's, then onto the `<a>` vue-router renders. */
207
+ const parts = mergedParts(rawProps);
208
+ const passedOn = (p) => s.fallthrough !== null && ((isAttrs(p) && s.comp.inheritAttrs) || dollarAttrs(s, p));
209
+ const fallsThrough = parts.some(passedOn);
210
+ if (fallsThrough) {
211
+ for (const name of s.comp.attrNames) {
212
+ const key = camelize(name);
213
+ if ((ROUTER_LINK_PROPS.has(key) && key !== "class") || ROUTER_LINK_INERT.has(key) || key === "custom" || key === "viewTransition") {
214
+ fail(s.comp, `\`${name}\`, an attribute ${s.comp.name} may be passed, would reach its \`<RouterLink>\` as a prop`, n);
215
+ }
216
+ if (key === "href" || key === "ariaCurrent")
217
+ fail(s.comp, `\`${name}\`, an attribute ${s.comp.name} may be passed, would replace its \`<RouterLink>\`'s own`, n);
218
+ }
219
+ e.open(`if ${s.fallthrough}.is_empty()`);
220
+ }
205
221
  e.open("if fv_link.exact");
206
222
  e.lit(` aria-current="${escapeHtml(ariaCurrent)}"`);
207
223
  e.close();
@@ -233,6 +249,40 @@ export function routerLink(s, e, n) {
233
249
  }
234
250
  else
235
251
  e.stmt(`out.push_str(&fv::scope_attrs(${base ?? '""'}, ${scopeId === null ? '""' : rustStr(scopeId)}, ${slotted ?? '""'}));`);
252
+ if (fallsThrough) {
253
+ e.close(" else {");
254
+ // The template's attributes for the link, merged in their order with those passed on.
255
+ const sources = [];
256
+ for (const p of parts) {
257
+ if (passedOn(p))
258
+ sources.push(`${s.fallthrough}.list()`);
259
+ else if (p.type === "ObjectExpression") {
260
+ const entries = p.properties
261
+ .filter((q) => {
262
+ const raw = q.key?.type === "Identifier" ? q.key.name : String(q.key?.value);
263
+ const key = camelize(raw);
264
+ return key === "class" || (!ROUTER_LINK_PROPS.has(key) && !ROUTER_LINK_INERT.has(key) && !/^on[^a-z]/.test(raw) && !IGNORED_PROPS.has(raw));
265
+ })
266
+ .map((q) => {
267
+ const raw = q.key.type === "Identifier" ? q.key.name : String(q.key.value);
268
+ return `(${rustStr(raw)}, ${attrOf(s, raw, q.value, "vnode")})`;
269
+ });
270
+ if (entries.length)
271
+ sources.push(`&[${entries.join(", ")}]`);
272
+ }
273
+ }
274
+ const ids = base === null && slotted === null
275
+ ? rustStr(scopeId === null ? "" : ` ${scopeId}`)
276
+ : `&fv::scope_attrs(${base ?? '""'}, ${scopeId === null ? '""' : rustStr(scopeId)}, ${slotted ?? '""'})`;
277
+ // vue-router's own: `aria-current` (`null` when the link is not exact), `href`, its classes.
278
+ const linkOwn = [
279
+ `("aria-current", if fv_link.exact { fv::Attr::str(${rustStr(ariaCurrent)}) } else { fv::Attr::Undefined })`,
280
+ '("href", fv::Attr::str(&fv_link.href))',
281
+ `("class", fv::Attr::from(fv::class_names(&[${linkItems.join(", ")}])))`,
282
+ ];
283
+ e.stmt(`fv::attrs_into(out, &[&[${linkOwn.join(", ")}], &fv::merge_props(&[${sources.join(", ")}])], 1, ${ids});`);
284
+ e.close();
285
+ }
236
286
  e.lit(">");
237
287
  if (slots && slots.type !== "NullLiteral") {
238
288
  if (slots.type !== "ObjectExpression")
package/dist/rust.d.ts CHANGED
@@ -7,6 +7,9 @@ export declare function structSource(st: Struct, comp: Component, doc: string):
7
7
  export declare function header(source: string, edit?: string): string;
8
8
  /** Rust expressions summing the lengths of the strings a value of type `ty` at `place` holds. */
9
9
  export declare function textLen(comp: Component, place: string, ty: Ty, seen?: Set<string>): string[];
10
+ /** Whether the component gets an `island()`: it renders from its props alone, which `data-props` is
11
+ * all the client gets to mount it with. */
12
+ export declare function isIsland(comp: Component): boolean;
10
13
  export declare function componentSource(comp: Component, ast: N[], ssr: string, components: Map<string, Component>): string;
11
14
  export declare function modSource(comps: Component[]): string;
12
15
  export declare function storesSource(dir: string): string;
package/dist/rust.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /* The Rust source written for each component, and the modules beside them. */
2
2
  import { parse as parseJs } from "@babel/parser";
3
3
  import { basename } from "node:path";
4
- import { fail, GenError, rustStr, snake, tagAst } from "./model.js";
4
+ import { fail, GenError, rustStr, snake, tagAst, takesAttrs } from "./model.js";
5
5
  import { allRoutes, ctx } from "./context.js";
6
6
  import { lookupStruct } from "./typescript.js";
7
7
  import { childOf } from "./expr.js";
@@ -179,27 +179,40 @@ export function textLen(comp, place, ty, seen = new Set()) {
179
179
  return [];
180
180
  }
181
181
  }
182
- /** `render`, and for a component a parent may hand scope ids to, `render_scoped`, which takes them
183
- * last, as `ssrRenderAttrs` writes them onto its root; `render` hands it none. */
182
+ /** `render`, and for a component a parent may hand scope ids or pass attributes to,
183
+ * `render_scoped`, which takes them last, as `ssrRenderAttrs` writes them onto its root; `render`
184
+ * hands it none. */
184
185
  function renderSource(comp, life, args, e) {
185
186
  const doc = "/// Write the component's server render into `out`.\n";
186
187
  // Props or ids the render never reads, as when a component only passes its slot on.
187
188
  const props = e.reads("props", 0) ? "props" : "_props";
188
- if (!comp.inherits)
189
+ if (!comp.inherits && !takesAttrs(comp))
189
190
  return `${doc}pub fn render(out: &mut String, ${props}: &Props${life}${extraParams(comp)}) {\n${e.lines.join("\n")}\n}`;
190
191
  // A root that is a fragment, or a `<Teleport>`, takes no ids.
191
192
  const attrs = e.reads("fv_attrs", 0) ? "fv_attrs" : "_fv_attrs";
193
+ // A component a parent may pass attributes takes them with the ids, as one `fv::Attrs`.
194
+ const [none, ty, what] = takesAttrs(comp)
195
+ ? ["&fv::Attrs::NONE", "&fv::Attrs<'_>", "the attributes a parent passes beyond the props, and the scope ids it hands the root"]
196
+ : ['""', "&str", "the scope ids a parent hands the root: ` data-v-…` each"];
192
197
  return `${doc}pub fn render(out: &mut String, props: &Props${life}${extraParams(comp)}) {
193
- render_scoped(out, props${args}, "");
198
+ render_scoped(out, props${args}, ${none});
194
199
  }
195
200
 
196
- /// [\`render\`], with the scope ids a parent hands the root: \` data-v-…\` each.
201
+ /// [\`render\`], with ${what}.
197
202
  #[doc(hidden)]
198
- pub fn render_scoped(out: &mut String, ${props}: &Props${life}${extraParams(comp)}, ${attrs}: &str) {
203
+ pub fn render_scoped(out: &mut String, ${props}: &Props${life}${extraParams(comp)}, ${attrs}: ${ty}) {
199
204
  ${e.lines.join("\n")}
200
205
  }`;
201
206
  }
207
+ /** Whether the component gets an `island()`: it renders from its props alone, which `data-props` is
208
+ * all the client gets to mount it with. */
209
+ export function isIsland(comp) {
210
+ return !takesSlots(comp) && !comp.usesRoute && !comp.usesStores && !comp.usesI18n && !comp.usesTeleports;
211
+ }
202
212
  export function componentSource(comp, ast, ssr, components) {
213
+ if (takesAttrs(comp) && comp.inheritAttrs && comp.attrsDropped) {
214
+ fail(comp, `a root \`<Transition>\` or \`<KeepAlive>\` around a \`v-if\` in ${comp.name}, which a parent passes attributes: Vue's server drops them, where its client puts them on the element; set \`inheritAttrs: false\` and bind \`$attrs\` on the element`, comp.attrsDropped);
215
+ }
203
216
  const { scope, lets } = scopeFor(comp, ast, components);
204
217
  const program = parseJs(ssr, { sourceType: "module" }).program;
205
218
  tagAst(program, "template");
@@ -229,6 +242,9 @@ export function componentSource(comp, ast, ssr, components) {
229
242
  const life = structLifetime(comp.props, comp) ? "<'_>" : "";
230
243
  const gen = life ? "<'p, 'a>" : "<'p>";
231
244
  const named = life ? "<'a>" : "";
245
+ // What holds its props borrows only what they borrow.
246
+ const owned = life ? "<'a>" : "";
247
+ const ownedLife = life ? "'a" : "'static";
232
248
  // An `interface Props` that `defineProps` takes is the props struct itself, written once below.
233
249
  const structs = [...comp.structs.values()]
234
250
  .filter((st) => st.name !== "Props" && !st.slot)
@@ -237,7 +253,7 @@ export function componentSource(comp, ast, ssr, components) {
237
253
  // `Cow` is imported only where a field is one: a lifetime that comes from another component's props
238
254
  // alone borrows through that type, not through a `Cow` written here.
239
255
  const usesCow = /Cow</.test(structs + structSource(comp.props, comp, ""));
240
- const plain = !takesSlots(comp) && !comp.usesRoute && !comp.usesStores && !comp.usesI18n && !comp.usesTeleports;
256
+ const plain = isIsland(comp);
241
257
  const slotFields = [
242
258
  ...comp.slotNames.map((n) => {
243
259
  const outlet = `\`<slot${n === "default" ? "" : ` name="${n}"`}>\``;
@@ -288,6 +304,16 @@ pub fn html${gen}(props: &'p Props${named}) -> fv::Html<'p, Props${named}> {
288
304
  pub fn island${gen}(props: &'p Props${named}) -> fv::Html<'p, Props${named}> {
289
305
  fv::Html::island(NAME, props, render)
290
306
  }
307
+
308
+ /// [\`html\`], holding the props: a handler that builds them can return it.
309
+ pub fn into_html${owned}(props: Props${named}) -> fv::Html<${ownedLife}, Props${named}> {
310
+ fv::Html::markup_owned(props, render)
311
+ }
312
+
313
+ /// [\`island\`], holding the props.
314
+ pub fn into_island${owned}(props: Props${named}) -> fv::Html<${ownedLife}, Props${named}> {
315
+ fv::Html::island_owned(NAME, props, render)
316
+ }
291
317
  `
292
318
  : `/// The component's markup, for a maud page that shows it.
293
319
  pub fn html${gen}(props: &'p Props${named}${params}) -> fv::Html<'p, Props${named}, impl Fn(&mut String, &Props${named}) + 'p> {
@@ -393,8 +419,9 @@ impl Fixture {
393
419
  //! The component renderers, one module per \`.vue\` file.
394
420
 
395
421
  // The modules pass rustc's default warnings and clippy's default lints, with one exception:
396
- // \`dead_code\`. Every component gets the whole of its API (\`render\`, \`html\`, \`island\`, \`NAME\`, a
397
- // constructor and a setter per optional prop) and an app calls only what it needs.
422
+ // \`dead_code\`. Every component gets the whole of its API (\`render\`, \`html\`, \`island\`, their
423
+ // \`into_\` forms, \`NAME\`, a constructor and a setter per optional prop) and an app calls only what
424
+ // it needs.
398
425
  #![allow(dead_code)]
399
426
 
400
427
  ${comps.map((c) => `pub mod ${c.module};`).join("\n")}${ctx.routes ? "\npub mod route_table;" : ""}${ctx.stores.size ? "\npub mod stores;" : ""}${ctx.typeStructs.size ? "\npub mod types;" : ""}${ctx.i18n ? "\npub mod i18n;" : ""}
package/dist/scoped.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Component } from "./model.ts";
1
+ import { type Component, type N } from "./model.ts";
2
2
  /** Set every component's `inherits` and `passesSlotIds`, each a least fixed point: a component's
3
3
  * slot content may be given an id when one of its outlets passes one, or forwards what the slot
4
4
  * content it sits in was given; a root may be handed ids by a scoped parent, by a parent that
@@ -8,3 +8,17 @@ export declare function scopeFlow(read: {
8
8
  ssr: string;
9
9
  children: Map<string, string>;
10
10
  }[]): void;
11
+ /** Whether `n` is the template's `$attrs`: `_ctx.$attrs`, or a setup binding of `useAttrs()`. */
12
+ export declare function isDollarAttrs(n: N, bindings: Set<string>): boolean;
13
+ /** Whether a key a parent passes a child reaches it as an attribute, unless it is a prop: not one
14
+ * Vue reserves, not a listener (which the server drops), not a model's modifiers. */
15
+ export declare function passedKey(child: Component, key: string): boolean;
16
+ /** Set every component's `attrNames` and `idsInAttrs`: the attributes its parents' calls pass it
17
+ * beyond its props, and those passed on to it — by a parent whose root it is, or that binds
18
+ * `$attrs` to it — from that parent's own, a least fixed point. Runs after `scopeFlow`. */
19
+ export declare function attrsFlow(read: {
20
+ comp: Component;
21
+ ssr: string;
22
+ children: Map<string, string>;
23
+ attrsBindings: Set<string>;
24
+ }[]): void;
package/dist/scoped.js CHANGED
@@ -8,8 +8,13 @@
8
8
  * child's `_attrs`: the id of the component that created it, what its parent passes on when the
9
9
  * child is that parent's root, and the slot scope ids of the slot content it is rendered in
10
10
  * (`renderComponentSubTree`). A component with `:slotted()` styles gives its slot content
11
- * `data-v-…-s` as well, which the content writes onto its elements as `_scopeId`. */
11
+ * `data-v-…-s` as well, which the content writes onto its elements as `_scopeId`.
12
+ *
13
+ * The same `_attrs` carries the attributes a parent passes a child beyond its props, ahead of the
14
+ * ids: which components may be passed some (`attrsFlow`) decides whether `fv_attrs` is an
15
+ * `fv::Attrs` holding both, or the ids alone. */
12
16
  import { parse as parseJs } from "@babel/parser";
17
+ import { declares } from "./model.js";
13
18
  /** The child components and outlets in one component's compiled template. */
14
19
  function flowOf(comp, ssr, children) {
15
20
  const calls = [];
@@ -88,3 +93,85 @@ export function scopeFlow(read) {
88
93
  }
89
94
  }
90
95
  }
96
+ /** Whether `n` is the template's `$attrs`: `_ctx.$attrs`, or a setup binding of `useAttrs()`. */
97
+ export function isDollarAttrs(n, bindings) {
98
+ if (n?.type !== "MemberExpression" || n.object.type !== "Identifier")
99
+ return false;
100
+ const prop = n.computed ? n.property.value : n.property.name;
101
+ if (n.object.name === "_ctx")
102
+ return prop === "$attrs";
103
+ return n.object.name === "$setup" && prop !== undefined && bindings.has(prop);
104
+ }
105
+ /** Whether a key a parent passes a child reaches it as an attribute, unless it is a prop: not one
106
+ * Vue reserves, not a listener (which the server drops), not a model's modifiers. */
107
+ export function passedKey(child, key) {
108
+ if (["", "key", "ref", "ref_for", "ref_key", "innerHTML", "textContent"].includes(key) || /^on[^a-z]/.test(key))
109
+ return false;
110
+ return !(key.endsWith("Modifiers") && declares(child, key === "modelModifiers" ? "modelValue" : key.slice(0, -"Modifiers".length)));
111
+ }
112
+ /** Set every component's `attrNames` and `idsInAttrs`: the attributes its parents' calls pass it
113
+ * beyond its props, and those passed on to it — by a parent whose root it is, or that binds
114
+ * `$attrs` to it — from that parent's own, a least fixed point. Runs after `scopeFlow`. */
115
+ export function attrsFlow(read) {
116
+ const byName = new Map(read.map((r) => [r.comp.name, r.comp]));
117
+ const flows = read.map((r) => {
118
+ const calls = [];
119
+ const self = new Set();
120
+ const walk = (n) => {
121
+ if (!n || typeof n !== "object")
122
+ return;
123
+ if (Array.isArray(n)) {
124
+ n.forEach(walk);
125
+ return;
126
+ }
127
+ if (n.type === "VariableDeclarator" && n.init?.type === "CallExpression" && n.init.callee.name === "_resolveComponent") {
128
+ if (n.init.arguments[0]?.value === r.comp.name && n.init.arguments[1])
129
+ self.add(n.id.name);
130
+ }
131
+ if (n.type === "CallExpression" && n.callee.type === "Identifier" && n.callee.name === "_ssrRenderComponent") {
132
+ const target = n.arguments[0];
133
+ const child = target?.type === "MemberExpression" && target.object.name === "$setup"
134
+ ? r.children.get(target.computed ? target.property.value : target.property.name)
135
+ : target?.type === "Identifier" && self.has(target.name)
136
+ ? r.comp.name
137
+ : undefined;
138
+ const props = n.arguments[1];
139
+ const parts = props?.type === "CallExpression" && props.callee.name === "_mergeProps" ? props.arguments : props ? [props] : [];
140
+ if (child) {
141
+ calls.push({
142
+ child,
143
+ keys: parts.filter((p) => p.type === "ObjectExpression").flatMap((p) => p.properties.filter((q) => q.type === "ObjectProperty" && !q.computed).map((q) => q.key.name ?? String(q.key.value))),
144
+ passesAttrs: parts.some((p) => p.type === "Identifier" && p.name === "_attrs"),
145
+ passesDollar: parts.some((p) => isDollarAttrs(p, r.attrsBindings)),
146
+ });
147
+ }
148
+ }
149
+ for (const [key, v] of Object.entries(n))
150
+ if (key !== "loc" && key !== "start" && key !== "end")
151
+ walk(v);
152
+ };
153
+ walk(parseJs(r.ssr, { sourceType: "module" }).program);
154
+ return { comp: r.comp, calls };
155
+ });
156
+ for (let changed = true; changed;) {
157
+ changed = false;
158
+ for (const f of flows) {
159
+ for (const c of f.calls) {
160
+ const child = byName.get(c.child);
161
+ if (!child)
162
+ continue;
163
+ const names = c.keys.filter((k) => passedKey(child, k));
164
+ if ((c.passesAttrs && f.comp.inheritAttrs) || c.passesDollar)
165
+ names.push(...f.comp.attrNames);
166
+ for (const name of names) {
167
+ if (declares(child, name) || child.attrNames.has(name))
168
+ continue;
169
+ child.attrNames.add(name);
170
+ changed = true;
171
+ }
172
+ if (c.passesAttrs && f.comp.inherits && !child.idsInAttrs)
173
+ child.idsInAttrs = changed = true;
174
+ }
175
+ }
176
+ }
177
+ }
package/dist/script.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /* `<script setup>` read: what the server evaluates, and what stays on the client. */
2
2
  import { basename } from "node:path";
3
- import { fail, GenError, snake, STR } from "./model.js";
3
+ import { fail, GenError, snake, STR, takesAttrs } from "./model.js";
4
4
  import { CONFIG_FILE, ctx } from "./context.js";
5
5
  import { definePropsType } from "./typescript.js";
6
6
  import { expr, fieldVal, storeGetter, theRoute } from "./expr.js";
@@ -92,7 +92,10 @@ export function scopeFor(comp, ast, components) {
92
92
  i18nT: new Set(),
93
93
  fill: false,
94
94
  vnode: false,
95
- attrs: comp.inherits ? "fv_attrs" : null,
95
+ // A component a parent may pass attributes takes them with the scope ids, as one `fv::Attrs`.
96
+ attrs: comp.inherits ? (takesAttrs(comp) ? "fv_attrs.ids()" : "fv_attrs") : null,
97
+ fallthrough: takesAttrs(comp) ? "fv_attrs" : null,
98
+ attrsBindings: new Set(),
96
99
  sid: null,
97
100
  };
98
101
  const lets = [];
@@ -100,6 +103,7 @@ export function scopeFor(comp, ast, components) {
100
103
  let storeToRefsName = null;
101
104
  let useRouteName = null;
102
105
  let useI18nName = null;
106
+ let useAttrsName = null;
103
107
  /** Setup bindings holding a store, by name. */
104
108
  const storeValues = new Map();
105
109
  for (const st of ast) {
@@ -142,6 +146,12 @@ export function scopeFor(comp, ast, components) {
142
146
  scope.clientOnly.set(sp.local.name, `\`${name}\` from vue-i18n does not run on the server`);
143
147
  }
144
148
  }
149
+ else if (from === "vue") {
150
+ for (const sp of st.specifiers) {
151
+ if (sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === "useAttrs")
152
+ useAttrsName = sp.local.name;
153
+ }
154
+ }
145
155
  else if (from === "pinia") {
146
156
  for (const sp of st.specifiers) {
147
157
  const name = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
@@ -252,6 +262,12 @@ export function scopeFor(comp, ast, components) {
252
262
  scope.setup.set(local, theRoute(scope, d));
253
263
  continue;
254
264
  }
265
+ // `const attrs = useAttrs()`: `$attrs`, which the template may bind whole.
266
+ if (useAttrsName !== null && init0?.type === "CallExpression" && init0.callee.type === "Identifier" && init0.callee.name === useAttrsName) {
267
+ scope.attrsBindings.add(local);
268
+ scope.clientOnly.set(local, "`useAttrs()` is bound whole, with `v-bind`; a value read from it has no type");
269
+ continue;
270
+ }
255
271
  // `const model = defineModel<string>()`: the model's prop.
256
272
  const model = comp.models.get(local);
257
273
  if (model !== undefined) {
package/dist/stores.js CHANGED
@@ -223,5 +223,7 @@ export function storeHome(file) {
223
223
  inheritAttrs: true,
224
224
  inherits: false,
225
225
  passesSlotIds: false,
226
+ attrNames: new Set(),
227
+ idsInAttrs: false,
226
228
  };
227
229
  }
@@ -4,8 +4,11 @@ import { Emitter } from "./emitter.ts";
4
4
  export declare function slot(s: Scope, e: Emitter, n: N): void;
5
5
  /** `isComment` in `@vue/server-renderer`: a chunk that is only comments and whitespace. */
6
6
  export declare function isComment(text: string): boolean;
7
- /** Whether a `_push` argument is content to `ssrRenderSlot`, rather than only comments. */
8
- export declare function pushesContent(s: Scope, n: N): boolean;
7
+ /** Whether a `_push` argument is content to `ssrRenderSlot` rather than only comments — `"run"`
8
+ * when that depends on the values it interpolates (`${of1}<!--[-->` is a comment when `of1` writes
9
+ * nothing). Vue asks it of each pushed string, so the generated code asks `fv::is_comment` of what
10
+ * that push wrote. */
11
+ export declare function pushesContent(n: N): boolean | "run";
9
12
  export declare function push(s: Scope, e: Emitter, n: N): void;
10
13
  export declare function renderChild(s: Scope, e: Emitter, n: N): void;
11
14
  /** `name: value` in a struct literal, or the name alone when the value is a variable of that name. */
@@ -15,8 +18,8 @@ export declare function takesSlots(c: Component): boolean;
15
18
  /** The arguments after `props` that a component's `render` takes. */
16
19
  export declare function extraParams(c: Component): string;
17
20
  /** `render(out, props[, slots][, route])` for a child, with the slot content this template gives
18
- * it as closures; `render_scoped`, with the scope ids its root is handed last, for a child that may
19
- * be handed some. */
21
+ * it as closures; `render_scoped`, with the scope ids its root is handed last — and the attributes
22
+ * it is passed, as `fv::Attrs` — for a child that may be handed some. */
20
23
  export declare function callChild(s: Scope, e: Emitter, child: Component, propsCode: string, slots: N, attrs: string | null): void;
21
24
  /** The statements a parent's slot content pushes: the `if (_push)` half of its `_withCtx`. */
22
25
  export declare function slotBody(s: Scope, value: N): N[];
@@ -28,7 +31,7 @@ export declare function slotContent(s: Scope, value: N): {
28
31
  };
29
32
  /** Whether slot content always pushes something that is not a comment: a push of content outside
30
33
  * any `if` or loop. Then nothing has to be decided at run time. */
31
- export declare function staticallyFilled(s: Scope, body: N[]): boolean;
34
+ export declare function staticallyFilled(body: N[]): boolean;
32
35
  /** `RowSlotProps`, `RowSlot`: the names of a scoped slot's props struct and content type. */
33
36
  export declare function slotTypeName(slotName: string, suffix: string): string;
34
37
  /** Whether a scoped slot prop's Rust type borrows, and so needs the props' lifetime `'v`. */