@reckona/mreact-router 0.0.81 → 0.0.83
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 +2 -0
- package/dist/adapters/aws-lambda.d.ts.map +1 -1
- package/dist/adapters/aws-lambda.js +17 -22
- package/dist/adapters/aws-lambda.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +3 -1
- package/dist/build.js.map +1 -1
- package/dist/client-route-inference.d.ts +2 -0
- package/dist/client-route-inference.d.ts.map +1 -0
- package/dist/client-route-inference.js +2 -0
- package/dist/client-route-inference.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +89 -24
- package/dist/client.js.map +1 -1
- package/dist/layout-composer.d.ts +24 -0
- package/dist/layout-composer.d.ts.map +1 -0
- package/dist/layout-composer.js +83 -0
- package/dist/layout-composer.js.map +1 -0
- package/dist/metadata.d.ts +9 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +419 -0
- package/dist/metadata.js.map +1 -0
- package/dist/middleware.d.ts +22 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +94 -0
- package/dist/middleware.js.map +1 -0
- package/dist/module-runner.d.ts +2 -2
- package/dist/module-runner.d.ts.map +1 -1
- package/dist/module-runner.js +1 -1
- package/dist/module-runner.js.map +1 -1
- package/dist/navigation-runtime.d.ts +2 -0
- package/dist/navigation-runtime.d.ts.map +1 -0
- package/dist/navigation-runtime.js +2 -0
- package/dist/navigation-runtime.js.map +1 -0
- package/dist/preload-policy.d.ts +24 -0
- package/dist/preload-policy.d.ts.map +1 -0
- package/dist/preload-policy.js +45 -0
- package/dist/preload-policy.js.map +1 -0
- package/dist/render.d.ts +1 -3
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +32 -676
- package/dist/render.js.map +1 -1
- package/dist/route-hydration-contract.d.ts +11 -0
- package/dist/route-hydration-contract.d.ts.map +1 -0
- package/dist/route-hydration-contract.js +21 -0
- package/dist/route-hydration-contract.js.map +1 -0
- package/dist/route-module-loader.d.ts +10 -0
- package/dist/route-module-loader.d.ts.map +1 -0
- package/dist/route-module-loader.js +61 -0
- package/dist/route-module-loader.js.map +1 -0
- package/dist/routes.js +6 -0
- package/dist/routes.js.map +1 -1
- package/dist/serve.d.ts +13 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +63 -28
- package/dist/serve.js.map +1 -1
- package/dist/server-action-inference.d.ts.map +1 -1
- package/dist/server-action-inference.js +37 -1
- package/dist/server-action-inference.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +2 -1
- package/dist/vite.js.map +1 -1
- package/package.json +11 -11
- package/src/adapters/aws-lambda.ts +21 -28
- package/src/build.ts +9 -6
- package/src/client-route-inference.ts +18 -0
- package/src/client.ts +124 -24
- package/src/layout-composer.ts +142 -0
- package/src/metadata.ts +578 -0
- package/src/middleware.ts +153 -0
- package/src/module-runner.ts +3 -2
- package/src/navigation-runtime.ts +16 -0
- package/src/preload-policy.ts +89 -0
- package/src/render.ts +75 -986
- package/src/route-hydration-contract.ts +22 -0
- package/src/route-module-loader.ts +95 -0
- package/src/routes.ts +8 -0
- package/src/serve.ts +110 -29
- package/src/server-action-inference.ts +49 -0
- package/src/vite.ts +6 -4
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import {
|
|
2
|
+
escapeHtmlAttribute,
|
|
3
|
+
escapeHtmlText as escapeHtml,
|
|
4
|
+
} from "@reckona/mreact-shared/html-escape";
|
|
5
|
+
import { contentSecurityPolicy } from "./csp.js";
|
|
6
|
+
import type { AppFileConvention } from "./file-conventions.js";
|
|
7
|
+
import type { AppRoute } from "./routes.js";
|
|
8
|
+
import { routeSecurityHeaders } from "./security-headers.js";
|
|
9
|
+
import type {
|
|
10
|
+
MetadataImage,
|
|
11
|
+
MetadataScalar,
|
|
12
|
+
MetadataThemeColor,
|
|
13
|
+
MetadataViewport,
|
|
14
|
+
RobotsManifest,
|
|
15
|
+
RouteHeadDescriptor,
|
|
16
|
+
RouteMetadata,
|
|
17
|
+
RouteParams,
|
|
18
|
+
SitemapEntry,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
|
|
21
|
+
type CspDirectiveMap = Record<string, readonly string[] | string>;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_HTML_RESPONSE_HEADERS = Object.freeze({
|
|
24
|
+
"content-type": "text/html; charset=utf-8",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function mergeRouteMetadata(metadata: readonly RouteMetadata[]): RouteMetadata | undefined {
|
|
28
|
+
if (metadata.length === 0) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return metadata.reduce<RouteMetadata>((merged, next) => {
|
|
33
|
+
const mergedMetadata: RouteMetadata = { ...merged, ...next };
|
|
34
|
+
const alternates = mergeObject(merged.alternates, next.alternates);
|
|
35
|
+
const csp = mergeCspMetadata(merged.csp, next.csp);
|
|
36
|
+
const head = mergeReadonlyArrays(merged.head, next.head);
|
|
37
|
+
const icons = mergeObject(merged.icons, next.icons);
|
|
38
|
+
const openGraph = mergeOpenGraphMetadata(merged.openGraph, next.openGraph);
|
|
39
|
+
|
|
40
|
+
if (alternates !== undefined) {
|
|
41
|
+
mergedMetadata.alternates = alternates;
|
|
42
|
+
}
|
|
43
|
+
if (csp !== undefined) {
|
|
44
|
+
mergedMetadata.csp = csp;
|
|
45
|
+
}
|
|
46
|
+
if (head !== undefined) {
|
|
47
|
+
mergedMetadata.head = head;
|
|
48
|
+
}
|
|
49
|
+
if (icons !== undefined) {
|
|
50
|
+
mergedMetadata.icons = icons;
|
|
51
|
+
}
|
|
52
|
+
if (openGraph !== undefined) {
|
|
53
|
+
mergedMetadata.openGraph = openGraph;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return mergedMetadata;
|
|
57
|
+
}, {});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function applyFileConventionMetadata(
|
|
61
|
+
metadata: RouteMetadata | undefined,
|
|
62
|
+
routes: readonly AppRoute[],
|
|
63
|
+
filename: string,
|
|
64
|
+
params: RouteParams,
|
|
65
|
+
): RouteMetadata | undefined {
|
|
66
|
+
const next: RouteMetadata = metadata === undefined ? {} : { ...metadata };
|
|
67
|
+
const iconRoute = routes.find((route) => route.kind === "asset" && route.convention === "icon");
|
|
68
|
+
const appleIconRoute = routes.find(
|
|
69
|
+
(route) => route.kind === "asset" && route.convention === "apple-icon",
|
|
70
|
+
);
|
|
71
|
+
const openGraphImagePath = fileConventionMetadataRoutePath(
|
|
72
|
+
routes,
|
|
73
|
+
filename,
|
|
74
|
+
params,
|
|
75
|
+
"opengraph-image",
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (iconRoute !== undefined && next.icons?.icon === undefined) {
|
|
79
|
+
next.icons = { ...next.icons, icon: iconRoute.path };
|
|
80
|
+
}
|
|
81
|
+
if (appleIconRoute !== undefined && next.icons?.apple === undefined) {
|
|
82
|
+
next.icons = { ...next.icons, apple: appleIconRoute.path };
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
openGraphImagePath !== undefined &&
|
|
86
|
+
next.openGraph?.image === undefined &&
|
|
87
|
+
(next.openGraph?.images === undefined || next.openGraph.images.length === 0)
|
|
88
|
+
) {
|
|
89
|
+
next.openGraph = { ...next.openGraph, image: openGraphImagePath };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Object.keys(next).length === 0 ? undefined : next;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function injectHeadMetadata(html: string, metadata: RouteMetadata | undefined): string {
|
|
96
|
+
if (metadata === undefined) {
|
|
97
|
+
return html;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let nextHtml =
|
|
101
|
+
metadata.lang === undefined
|
|
102
|
+
? html
|
|
103
|
+
: injectHtmlLangAttribute(html, metadataString(metadata.lang, "lang"));
|
|
104
|
+
const tags = [
|
|
105
|
+
metadata.title === undefined
|
|
106
|
+
? undefined
|
|
107
|
+
: `<title>${escapeHtml(metadataString(metadata.title, "title"))}</title>`,
|
|
108
|
+
metadata.description === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: `<meta name="description" content="${escapeHtmlAttribute(metadataString(metadata.description, "description"))}">`,
|
|
111
|
+
metadata.alternates?.canonical === undefined
|
|
112
|
+
? undefined
|
|
113
|
+
: `<link rel="canonical" href="${escapeHtmlAttribute(metadataString(metadata.alternates.canonical, "alternates.canonical"))}">`,
|
|
114
|
+
metadata.openGraph?.title === undefined
|
|
115
|
+
? undefined
|
|
116
|
+
: `<meta property="og:title" content="${escapeHtmlAttribute(metadataString(metadata.openGraph.title, "openGraph.title"))}">`,
|
|
117
|
+
metadata.openGraph?.description === undefined
|
|
118
|
+
? undefined
|
|
119
|
+
: `<meta property="og:description" content="${escapeHtmlAttribute(metadataString(metadata.openGraph.description, "openGraph.description"))}">`,
|
|
120
|
+
...openGraphImages(metadata.openGraph).map(
|
|
121
|
+
(image) => `<meta property="og:image" content="${escapeHtmlAttribute(image)}">`,
|
|
122
|
+
),
|
|
123
|
+
metadata.icons?.icon === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: `<link rel="icon" href="${escapeHtmlAttribute(metadataString(metadata.icons.icon, "icons.icon"))}">`,
|
|
126
|
+
metadata.icons?.apple === undefined
|
|
127
|
+
? undefined
|
|
128
|
+
: `<link rel="apple-touch-icon" href="${escapeHtmlAttribute(metadataString(metadata.icons.apple, "icons.apple"))}">`,
|
|
129
|
+
metadata.robots === undefined
|
|
130
|
+
? undefined
|
|
131
|
+
: `<meta name="robots" content="${escapeHtmlAttribute(robotsContent(metadata.robots))}">`,
|
|
132
|
+
metadata.themeColor === undefined ? undefined : themeColorTag(metadata.themeColor),
|
|
133
|
+
metadata.viewport === undefined
|
|
134
|
+
? undefined
|
|
135
|
+
: `<meta name="viewport" content="${escapeHtmlAttribute(viewportContent(metadata.viewport))}">`,
|
|
136
|
+
...headDescriptorTags(metadata.head, metadata.csp?.nonce),
|
|
137
|
+
]
|
|
138
|
+
.filter((tag): tag is string => tag !== undefined)
|
|
139
|
+
.join("");
|
|
140
|
+
|
|
141
|
+
if (tags === "") {
|
|
142
|
+
return nextHtml;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (/<head(?:\s[^>]*)?>/i.test(nextHtml)) {
|
|
146
|
+
return nextHtml.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${tags}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (/<html(?:\s[^>]*)?>/i.test(nextHtml)) {
|
|
150
|
+
return nextHtml.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${tags}</head>`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return `<head>${tags}</head>${nextHtml}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function responseHeadersForMetadata(
|
|
157
|
+
metadata: RouteMetadata | undefined,
|
|
158
|
+
request: Request,
|
|
159
|
+
extra?: Readonly<Record<string, string>>,
|
|
160
|
+
): HeadersInit {
|
|
161
|
+
const csp = contentSecurityPolicy(metadata?.csp);
|
|
162
|
+
const security = routeSecurityHeaders({
|
|
163
|
+
request,
|
|
164
|
+
security: metadata?.security,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (csp === undefined && extra === undefined) {
|
|
168
|
+
return {
|
|
169
|
+
...DEFAULT_HTML_RESPONSE_HEADERS,
|
|
170
|
+
...security,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
...DEFAULT_HTML_RESPONSE_HEADERS,
|
|
176
|
+
...security,
|
|
177
|
+
...(csp === undefined ? undefined : { "content-security-policy": csp }),
|
|
178
|
+
...extra,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function serializeRobots(manifest: RobotsManifest): string {
|
|
183
|
+
const lines: string[] = [];
|
|
184
|
+
const rules =
|
|
185
|
+
manifest.rules === undefined
|
|
186
|
+
? []
|
|
187
|
+
: Array.isArray(manifest.rules)
|
|
188
|
+
? manifest.rules
|
|
189
|
+
: [manifest.rules];
|
|
190
|
+
|
|
191
|
+
for (const rule of rules) {
|
|
192
|
+
for (const userAgent of arrayValue(rule.userAgent)) {
|
|
193
|
+
lines.push(`User-agent: ${userAgent}`);
|
|
194
|
+
}
|
|
195
|
+
for (const allow of arrayValue(rule.allow)) {
|
|
196
|
+
lines.push(`Allow: ${allow}`);
|
|
197
|
+
}
|
|
198
|
+
for (const disallow of arrayValue(rule.disallow)) {
|
|
199
|
+
lines.push(`Disallow: ${disallow}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const sitemap of arrayValue(manifest.sitemap)) {
|
|
204
|
+
lines.push(`Sitemap: ${sitemap}`);
|
|
205
|
+
}
|
|
206
|
+
if (manifest.host !== undefined) {
|
|
207
|
+
lines.push(`Host: ${manifest.host}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return `${lines.join("\n")}\n`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function serializeSitemap(entries: readonly SitemapEntry[]): string {
|
|
214
|
+
const urls = entries
|
|
215
|
+
.map((entry) => {
|
|
216
|
+
const fields = [
|
|
217
|
+
`<loc>${escapeXml(entry.url)}</loc>`,
|
|
218
|
+
entry.lastModified === undefined
|
|
219
|
+
? undefined
|
|
220
|
+
: `<lastmod>${escapeXml(sitemapDate(entry.lastModified))}</lastmod>`,
|
|
221
|
+
entry.changeFrequency === undefined
|
|
222
|
+
? undefined
|
|
223
|
+
: `<changefreq>${escapeXml(entry.changeFrequency)}</changefreq>`,
|
|
224
|
+
entry.priority === undefined ? undefined : `<priority>${entry.priority}</priority>`,
|
|
225
|
+
].filter((field): field is string => field !== undefined);
|
|
226
|
+
|
|
227
|
+
return `<url>${fields.join("")}</url>`;
|
|
228
|
+
})
|
|
229
|
+
.join("");
|
|
230
|
+
|
|
231
|
+
return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function fileConventionMetadataRoutePath(
|
|
235
|
+
routes: readonly AppRoute[],
|
|
236
|
+
filename: string,
|
|
237
|
+
params: RouteParams,
|
|
238
|
+
convention: AppFileConvention,
|
|
239
|
+
): string | undefined {
|
|
240
|
+
const pageRoute = routes.find((route) => route.kind === "page" && route.file === filename);
|
|
241
|
+
const candidateRoutes = routes.filter(
|
|
242
|
+
(route) =>
|
|
243
|
+
(route.kind === "asset" || route.kind === "metadata") &&
|
|
244
|
+
route.convention === convention,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (pageRoute !== undefined) {
|
|
248
|
+
const expectedPath = pageRoute.path === "/" ? `/${convention}` : `${pageRoute.path}/${convention}`;
|
|
249
|
+
const routeLocal = candidateRoutes.find((route) => route.path === expectedPath);
|
|
250
|
+
const routeLocalPath =
|
|
251
|
+
routeLocal === undefined ? undefined : concreteRoutePath(routeLocal.path, params);
|
|
252
|
+
|
|
253
|
+
if (routeLocalPath !== undefined) {
|
|
254
|
+
return routeLocalPath;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return candidateRoutes.find((route) => route.path === `/${convention}`)?.path;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function concreteRoutePath(path: string, params: RouteParams): string | undefined {
|
|
262
|
+
const segments = path === "/" ? [] : path.slice(1).split("/");
|
|
263
|
+
const concrete: string[] = [];
|
|
264
|
+
|
|
265
|
+
for (const segment of segments) {
|
|
266
|
+
if (segment.startsWith(":...")) {
|
|
267
|
+
const value = params[segment.slice(4)];
|
|
268
|
+
const values = Array.isArray(value)
|
|
269
|
+
? value
|
|
270
|
+
: typeof value === "string"
|
|
271
|
+
? value.split("/").filter((part) => part !== "")
|
|
272
|
+
: undefined;
|
|
273
|
+
|
|
274
|
+
if (values === undefined) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
concrete.push(...values.map((part) => encodeURIComponent(part)));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (segment.startsWith(":")) {
|
|
282
|
+
const value = params[segment.slice(1)];
|
|
283
|
+
const stringValue = Array.isArray(value) ? value[0] : value;
|
|
284
|
+
|
|
285
|
+
if (typeof stringValue !== "string") {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
concrete.push(encodeURIComponent(stringValue));
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
concrete.push(segment);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return `/${concrete.join("/")}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function mergeObject<T extends object>(left: T | undefined, right: T | undefined): T | undefined {
|
|
299
|
+
if (left === undefined) {
|
|
300
|
+
return right;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (right === undefined) {
|
|
304
|
+
return left;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { ...left, ...right };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function mergeReadonlyArrays<T>(
|
|
311
|
+
left: readonly T[] | undefined,
|
|
312
|
+
right: readonly T[] | undefined,
|
|
313
|
+
): readonly T[] | undefined {
|
|
314
|
+
if (left === undefined || left.length === 0) {
|
|
315
|
+
return right;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (right === undefined || right.length === 0) {
|
|
319
|
+
return left;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return [...left, ...right];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function mergeCspMetadata(
|
|
326
|
+
left: RouteMetadata["csp"],
|
|
327
|
+
right: RouteMetadata["csp"],
|
|
328
|
+
): RouteMetadata["csp"] | undefined {
|
|
329
|
+
if (right?.disable === true) {
|
|
330
|
+
return { disable: true };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (left === undefined) {
|
|
334
|
+
if (right === undefined) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const merged: NonNullable<RouteMetadata["csp"]> = { ...right };
|
|
339
|
+
const directives = applyCspOverrides(undefined, right);
|
|
340
|
+
|
|
341
|
+
if (directives !== undefined) {
|
|
342
|
+
merged.directives = directives;
|
|
343
|
+
} else {
|
|
344
|
+
delete merged.directives;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return merged;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (right === undefined) {
|
|
351
|
+
return left;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const merged: NonNullable<RouteMetadata["csp"]> = {
|
|
355
|
+
...left,
|
|
356
|
+
...right,
|
|
357
|
+
};
|
|
358
|
+
const directives = applyCspOverrides(left.directives, right);
|
|
359
|
+
|
|
360
|
+
if (directives !== undefined) {
|
|
361
|
+
merged.directives = directives;
|
|
362
|
+
} else {
|
|
363
|
+
delete merged.directives;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return merged;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function applyCspOverrides(
|
|
370
|
+
left: CspDirectiveMap | undefined,
|
|
371
|
+
right: RouteMetadata["csp"] | undefined,
|
|
372
|
+
): CspDirectiveMap | undefined {
|
|
373
|
+
if (right === undefined) {
|
|
374
|
+
return left;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const merged = { ...left, ...right.directives };
|
|
378
|
+
|
|
379
|
+
for (const [name, value] of Object.entries(right.replace ?? {})) {
|
|
380
|
+
merged[name] = value;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const name of right.remove ?? []) {
|
|
384
|
+
delete merged[name];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return Object.keys(merged).length === 0 ? undefined : merged;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function mergeOpenGraphMetadata(
|
|
391
|
+
left: RouteMetadata["openGraph"],
|
|
392
|
+
right: RouteMetadata["openGraph"],
|
|
393
|
+
): RouteMetadata["openGraph"] | undefined {
|
|
394
|
+
if (left === undefined) {
|
|
395
|
+
return right;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (right === undefined) {
|
|
399
|
+
return left;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const merged: NonNullable<RouteMetadata["openGraph"]> = {
|
|
403
|
+
...left,
|
|
404
|
+
...right,
|
|
405
|
+
};
|
|
406
|
+
const images = mergeReadonlyArrays(openGraphImages(left), openGraphImages(right));
|
|
407
|
+
|
|
408
|
+
if (images !== undefined && images.length > 0) {
|
|
409
|
+
merged.images = images;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return merged;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function injectHtmlLangAttribute(html: string, lang: string): string {
|
|
416
|
+
const escapedLang = escapeHtmlAttribute(lang);
|
|
417
|
+
|
|
418
|
+
if (!/<html(?:\s[^>]*)?>/i.test(html)) {
|
|
419
|
+
return html;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return html.replace(/<html(\s[^>]*)?>/i, (_match, attrs = "") => {
|
|
423
|
+
const strippedAttrs = String(attrs).replace(/\s+lang=(?:"[^"]*"|'[^']*'|[^\s>]+)/i, "");
|
|
424
|
+
return `<html lang="${escapedLang}"${strippedAttrs}>`;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function sitemapDate(value: Date | number | string): string {
|
|
429
|
+
if (value instanceof Date) {
|
|
430
|
+
return value.toISOString();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return typeof value === "number" ? new Date(value).toISOString() : value;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function arrayValue<T>(value: T | readonly T[] | undefined): readonly T[] {
|
|
437
|
+
if (value === undefined) {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return Array.isArray(value) ? (value as readonly T[]) : [value as T];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function escapeXml(value: string): string {
|
|
445
|
+
return value
|
|
446
|
+
.replaceAll("&", "&")
|
|
447
|
+
.replaceAll("<", "<")
|
|
448
|
+
.replaceAll(">", ">")
|
|
449
|
+
.replaceAll('"', """);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function headDescriptorTags(
|
|
453
|
+
descriptors: readonly RouteHeadDescriptor[] | undefined,
|
|
454
|
+
nonce: string | undefined,
|
|
455
|
+
): string[] {
|
|
456
|
+
return (descriptors ?? []).flatMap((descriptor) => {
|
|
457
|
+
const descriptorNonce = descriptor.nonce === true ? nonce : descriptor.nonce || undefined;
|
|
458
|
+
const attrs: Record<string, boolean | number | string | undefined> = {
|
|
459
|
+
...descriptor.attrs,
|
|
460
|
+
...(descriptorNonce === undefined ? {} : { nonce: descriptorNonce }),
|
|
461
|
+
};
|
|
462
|
+
const attrText = Object.entries(attrs)
|
|
463
|
+
.flatMap(([name, value]) => {
|
|
464
|
+
if (value === undefined || value === false) {
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return value === true
|
|
469
|
+
? [escapeHtmlAttribute(name)]
|
|
470
|
+
: [`${escapeHtmlAttribute(name)}="${escapeHtmlAttribute(String(value))}"`];
|
|
471
|
+
})
|
|
472
|
+
.join(" ");
|
|
473
|
+
const open = attrText === "" ? `<${descriptor.tag}>` : `<${descriptor.tag} ${attrText}>`;
|
|
474
|
+
|
|
475
|
+
if (descriptor.tag === "meta" || descriptor.tag === "link" || descriptor.tag === "base") {
|
|
476
|
+
return [open.slice(0, -1) + ">"];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return [`${open}${escapeHeadTextContent(descriptor.content ?? "")}</${descriptor.tag}>`];
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function escapeHeadTextContent(value: string): string {
|
|
484
|
+
return value.replaceAll("<", "\\u003c");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function metadataString(value: MetadataScalar, path: string): string {
|
|
488
|
+
if (isMetadataScalar(value)) {
|
|
489
|
+
return String(value);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
throw new Error(`Invalid metadata field ${path}: expected string, number, or boolean.`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function metadataKebabName(name: string): string {
|
|
496
|
+
return name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function viewportContent(viewport: MetadataScalar | MetadataViewport): string {
|
|
500
|
+
if (isMetadataScalar(viewport)) {
|
|
501
|
+
return metadataString(viewport, "viewport");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return Object.entries(viewport)
|
|
505
|
+
.flatMap(([key, value]) => {
|
|
506
|
+
if (value === undefined || value === null || value === false) {
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return [`${metadataKebabName(key)}=${metadataString(value, `viewport.${key}`)}`];
|
|
511
|
+
})
|
|
512
|
+
.join(", ");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function themeColorTag(themeColor: MetadataScalar | MetadataThemeColor): string {
|
|
516
|
+
if (isMetadataScalar(themeColor)) {
|
|
517
|
+
return `<meta name="theme-color" content="${escapeHtmlAttribute(metadataString(themeColor, "themeColor"))}">`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const content = themeColor.color;
|
|
521
|
+
if (!isMetadataScalar(content)) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
"Invalid metadata field themeColor.color: expected string, number, or boolean.",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const media =
|
|
528
|
+
themeColor.media === undefined
|
|
529
|
+
? ""
|
|
530
|
+
: ` media="${escapeHtmlAttribute(metadataString(metadataScalarField(themeColor.media, "themeColor.media"), "themeColor.media"))}"`;
|
|
531
|
+
|
|
532
|
+
return `<meta name="theme-color"${media} content="${escapeHtmlAttribute(metadataString(content, "themeColor.color"))}">`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function metadataScalarField(value: unknown, path: string): MetadataScalar {
|
|
536
|
+
if (isMetadataScalar(value)) {
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
throw new Error(`Invalid metadata field ${path}: expected string, number, or boolean.`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isMetadataScalar(value: unknown): value is MetadataScalar {
|
|
544
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function openGraphImages(openGraph: RouteMetadata["openGraph"]): readonly string[] {
|
|
548
|
+
if (openGraph?.images !== undefined) {
|
|
549
|
+
return openGraph.images.map((image, index) =>
|
|
550
|
+
metadataImageUrl(image, `openGraph.images.${index}`),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return openGraph?.image === undefined ? [] : [metadataImageUrl(openGraph.image, "openGraph.image")];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function metadataImageUrl(value: MetadataImage | MetadataScalar, path: string): string {
|
|
558
|
+
if (isMetadataScalar(value)) {
|
|
559
|
+
return metadataString(value, path);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (typeof value === "object" && value !== null && "url" in value) {
|
|
563
|
+
return metadataString(value.url, `${path}.url`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
throw new Error(`Invalid metadata field ${path}: expected string, number, boolean, or object with url.`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function robotsContent(robots: NonNullable<RouteMetadata["robots"]>): string {
|
|
570
|
+
if (typeof robots === "string") {
|
|
571
|
+
return robots;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return [
|
|
575
|
+
robots.index === false ? "noindex" : "index",
|
|
576
|
+
robots.follow === false ? "nofollow" : "follow",
|
|
577
|
+
].join(",");
|
|
578
|
+
}
|