@ultimat3/render 22.15.0 → 24.0.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/CLAUDE.md +15 -6
- package/README.md +23 -5
- package/package.json +7 -6
- package/src/asset.ts +5 -2
- package/src/client-path-style-tag.ts +25 -0
- package/src/css-modules.ts +58 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +2 -2
- package/src/head.ts +9 -10
- package/src/html.ts +13 -19
- package/src/hydrate.ts +3 -3
- package/src/index.ts +8 -2
- package/src/island-collector.ts +2 -1
- package/src/island.ts +1 -1
- package/src/jsx.ts +24 -3
- package/src/modes.ts +14 -3
- package/src/module-loader.ts +48 -8
- package/src/mounted-routes.ts +74 -0
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-rules.ts +22 -6
- package/src/navigation-scroll.ts +51 -0
- package/src/navigation-swap.ts +10 -2
- package/src/navigation-tabs.ts +58 -0
- package/src/navigation.ts +25 -53
- package/src/registry.ts +107 -55
- package/src/render-html.ts +4 -4
- package/src/render-isr.ts +127 -65
- package/src/render-static.ts +20 -6
- package/src/render-stream.ts +6 -5
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +10 -18
- package/src/sass-cache.ts +22 -3
- package/src/server.ts +4 -0
- package/src/static-path.ts +15 -2
- package/src/type-pins.tsx +4 -3
- package/src/navigation-controller-fixture.ts +0 -91
- package/src/navigation-dom-fixture.ts +0 -424
package/src/navigation.ts
CHANGED
|
@@ -14,19 +14,15 @@
|
|
|
14
14
|
* on documents whose surface opted in (`navigation: { client: [...] }` in `app.config.ts`).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
CLIENT_BUILD_META,
|
|
19
|
-
CLIENT_SCOPE_META,
|
|
20
|
-
onClientWrite,
|
|
21
|
-
onRescope,
|
|
22
|
-
} from '@ultimat3/core/page';
|
|
17
|
+
import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
|
|
23
18
|
import { navigationCache } from './navigation-cache';
|
|
24
19
|
import {
|
|
25
20
|
anchorOf,
|
|
26
21
|
announcer,
|
|
27
|
-
fieldText,
|
|
28
22
|
focusMain,
|
|
23
|
+
formAction,
|
|
29
24
|
formFields,
|
|
25
|
+
formPairs,
|
|
30
26
|
handOver,
|
|
31
27
|
linkFacts,
|
|
32
28
|
type RunningTransition,
|
|
@@ -37,7 +33,6 @@ import {
|
|
|
37
33
|
type EntryState,
|
|
38
34
|
entryOf,
|
|
39
35
|
STATE_KEY,
|
|
40
|
-
saveEntryScroll,
|
|
41
36
|
scrollAfter,
|
|
42
37
|
withoutFragment,
|
|
43
38
|
} from './navigation-history';
|
|
@@ -60,6 +55,7 @@ import {
|
|
|
60
55
|
responseVerdict,
|
|
61
56
|
reusable,
|
|
62
57
|
} from './navigation-rules';
|
|
58
|
+
import { scrollKeeper } from './navigation-scroll';
|
|
63
59
|
import {
|
|
64
60
|
documentHead,
|
|
65
61
|
loadStylesheets,
|
|
@@ -68,6 +64,7 @@ import {
|
|
|
68
64
|
runScripts,
|
|
69
65
|
swapDocument,
|
|
70
66
|
} from './navigation-swap';
|
|
67
|
+
import { tabSync } from './navigation-tabs';
|
|
71
68
|
|
|
72
69
|
export interface NavigateOptions {
|
|
73
70
|
readonly method?: 'GET' | 'POST';
|
|
@@ -84,9 +81,6 @@ export interface NavigationRouter {
|
|
|
84
81
|
stop(): void;
|
|
85
82
|
}
|
|
86
83
|
|
|
87
|
-
/** One channel per origin: a principal change or a write in one tab empties every tab's cache. */
|
|
88
|
-
const CHANNEL = 'ultimate:navigation';
|
|
89
|
-
|
|
90
84
|
interface RouterWindow extends Window {
|
|
91
85
|
__xNavigation?: NavigationRouter;
|
|
92
86
|
}
|
|
@@ -122,28 +116,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
122
116
|
const live = announcer(doc);
|
|
123
117
|
const owned = documentHead(doc);
|
|
124
118
|
notePersisted(doc);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
if (channel !== undefined) channel.onmessage = () => cache.clear();
|
|
129
|
-
/** This tab and every other: a write or a new principal makes every held page suspect. */
|
|
130
|
-
const forget = (): void => {
|
|
131
|
-
cache.clear();
|
|
132
|
-
channel?.postMessage('clear');
|
|
133
|
-
};
|
|
134
|
-
const offWrite = onClientWrite(forget);
|
|
135
|
-
const offRescope = onRescope(forget);
|
|
136
|
-
|
|
137
|
-
const saveScroll = (): void => saveEntryScroll(win, rendered);
|
|
138
|
-
|
|
139
|
-
saveScroll();
|
|
140
|
-
// Saved as the visitor scrolls, once the scroll settles — so FORWARD restores too, and a
|
|
141
|
-
// browser's cap on `replaceState` calls is never approached.
|
|
142
|
-
let settle: number | undefined;
|
|
143
|
-
const onScroll = (): void => {
|
|
144
|
-
win.clearTimeout(settle);
|
|
145
|
-
settle = win.setTimeout(saveScroll, 150);
|
|
146
|
-
};
|
|
119
|
+
const tabs = tabSync(win, () => cache.clear());
|
|
120
|
+
const forget = tabs.forget;
|
|
121
|
+
const scroll = scrollKeeper(win, () => rendered);
|
|
147
122
|
|
|
148
123
|
const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
|
|
149
124
|
fetchDocument(win, doc, url, purpose, init);
|
|
@@ -172,12 +147,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
172
147
|
signal: AbortSignal,
|
|
173
148
|
) => {
|
|
174
149
|
if (method === 'POST') {
|
|
150
|
+
// Before AND once it settles, landed or not: a guess sent while the write was in flight
|
|
151
|
+
// may have rendered the old state, and a write that failed here may still have committed.
|
|
175
152
|
forget();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
...(options.body === undefined ? {} : { body: options.body }),
|
|
179
|
-
signal,
|
|
180
|
-
});
|
|
153
|
+
const body = options.body === undefined ? {} : { body: options.body };
|
|
154
|
+
return request(url, 'soft', { method, ...body, signal }).finally(forget);
|
|
181
155
|
}
|
|
182
156
|
const held = cache.peek(url);
|
|
183
157
|
cache.delete(url);
|
|
@@ -212,7 +186,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
212
186
|
inflight = mine;
|
|
213
187
|
win.clearTimeout(intent);
|
|
214
188
|
navigatingTo = withoutFragment(url);
|
|
215
|
-
if (options.history !== 'none')
|
|
189
|
+
if (options.history !== 'none') scroll.save();
|
|
216
190
|
const progress = win.setTimeout(
|
|
217
191
|
() => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
|
|
218
192
|
NAVIGATION_PROGRESS_DELAY_MS,
|
|
@@ -274,11 +248,15 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
274
248
|
if (next === null) return;
|
|
275
249
|
const landed = `${withoutFragment(url)}${new URL(url).hash}`;
|
|
276
250
|
try {
|
|
277
|
-
|
|
251
|
+
// Owned from the moment they are appended: a navigation aborted after this point left its
|
|
252
|
+
// sheets in the head for good, styling every page after it — the next swap retires an
|
|
253
|
+
// owned sheet its page does not link.
|
|
254
|
+
await loadStylesheets(doc, missingStylesheets(doc, next), owned);
|
|
278
255
|
if (mine.signal.aborted) return;
|
|
279
256
|
let swapped: ReturnType<typeof swapDocument> | undefined;
|
|
280
|
-
const track = (running: RunningTransition
|
|
281
|
-
animating = running;
|
|
257
|
+
const track = (running: RunningTransition, finished: boolean): void => {
|
|
258
|
+
if (!finished) animating = running;
|
|
259
|
+
else if (animating === running) animating = undefined;
|
|
282
260
|
};
|
|
283
261
|
await transition(
|
|
284
262
|
win,
|
|
@@ -375,7 +353,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
375
353
|
const say = (attr: string, fallback: string): string =>
|
|
376
354
|
submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
|
|
377
355
|
const facts: FormFacts = {
|
|
378
|
-
action:
|
|
356
|
+
action: formAction(say('action', ''), win.location.href, doc.baseURI),
|
|
379
357
|
current: win.location.href,
|
|
380
358
|
method: say('method', 'get').toLowerCase(),
|
|
381
359
|
enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
|
|
@@ -388,9 +366,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
388
366
|
hasFile: [...fields.values()].some(
|
|
389
367
|
(value: unknown) => value instanceof File && value.name !== '',
|
|
390
368
|
),
|
|
391
|
-
fields:
|
|
392
|
-
([name, value]: [string, unknown]) => [name, fieldText(value)] as const,
|
|
393
|
-
),
|
|
369
|
+
fields: formPairs(fields),
|
|
394
370
|
};
|
|
395
371
|
const verdict = formVerdict(facts);
|
|
396
372
|
if (verdict.kind === 'native') return;
|
|
@@ -438,8 +414,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
438
414
|
* screen it is fetched; when it is, only the scroll moves — the app keeps its own history.
|
|
439
415
|
*/
|
|
440
416
|
const onPop = (event: PopStateEvent): void => {
|
|
441
|
-
|
|
442
|
-
win.clearTimeout(settle);
|
|
417
|
+
scroll.cancel();
|
|
443
418
|
const url = win.location.href;
|
|
444
419
|
const entry = entryOf(event.state);
|
|
445
420
|
const wanted = entry?.doc ?? withoutFragment(url);
|
|
@@ -464,7 +439,6 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
464
439
|
// had its chance to `preventDefault`, which the rules then honour.
|
|
465
440
|
win.addEventListener('click', onClick);
|
|
466
441
|
win.addEventListener('submit', onSubmit);
|
|
467
|
-
win.addEventListener('scroll', onScroll, { passive: true });
|
|
468
442
|
doc.addEventListener('pointerover', onIntent, { passive: true });
|
|
469
443
|
doc.addEventListener('pointerout', onLeave, { passive: true });
|
|
470
444
|
doc.addEventListener('focusin', onIntent);
|
|
@@ -480,7 +454,6 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
480
454
|
stop() {
|
|
481
455
|
win.removeEventListener('click', onClick);
|
|
482
456
|
win.removeEventListener('submit', onSubmit);
|
|
483
|
-
win.removeEventListener('scroll', onScroll);
|
|
484
457
|
doc.removeEventListener('pointerover', onIntent);
|
|
485
458
|
doc.removeEventListener('pointerout', onLeave);
|
|
486
459
|
doc.removeEventListener('focusin', onIntent);
|
|
@@ -489,9 +462,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
489
462
|
doc.removeEventListener('pointerdown', onPress, { capture: true });
|
|
490
463
|
doc.removeEventListener('pointerup', onRelease, { capture: true });
|
|
491
464
|
doc.removeEventListener('pointercancel', onRelease, { capture: true });
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
channel?.close();
|
|
465
|
+
scroll.stop();
|
|
466
|
+
tabs.stop();
|
|
495
467
|
delete win.__xNavigation;
|
|
496
468
|
},
|
|
497
469
|
};
|
package/src/registry.ts
CHANGED
|
@@ -5,19 +5,32 @@
|
|
|
5
5
|
* from. Nothing downstream may keep its own list of routes.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { CacheTag } from '@ultimat3/cache';
|
|
9
|
+
import { serializeTag, surrogateKeys } from '@ultimat3/cache';
|
|
8
10
|
import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
9
|
-
import { finiteCount } from '@ultimat3/core';
|
|
11
|
+
import { finiteCount, isUltimateError, renderFixLiteral } from '@ultimat3/core';
|
|
10
12
|
import { byCodeUnit } from './code-unit-order';
|
|
11
13
|
import {
|
|
12
14
|
RouteDuplicateError,
|
|
13
15
|
RouteFileInvalidError,
|
|
16
|
+
RouteModeInvalidError,
|
|
14
17
|
RouteUnnormalizedError,
|
|
15
18
|
SurfaceBoundaryError,
|
|
16
19
|
} from './errors';
|
|
17
20
|
import { assertModeInvariants, defaultIslandBudget } from './modes';
|
|
21
|
+
import type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
|
|
22
|
+
import {
|
|
23
|
+
clearMountedRoutes,
|
|
24
|
+
mountCollision,
|
|
25
|
+
mountedAt,
|
|
26
|
+
mountedRoutes,
|
|
27
|
+
setMountedRoutes,
|
|
28
|
+
} from './mounted-routes';
|
|
18
29
|
import type { RouteConfig, RouteData } from './route';
|
|
19
30
|
import { isRouteConfig, tagKeys } from './route';
|
|
20
31
|
import type { RouteComponent } from './route-component';
|
|
32
|
+
import type { CompiledPattern } from './route-pattern';
|
|
33
|
+
import { compilePattern } from './route-pattern';
|
|
21
34
|
import type { Surface } from './surfaces';
|
|
22
35
|
import { locateSurface } from './surfaces';
|
|
23
36
|
|
|
@@ -82,16 +95,14 @@ export interface RouteDescriptor {
|
|
|
82
95
|
*/
|
|
83
96
|
readonly islandSources: readonly string[];
|
|
84
97
|
readonly budgetJs: string | null;
|
|
85
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Present on a route NO surface file declares: who mounted it and every permission that gates
|
|
100
|
+
* it. Absent on a file route — so a reader never has to ask whether `file` is a path.
|
|
101
|
+
*/
|
|
102
|
+
readonly mount?: RouteMount;
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
export
|
|
89
|
-
readonly source: string;
|
|
90
|
-
readonly regex: RegExp;
|
|
91
|
-
readonly keys: readonly string[];
|
|
92
|
-
/** Higher wins when two patterns match the same pathname. */
|
|
93
|
-
readonly specificity: number;
|
|
94
|
-
}
|
|
105
|
+
export type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
|
|
95
106
|
|
|
96
107
|
/**
|
|
97
108
|
* `apps/web/site/blog/[slug]/page.tsx` → `{ surface: 'site', path: '/blog/:slug' }`.
|
|
@@ -194,34 +205,6 @@ function toUrlSegment(segment: string): string {
|
|
|
194
205
|
return segment;
|
|
195
206
|
}
|
|
196
207
|
|
|
197
|
-
export function compilePattern(path: string): CompiledPattern {
|
|
198
|
-
const keys: string[] = [];
|
|
199
|
-
let specificity = 0;
|
|
200
|
-
const segments = path.split('/').filter((s) => s.length > 0);
|
|
201
|
-
|
|
202
|
-
const parts = segments.map((segment) => {
|
|
203
|
-
if (segment.startsWith('*')) {
|
|
204
|
-
keys.push(segment.slice(1));
|
|
205
|
-
specificity += 1;
|
|
206
|
-
return '(.*)';
|
|
207
|
-
}
|
|
208
|
-
if (segment.startsWith(':')) {
|
|
209
|
-
keys.push(segment.slice(1));
|
|
210
|
-
specificity += 10;
|
|
211
|
-
return '([^/]+)';
|
|
212
|
-
}
|
|
213
|
-
specificity += 100;
|
|
214
|
-
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
source: path,
|
|
219
|
-
regex: new RegExp(`^/${parts.join('/')}/?$`),
|
|
220
|
-
keys,
|
|
221
|
-
specificity,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
208
|
const routes = new Map<string, RouteEntry>();
|
|
226
209
|
/** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
|
|
227
210
|
let described: readonly RouteDescriptor[] | undefined;
|
|
@@ -241,6 +224,27 @@ export interface RegisterRouteInput<TData = RouteData> {
|
|
|
241
224
|
readonly component?: RouteComponent;
|
|
242
225
|
}
|
|
243
226
|
|
|
227
|
+
/**
|
|
228
|
+
* A `revalidate.tags` entry goes out as a purge key on every response of the route
|
|
229
|
+
* (`render-isr.ts`), so one a CDN would split is refused HERE, naming the file. Asked of
|
|
230
|
+
* `@ultimat3/cache`'s own screen rather than restated: found at serve time it is a 500 on a
|
|
231
|
+
* public page, found at purge time it is a document nothing can clear.
|
|
232
|
+
*/
|
|
233
|
+
function assertPurgeableTags(file: string, tags: readonly CacheTag[] | undefined): void {
|
|
234
|
+
for (const owned of tags ?? []) {
|
|
235
|
+
try {
|
|
236
|
+
surrogateKeys([owned], file);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (!isUltimateError(error) || error.code !== 'X_CACHE_PURGE_FAILED') throw error;
|
|
239
|
+
const wire = renderFixLiteral(serializeTag(owned), '<the tag>');
|
|
240
|
+
throw new RouteModeInvalidError(
|
|
241
|
+
`${file} declares revalidate tag ${wire}, which cannot be a CDN purge key — a key is split on whitespace and commas and capped at 1024 bytes, so every response of the route would carry one no purge can name`,
|
|
242
|
+
`edit revalidate.tags in ${file}: replace ${wire} with a tag whose entity and id carry no whitespace or comma`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
244
248
|
/** Register a route and enforce every invariant that needs the surrounding module. */
|
|
245
249
|
export function registerRoute<TData = RouteData>(
|
|
246
250
|
input: RegisterRouteInput<TData>,
|
|
@@ -277,6 +281,8 @@ export function registerRoute<TData = RouteData>(
|
|
|
277
281
|
suspenseBoundaries,
|
|
278
282
|
});
|
|
279
283
|
|
|
284
|
+
assertPurgeableTags(input.file, config.revalidate?.tags);
|
|
285
|
+
|
|
280
286
|
const existing = routes.get(path);
|
|
281
287
|
if (existing !== undefined && existing.file !== input.file) {
|
|
282
288
|
throw new RouteDuplicateError(
|
|
@@ -284,6 +290,8 @@ export function registerRoute<TData = RouteData>(
|
|
|
284
290
|
`rename or delete one of them — the route table is keyed by URL`,
|
|
285
291
|
);
|
|
286
292
|
}
|
|
293
|
+
const claimed = mountedAt(path);
|
|
294
|
+
if (claimed !== undefined) throw mountCollision(path, input.file, claimed.mount);
|
|
287
295
|
|
|
288
296
|
const entry: RouteEntry<TData> = {
|
|
289
297
|
file: input.file,
|
|
@@ -329,8 +337,28 @@ function withIslandBudget<TData>(config: RouteConfig<TData>, surface: Surface):
|
|
|
329
337
|
return Object.freeze(derived);
|
|
330
338
|
}
|
|
331
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Declare the routes a package MOUNTS — screens no surface file exists for, like the admin's — so
|
|
342
|
+
* the one route list carries them. Described, never rendered here: the mounting host serves them,
|
|
343
|
+
* and `routeEntries()` stays the pages a file declares. Re-declaring a `key` replaces its set,
|
|
344
|
+
* because `x dev` re-evaluates the module that declared it on every save.
|
|
345
|
+
*/
|
|
346
|
+
export function registerMountedRoutes(
|
|
347
|
+
mount: RouteMountInput,
|
|
348
|
+
declared: readonly MountedRouteInput[],
|
|
349
|
+
): void {
|
|
350
|
+
for (const route of declared) {
|
|
351
|
+
const file = routes.get(route.path)?.file;
|
|
352
|
+
if (file !== undefined) throw mountCollision(route.path, file, mount);
|
|
353
|
+
assertPurgeableTags(mount.file, route.config.revalidate?.tags);
|
|
354
|
+
}
|
|
355
|
+
setMountedRoutes(mount, declared);
|
|
356
|
+
described = undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
332
359
|
export function clearRoutes(): void {
|
|
333
360
|
routes.clear();
|
|
361
|
+
clearMountedRoutes();
|
|
334
362
|
described = undefined;
|
|
335
363
|
}
|
|
336
364
|
|
|
@@ -361,26 +389,50 @@ export function describeRoutes(): readonly RouteDescriptor[] {
|
|
|
361
389
|
}
|
|
362
390
|
|
|
363
391
|
function buildDescriptors(): RouteDescriptor[] {
|
|
364
|
-
|
|
365
|
-
path
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
392
|
+
const files = routeEntries().map((entry) =>
|
|
393
|
+
descriptorOf(entry.path, entry.file, entry.surface, entry.config, entry.pattern.keys.length),
|
|
394
|
+
);
|
|
395
|
+
const mounts = mountedRoutes().map(
|
|
396
|
+
(route): RouteDescriptor => ({
|
|
397
|
+
...descriptorOf(
|
|
398
|
+
route.path,
|
|
399
|
+
route.mount.file,
|
|
400
|
+
route.mount.surface,
|
|
401
|
+
route.config,
|
|
402
|
+
compilePattern(route.path).keys.length,
|
|
403
|
+
),
|
|
404
|
+
mount: { by: route.mount.by, permissions: [...route.permissions] },
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
return mounts.length === 0
|
|
408
|
+
? files
|
|
409
|
+
: [...files, ...mounts].sort((a, b) => byCodeUnit(a.path, b.path));
|
|
382
410
|
}
|
|
383
411
|
|
|
412
|
+
const descriptorOf = (
|
|
413
|
+
path: string,
|
|
414
|
+
file: string,
|
|
415
|
+
surface: Surface,
|
|
416
|
+
config: RouteConfig,
|
|
417
|
+
params: number,
|
|
418
|
+
): RouteDescriptor => ({
|
|
419
|
+
path,
|
|
420
|
+
file,
|
|
421
|
+
surface,
|
|
422
|
+
mode: config.render,
|
|
423
|
+
offline: config.offline,
|
|
424
|
+
hydrate: config.hydrate,
|
|
425
|
+
revalidateTags: tagKeys(config.revalidate?.tags),
|
|
426
|
+
revalidateTtl: config.revalidate?.ttl ?? null,
|
|
427
|
+
prerenderable: config.prerender !== undefined,
|
|
428
|
+
dynamic: params > 0,
|
|
429
|
+
hasPolicy: config.policy !== undefined,
|
|
430
|
+
personal: isPersonal(config),
|
|
431
|
+
islands: config.islands.map((spec) => spec.moduleId),
|
|
432
|
+
islandSources: config.islands.map((spec) => spec.src),
|
|
433
|
+
budgetJs: config.budget.js ?? null,
|
|
434
|
+
});
|
|
435
|
+
|
|
384
436
|
/** See `RouteDescriptor.personal`. */
|
|
385
437
|
function isPersonal(config: RouteConfig): boolean {
|
|
386
438
|
if (config.policy !== undefined || config.render === 'stream') return true;
|
package/src/render-html.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
IslandPropsInvalidError,
|
|
12
12
|
PrerenderFailedError,
|
|
13
13
|
} from './errors';
|
|
14
|
-
import {
|
|
14
|
+
import { escapeHtml, renderAttributes, VOID_ELEMENTS } from './html';
|
|
15
15
|
import { emitIslandAttributes, emitIslandProps } from './hydrate';
|
|
16
16
|
import type { IslandNode } from './island';
|
|
17
17
|
import { isIslandNode } from './island';
|
|
@@ -62,8 +62,8 @@ export interface RenderHtmlOptions {
|
|
|
62
62
|
async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): Promise<string> {
|
|
63
63
|
assertDepth(depth);
|
|
64
64
|
if (value === null || value === undefined || value === false || value === true) return '';
|
|
65
|
-
if (typeof value === 'string') return
|
|
66
|
-
if (typeof value === 'number' || typeof value === 'bigint') return
|
|
65
|
+
if (typeof value === 'string') return escapeHtml(value);
|
|
66
|
+
if (typeof value === 'number' || typeof value === 'bigint') return escapeHtml(String(value));
|
|
67
67
|
if (value instanceof Promise) return unwrap(await value, depth, walk);
|
|
68
68
|
// Before the array branch, never after: an island node IS an array — the only object shape the
|
|
69
69
|
// configured `JSX.Element` admits — so the generic branch would render its empty contents and
|
|
@@ -75,7 +75,7 @@ async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): P
|
|
|
75
75
|
}
|
|
76
76
|
if (isJsxNode(value)) return renderNode(value.type, value.props, depth, walk);
|
|
77
77
|
if (typeof value === 'function') return unwrap((value as () => unknown)(), depth + 1, walk);
|
|
78
|
-
return
|
|
78
|
+
return escapeHtml(String(value));
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|