ferrovue 0.5.0 → 0.7.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 +306 -62
- package/dist/args.d.ts +28 -0
- package/dist/args.js +69 -0
- package/dist/attrs.d.ts +2 -1
- package/dist/attrs.js +10 -4
- package/dist/calls.js +5 -2
- package/dist/children.d.ts +2 -2
- package/dist/children.js +84 -16
- package/dist/classes.d.ts +4 -0
- package/dist/classes.js +23 -1
- package/dist/cli.js +80 -81
- package/dist/client-render.d.ts +23 -0
- package/dist/client-render.js +143 -0
- package/dist/client.d.ts +74 -10
- package/dist/client.js +373 -42
- package/dist/compiler.d.ts +9 -5
- package/dist/compiler.js +80 -18
- package/dist/component.d.ts +7 -3
- package/dist/component.js +38 -27
- package/dist/conformance.d.ts +85 -0
- package/dist/conformance.js +327 -0
- package/dist/constants.js +4 -2
- package/dist/context.d.ts +66 -10
- package/dist/context.js +176 -5
- package/dist/diagnostics.d.ts +6 -4
- package/dist/diagnostics.js +7 -3
- package/dist/dynamic.d.ts +18 -0
- package/dist/dynamic.js +576 -0
- package/dist/editor.d.ts +18 -0
- package/dist/editor.js +59 -0
- package/dist/errors.d.ts +213 -4
- package/dist/errors.js +129 -4
- package/dist/expr.d.ts +2 -0
- package/dist/expr.js +91 -12
- package/dist/fallthrough.d.ts +1 -0
- package/dist/fallthrough.js +10 -2
- package/dist/file-routes.d.ts +33 -0
- package/dist/file-routes.js +224 -0
- package/dist/files.d.ts +13 -0
- package/dist/files.js +49 -0
- package/dist/fixture.d.ts +16 -0
- package/dist/fixture.js +22 -7
- package/dist/hydration.d.ts +12 -1
- package/dist/hydration.js +47 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/inputs.d.ts +24 -0
- package/dist/inputs.js +40 -0
- package/dist/link-router.d.ts +7 -3
- package/dist/link-router.js +8 -4
- package/dist/lists.d.ts +16 -1
- package/dist/lists.js +78 -4
- package/dist/loops.js +4 -4
- package/dist/model.d.ts +20 -1
- package/dist/model.js +46 -1
- package/dist/numbers.js +3 -2
- package/dist/page-routes.d.ts +10 -0
- package/dist/page-routes.js +10 -0
- package/dist/paths.d.ts +5 -0
- package/dist/paths.js +7 -0
- package/dist/plugin.d.ts +15 -1
- package/dist/plugin.js +3 -0
- package/dist/plugins/client-only.js +3 -0
- package/dist/plugins/css-modules.d.ts +39 -0
- package/dist/plugins/css-modules.js +135 -0
- package/dist/plugins/head.d.ts +15 -0
- package/dist/plugins/head.js +385 -0
- package/dist/plugins/i18n.js +5 -12
- package/dist/plugins/index.js +4 -1
- package/dist/plugins/provide.d.ts +44 -0
- package/dist/plugins/provide.js +530 -0
- package/dist/plugins/router-link.js +7 -3
- package/dist/plugins/router.d.ts +4 -1
- package/dist/plugins/router.js +58 -11
- package/dist/plugins/scoped.d.ts +10 -0
- package/dist/plugins/scoped.js +17 -4
- package/dist/plugins/shared-types.js +2 -1
- package/dist/plugins/stores.js +26 -15
- package/dist/routes.d.ts +5 -2
- package/dist/routes.js +8 -2
- package/dist/rust.d.ts +9 -1
- package/dist/rust.js +83 -32
- package/dist/schema.d.ts +25 -0
- package/dist/schema.js +73 -0
- package/dist/script.js +31 -8
- package/dist/settle.d.ts +2 -0
- package/dist/settle.js +5 -0
- package/dist/slots.js +20 -4
- package/dist/ssr.d.ts +3 -0
- package/dist/ssr.js +67 -0
- package/dist/strings.d.ts +3 -0
- package/dist/strings.js +16 -2
- package/dist/styles.d.ts +5 -1
- package/dist/styles.js +131 -4
- package/dist/template.js +92 -11
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js +3 -30
- package/dist/types.d.ts +5 -0
- package/dist/typescript.d.ts +11 -1
- package/dist/typescript.js +131 -36
- package/dist/vhtml.d.ts +4 -0
- package/dist/vhtml.js +63 -0
- package/dist/vite.d.ts +25 -11
- package/dist/vite.js +166 -51
- package/dist/volar.d.ts +15 -0
- package/dist/volar.js +87 -0
- package/package.json +41 -7
- package/schema.json +333 -0
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSSRApp, createTextVNode, defineComponent, h, onMounted, ref } from "vue";
|
|
1
|
+
import { createApp, 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) {
|
|
@@ -12,41 +12,285 @@ function parseJson(text) {
|
|
|
12
12
|
return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
|
|
13
13
|
}
|
|
14
14
|
/** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
|
|
15
|
-
* the app mounts, so every store starts from it and the
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
* the app mounts, so every store starts from it and the markup agrees with the server's. The state
|
|
16
|
+
* is read from the script with the `id` given (`__pinia` by default) in `doc`, or given as
|
|
17
|
+
* `{ text }`: the `state` of a fetched page that `readPage` read. Either way the numbers JSON
|
|
18
|
+
* cannot carry (`NaN`, `Infinity`) are read back. Pinia is left alone when there is no state. */
|
|
19
|
+
export function hydrateState(pinia, from = "__pinia", doc = document) {
|
|
20
|
+
const text = typeof from === "string" ? doc.getElementById(from)?.textContent : from.text;
|
|
18
21
|
if (text)
|
|
19
22
|
pinia.state.value = parseJson(text);
|
|
20
23
|
}
|
|
24
|
+
const SPACE = /[\t\n\f\r ]/;
|
|
25
|
+
const TAG_END = /[\t\n\f\r />]/;
|
|
26
|
+
const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript"]);
|
|
27
|
+
const FOREIGN = new Set(["svg", "math"]);
|
|
28
|
+
const LEAVES_FOREIGN = new Set("b big blockquote body br center code dd div dl dt em embed h1 h2 h3 h4 h5 h6 head hr i img li listing menu meta nobr ol p pre ruby s small span strong strike sub sup table tt u ul var".split(" "));
|
|
29
|
+
const REFERENCES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
30
|
+
function decodeAttr(value) {
|
|
31
|
+
if (!value.includes("&"))
|
|
32
|
+
return value;
|
|
33
|
+
return value.replace(/&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|(amp|lt|gt|quot|apos);)/g, (_match, dec, hex, named) => {
|
|
34
|
+
if (named)
|
|
35
|
+
return REFERENCES[named];
|
|
36
|
+
const code = dec ? Number.parseInt(dec, 10) : Number.parseInt(hex, 16);
|
|
37
|
+
return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : "\uFFFD";
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function readTag(html, at) {
|
|
41
|
+
let i = at;
|
|
42
|
+
while (i < html.length && !TAG_END.test(html[i]))
|
|
43
|
+
i++;
|
|
44
|
+
const name = html.slice(at, i).toLowerCase();
|
|
45
|
+
const attrs = new Map();
|
|
46
|
+
let selfClosing = false;
|
|
47
|
+
for (;;) {
|
|
48
|
+
while (i < html.length && (SPACE.test(html[i]) || html[i] === "/"))
|
|
49
|
+
selfClosing = html[i++] === "/";
|
|
50
|
+
if (i >= html.length)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (html[i] === ">")
|
|
53
|
+
return { name, attrs, end: i + 1, selfClosing };
|
|
54
|
+
selfClosing = false;
|
|
55
|
+
const start = i++;
|
|
56
|
+
while (i < html.length && !/[\t\n\f\r />=]/.test(html[i]))
|
|
57
|
+
i++;
|
|
58
|
+
const key = html.slice(start, i).toLowerCase();
|
|
59
|
+
while (i < html.length && SPACE.test(html[i]))
|
|
60
|
+
i++;
|
|
61
|
+
let value = "";
|
|
62
|
+
if (html[i] === "=") {
|
|
63
|
+
i++;
|
|
64
|
+
while (i < html.length && SPACE.test(html[i]))
|
|
65
|
+
i++;
|
|
66
|
+
const quote = html[i];
|
|
67
|
+
if (quote === '"' || quote === "'") {
|
|
68
|
+
const close = html.indexOf(quote, i + 1);
|
|
69
|
+
if (close < 0)
|
|
70
|
+
return undefined;
|
|
71
|
+
value = html.slice(i + 1, close);
|
|
72
|
+
i = close + 1;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const from = i;
|
|
76
|
+
while (i < html.length && !/[\t\n\f\r >]/.test(html[i]))
|
|
77
|
+
i++;
|
|
78
|
+
value = html.slice(from, i);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!attrs.has(key))
|
|
82
|
+
attrs.set(key, decodeAttr(value));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function tagAt(lower, at, name, closing) {
|
|
86
|
+
const open = closing ? `</${name}` : `<${name}`;
|
|
87
|
+
return lower.startsWith(open, at) && TAG_END.test(lower[at + open.length] ?? "");
|
|
88
|
+
}
|
|
89
|
+
function scriptDataEnd(lower, from) {
|
|
90
|
+
let escaped = 0;
|
|
91
|
+
let dashes = 0;
|
|
92
|
+
for (let i = from; i < lower.length; i++) {
|
|
93
|
+
if (escaped === 0) {
|
|
94
|
+
if (lower.startsWith("<!--", i)) {
|
|
95
|
+
escaped = 1;
|
|
96
|
+
dashes = 2;
|
|
97
|
+
i += 3;
|
|
98
|
+
}
|
|
99
|
+
else if (tagAt(lower, i, "script", true))
|
|
100
|
+
return i;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const ch = lower[i];
|
|
104
|
+
if (ch === "-") {
|
|
105
|
+
dashes++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === ">" && dashes >= 2)
|
|
109
|
+
escaped = 0;
|
|
110
|
+
else if (ch === "<" && tagAt(lower, i, "script", true)) {
|
|
111
|
+
if (escaped === 1)
|
|
112
|
+
return i;
|
|
113
|
+
escaped = 1;
|
|
114
|
+
}
|
|
115
|
+
else if (ch === "<" && escaped === 1 && tagAt(lower, i, "script", false))
|
|
116
|
+
escaped = 2;
|
|
117
|
+
dashes = 0;
|
|
118
|
+
}
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
function rawTextEnd(lower, from, name) {
|
|
122
|
+
if (name === "script")
|
|
123
|
+
return scriptDataEnd(lower, from);
|
|
124
|
+
for (let i = lower.indexOf(`</${name}`, from); i >= 0; i = lower.indexOf(`</${name}`, i + 1))
|
|
125
|
+
if (tagAt(lower, i, name, true))
|
|
126
|
+
return i;
|
|
127
|
+
return -1;
|
|
128
|
+
}
|
|
129
|
+
function commentEnd(html, from) {
|
|
130
|
+
if (html.startsWith(">", from))
|
|
131
|
+
return from + 1;
|
|
132
|
+
if (html.startsWith("->", from))
|
|
133
|
+
return from + 2;
|
|
134
|
+
for (let i = html.indexOf("--", from); i >= 0; i = html.indexOf("--", i + 1)) {
|
|
135
|
+
if (html[i + 2] === ">")
|
|
136
|
+
return i + 3;
|
|
137
|
+
if (html.startsWith("!>", i + 2))
|
|
138
|
+
return i + 4;
|
|
139
|
+
}
|
|
140
|
+
return html.length;
|
|
141
|
+
}
|
|
142
|
+
function scriptText(html, id) {
|
|
143
|
+
const lower = html.toLowerCase();
|
|
144
|
+
let template = 0;
|
|
145
|
+
let foreign = 0;
|
|
146
|
+
let i = 0;
|
|
147
|
+
for (;;) {
|
|
148
|
+
const lt = html.indexOf("<", i);
|
|
149
|
+
if (lt < 0)
|
|
150
|
+
return undefined;
|
|
151
|
+
i = lt + 1;
|
|
152
|
+
if (html.startsWith("<!--", lt)) {
|
|
153
|
+
i = commentEnd(html, lt + 4);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (foreign > 0 && html.startsWith("<![CDATA[", lt)) {
|
|
157
|
+
const close = html.indexOf("]]>", lt);
|
|
158
|
+
i = close < 0 ? html.length : close + 3;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const closing = html[lt + 1] === "/";
|
|
162
|
+
const at = closing ? lt + 2 : lt + 1;
|
|
163
|
+
if (!/[a-zA-Z]/.test(html[at] ?? "")) {
|
|
164
|
+
if (html[lt + 1] === "!" || html[lt + 1] === "?" || (closing && at < html.length)) {
|
|
165
|
+
const close = html.indexOf(">", at);
|
|
166
|
+
i = close < 0 ? html.length : close + 1;
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const tag = readTag(html, at);
|
|
171
|
+
if (!tag)
|
|
172
|
+
return undefined;
|
|
173
|
+
i = tag.end;
|
|
174
|
+
if (FOREIGN.has(tag.name)) {
|
|
175
|
+
if (closing)
|
|
176
|
+
foreign = Math.max(0, foreign - 1);
|
|
177
|
+
else if (!tag.selfClosing)
|
|
178
|
+
foreign++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (foreign > 0) {
|
|
182
|
+
if (closing ? tag.name === "br" || tag.name === "p" : LEAVES_FOREIGN.has(tag.name))
|
|
183
|
+
foreign = 0;
|
|
184
|
+
else
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (tag.name === "template") {
|
|
188
|
+
template = closing ? Math.max(0, template - 1) : template + 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (closing || !RAW_TEXT.has(tag.name)) {
|
|
192
|
+
if (tag.name === "plaintext")
|
|
193
|
+
return undefined;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const end = rawTextEnd(lower, i, tag.name);
|
|
197
|
+
if (tag.name === "script" && template === 0 && tag.attrs.get("id") === id)
|
|
198
|
+
return html.slice(i, end < 0 ? html.length : end);
|
|
199
|
+
if (end < 0)
|
|
200
|
+
return undefined;
|
|
201
|
+
const close = readTag(html, end + 2);
|
|
202
|
+
if (!close)
|
|
203
|
+
return undefined;
|
|
204
|
+
i = close.end;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Read the record and the state of a page out of the HTML of its document, as a navigation that
|
|
208
|
+
* fetches the next page receives it. It needs no `DOMParser`, so it also runs in a worker and on a
|
|
209
|
+
* server. Each script is the one the browser's `getElementById` would find in the parsed document:
|
|
210
|
+
* the first `<script>` with that `id` outside a `<template>`, an `<svg>` and a `<math>`, with
|
|
211
|
+
* comments, attribute values and the text of `<script>`, `<style>`, `<textarea>`, `<title>` and the
|
|
212
|
+
* other raw-text elements passed over as the HTML parser passes over them. Throws when the page
|
|
213
|
+
* carries no record. */
|
|
214
|
+
export function readPage(html, options = {}) {
|
|
215
|
+
const id = options.record ?? "__fv_page";
|
|
216
|
+
const text = scriptText(html, id);
|
|
217
|
+
if (!text)
|
|
218
|
+
throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
|
|
219
|
+
return { record: parseJson(text), state: scriptText(html, options.state ?? "__pinia") || undefined };
|
|
220
|
+
}
|
|
221
|
+
const INTERACTIONS = ["pointerenter", "click", "focus"];
|
|
222
|
+
const strategy = (wait) => (el, hydrate) => wait(() => void hydrate(), (each) => void each(el)) ?? (() => { });
|
|
223
|
+
const idle = (el, hydrate) => {
|
|
224
|
+
if (typeof requestIdleCallback === "function")
|
|
225
|
+
return strategy(hydrateOnIdle())(el, hydrate);
|
|
226
|
+
const timer = setTimeout(() => void hydrate(), 200);
|
|
227
|
+
return () => clearTimeout(timer);
|
|
228
|
+
};
|
|
229
|
+
const interaction = (names) => (el, hydrate) => {
|
|
230
|
+
const events = names.length ? names : INTERACTIONS;
|
|
231
|
+
const early = [];
|
|
232
|
+
const stop = () => {
|
|
233
|
+
for (const name of events)
|
|
234
|
+
el.removeEventListener(name, listen, true);
|
|
235
|
+
};
|
|
236
|
+
const replay = () => {
|
|
237
|
+
stop();
|
|
238
|
+
for (const e of early.splice(0)) {
|
|
239
|
+
if (e.target instanceof Node && e.target.isConnected)
|
|
240
|
+
e.target.dispatchEvent(new e.constructor(e.type, e));
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
function listen(e) {
|
|
244
|
+
if (early.push(e) === 1)
|
|
245
|
+
void hydrate().then((mounted) => (mounted ? replay() : stop()));
|
|
246
|
+
}
|
|
247
|
+
for (const name of events)
|
|
248
|
+
el.addEventListener(name, listen, true);
|
|
249
|
+
return stop;
|
|
250
|
+
};
|
|
251
|
+
function trigger(when) {
|
|
252
|
+
const colon = when.indexOf(":");
|
|
253
|
+
const [kind, arg] = colon < 0 ? [when, undefined] : [when.slice(0, colon), when.slice(colon + 1)];
|
|
254
|
+
if (kind === "visible")
|
|
255
|
+
return strategy(hydrateOnVisible(arg ? { rootMargin: arg } : undefined));
|
|
256
|
+
if (kind === "idle" && arg === undefined)
|
|
257
|
+
return idle;
|
|
258
|
+
if (kind === "interaction")
|
|
259
|
+
return interaction((arg ?? "").split(/\s+/).filter(Boolean));
|
|
260
|
+
if (kind === "media" && arg !== undefined)
|
|
261
|
+
return strategy(hydrateOnMediaQuery(arg || "all"));
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
21
264
|
/** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
|
|
22
265
|
* `Html::island` wrote becomes an app of the component of that name, given the props the server
|
|
23
266
|
* 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.
|
|
267
|
+
* of them at once, and the islands mount in document order once every one has loaded. An island
|
|
268
|
+
* with a `data-hydrate` (`Html::hydrate`) waits instead: once it is visible (or within a root margin
|
|
269
|
+
* of the viewport), the browser is idle,
|
|
270
|
+
* it is interacted with or a media query matches, its component is loaded and it hydrates. */
|
|
25
271
|
export async function mountIslands(components, options = {}) {
|
|
26
272
|
const root = options.root ?? document;
|
|
27
273
|
const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
|
|
28
274
|
const elements = Array.from(root.querySelectorAll("[data-island]"));
|
|
29
275
|
const loading = new Map();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
loading.set(name, load(components[name]));
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
options.router?.isReady(),
|
|
38
|
-
]);
|
|
276
|
+
const loadIsland = (name) => {
|
|
277
|
+
let pending = loading.get(name);
|
|
278
|
+
if (!pending)
|
|
279
|
+
loading.set(name, (pending = load(components[name])));
|
|
280
|
+
return pending;
|
|
281
|
+
};
|
|
282
|
+
const ready = options.router?.isReady();
|
|
39
283
|
const apps = [];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
284
|
+
const waiting = [];
|
|
285
|
+
let stopped = false;
|
|
286
|
+
const mount = (el, name, component) => {
|
|
43
287
|
if (!component) {
|
|
44
288
|
report(el, `no component called ${JSON.stringify(name)} was given (\`ferrovue/islands\` holds every component that has an \`island()\`)`);
|
|
45
|
-
|
|
289
|
+
return;
|
|
46
290
|
}
|
|
47
291
|
if (component instanceof Error) {
|
|
48
292
|
report(el, `${name} did not load: ${component.message}`);
|
|
49
|
-
|
|
293
|
+
return;
|
|
50
294
|
}
|
|
51
295
|
let props;
|
|
52
296
|
try {
|
|
@@ -54,21 +298,73 @@ export async function mountIslands(components, options = {}) {
|
|
|
54
298
|
}
|
|
55
299
|
catch {
|
|
56
300
|
report(el, `the props of ${name} are not JSON`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// One island that throws as it mounts (its setup, a plugin) is reported, and leaves the others
|
|
304
|
+
// to hydrate.
|
|
305
|
+
try {
|
|
306
|
+
const app = createSSRApp(component, props);
|
|
307
|
+
if (options.pinia)
|
|
308
|
+
app.use(options.pinia);
|
|
309
|
+
if (options.router)
|
|
310
|
+
app.use(options.router);
|
|
311
|
+
for (const plugin of options.plugins ?? [])
|
|
312
|
+
app.use(plugin);
|
|
313
|
+
app.mount(el);
|
|
314
|
+
apps.push(app);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
report(el, `${name} did not mount: ${e instanceof Error ? e.message : String(e)}`);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const now = [];
|
|
321
|
+
for (const el of elements) {
|
|
322
|
+
const name = el.dataset.island ?? "";
|
|
323
|
+
const given = Object.hasOwn(components, name);
|
|
324
|
+
const when = el.dataset.hydrate;
|
|
325
|
+
const wait = given && when !== undefined ? trigger(when) : undefined;
|
|
326
|
+
if (!wait) {
|
|
327
|
+
if (given && when !== undefined)
|
|
328
|
+
report(el, `${name} has data-hydrate=${JSON.stringify(when)}, which this version of ferrovue does not know, so it hydrated at once`);
|
|
329
|
+
if (given)
|
|
330
|
+
void loadIsland(name);
|
|
331
|
+
now.push(el);
|
|
57
332
|
continue;
|
|
58
333
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
334
|
+
let started = false;
|
|
335
|
+
try {
|
|
336
|
+
waiting.push(wait(el, async () => {
|
|
337
|
+
if (started || stopped)
|
|
338
|
+
return false;
|
|
339
|
+
started = true;
|
|
340
|
+
const [component] = await Promise.all([loadIsland(name), ready]);
|
|
341
|
+
if (stopped)
|
|
342
|
+
return false;
|
|
343
|
+
mount(el, name, component);
|
|
344
|
+
return true;
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
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`);
|
|
349
|
+
void loadIsland(name);
|
|
350
|
+
now.push(el);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const names = new Set(now.map((el) => el.dataset.island ?? "").filter((name) => Object.hasOwn(components, name)));
|
|
354
|
+
const [loaded] = await Promise.all([
|
|
355
|
+
Promise.all([...names].map(async (name) => [name, await loadIsland(name)])).then((pairs) => new Map(pairs)),
|
|
356
|
+
ready,
|
|
357
|
+
]);
|
|
358
|
+
for (const el of now) {
|
|
359
|
+
const name = el.dataset.island ?? "";
|
|
360
|
+
mount(el, name, loaded.get(name));
|
|
68
361
|
}
|
|
69
362
|
return {
|
|
70
363
|
apps,
|
|
71
364
|
unmount() {
|
|
365
|
+
stopped = true;
|
|
366
|
+
for (const stop of waiting.splice(0))
|
|
367
|
+
stop();
|
|
72
368
|
for (const app of apps.splice(0))
|
|
73
369
|
app.unmount();
|
|
74
370
|
},
|
|
@@ -113,13 +409,15 @@ async function load(component) {
|
|
|
113
409
|
}
|
|
114
410
|
/** The app of a page: its root renders `layout` with the record's props, and each slot as the
|
|
115
411
|
* components the record names, in order, as a plain array, which hydrates the markup the server
|
|
116
|
-
* wrote with no fragment of its own. `
|
|
412
|
+
* wrote with no fragment of its own. With `hydrate: false` the same tree is rendered from scratch,
|
|
413
|
+
* so the first page and every page shown after it are one shape of app. `components` must hold
|
|
414
|
+
* every component the record names. */
|
|
117
415
|
export function createPageApp(layout, record, components, options = {}) {
|
|
118
416
|
const slots = Object.fromEntries(Object.entries(record.slots).map(([name, parts]) => [
|
|
119
417
|
name,
|
|
120
418
|
() => (parts.length ? parts.map((part) => h(components[part.c], part.p)) : [createTextVNode("")]),
|
|
121
419
|
]));
|
|
122
|
-
const app = createSSRApp({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
|
|
420
|
+
const app = (options.hydrate === false ? createApp : createSSRApp)({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
|
|
123
421
|
if (options.pinia)
|
|
124
422
|
app.use(options.pinia);
|
|
125
423
|
if (options.router)
|
|
@@ -128,32 +426,65 @@ export function createPageApp(layout, record, components, options = {}) {
|
|
|
128
426
|
app.use(plugin);
|
|
129
427
|
return app;
|
|
130
428
|
}
|
|
429
|
+
function containerOf(options) {
|
|
430
|
+
const selector = options.container ?? "#app";
|
|
431
|
+
const container = typeof selector === "string" ? (options.doc ?? document).querySelector(selector) : selector;
|
|
432
|
+
if (!container)
|
|
433
|
+
throw new Error(`[ferrovue] the page has no ${selector} to mount on`);
|
|
434
|
+
return container;
|
|
435
|
+
}
|
|
436
|
+
async function loadPage(caller, layout, record, components) {
|
|
437
|
+
const names = new Set(Object.values(record.slots).flatMap((parts) => parts.map((part) => part.c)));
|
|
438
|
+
const loading = [...names].map(async (name) => {
|
|
439
|
+
if (!Object.hasOwn(components, name))
|
|
440
|
+
throw new Error(`[ferrovue] no component called ${JSON.stringify(name)} was given to ${caller}`);
|
|
441
|
+
return [name, await loadOrThrow(name, components[name])];
|
|
442
|
+
});
|
|
443
|
+
const [root, parts] = await Promise.all([loadOrThrow("the layout", layout), Promise.all(loading)]);
|
|
444
|
+
return [root, Object.fromEntries(parts)];
|
|
445
|
+
}
|
|
131
446
|
/** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
|
|
132
447
|
* no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
|
|
133
448
|
* as the server rendered it, when the container or the record is missing or a component is not
|
|
134
449
|
* given or does not load. */
|
|
135
450
|
export async function mountPage(layout, components, options = {}) {
|
|
136
451
|
const doc = options.doc ?? document;
|
|
137
|
-
const
|
|
138
|
-
const container = typeof selector === "string" ? doc.querySelector(selector) : selector;
|
|
139
|
-
if (!container)
|
|
140
|
-
throw new Error(`[ferrovue] the page has no ${selector} to mount on`);
|
|
452
|
+
const container = containerOf(options);
|
|
141
453
|
const id = options.record ?? "__fv_page";
|
|
142
454
|
const text = doc.getElementById(id)?.textContent;
|
|
143
455
|
if (!text)
|
|
144
456
|
throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
|
|
145
457
|
const record = parseJson(text);
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
if (!Object.hasOwn(components, name))
|
|
149
|
-
throw new Error(`[ferrovue] no component called ${JSON.stringify(name)} was given to mountPage`);
|
|
150
|
-
return [name, await loadOrThrow(name, components[name])];
|
|
151
|
-
});
|
|
152
|
-
const [root, parts] = await Promise.all([loadOrThrow("the layout", layout), Promise.all(loading), options.router?.isReady()]);
|
|
153
|
-
const app = createPageApp(root, record, Object.fromEntries(parts), options);
|
|
458
|
+
const [[root, parts]] = await Promise.all([loadPage("mountPage", layout, record, components), options.router?.isReady()]);
|
|
459
|
+
const app = createPageApp(root, record, parts, options);
|
|
154
460
|
app.mount(container);
|
|
155
461
|
return app;
|
|
156
462
|
}
|
|
463
|
+
/** Show the next page of a navigation from its record, as `readPage` read it out of the fetched
|
|
464
|
+
* document: load the components it names (and no others), render the app `createPageApp` builds
|
|
465
|
+
* with `hydrate: false` into a copy of the container with no children, wait for the router, put the
|
|
466
|
+
* copy in the container's place, mount, and then unmount `previous`. The next app is mounted
|
|
467
|
+
* before the previous one is unmounted, so a router both use stays started. `router` must be at the
|
|
468
|
+
* next page's location: `linkRouter` with the `location` the navigation goes to. Rejects, leaving
|
|
469
|
+
* the page shown as it is, when the container is missing or a component is not given or does not
|
|
470
|
+
* load. */
|
|
471
|
+
export async function renderPage(layout, components, record, options = {}) {
|
|
472
|
+
const container = containerOf(options);
|
|
473
|
+
const [root, parts] = await loadPage("renderPage", layout, record, components);
|
|
474
|
+
const app = createPageApp(root, record, parts, { ...options, hydrate: false });
|
|
475
|
+
await options.router?.isReady();
|
|
476
|
+
const next = container.cloneNode(false);
|
|
477
|
+
container.replaceWith(next);
|
|
478
|
+
try {
|
|
479
|
+
app.mount(next);
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
next.replaceWith(container);
|
|
483
|
+
throw e;
|
|
484
|
+
}
|
|
485
|
+
options.previous?.unmount();
|
|
486
|
+
return app;
|
|
487
|
+
}
|
|
157
488
|
async function loadOrThrow(name, component) {
|
|
158
489
|
const result = await load(component);
|
|
159
490
|
if (result instanceof Error)
|
package/dist/compiler.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type Config } from "./context.ts";
|
|
2
|
-
/** Every generated file, keyed by its name in the output directory.
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/** Every generated file, keyed by its name in the output directory. `sources` holds the text of
|
|
3
|
+
* components by path, read in place of their files, as an editor's unsaved buffers. */
|
|
4
|
+
export declare function generate(root: string, config?: Config, sources?: ReadonlyMap<string, string>): Map<string, string>;
|
|
5
|
+
/** Whether a file starts with the header ferrovue writes, so `write` removes only what an earlier
|
|
6
|
+
* run wrote and never a module of the app's own put in the output directory by mistake. */
|
|
7
|
+
export declare function isGenerated(file: string): boolean;
|
|
5
8
|
/** What \`write\` changed in the output directory. */
|
|
6
9
|
export interface Written {
|
|
7
10
|
/** Every file the output directory now holds. */
|
|
@@ -18,6 +21,7 @@ export interface Written {
|
|
|
18
21
|
* so a Rust build that watches them rebuilds no more than it must, and removing what no component
|
|
19
22
|
* produces any more. */
|
|
20
23
|
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";
|
|
22
|
-
export { GenError } from "./model.ts";
|
|
24
|
+
export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type RoutesSource, type ScopeIdMode, type TwinSpec, type TypeName } from "./context.ts";
|
|
25
|
+
export { GenError, type Location as SourceLocation } from "./model.ts";
|
|
26
|
+
export { docsUrl, ERRORS, type Code as ErrorCode, type ErrorDoc } from "./errors.ts";
|
|
23
27
|
export declare const VERSION: string;
|