ferrovue 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +201 -50
  2. package/dist/attrs.d.ts +2 -1
  3. package/dist/attrs.js +7 -4
  4. package/dist/children.d.ts +2 -2
  5. package/dist/children.js +66 -13
  6. package/dist/classes.d.ts +4 -0
  7. package/dist/classes.js +23 -1
  8. package/dist/client.d.ts +9 -4
  9. package/dist/client.js +106 -17
  10. package/dist/compiler.d.ts +1 -1
  11. package/dist/compiler.js +38 -10
  12. package/dist/component.d.ts +1 -1
  13. package/dist/component.js +2 -3
  14. package/dist/conformance.d.ts +64 -0
  15. package/dist/conformance.js +276 -0
  16. package/dist/context.d.ts +15 -6
  17. package/dist/context.js +3 -0
  18. package/dist/dynamic.d.ts +18 -0
  19. package/dist/dynamic.js +576 -0
  20. package/dist/errors.d.ts +168 -1
  21. package/dist/errors.js +96 -1
  22. package/dist/file-routes.d.ts +33 -0
  23. package/dist/file-routes.js +224 -0
  24. package/dist/fixture.d.ts +16 -0
  25. package/dist/fixture.js +22 -7
  26. package/dist/index.d.ts +1 -1
  27. package/dist/link-router.d.ts +1 -1
  28. package/dist/link-router.js +1 -0
  29. package/dist/lists.d.ts +4 -0
  30. package/dist/lists.js +2 -2
  31. package/dist/loops.js +4 -4
  32. package/dist/model.d.ts +11 -0
  33. package/dist/model.js +44 -1
  34. package/dist/numbers.js +3 -2
  35. package/dist/page-routes.d.ts +9 -0
  36. package/dist/page-routes.js +10 -0
  37. package/dist/plugin.d.ts +13 -1
  38. package/dist/plugin.js +3 -0
  39. package/dist/plugins/client-only.js +3 -0
  40. package/dist/plugins/head.d.ts +15 -0
  41. package/dist/plugins/head.js +385 -0
  42. package/dist/plugins/index.js +3 -1
  43. package/dist/plugins/provide.d.ts +44 -0
  44. package/dist/plugins/provide.js +528 -0
  45. package/dist/plugins/router-link.js +7 -3
  46. package/dist/plugins/router.d.ts +4 -1
  47. package/dist/plugins/router.js +56 -10
  48. package/dist/plugins/stores.js +19 -9
  49. package/dist/routes.d.ts +5 -2
  50. package/dist/routes.js +8 -2
  51. package/dist/rust.js +46 -18
  52. package/dist/script.js +19 -3
  53. package/dist/settle.d.ts +2 -0
  54. package/dist/settle.js +5 -0
  55. package/dist/slots.js +18 -4
  56. package/dist/ssr.d.ts +3 -0
  57. package/dist/ssr.js +29 -0
  58. package/dist/strings.d.ts +3 -0
  59. package/dist/strings.js +16 -2
  60. package/dist/template.js +92 -11
  61. package/dist/testing.d.ts +2 -3
  62. package/dist/testing.js +2 -29
  63. package/dist/types.d.ts +5 -0
  64. package/dist/typescript.js +7 -3
  65. package/dist/vhtml.d.ts +4 -0
  66. package/dist/vhtml.js +63 -0
  67. package/dist/vite.d.ts +7 -1
  68. package/dist/vite.js +65 -5
  69. package/package.json +15 -1
package/dist/children.js CHANGED
@@ -8,9 +8,10 @@ import { atom, bare } from "./parens.js";
8
8
  import { Emitter } from "./emitter.js";
9
9
  import { attrOf, dollarAttrs, IGNORED_PROPS, isAttrs, mergedParts } from "./attrs.js";
10
10
  import { passedKey } from "./fallthrough.js";
11
- import { claim, paramsOf, slotFieldsOf } from "./plugin.js";
11
+ import { claim, paramsOf, slotContextOf, slotFieldsOf } from "./plugin.js";
12
12
  import { statements } from "./template.js";
13
13
  import { slotContent, slotFieldBorrows, staticallyFilled } from "./slots.js";
