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.
Files changed (108) hide show
  1. package/README.md +306 -62
  2. package/dist/args.d.ts +28 -0
  3. package/dist/args.js +69 -0
  4. package/dist/attrs.d.ts +2 -1
  5. package/dist/attrs.js +10 -4
  6. package/dist/calls.js +5 -2
  7. package/dist/children.d.ts +2 -2
  8. package/dist/children.js +84 -16
  9. package/dist/classes.d.ts +4 -0
  10. package/dist/classes.js +23 -1
  11. package/dist/cli.js +80 -81
  12. package/dist/client-render.d.ts +23 -0
  13. package/dist/client-render.js +143 -0
  14. package/dist/client.d.ts +74 -10
  15. package/dist/client.js +373 -42
  16. package/dist/compiler.d.ts +9 -5
  17. package/dist/compiler.js +80 -18
  18. package/dist/component.d.ts +7 -3
  19. package/dist/component.js +38 -27
  20. package/dist/conformance.d.ts +85 -0
  21. package/dist/conformance.js +327 -0
  22. package/dist/constants.js +4 -2
  23. package/dist/context.d.ts +66 -10
  24. package/dist/context.js +176 -5
  25. package/dist/diagnostics.d.ts +6 -4
  26. package/dist/diagnostics.js +7 -3
  27. package/dist/dynamic.d.ts +18 -0
  28. package/dist/dynamic.js +576 -0
  29. package/dist/editor.d.ts +18 -0
  30. package/dist/editor.js +59 -0
  31. package/dist/errors.d.ts +213 -4
  32. package/dist/errors.js +129 -4
  33. package/dist/expr.d.ts +2 -0
  34. package/dist/expr.js +91 -12
  35. package/dist/fallthrough.d.ts +1 -0
  36. package/dist/fallthrough.js +10 -2
  37. package/dist/file-routes.d.ts +33 -0
  38. package/dist/file-routes.js +224 -0
  39. package/dist/files.d.ts +13 -0
  40. package/dist/files.js +49 -0
  41. package/dist/fixture.d.ts +16 -0
  42. package/dist/fixture.js +22 -7
  43. package/dist/hydration.d.ts +12 -1
  44. package/dist/hydration.js +47 -1
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.js +1 -1
  47. package/dist/inputs.d.ts +24 -0
  48. package/dist/inputs.js +40 -0
  49. package/dist/link-router.d.ts +7 -3
  50. package/dist/link-router.js +8 -4
  51. package/dist/lists.d.ts +16 -1
  52. package/dist/lists.js +78 -4
  53. package/dist/loops.js +4 -4
  54. package/dist/model.d.ts +20 -1
  55. package/dist/model.js +46 -1
  56. package/dist/numbers.js +3 -2
  57. package/dist/page-routes.d.ts +10 -0
  58. package/dist/page-routes.js +10 -0
  59. package/dist/paths.d.ts +5 -0
  60. package/dist/paths.js +7 -0
  61. package/dist/plugin.d.ts +15 -1
  62. package/dist/plugin.js +3 -0
  63. package/dist/plugins/client-only.js +3 -0
  64. package/dist/plugins/css-modules.d.ts +39 -0
  65. package/dist/plugins/css-modules.js +135 -0
  66. package/dist/plugins/head.d.ts +15 -0
  67. package/dist/plugins/head.js +385 -0
  68. package/dist/plugins/i18n.js +5 -12
  69. package/dist/plugins/index.js +4 -1
  70. package/dist/plugins/provide.d.ts +44 -0
  71. package/dist/plugins/provide.js +530 -0
  72. package/dist/plugins/router-link.js +7 -3
  73. package/dist/plugins/router.d.ts +4 -1
  74. package/dist/plugins/router.js +58 -11
  75. package/dist/plugins/scoped.d.ts +10 -0
  76. package/dist/plugins/scoped.js +17 -4
  77. package/dist/plugins/shared-types.js +2 -1
  78. package/dist/plugins/stores.js +26 -15
  79. package/dist/routes.d.ts +5 -2
  80. package/dist/routes.js +8 -2
  81. package/dist/rust.d.ts +9 -1
  82. package/dist/rust.js +83 -32
  83. package/dist/schema.d.ts +25 -0
  84. package/dist/schema.js +73 -0
  85. package/dist/script.js +31 -8
  86. package/dist/settle.d.ts +2 -0
  87. package/dist/settle.js +5 -0
  88. package/dist/slots.js +20 -4
  89. package/dist/ssr.d.ts +3 -0
  90. package/dist/ssr.js +67 -0
  91. package/dist/strings.d.ts +3 -0
  92. package/dist/strings.js +16 -2
  93. package/dist/styles.d.ts +5 -1
  94. package/dist/styles.js +131 -4
  95. package/dist/template.js +92 -11
  96. package/dist/testing.d.ts +4 -4
  97. package/dist/testing.js +3 -30
  98. package/dist/types.d.ts +5 -0
  99. package/dist/typescript.d.ts +11 -1
  100. package/dist/typescript.js +131 -36
  101. package/dist/vhtml.d.ts +4 -0
  102. package/dist/vhtml.js +63 -0
  103. package/dist/vite.d.ts +25 -11
  104. package/dist/vite.js +166 -51
  105. package/dist/volar.d.ts +15 -0
  106. package/dist/volar.js +87 -0
  107. package/package.json +41 -7
  108. 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 hydrated markup agrees with the server's. */
16
- export function hydrateState(pinia, id = "__pinia", doc = document) {
17
- const text = doc.getElementById(id)?.textContent;
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
- 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
- ]);
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
- for (const el of elements) {
41
- const name = el.dataset.island ?? "";
42
- const component = loaded.get(name);
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
- continue;
289
+ return;
46
290
  }
47
291
  if (component instanceof Error) {
48
292
  report(el, `${name} did not load: ${component.message}`);
49
- continue;
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
- const app = createSSRApp(component, props);
60
- if (options.pinia)
61
- app.use(options.pinia);
62
- if (options.router)
63
- app.use(options.router);
64
- for (const plugin of options.plugins ?? [])
65
- app.use(plugin);
66
- app.mount(el);
67
- apps.push(app);
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. `components` must hold every component the record names. */
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 selector = options.container ?? "#app";
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 names = new Set(Object.values(record.slots).flatMap((parts) => parts.map((part) => part.c)));
147
- const loading = [...names].map(async (name) => {
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)
@@ -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
- export declare function generate(root: string, config?: Config): Map<string, string>;
4
- /** Write what `generate` produces to the configured directory, replacing what is there. */
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;