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.
- package/README.md +201 -50
- package/dist/attrs.d.ts +2 -1
- package/dist/attrs.js +7 -4
- package/dist/children.d.ts +2 -2
- package/dist/children.js +66 -13
- package/dist/classes.d.ts +4 -0
- package/dist/classes.js +23 -1
- package/dist/client.d.ts +9 -4
- package/dist/client.js +106 -17
- package/dist/compiler.d.ts +1 -1
- package/dist/compiler.js +38 -10
- package/dist/component.d.ts +1 -1
- package/dist/component.js +2 -3
- package/dist/conformance.d.ts +64 -0
- package/dist/conformance.js +276 -0
- package/dist/context.d.ts +15 -6
- package/dist/context.js +3 -0
- package/dist/dynamic.d.ts +18 -0
- package/dist/dynamic.js +576 -0
- package/dist/errors.d.ts +168 -1
- package/dist/errors.js +96 -1
- package/dist/file-routes.d.ts +33 -0
- package/dist/file-routes.js +224 -0
- package/dist/fixture.d.ts +16 -0
- package/dist/fixture.js +22 -7
- package/dist/index.d.ts +1 -1
- package/dist/link-router.d.ts +1 -1
- package/dist/link-router.js +1 -0
- package/dist/lists.d.ts +4 -0
- package/dist/lists.js +2 -2
- package/dist/loops.js +4 -4
- package/dist/model.d.ts +11 -0
- package/dist/model.js +44 -1
- package/dist/numbers.js +3 -2
- package/dist/page-routes.d.ts +9 -0
- package/dist/page-routes.js +10 -0
- package/dist/plugin.d.ts +13 -1
- package/dist/plugin.js +3 -0
- package/dist/plugins/client-only.js +3 -0
- package/dist/plugins/head.d.ts +15 -0
- package/dist/plugins/head.js +385 -0
- package/dist/plugins/index.js +3 -1
- package/dist/plugins/provide.d.ts +44 -0
- package/dist/plugins/provide.js +528 -0
- package/dist/plugins/router-link.js +7 -3
- package/dist/plugins/router.d.ts +4 -1
- package/dist/plugins/router.js +56 -10
- package/dist/plugins/stores.js +19 -9
- package/dist/routes.d.ts +5 -2
- package/dist/routes.js +8 -2
- package/dist/rust.js +46 -18
- package/dist/script.js +19 -3
- package/dist/settle.d.ts +2 -0
- package/dist/settle.js +5 -0
- package/dist/slots.js +18 -4
- package/dist/ssr.d.ts +3 -0
- package/dist/ssr.js +29 -0
- package/dist/strings.d.ts +3 -0
- package/dist/strings.js +16 -2
- package/dist/template.js +92 -11
- package/dist/testing.d.ts +2 -3
- package/dist/testing.js +2 -29
- package/dist/types.d.ts +5 -0
- package/dist/typescript.js +7 -3
- package/dist/vhtml.d.ts +4 -0
- package/dist/vhtml.js +63 -0
- package/dist/vite.d.ts +7 -1
- package/dist/vite.js +65 -5
- 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
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
loading.set(name, load(components[name]));
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
89
|
+
return;
|
|
46
90
|
}
|
|
47
91
|
if (component instanceof Error) {
|
|
48
92
|
report(el, `${name} did not load: ${component.message}`);
|
|
49
|
-
|
|
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
|
-
|
|
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
|
},
|
package/dist/compiler.d.ts
CHANGED
|
@@ -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 =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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(
|
|
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. */
|
package/dist/component.d.ts
CHANGED
|
@@ -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 {};
|