14
+ import { slotAsVnodes } from "./dynamic.js";
14
15
  export function renderChild(s, e, n, twin) {
15
16
  const [target, rawProps, slots, , slotScopeId] = n.arguments;
16
17
  let local = null;
@@ -22,6 +23,7 @@ export function renderChild(s, e, n, twin) {
22
23
  const child = twin?.comp ?? (childName ? s.components.get(childName) : undefined);
23
24
  if (!child)
24
25
  fail(s.comp, "FV0501", `a child component must be imported from a \`.vue\` file among the components compiled, in ${ctx.componentsDir.replace(/\/$/, "")}`, n);
26
+ const later = !twin && local !== null && s.loadedLater.has(local);
25
27
  for (const p of ctx.plugins)
26
28
  p.child?.(s, child, n);
27
29
  const parts = !rawProps || rawProps.type === "NullLiteral" ? [] : mergedParts(rawProps);
@@ -44,7 +46,7 @@ export function renderChild(s, e, n, twin) {
44
46
  const v = expr(s, objects[0]);
45
47
  const own = child.name === s.comp.name && v.ty.k === "struct" && v.ty.name === "Props";
46
48
  if (own || (v.ty.k === "child" && v.ty.name === child.name)) {
47
- callChild(s, e, child, v.code, slots, childAttrsArg(s, child, parts, merges, null, ids, n));
49
+ callChild(s, e, child, v.code, slots, childAttrsArg(s, child, parts, merges, null, ids, n), later);
48
50
  return;
49
51
  }
50
52
  fail(s.comp, "FV0503", `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, n);
@@ -54,7 +56,8 @@ export function renderChild(s, e, n, twin) {
54
56
  fail(s.comp, "FV0503", `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, p);
55
57
  }
56
58
  const given = new Map();
57
- const fallthrough = new Set();
59
+ const fallthrough = new Map();
60
+ const loaded = !merges && local !== null && s.loadedLater.has(local);
58
61
  for (const obj of objects) {
59
62
  for (const p of obj.properties) {
60
63
  if (p.type !== "ObjectProperty" || p.computed)
@@ -72,7 +75,7 @@ export function renderChild(s, e, n, twin) {
72
75
  const name = propsToAttrMap[key] ?? key.toLowerCase();
73
76
  if (key !== "class" && key !== "style" && !isSSRSafeAttrName(name))
74
77
  fail(s.comp, "FV0404", `unsafe attribute name \`${name}\``, p);
75
- fallthrough.add(p);
78
+ fallthrough.set(p, loaded && key === "class" ? loadedClass(s, p.value) : p.value);
76
79
  }
77
80
  }
78
81
  if (twin) {
@@ -92,11 +95,38 @@ export function renderChild(s, e, n, twin) {
92
95
  const v = expr(s, node);
93
96
  return fieldInit(f.rust, ownInto(s.comp, { ...v, ty: markHome(v.ty, s.comp.name) }, markHome(f.ty, child.name), node));
94
97
  });
95
- callChild(s, e, child, `&super::${child.module}::Props { ${inits.join(", ")} }`, slots, childAttrsArg(s, child, parts, merges, fallthrough, ids, n));
98
+ callChild(s, e, child, `&super::${child.module}::Props { ${inits.join(", ")} }`, slots, childAttrsArg(s, child, parts, merges, fallthrough, ids, n), later);
96
99
  }
97
100
  export function array(key) {
98
101
  return /^(0|[1-9]\d*)$/.test(key) && Number(key) < 2 ** 32 - 1;
99
102
  }
103
+ /** A string `:class` given to a component `defineAsyncComponent` loads, in props no `mergeProps`
104
+ * normalised: Vue's wrapper hands on the class once as written and once trimmed, so a class with
105
+ * whitespace around it is written twice. */
106
+ const lengthOf = (n) => ({ type: "MemberExpression", object: n, property: { type: "Identifier", name: "length" }, computed: false });
107
+ function holdsClassLiteral(n) {
108
+ if (n.type === "ObjectExpression" || n.type === "ArrayExpression")
109
+ return true;
110
+ if (n.type === "ConditionalExpression")
111
+ return holdsClassLiteral(n.consequent) || holdsClassLiteral(n.alternate);
112
+ if (n.type === "LogicalExpression")
113
+ return holdsClassLiteral(n.left) || holdsClassLiteral(n.right);
114
+ return false;
115
+ }
116
+ function loadedClass(s, value) {
117
+ if (value.type === "ConditionalExpression" && (holdsClassLiteral(value.consequent) || holdsClassLiteral(value.alternate))) {
118
+ return { ...value, consequent: loadedClass(s, value.consequent), alternate: loadedClass(s, value.alternate) };
119
+ }
120
+ if (holdsClassLiteral(value) || expr(s, value).ty.k !== "str")
121
+ return value;
122
+ const trimmed = { type: "CallExpression", callee: { type: "MemberExpression", object: value, property: { type: "Identifier", name: "trim" }, computed: false }, arguments: [] };
123
+ return {
124
+ type: "ConditionalExpression",
125
+ test: { type: "BinaryExpression", operator: "===", left: lengthOf(trimmed), right: lengthOf(value) },
126
+ consequent: value,
127
+ alternate: { type: "ArrayExpression", elements: [trimmed, value] },
128
+ };
129
+ }
100
130
  function childAttrsArg(s, child, parts, merges, fallthrough, ids, n) {
101
131
  const sources = [];
102
132
  for (const p of parts) {
@@ -106,7 +136,7 @@ function childAttrsArg(s, child, parts, merges, fallthrough, ids, n) {
106
136
  else if (p.type === "ObjectExpression" && fallthrough !== null) {
107
137
  const entries = p.properties.filter((q) => fallthrough.has(q)).map((q) => {
108
138
  const key = q.key.type === "Identifier" ? q.key.name : String(q.key.value);
109
- return `(${rustStr(key)}, ${attrOf(s, key, q.value, "vnode")})`;
139
+ return `(${rustStr(key)}, ${attrOf(s, key, fallthrough.get(q), "vnode")})`;
110
140
  });
111
141
  if (entries.length)
112
142
  sources.push(`&[${entries.join(", ")}]`);
@@ -129,15 +159,23 @@ export function fieldInit(name, value) {
129
159
  export function takesSlots(c) {
130
160
  return c.slotNames.length > 0 || slotFieldsOf(c).length > 0;
131
161
  }
132
- export function extraParams(c) {
133
- return (takesSlots(c) ? ", fv_slots: Slots<'_>" : "") + paramsOf(c).map((p) => `, ${p.name}: ${p.ty}`).join("");
162
+ export function extraParams(c, unread = () => false) {
163
+ return (takesSlots(c) ? ", fv_slots: Slots<'_>" : "") + paramsOf(c).map((p) => `, ${unread(p.name) ? "_" : ""}${p.name}: ${p.ty}`).join("");
134
164
  }
135
- export function callChild(s, e, child, propsCode, slots, attrs) {
165
+ export function callChild(s, e, child, propsCode, slots, attrs, later = false) {
136
166
  const m = `super::${child.module}`;
137
167
  const scoped = child.inherits || takesAttrs(child);
138
168
  const trailing = paramsOf(child).map((p) => `, ${p.name}`).join("") + (scoped ? `, ${attrs ?? (takesAttrs(child) ? "&fv::Attrs::NONE" : '""')}` : "");
139
169
  const render = scoped ? "render_scoped" : "render";
170
+ const around = later ? ctx.plugins.flatMap((p) => p.loadedLater?.(child) ?? []) : [];
171
+ if (around.length) {
172
+ e.open("");
173
+ for (const line of around)
174
+ e.stmt(line);
175
+ }
140
176
  callWith(s, e, child, m, `${m}::${render}(out, ${propsCode}`, `${m}::Slots`, trailing, slots);
177
+ if (around.length)
178
+ e.close();
141
179
  }
142
180
  export function callWith(s, e, child, m, head, slotsTy, trailing, slots, vnode = false) {
143
181
  const given = new Map();
@@ -160,6 +198,9 @@ export function callWith(s, e, child, m, head, slotsTy, trailing, slots, vnode =
160
198
  return;
161
199
  }
162
200
  e.open(`${head}, ${slotsTy}`);
201
+ const context = slotContextOf(child);
202
+ const contextParams = context.map((p) => `, ${p.name}: ${p.ty}`).join("");
203
+ const opaque = vnode ? child.name : s.opaque;
163
204
  for (const name of child.slotNames) {
164
205
  const field = snake(name);
165
206
  const value = given.get(name);
@@ -168,6 +209,7 @@ export function callWith(s, e, child, m, head, slotsTy, trailing, slots, vnode =
168
209
  continue;
169
210
  }
170
211
  const { body, param } = slotContent(s, value);
212
+ const asVnodes = vnode || s.vnode || slotAsVnodes(child, name);
171
213
  const shape = child.slotShapes.get(name);
172
214
  const takesNone = param?.type === "Identifier" && param.name === "_";
173
215
  const sid = child.passesSlotIds ? `fv_sid${++ctx.narrowCount}` : null;
@@ -205,18 +247,29 @@ export function callWith(s, e, child, m, head, slotsTy, trailing, slots, vnode =
205
247
  else if (!takesNone)
206
248
  fail(s.comp, "FV0905", "slot props are a name or an object pattern", param);
207
249
  const life = shape.fields.some((f) => slotFieldBorrows(f.ty)) ? "<'_>" : "";
208
- e.open(`${field}: Some(&|out: &mut String, ${sp}: &${m}::${shape.name}${life}${sidParam}| -> bool`);
250
+ e.open(`${field}: Some(&|out: &mut String, ${sp}: &${m}::${shape.name}${life}${sidParam}${contextParams}| -> bool`);
209
251
  const opened = e.lines.length - 1;
210
- content({ ...s, locals, sid, vnode });
252
+ content({ ...s, locals, sid, vnode: asVnodes, opaque });
211
253
  unread(opened, sp);
212
254
  unread(opened, sid);
255
+ for (const p of context)
256
+ unread(opened, p.name);
213
257
  e.close("),");
214
258
  continue;
215
259
  }
216
260
  if (!takesNone)
217
261
  fail(s.comp, "FV0906", `\`<slot${name === "default" ? "" : ` name="${name}"`}>\` in ${child.name} passes no props`, param);
218
- const inner = { ...s, sid, vnode };
219
- if (sid !== null) {
262
+ const inner = { ...s, sid, vnode: asVnodes, opaque };
263
+ if (context.length) {
264
+ e.open(`${field}: Some(&|out: &mut String${sidParam}${contextParams}| -> bool`);
265
+ const opened = e.lines.length - 1;
266
+ content(inner);
267
+ unread(opened, sid);
268
+ for (const p of context)
269
+ unread(opened, p.name);
270
+ e.close("),");
271
+ }
272
+ else if (sid !== null) {
220
273
  e.open(`${field}: Some(fv::Slot::slotted(&|out: &mut String${sidParam}| -> bool`);
221
274
  const opened = e.lines.length - 1;
222
275
  content(inner);
package/dist/classes.d.ts CHANGED
@@ -7,6 +7,10 @@ export type ClassItem = {
7
7
  };
8
8
  export declare function classItems(s: Scope, n: N): ClassItem[];
9
9
  export declare function renderClass(s: Scope, e: Emitter, n: N, after?: boolean): void;
10
+ /** The class list of an element in content Vue renders from virtual nodes, in the order Vue's
11
+ * client compiler merges a static `class` written before `:class`: the static names first, at the
12
+ * place of the static `class`. */
13
+ export declare function vnodeClass(s: Scope, n: N): N;
10
14
  export declare function classPresent(s: Scope, n: N): string;
11
15
  export declare function maybeEqual(s: Scope, n: N): boolean;
12
16
  export declare function literalClass(n: N): string | null;
package/dist/classes.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { escapeHtml } from "@vue/shared";
2
- import { fail, nothing, rustStr, STR } from "./model.js";
2
+ import { directiveOwner, fail, nothing, rustStr, STR } from "./model.js";
3
3
  import { expr } from "./expr.js";
4
4
  import { cond, known, truthy } from "./narrowing.js";
5
5
  import { meet } from "./strings.js";
@@ -102,6 +102,28 @@ export function renderClass(s, e, n, after = false) {
102
102
  e.stmt(`fv::class_into(out, ${wrote}, &[${rest.join(", ")}]);`);
103
103
  }
104
104
  }
105
+ const UNWRITTEN = new Set(["on", "if", "else-if", "else", "for", "slot", "once", "memo", "cloak", "pre"]);
106
+ /** The class list of an element in content Vue renders from virtual nodes, in the order Vue's
107
+ * client compiler merges a static `class` written before `:class`: the static names first, at the
108
+ * place of the static `class`. */
109
+ export function vnodeClass(s, n) {
110
+ const last = n.type === "ArrayExpression" ? n.elements.at(-1) : null;
111
+ if (n.fvMerged || last?.type !== "StringLiteral" || n.elements.length < 2)
112
+ return n;
113
+ const el = directiveOwner(s.comp, n.elements[0]);
114
+ if (!el)
115
+ return n;
116
+ const own = el.props.findIndex((p) => p.type === 6 && p.name === "class");
117
+ const bound = el.props.findIndex((p) => p.type === 7 && p.name === "bind" && p.arg?.content === "class");
118
+ if (own < 0 || bound < own)
119
+ return n;
120
+ const between = el.props.slice(own + 1, bound).find((p) => p.type === 6 || !UNWRITTEN.has(p.name));
121
+ if (between) {
122
+ const at = { type: "VueTemplate", loc: { start: { line: between.loc.start.line, column: between.loc.start.column - 1 } }, __fv: "source" };
123
+ fail(s.comp, "FV1013", "in content Vue renders from virtual nodes, a `class` written before `:class` is written where the `class` stands, and Vue's server compiler moves it to where `:class` stands; write the two next to each other, or `:class` first", at);
124
+ }
125
+ return { ...n, elements: [last, ...n.elements.slice(0, -1)] };
126
+ }
105
127
  export function classPresent(s, n) {
106
128
  if (n.fvMerged)
107
129
  return n.elements.reduce((acc, el) => logical(acc, "||", classPresent(s, el)), "false");
package/dist/client.d.ts CHANGED
@@ -15,7 +15,8 @@ export interface MountOptions {
15
15
  /** Where to look for islands: the whole document by default. */
16
16
  root?: ParentNode;
17
17
  /** Told about an island left as the server rendered it, because its component is not among
18
- * those given or its props are malformed. Warns on the console by default. */
18
+ * those given, did not load or its props are malformed, and about one hydrated at once because
19
+ * its `data-hydrate` is not one this version knows. Warns on the console by default. */
19
20
  onError?: (element: Element, problem: string) => void;
20
21
  }
21
22
  /** A component, or a function that loads one (`() => import("./Counter.vue")`), so that the code
@@ -28,15 +29,19 @@ export type IslandComponent = Component | (() => Promise<Component | {
28
29
  }>);
29
30
  /** The islands mounted. */
30
31
  export interface Islands {
31
- /** One app per island, in document order. */
32
+ /** One app per island hydrated so far: those hydrated at once in document order, then each
33
+ * island with a `data-hydrate` as it hydrates. */
32
34
  apps: App[];
33
- /** Unmount every island, as a page leaving would. */
35
+ /** Unmount every island and stop waiting to hydrate the others, as a page leaving would. */
34
36
  unmount(): void;
35
37
  }
36
38
  /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
37
39
  * `Html::island` wrote becomes an app of the component of that name, given the props the server
38
40
  * rendered it with, mounted where it is. A loader is called only for an island the page holds, all
39
- * of them at once, and the islands mount in document order once every one has loaded. */
41
+ * of them at once, and the islands mount in document order once every one has loaded. An island
42
+ * with a `data-hydrate` (`Html::hydrate`) waits instead: once it is visible (or within a root margin
43
+ * of the viewport), the browser is idle,
44
+ * it is interacted with or a media query matches, its component is loaded and it hydrates. */
40
45
  export declare function mountIslands(components: Record<string, IslandComponent>, options?: MountOptions): Promise<Islands>;
41
46
  /** Content only the browser renders. The server writes the `#fallback` slot, or nothing; the
42
47
  * browser hydrates that fallback, then swaps in the default slot once mounted. ferrovue does not
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createSSRApp, createTextVNode, defineComponent, h, onMounted, ref } from "vue";
1
+ import { createSSRApp, createTextVNode, defineComponent, h, hydrateOnIdle, hydrateOnMediaQuery, hydrateOnVisible, onMounted, ref, } from "vue";
2
2
  const BARE = /"(?:[^"\\]|\\.)*"|-?Infinity|NaN/g;
3
3
  const NON_FINITE = { NaN: Number.NaN, Infinity: Number.POSITIVE_INFINITY, "-Infinity": Number.NEGATIVE_INFINITY };
4
4
  function parseJson(text) {
@@ -18,35 +18,79 @@ export function hydrateState(pinia, id = "__pinia", doc = document) {
18
18
  if (text)
19
19
  pinia.state.value = parseJson(text);
20
20
  }
21
+ const INTERACTIONS = ["pointerenter", "click", "focus"];
22
+ const strategy = (wait) => (el, hydrate) => wait(() => void hydrate(), (each) => void each(el)) ?? (() => { });
23
+ const idle = (el, hydrate) => {
24
+ if (typeof requestIdleCallback === "function")
25
+ return strategy(hydrateOnIdle())(el, hydrate);
26
+ const timer = setTimeout(() => void hydrate(), 200);
27
+ return () => clearTimeout(timer);
28
+ };
29
+ const interaction = (names) => (el, hydrate) => {
30
+ const events = names.length ? names : INTERACTIONS;
31
+ const early = [];
32
+ const stop = () => {
33
+ for (const name of events)
34
+ el.removeEventListener(name, listen, true);
35
+ };
36
+ const replay = () => {
37
+ stop();
38
+ for (const e of early.splice(0)) {
39
+ if (e.target instanceof Node && e.target.isConnected)
40
+ e.target.dispatchEvent(new e.constructor(e.type, e));
41
+ }
42
+ };
43
+ function listen(e) {
44
+ if (early.push(e) === 1)
45
+ void hydrate().then((mounted) => (mounted ? replay() : stop()));
46
+ }
47
+ for (const name of events)
48
+ el.addEventListener(name, listen, true);
49
+ return stop;
50
+ };
51
+ function trigger(when) {
52
+ const colon = when.indexOf(":");
53
+ const [kind, arg] = colon < 0 ? [when, undefined] : [when.slice(0, colon), when.slice(colon + 1)];
54
+ if (kind === "visible")
55
+ return strategy(hydrateOnVisible(arg ? { rootMargin: arg } : undefined));
56
+ if (kind === "idle" && arg === undefined)
57
+ return idle;
58
+ if (kind === "interaction")
59
+ return interaction((arg ?? "").split(/\s+/).filter(Boolean));
60
+ if (kind === "media" && arg !== undefined)
61
+ return strategy(hydrateOnMediaQuery(arg || "all"));
62
+ return undefined;
63
+ }
21
64
  /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
22
65
  * `Html::island` wrote becomes an app of the component of that name, given the props the server
23
66
  * rendered it with, mounted where it is. A loader is called only for an island the page holds, all
24
- * of them at once, and the islands mount in document order once every one has loaded. */
67
+ * of them at once, and the islands mount in document order once every one has loaded. An island
68
+ * with a `data-hydrate` (`Html::hydrate`) waits instead: once it is visible (or within a root margin
69
+ * of the viewport), the browser is idle,
70
+ * it is interacted with or a media query matches, its component is loaded and it hydrates. */
25
71
  export async function mountIslands(components, options = {}) {
26
72
  const root = options.root ?? document;
27
73
  const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
28
74
  const elements = Array.from(root.querySelectorAll("[data-island]"));
29
75
  const loading = new Map();
30
- for (const el of elements) {
31
- const name = el.dataset.island ?? "";
32
- if (Object.hasOwn(components, name) && !loading.has(name))
33
- loading.set(name, load(components[name]));
34
- }
35
- const [loaded] = await Promise.all([
36
- Promise.all([...loading].map(async ([name, pending]) => [name, await pending])).then((pairs) => new Map(pairs)),
37
- options.router?.isReady(),
38
- ]);
76
+ const loadIsland = (name) => {
77
+ let pending = loading.get(name);
78
+ if (!pending)
79
+ loading.set(name, (pending = load(components[name])));
80
+ return pending;
81
+ };
82
+ const ready = options.router?.isReady();
39
83
  const apps = [];
40
- for (const el of elements) {
41
- const name = el.dataset.island ?? "";
42
- const component = loaded.get(name);
84
+ const waiting = [];
85
+ let stopped = false;
86
+ const mount = (el, name, component) => {
43
87
  if (!component) {
44
88
  report(el, `no component called ${JSON.stringify(name)} was given (\`ferrovue/islands\` holds every component that has an \`island()\`)`);
45
- continue;
89
+ return;
46
90
  }
47
91
  if (component instanceof Error) {
48
92
  report(el, `${name} did not load: ${component.message}`);
49
- continue;
93
+ return;
50
94
  }
51
95
  let props;
52
96
  try {
@@ -54,7 +98,7 @@ export async function mountIslands(components, options = {}) {
54
98
  }
55
99
  catch {
56
100
  report(el, `the props of ${name} are not JSON`);
57
- continue;
101
+ return;
58
102
  }
59
103
  const app = createSSRApp(component, props);
60
104
  if (options.pinia)
@@ -65,10 +109,55 @@ export async function mountIslands(components, options = {}) {
65
109
  app.use(plugin);
66
110
  app.mount(el);
67
111
  apps.push(app);
112
+ };
113
+ const now = [];
114
+ for (const el of elements) {
115
+ const name = el.dataset.island ?? "";
116
+ const given = Object.hasOwn(components, name);
117
+ const when = el.dataset.hydrate;
118
+ const wait = given && when !== undefined ? trigger(when) : undefined;
119
+ if (!wait) {
120
+ if (given && when !== undefined)
121
+ report(el, `${name} has data-hydrate=${JSON.stringify(when)}, which this version of ferrovue does not know, so it hydrated at once`);
122
+ if (given)
123
+ void loadIsland(name);
124
+ now.push(el);
125
+ continue;
126
+ }
127
+ let started = false;
128
+ try {
129
+ waiting.push(wait(el, async () => {
130
+ if (started || stopped)
131
+ return false;
132
+ started = true;
133
+ const [component] = await Promise.all([loadIsland(name), ready]);
134
+ if (stopped)
135
+ return false;
136
+ mount(el, name, component);
137
+ return true;
138
+ }));
139
+ }
140
+ catch (e) {
141
+ report(el, `${name} has data-hydrate=${JSON.stringify(when)}, which the browser rejects (${e instanceof Error ? e.message : String(e)}), so it hydrated at once`);
142
+ void loadIsland(name);
143
+ now.push(el);
144
+ }
145
+ }
146
+ const names = new Set(now.map((el) => el.dataset.island ?? "").filter((name) => Object.hasOwn(components, name)));
147
+ const [loaded] = await Promise.all([
148
+ Promise.all([...names].map(async (name) => [name, await loadIsland(name)])).then((pairs) => new Map(pairs)),
149
+ ready,
150
+ ]);
151
+ for (const el of now) {
152
+ const name = el.dataset.island ?? "";
153
+ mount(el, name, loaded.get(name));
68
154
  }
69
155
  return {
70
156
  apps,
71
157
  unmount() {
158
+ stopped = true;
159
+ for (const stop of waiting.splice(0))
160
+ stop();
72
161
  for (const app of apps.splice(0))
73
162
  app.unmount();
74
163
  },
@@ -18,6 +18,6 @@ export interface Written {
18
18
  * so a Rust build that watches them rebuilds no more than it must, and removing what no component
19
19
  * produces any more. */
20
20
  export declare function write(root: string, config?: Config): Written;
21
- export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type TwinSpec, type TypeName } from "./context.ts";
21
+ export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type RoutesSource, type TwinSpec, type TypeName } from "./context.ts";
22
22
  export { GenError } from "./model.ts";
23
23
  export declare const VERSION: string;
package/dist/compiler.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { basename, join } from "node:path";
2
+ import { basename, join, relative } from "node:path";
3
3
  import { ctx, loadConfig, tyOfName } from "./context.js";
4
+ import { failIn, snake } from "./model.js";
4
5
  import { importsOf, readComponent } from "./component.js";
5
6
  import { scopeFor } from "./script.js";
6
7
  import { attrsFlow } from "./fallthrough.js";
8
+ import { prepareDynamic } from "./dynamic.js";
7
9
  import { componentSource, isIsland, modSource } from "./rust.js";
8
10
  import { renderParams } from "./plugin.js";
9
11
  import { PLUGINS } from "./plugins/index.js";
@@ -15,6 +17,7 @@ export function generate(root, config = loadConfig(root)) {
15
17
  ctx.builders = config.builders ?? true;
16
18
  ctx.clientDirectives = new Set(config.clientDirectives ?? []);
17
19
  ctx.rootDir = root;
20
+ ctx.vnodeTag = null;
18
21
  ctx.typeStructs = new Map();
19
22
  ctx.typeAliases = new Map();
20
23
  ctx.typeFiles = new Map();
@@ -32,16 +35,34 @@ export function generate(root, config = loadConfig(root)) {
32
35
  { rust: h.rust, params: h.params.map(tyOfName), ret: tyOfName(h.returns), maxLen: h.maxLen ?? 0 },
33
36
  ]));
34
37
  const dir = join(root, config.components);
35
- const files = readdirSync(dir)
36
- .filter((f) => f.endsWith(".vue"))
37
- .toSorted()
38
- .map((f) => join(dir, f));
39
- const children = new Set(files.flatMap((f) => importsOf(readFileSync(f, "utf8")).filter((c) => c !== basename(f, ".vue"))));
40
- const read = files.map((f) => readComponent(f, root, children.has(basename(f, ".vue"))));
38
+ const files = [
39
+ ...readdirSync(dir)
40
+ .filter((f) => f.endsWith(".vue"))
41
+ .toSorted()
42
+ .map((f) => ({ file: join(dir, f), name: basename(f, ".vue") })),
43
+ ...PLUGINS.flatMap((p) => p.components?.() ?? []).map((c) => ({ file: join(root, c.file), name: c.name })),
44
+ ];
45
+ const modulesOf = new Map();
46
+ for (const f of files) {
47
+ const rel = relative(root, f.file);
48
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(f.name)) {
49
+ failIn(rel, "FV0007", `the component is called \`${f.name}\` after its file, which is not a Rust name: name the file in PascalCase, letters and digits (\`UserCard.vue\`)`);
50
+ }
51
+ const file = moduleFile(snake(f.name));
52
+ if (file === "mod.rs")
53
+ failIn(rel, "FV0007", `the component's module would be \`mod.rs\`, which holds the generated modules: rename the file`);
54
+ const taken = modulesOf.get(file);
55
+ if (taken)
56
+ failIn(rel, "FV0007", `the component's module would be \`${file}\`, as ${taken}'s is: rename one of them`);
57
+ modulesOf.set(file, rel);
58
+ }
59
+ const children = new Set(files.flatMap((f) => importsOf(readFileSync(f.file, "utf8")).filter((c) => c !== f.name)));
60
+ const read = files.map((f) => readComponent(f.file, root, children.has(f.name), f.name));
41
61
  const components = new Map(read.map((r) => [r.comp.name, r.comp]));
42
62
  ctx.components = components;
43
63
  const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
44
- const all = read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
64
+ const analysed = prepareDynamic(read.map((r, i) => ({ ...r, scope: scopes[i] })));
65
+ const all = read.map((r, i) => ({ comp: r.comp, ssr: analysed[i], children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
45
66
  for (const p of PLUGINS)
46
67
  p.analyse?.(all);
47
68
  attrsFlow(all);
@@ -75,14 +96,21 @@ export function generate(root, config = loadConfig(root)) {
75
96
  read.forEach(place);
76
97
  for (const r of ordered) {
77
98
  ctx.narrowCount = 0;
78
- out.set(`${r.comp.module}.rs`, componentSource(r.comp, r.ast, r.ssr, components));
99
+ out.set(moduleFile(r.comp.module), componentSource(r.comp, r.ast, r.ssr, components));
79
100
  }
80
101
  const modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
81
102
  out.set("mod.rs", modSource(read.map((r) => r.comp), modules.map(([file]) => file)));
82
- for (const [file, text] of modules)
103
+ for (const [file, text] of modules) {
104
+ const taken = modulesOf.get(file);
105
+ if (taken)
106
+ failIn(taken, "FV0007", `the component's module would be \`${file}\`, which ferrovue writes for the project's ${file.replace(/\.rs$/, "").replaceAll("_", " ")}: rename the file`);
83
107
  out.set(file, text);
108
+ }
84
109
  return out;
85
110
  }
111
+ function moduleFile(module) {
112
+ return `${module.replace(/^r#/, "")}.rs`;
113
+ }
86
114
  /** Write what \`generate\` produces to the configured directory: only the files whose text changed,
87
115
  * so a Rust build that watches them rebuilds no more than it must, and removing what no component
88
116
  * produces any more. */
@@ -1,7 +1,7 @@
1
1
  import { type Component, type N } from "./model.ts";
2
2
  /** The `.vue` files a component imports, by name. */
3
3
  export declare function importsOf(source: string): string[];
4
- export declare function readComponent(file: string, root: string, isChild: boolean): {
4
+ export declare function readComponent(file: string, root: string, isChild: boolean, name?: string): {
5
5
  comp: Component;
6
6
  ast: N[];
7
7
  ssr: string;
package/dist/component.js CHANGED
@@ -114,15 +114,14 @@ function scriptOf(comp, descriptor) {
114
114
  throw new GenError("FV0001", e.message, { file: comp.file, ...where }, e.message.split("\n")[0]);
115
115
  }
116
116
  }
117
- export function readComponent(file, root, isChild) {
118
- const name = basename(file, ".vue");
117
+ export function readComponent(file, root, isChild, name = basename(file, ".vue")) {
119
118
  const source = readFileSync(file, "utf8");
120
119
  const { descriptor, errors } = parseSfc(source, { filename: file });
121
120
  const rel = relative(root, file);
122
121
  const comp = blankComponent(name, snake(name), rel);
123
122
  comp.source = source;
124
123
  if (errors.length)
125
- fail(comp, "FV0002", String(errors[0]), vueErrorNode(errors[0]));
124
+ fail(comp, "FV0002", String(errors[0]).replaceAll(file, rel), vueErrorNode(errors[0]) ?? sourceAt(source, 0));
126
125
  if (descriptor.script && !descriptor.scriptSetup) {
127
126
  const needs = isChild ? "a child component must have" : "an island needs";
128
127
  fail(comp, "FV0003", `${needs} \`<script setup lang="ts">\`, or no script at all: a \`<script>\` without \`setup\` (the Options API, \`defineComponent\`) is not translated`, blockAt(source, descriptor.script));
@@ -0,0 +1,64 @@
1
+ import type { App, Component } from "vue";
2
+ import { type Config } from "./compiler.ts";
3
+ import { type RouteEntry, type RouterOptions } from "./fixture.ts";
4
+ /** What `conformanceSuite` checks: the project, its components and its fixtures. `pinia`,
5
+ * `vueRouter` and `vueI18n` are the application's own modules (`pinia: await import("pinia")`),
6
+ * which the fixtures install so that its stores and composables find them. */
7
+ export interface ConformanceOptions extends Pick<RouterOptions, "pinia" | "vueRouter" | "vueI18n"> {
8
+ /** The project's `ferrovue.config.json`. */
9
+ config: string;
10
+ /** Every component in the configured `components` directory, by file name or by the path
11
+ * `import.meta.glob` gives it: `import.meta.glob("../components/*.vue", { eager: true })`; and
12
+ * with a folder of pages, every page, by the path `import.meta.glob("../pages/**\/*.vue")` gives
13
+ * it or by its component name (`BooksId`). */
14
+ components: Record<string, Component | {
15
+ default: Component;
16
+ }>;
17
+ /** The fixtures, one directory per component holding `<case>.json` and `<case>.html`:
18
+ * `fixtures` beside the configuration when not given. */
19
+ fixtures?: string;
20
+ /** Write each fixture's `.html` from Vue's render instead of comparing it: when
21
+ * `FERROVUE_FIXTURES_WRITE=1` is set, if not given. */
22
+ record?: boolean;
23
+ }
24
+ interface TestApi {
25
+ describe(name: string, body: () => void): void;
26
+ it(name: string, body: () => Promise<void> | void): void;
27
+ }
28
+ export declare const TELEPORTS = "<!--fv-teleports-->";
29
+ export declare const HEAD = "<!--fv-head-->";
30
+ /** What unhead's server renderer wrote for a recorded render's head, as `renderSSRHead` gives it. */
31
+ export interface RecordedHead {
32
+ headTags: string;
33
+ bodyTags: string;
34
+ bodyTagsOpen: string;
35
+ htmlAttrs: string;
36
+ bodyAttrs: string;
37
+ }
38
+ /** The head a recorded render wrote after its body, if it wrote one. */
39
+ export declare function recordedHead(html: string): RecordedHead | null;
40
+ /** Render a fixture's app as the conformance suite records it: Vue's HTML, then what was teleported
41
+ * and what `useHead` asked for, each after its marker. */
42
+ export declare function renderFixture(app: App): Promise<string>;
43
+ /** Put a recorded render's head into the document, as a page would write it: its tags in `<head>`,
44
+ * and its attributes on `<html>` and `<body>`. */
45
+ export declare function placeHead(head: RecordedHead | null, doc?: Document): void;
46
+ /** A recorded render as a page body to hydrate: the main HTML in `<div id="root">`, what was
47
+ * teleported in each target, and the tags the head puts at the start and end of `<body>`. */
48
+ export declare function hydrationBody(html: string): string;
49
+ /** Where two renders first differ, with the text around it in each. */
50
+ export declare function firstDifference(recorded: string, rendered: string): string;
51
+ /** The routes and the router and vue-i18n options a project's fixtures render with, from its configuration. */
52
+ export declare function fixtureOptions(root: string, config: Config): {
53
+ routes: RouteEntry[] | null;
54
+ options: RouterOptions;
55
+ };
56
+ /** Register the conformance suite with the given `describe` and `it`. */
57
+ export declare function registerConformance(api: TestApi, options: ConformanceOptions): void;
58
+ /** Register vitest tests holding a project's components to Vue, as ferrovue's own suite does:
59
+ * every component has fixtures and every fixture a component, the generated Rust is what the
60
+ * generator writes now, Vue renders each fixture to its recorded `.html` (or records it, with
61
+ * `FERROVUE_FIXTURES_WRITE=1`), and each recorded `.html` hydrates with no warning and the server's
62
+ * nodes kept. Await it at the top of a test file run in a DOM environment such as happy-dom. */
63
+ export declare function conformanceSuite(options: ConformanceOptions): Promise<void>;
64
+ export {};