@webjsdev/server 0.8.80 → 0.8.82
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/index.d.ts +41 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component-elision.js +125 -3
- package/src/dev/serve.js +5 -14
- package/src/metadata-routes.js +199 -0
- package/src/router.js +5 -2
- package/src/sitemap.js +49 -0
- package/src/ssr/head.js +12 -7
- package/src/ssr/render.js +6 -1
- package/src/ssr/seo.js +268 -0
package/index.d.ts
CHANGED
|
@@ -910,6 +910,47 @@ export declare function sitemap(entries: SitemapEntry[]): string;
|
|
|
910
910
|
/** Serialize child sitemaps into a `<sitemapindex>` XML document. */
|
|
911
911
|
export declare function sitemapIndex(sitemaps: SitemapIndexEntry[]): string;
|
|
912
912
|
|
|
913
|
+
/** One `User-agent` group of a robots.txt (Next's `MetadataRoute.Robots` rule). */
|
|
914
|
+
export interface RobotsRule {
|
|
915
|
+
userAgent?: string | string[];
|
|
916
|
+
allow?: string | string[];
|
|
917
|
+
disallow?: string | string[];
|
|
918
|
+
crawlDelay?: number;
|
|
919
|
+
}
|
|
920
|
+
/** A robots.txt as data (Next's `MetadataRoute.Robots`). `app/robots.ts` may return it. */
|
|
921
|
+
export interface RobotsConfig {
|
|
922
|
+
/** Defaults to allowing every crawler everywhere. */
|
|
923
|
+
rules?: RobotsRule | RobotsRule[];
|
|
924
|
+
sitemap?: string | string[];
|
|
925
|
+
host?: string;
|
|
926
|
+
}
|
|
927
|
+
/** Serialize a robots config into robots.txt text. */
|
|
928
|
+
export declare function robots(config?: RobotsConfig): string;
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* The argument every metadata route (`app/sitemap.ts`, `app/robots.ts`, the
|
|
932
|
+
* image routes, ...) receives (#1564).
|
|
933
|
+
*/
|
|
934
|
+
export interface MetadataRouteContext {
|
|
935
|
+
request: Request;
|
|
936
|
+
url: URL;
|
|
937
|
+
/** The public origin: `SITE_URL` when set, else the request origin. No trailing slash. */
|
|
938
|
+
siteUrl: string;
|
|
939
|
+
/**
|
|
940
|
+
* Every public page as a sitemap entry: static pages, plus dynamic pages that
|
|
941
|
+
* export `generateSitemapParams()`. Pages whose static metadata says
|
|
942
|
+
* `robots: { index: false }` are left out.
|
|
943
|
+
*/
|
|
944
|
+
pages(): Promise<SitemapEntry[]>;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* What `app/sitemap.ts` may return: an array of entries (urls may be paths,
|
|
948
|
+
* resolved against `siteUrl`), an XML string, or a Response.
|
|
949
|
+
*/
|
|
950
|
+
export type SitemapRouteResult = SitemapEntry[] | string | Response;
|
|
951
|
+
/** What `app/robots.ts` may return: a config object, text, or a Response. */
|
|
952
|
+
export type RobotsRouteResult = RobotsConfig | string | Response;
|
|
953
|
+
|
|
913
954
|
// ---------------------------------------------------------------------------
|
|
914
955
|
// stream.js (#248): stream-action helpers (the `<webjs-stream>` HTML the
|
|
915
956
|
// client `renderStream` / element applies surgically)
|
package/index.js
CHANGED
|
@@ -59,7 +59,7 @@ export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSess
|
|
|
59
59
|
export { broadcast } from './src/broadcast.js';
|
|
60
60
|
export { diskStore, setFileStore, getFileStore, generateKey, signedUrl, verifySignedUrl, assertSafeKey, DEFAULT_UPLOAD_DIR } from './src/file-storage.js';
|
|
61
61
|
export { json, readBody } from './src/json.js';
|
|
62
|
-
export { sitemap, sitemapIndex } from './src/sitemap.js';
|
|
62
|
+
export { sitemap, sitemapIndex, robots } from './src/sitemap.js';
|
|
63
63
|
export { stream, streamResponse, acceptsStream, STREAM_MIME } from './src/stream.js';
|
|
64
64
|
export { attachWebSocket } from './src/websocket.js';
|
|
65
65
|
export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.82",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/component-elision.js
CHANGED
|
@@ -285,6 +285,118 @@ const PURE_DATA_CONSTRUCTORS = new Set([
|
|
|
285
285
|
'BigInt64Array', 'BigUint64Array',
|
|
286
286
|
]);
|
|
287
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Marks where `hasModuleScopeSideEffect` dropped a top-level `{...}` body from
|
|
290
|
+
* its depth-0 frame, so an arrow followed by a block body is not mistaken for
|
|
291
|
+
* an expression body that runs on into the next statement. Removed again
|
|
292
|
+
* before the call scan.
|
|
293
|
+
*/
|
|
294
|
+
const BLOCK = '\x01';
|
|
295
|
+
|
|
296
|
+
/** `function`, `function*`, `async function name` up to its parameter list's `(`. */
|
|
297
|
+
const FUNCTION_PARAMS_RE = /(?<![\w$.])function\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*\(/g;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The text before an arrow's `=>` when that arrow is a declarator initializer
|
|
301
|
+
* (`const usd = (m) =>`, `let f = async x =>`, `, g = (a) =>`). The parameter
|
|
302
|
+
* list must hold no parens, so a call in a parameter default
|
|
303
|
+
* (`(a = init()) =>`) keeps its arrow unrecognised and still ships.
|
|
304
|
+
*/
|
|
305
|
+
const ARROW_DECLARATOR_RE =
|
|
306
|
+
/(?:(?<![\w$.])(?:const|let|var)\s+|,\s*)[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*$/;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* An expression body that ends this way cannot stop at the newline (a binary
|
|
310
|
+
* operator, `?`, `:`, or a curried `=>`). Postfix `++` / `--` and a regex
|
|
311
|
+
* literal's closing `/` end a statement, so neither is listed.
|
|
312
|
+
*/
|
|
313
|
+
const BODY_CONTINUES_RE = /(?:=>|&&|\|\||\?\?|[?:=*%|&^<>]|(?<![+])\+|(?<![-])-)\s*$/;
|
|
314
|
+
|
|
315
|
+
/** A line starting this way continues the previous one: no statement starts with it. */
|
|
316
|
+
const LINE_CONTINUES_RE = /^\s*(?:\?|:|&&|\|\||\.(?![.\d])|\*|%|=(?!>)|\|)/;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Removes the two depth-0 shapes that look like calls and run nothing at module
|
|
320
|
+
* load: a `function` declaration's parameter list (a default such as
|
|
321
|
+
* `now = Date.now()` runs per call) and an arrow's expression body when the
|
|
322
|
+
* arrow initializes a declarator (`const usd = (m) => Math.round(m) / MICROS`).
|
|
323
|
+
*
|
|
324
|
+
* The arrow body ends at a `,` or `;` at its own paren/bracket depth 0, at a
|
|
325
|
+
* `)` / `]` closing something outside it, or at a newline once the body has
|
|
326
|
+
* content, unless `BODY_CONTINUES_RE` / `LINE_CONTINUES_RE` prove the
|
|
327
|
+
* expression carries on. Ending early is always safe, because whatever is left
|
|
328
|
+
* stays in the frame and is still scanned; ending late could swallow a real
|
|
329
|
+
* statement, so every rule errs towards ending.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} frame depth-0 text, with `BLOCK` where a `{...}` was dropped
|
|
332
|
+
* @returns {string | null} the frame without those parts, or null when a
|
|
333
|
+
* parameter list does not close (the caller then ships)
|
|
334
|
+
*/
|
|
335
|
+
function blankDeclarationOnlyCode(frame) {
|
|
336
|
+
let out = '';
|
|
337
|
+
let last = 0;
|
|
338
|
+
for (const m of frame.matchAll(FUNCTION_PARAMS_RE)) {
|
|
339
|
+
if (m.index < last) continue;
|
|
340
|
+
const open = m.index + m[0].length - 1;
|
|
341
|
+
let depth = 0;
|
|
342
|
+
let close = -1;
|
|
343
|
+
for (let j = open; j < frame.length; j++) {
|
|
344
|
+
if (frame[j] === '(') depth++;
|
|
345
|
+
else if (frame[j] === ')' && --depth === 0) { close = j; break; }
|
|
346
|
+
}
|
|
347
|
+
if (close < 0) return null;
|
|
348
|
+
out += frame.slice(last, open + 1);
|
|
349
|
+
last = close;
|
|
350
|
+
}
|
|
351
|
+
frame = out + frame.slice(last);
|
|
352
|
+
|
|
353
|
+
out = '';
|
|
354
|
+
let depth = 0;
|
|
355
|
+
for (let i = 0; i < frame.length; i++) {
|
|
356
|
+
const c = frame[i];
|
|
357
|
+
if (c === '(' || c === '[') depth++;
|
|
358
|
+
else if ((c === ')' || c === ']') && depth > 0) depth--;
|
|
359
|
+
if (depth === 0 && c === '=' && frame[i + 1] === '>' && ARROW_DECLARATOR_RE.test(out)) {
|
|
360
|
+
out += '=>';
|
|
361
|
+
i = arrowBodyEnd(frame, i + 2) - 1;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
out += c;
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Index where an arrow's expression body starting at `start` ends (see
|
|
371
|
+
* `blankDeclarationOnlyCode`).
|
|
372
|
+
*
|
|
373
|
+
* @param {string} frame
|
|
374
|
+
* @param {number} start index just past the `=>`
|
|
375
|
+
* @returns {number}
|
|
376
|
+
*/
|
|
377
|
+
function arrowBodyEnd(frame, start) {
|
|
378
|
+
let depth = 0;
|
|
379
|
+
let i = start;
|
|
380
|
+
for (; i < frame.length; i++) {
|
|
381
|
+
const c = frame[i];
|
|
382
|
+
if (c === '(' || c === '[') { depth++; continue; }
|
|
383
|
+
if (c === ')' || c === ']') {
|
|
384
|
+
if (depth === 0) return i;
|
|
385
|
+
depth--;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (depth > 0) continue;
|
|
389
|
+
if (c === ',' || c === ';') return i;
|
|
390
|
+
if (c === '\n') {
|
|
391
|
+
const body = frame.slice(start, i);
|
|
392
|
+
if (!body.trim() || BODY_CONTINUES_RE.test(body)) continue;
|
|
393
|
+
if (LINE_CONTINUES_RE.test(frame.slice(i + 1))) continue;
|
|
394
|
+
return i;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return i;
|
|
398
|
+
}
|
|
399
|
+
|
|
288
400
|
/**
|
|
289
401
|
* Module-scope client work, detected by an ALLOWLIST of safe top-level forms
|
|
290
402
|
* rather than a denylist of browser globals. A module that runs ANY code when
|
|
@@ -320,8 +432,13 @@ const PURE_DATA_CONSTRUCTORS = new Set([
|
|
|
320
432
|
* request path is covered; a DIRECT caller handing it `.ts` source as authored
|
|
321
433
|
* is opting into that false positive.
|
|
322
434
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
435
|
+
* A `function` declaration's parameter list and the expression body of an
|
|
436
|
+
* arrow that initializes a `const` / `let` / `var` declarator are dropped from
|
|
437
|
+
* the frame first (`blankDeclarationOnlyCode`): `now = Date.now()` and
|
|
438
|
+
* `(m) => Math.round(m) / MICROS` declare code, they do not run it.
|
|
439
|
+
*
|
|
440
|
+
* Over-detection is safe (a pure top-level helper call, or an arrow outside a
|
|
441
|
+
* declarator whose body calls something, only ships). The accepted residual misses, all
|
|
325
442
|
* contrived and structural (so they do not rot), are a call buried inside a
|
|
326
443
|
* top-level object / array initializer or a destructuring default, and a
|
|
327
444
|
* side-effecting tagged-template hole evaluated at module scope.
|
|
@@ -361,7 +478,7 @@ export function hasModuleScopeSideEffect(src, literals) {
|
|
|
361
478
|
if (depth === 0) frame += "''";
|
|
362
479
|
continue;
|
|
363
480
|
}
|
|
364
|
-
if (c === '{') { depth++; continue; }
|
|
481
|
+
if (c === '{') { if (depth === 0) frame += BLOCK; depth++; continue; }
|
|
365
482
|
if (c === '}') { if (depth > 0) depth--; continue; }
|
|
366
483
|
if (depth === 0) frame += c;
|
|
367
484
|
}
|
|
@@ -369,6 +486,11 @@ export function hasModuleScopeSideEffect(src, literals) {
|
|
|
369
486
|
// literal with a stray brace is the common case). Ship rather than risk a
|
|
370
487
|
// hidden top-level statement.
|
|
371
488
|
if (depth !== 0) return true;
|
|
489
|
+
// Parameter lists and declarator arrow bodies are declarations, not code
|
|
490
|
+
// that runs at load.
|
|
491
|
+
const declared = blankDeclarationOnlyCode(frame);
|
|
492
|
+
if (declared === null) return true;
|
|
493
|
+
frame = declared.replaceAll(BLOCK, '');
|
|
372
494
|
// Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
|
|
373
495
|
// identifier-before-paren match below).
|
|
374
496
|
if (/\?\.\s*[([]/.test(frame)) return true;
|
package/src/dev/serve.js
CHANGED
|
@@ -35,6 +35,7 @@ import { toUrlPath } from '../ssr/preloads.js';
|
|
|
35
35
|
import { BUFFERED_MARKER } from '../conditional-get.js';
|
|
36
36
|
import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js';
|
|
37
37
|
import { devImportSpecifier } from '../dev-import.js';
|
|
38
|
+
import { runMetadataRoute } from '../metadata-routes.js';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Serve framework-internal static assets that depend on NEITHER the whole-app
|
|
@@ -363,20 +364,10 @@ export async function handleCore(req, ctx) {
|
|
|
363
364
|
const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path);
|
|
364
365
|
if (meta) {
|
|
365
366
|
try {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (result instanceof Response) return result;
|
|
371
|
-
// If it returns a string, determine content type from the URL path.
|
|
372
|
-
const ct = path.endsWith('.xml') ? 'application/xml; charset=utf-8'
|
|
373
|
-
: path.endsWith('.txt') ? 'text/plain; charset=utf-8'
|
|
374
|
-
: path.endsWith('.json') ? 'application/json; charset=utf-8'
|
|
375
|
-
: 'application/octet-stream';
|
|
376
|
-
return new Response(typeof result === 'string' ? result : JSON.stringify(result), {
|
|
377
|
-
headers: { 'content-type': ct, 'cache-control': dev ? 'no-cache' : 'public, max-age=3600' },
|
|
378
|
-
});
|
|
379
|
-
}
|
|
367
|
+
// The route gets `{ request, url, siteUrl, pages }` and may return a
|
|
368
|
+
// Response, a string, or Next's sitemap array / robots object (#1564).
|
|
369
|
+
const res = await runMetadataRoute(meta, { req, url, path, dev, routeTable: state.routeTable });
|
|
370
|
+
if (res) return res;
|
|
380
371
|
} catch (e) {
|
|
381
372
|
if (reportError) reportError(e, req, 'metadata');
|
|
382
373
|
if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata route dispatch (`app/sitemap.ts`, `app/robots.ts`, `app/manifest.ts`,
|
|
3
|
+
* the image routes) and the page listing a sitemap is built from (#1564).
|
|
4
|
+
*
|
|
5
|
+
* A metadata route's default export is called with a context:
|
|
6
|
+
*
|
|
7
|
+
* { request, url, siteUrl, pages }
|
|
8
|
+
*
|
|
9
|
+
* - `siteUrl` is the app's public origin: `SITE_URL` when set, else the
|
|
10
|
+
* request origin (forwarded headers applied). A sitemap must list absolute
|
|
11
|
+
* URLs, and only the request knows the host when no site URL is configured.
|
|
12
|
+
* - `pages()` lists every public page as sitemap entries (see `listPages`).
|
|
13
|
+
*
|
|
14
|
+
* Return values: a `Response` is sent as is; a string is sent with a content
|
|
15
|
+
* type picked from the URL; an ARRAY from `sitemap` is serialized with
|
|
16
|
+
* `sitemap()` (relative URLs resolved against `siteUrl`); an OBJECT from
|
|
17
|
+
* `robots` is serialized with `robots()`; any other object is JSON. The last
|
|
18
|
+
* two are Next's `MetadataRoute.Sitemap` / `MetadataRoute.Robots` shapes, so
|
|
19
|
+
* code written the Next way works unchanged.
|
|
20
|
+
*/
|
|
21
|
+
import { sitemap as sitemapXml, robots as robotsTxt } from './sitemap.js';
|
|
22
|
+
import { isUrlSegment } from './router.js';
|
|
23
|
+
import { devImportSpecifier } from './dev-import.js';
|
|
24
|
+
import { siteUrlFromEnv } from './ssr/seo.js';
|
|
25
|
+
import { basePath } from './importmap.js';
|
|
26
|
+
import { withBasePath } from './base-path.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {{ url: string, lastModified?: string | Date, changeFrequency?: string, priority?: number }} PageEntry
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a page's static metadata chain (layouts, then the page; the deepest
|
|
34
|
+
* `robots` wins, matching the metadata merge) asks search engines not to index.
|
|
35
|
+
* Only the static `metadata` export is read: `generateMetadata` may depend on
|
|
36
|
+
* the request (a session, a record), which a sitemap request does not carry.
|
|
37
|
+
* @param {string[]} files
|
|
38
|
+
* @param {boolean} dev
|
|
39
|
+
*/
|
|
40
|
+
async function isNoindex(files, dev) {
|
|
41
|
+
/** @type {unknown} */
|
|
42
|
+
let robots;
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
try {
|
|
45
|
+
const mod = await import(devImportSpecifier(file, dev));
|
|
46
|
+
if (mod.metadata && typeof mod.metadata === 'object' && mod.metadata.robots !== undefined) {
|
|
47
|
+
robots = mod.metadata.robots;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// A module that fails to load is reported when the page renders.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (typeof robots === 'string') return /\bnoindex\b|\bnone\b/i.test(robots);
|
|
54
|
+
if (robots && typeof robots === 'object') return /** @type {any} */ (robots).index === false;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fill a dynamic route's segments from one params object, or null when a
|
|
60
|
+
* param is missing.
|
|
61
|
+
* @param {string[]} segs URL segments (groups and private folders removed)
|
|
62
|
+
* @param {Record<string, unknown>} params
|
|
63
|
+
*/
|
|
64
|
+
function fillSegments(segs, params) {
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const seg of segs) {
|
|
67
|
+
const optional = seg.startsWith('[[...') && seg.endsWith(']]');
|
|
68
|
+
const rest = !optional && seg.startsWith('[...') && seg.endsWith(']');
|
|
69
|
+
if (optional || rest) {
|
|
70
|
+
const name = optional ? seg.slice(5, -2) : seg.slice(4, -1);
|
|
71
|
+
const v = params[name];
|
|
72
|
+
const parts = Array.isArray(v) ? v.map(String) : v == null || v === '' ? [] : [String(v)];
|
|
73
|
+
if (!parts.length && rest) return null;
|
|
74
|
+
out.push(...parts.map(encodeURIComponent));
|
|
75
|
+
} else if (seg.startsWith('[') && seg.endsWith(']')) {
|
|
76
|
+
const v = params[seg.slice(1, -1)];
|
|
77
|
+
if (v == null || v === '') return null;
|
|
78
|
+
out.push(encodeURIComponent(String(v)));
|
|
79
|
+
} else {
|
|
80
|
+
out.push(seg);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return '/' + out.join('/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* List the app's public pages as sitemap entries.
|
|
88
|
+
*
|
|
89
|
+
* - Every static page route, with route groups and private folders removed.
|
|
90
|
+
* - A dynamic route only when its page exports `generateSitemapParams()`,
|
|
91
|
+
* returning params objects (`[{ slug: 'a' }]`) or entries carrying them
|
|
92
|
+
* (`[{ params: { slug: 'a' }, lastModified }]`), typically from a query.
|
|
93
|
+
* - Skipped: a page whose static metadata chain says `robots: { index: false }`
|
|
94
|
+
* (or a `noindex` string), and parallel-route slots (`@x`) / interception
|
|
95
|
+
* folders (`(.)x`), which are not addressable pages on their own.
|
|
96
|
+
*
|
|
97
|
+
* @param {{ pages: Array<{ file: string, routeDir: string, metadataFiles: string[] }> }} routeTable
|
|
98
|
+
* @param {{ siteUrl: string, dev: boolean }} opts
|
|
99
|
+
* @returns {Promise<PageEntry[]>}
|
|
100
|
+
*/
|
|
101
|
+
export async function listPages(routeTable, { siteUrl, dev }) {
|
|
102
|
+
const bp = basePath();
|
|
103
|
+
const abs = (/** @type {string} */ path) => siteUrl + withBasePath(path, bp);
|
|
104
|
+
/** @type {PageEntry[]} */
|
|
105
|
+
const out = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const push = (/** @type {PageEntry} */ e) => {
|
|
108
|
+
if (seen.has(e.url)) return;
|
|
109
|
+
seen.add(e.url);
|
|
110
|
+
out.push(e);
|
|
111
|
+
};
|
|
112
|
+
for (const page of routeTable.pages) {
|
|
113
|
+
const dirSegs = page.routeDir === '.' ? [] : page.routeDir.split('/');
|
|
114
|
+
if (dirSegs.some((s) => s.startsWith('@') || /^\(\.+\)/.test(s))) continue;
|
|
115
|
+
if (await isNoindex(page.metadataFiles || [page.file], dev)) continue;
|
|
116
|
+
const segs = dirSegs.filter(isUrlSegment);
|
|
117
|
+
if (!segs.some((s) => s.startsWith('['))) {
|
|
118
|
+
push({ url: abs(segs.length ? '/' + segs.join('/') : '/') });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
let mod;
|
|
122
|
+
try {
|
|
123
|
+
mod = await import(devImportSpecifier(page.file, dev));
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (typeof mod.generateSitemapParams !== 'function') continue;
|
|
128
|
+
const list = await mod.generateSitemapParams();
|
|
129
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
130
|
+
if (!item || typeof item !== 'object') continue;
|
|
131
|
+
const entry = item.params && typeof item.params === 'object' ? item : { params: item };
|
|
132
|
+
const path = fillSegments(segs, entry.params);
|
|
133
|
+
if (!path) continue;
|
|
134
|
+
/** @type {PageEntry} */
|
|
135
|
+
const e = { url: abs(path) };
|
|
136
|
+
if (entry.lastModified) e.lastModified = entry.lastModified;
|
|
137
|
+
if (entry.changeFrequency) e.changeFrequency = entry.changeFrequency;
|
|
138
|
+
if (typeof entry.priority === 'number') e.priority = entry.priority;
|
|
139
|
+
push(e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve each entry's url against the site URL (an author may return paths).
|
|
147
|
+
* @param {unknown[]} entries
|
|
148
|
+
* @param {string} siteUrl
|
|
149
|
+
*/
|
|
150
|
+
function absoluteEntries(entries, siteUrl) {
|
|
151
|
+
return entries.map((e) => {
|
|
152
|
+
if (!e || typeof e !== 'object' || typeof (/** @type {any} */ (e).url) !== 'string') return e;
|
|
153
|
+
const url = /** @type {any} */ (e).url;
|
|
154
|
+
if (/^https?:\/\//i.test(url)) return e;
|
|
155
|
+
try {
|
|
156
|
+
return { ...e, url: new URL(url, siteUrl + '/').toString() };
|
|
157
|
+
} catch {
|
|
158
|
+
return e;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Run a metadata route and turn its return value into a Response.
|
|
165
|
+
* @param {{ stem: string, file: string, urlPath: string }} meta
|
|
166
|
+
* @param {{ req: Request, url: URL, path: string, dev: boolean, routeTable: any }} opts
|
|
167
|
+
* @returns {Promise<Response | null>} null when the module has no default export
|
|
168
|
+
*/
|
|
169
|
+
export async function runMetadataRoute(meta, { req, url, path, dev, routeTable }) {
|
|
170
|
+
const mod = await import(devImportSpecifier(meta.file, dev));
|
|
171
|
+
if (typeof mod.default !== 'function') return null;
|
|
172
|
+
const siteUrl = siteUrlFromEnv() || url.origin;
|
|
173
|
+
const ctx = {
|
|
174
|
+
request: req,
|
|
175
|
+
url,
|
|
176
|
+
siteUrl,
|
|
177
|
+
pages: () => listPages(routeTable, { siteUrl, dev }),
|
|
178
|
+
};
|
|
179
|
+
const result = await mod.default(ctx);
|
|
180
|
+
if (result instanceof Response) return result;
|
|
181
|
+
const cacheControl = dev ? 'no-cache' : 'public, max-age=3600';
|
|
182
|
+
if (meta.stem === 'sitemap' && Array.isArray(result)) {
|
|
183
|
+
return new Response(sitemapXml(/** @type {any} */ (absoluteEntries(result, siteUrl))), {
|
|
184
|
+
headers: { 'content-type': 'application/xml; charset=utf-8', 'cache-control': cacheControl },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (meta.stem === 'robots' && result && typeof result === 'object') {
|
|
188
|
+
return new Response(robotsTxt(/** @type {any} */ (result)), {
|
|
189
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': cacheControl },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const ct = path.endsWith('.xml') ? 'application/xml; charset=utf-8'
|
|
193
|
+
: path.endsWith('.txt') ? 'text/plain; charset=utf-8'
|
|
194
|
+
: path.endsWith('.json') ? 'application/json; charset=utf-8'
|
|
195
|
+
: 'application/octet-stream';
|
|
196
|
+
return new Response(typeof result === 'string' ? result : JSON.stringify(result), {
|
|
197
|
+
headers: { 'content-type': ct, 'cache-control': cacheControl },
|
|
198
|
+
});
|
|
199
|
+
}
|
package/src/router.js
CHANGED
|
@@ -157,7 +157,10 @@ export async function buildRouteTable(appDir) {
|
|
|
157
157
|
} else if (METADATA_STEMS.has(stem) && (dir === '.' || dir.split('/').every(s => !s.startsWith('[')))) {
|
|
158
158
|
// Metadata route: sitemap.ts, robots.ts, icon.ts, etc.
|
|
159
159
|
// Only at root or static segments (no dynamic params in metadata routes).
|
|
160
|
-
|
|
160
|
+
// A nested route answers under its own segment (`app/blog/opengraph-image.ts`
|
|
161
|
+
// -> `/blog/opengraph-image`, #1564); route groups add no URL segment.
|
|
162
|
+
const prefix = dir === '.' ? '' : '/' + dir.split('/').filter(isUrlSegment).join('/');
|
|
163
|
+
const urlPath = (prefix === '/' ? '' : prefix) + (METADATA_URL_MAP[stem] || `/${stem}`);
|
|
161
164
|
metadataRoutes.push({ stem, file, urlPath });
|
|
162
165
|
} else if (stem === 'route') {
|
|
163
166
|
// route.js / route.ts can live anywhere under app/ (matches NextJs).
|
|
@@ -227,7 +230,7 @@ function chainOf(routeDir) {
|
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
/** @param {string} seg */
|
|
230
|
-
function isUrlSegment(seg) {
|
|
233
|
+
export function isUrlSegment(seg) {
|
|
231
234
|
if (seg.startsWith('(') && seg.endsWith(')')) return false; // route group
|
|
232
235
|
if (seg.startsWith('_')) return false; // private
|
|
233
236
|
return true;
|
package/src/sitemap.js
CHANGED
|
@@ -159,3 +159,52 @@ export function sitemapIndex(sitemaps) {
|
|
|
159
159
|
'</sitemapindex>\n'
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* One `User-agent` group for {@link robots}.
|
|
165
|
+
* @typedef {object} RobotsRule
|
|
166
|
+
* @property {string | string[]} [userAgent] Defaults to `*`.
|
|
167
|
+
* @property {string | string[]} [allow]
|
|
168
|
+
* @property {string | string[]} [disallow]
|
|
169
|
+
* @property {number} [crawlDelay]
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The Next.js `MetadataRoute.Robots` shape.
|
|
174
|
+
* @typedef {object} RobotsConfig
|
|
175
|
+
* @property {RobotsRule | RobotsRule[]} [rules] Defaults to allow everything.
|
|
176
|
+
* @property {string | string[]} [sitemap] Absolute sitemap URL(s).
|
|
177
|
+
* @property {string} [host]
|
|
178
|
+
*/
|
|
179
|
+
|
|
180
|
+
/** @param {unknown} v @returns {string[]} */
|
|
181
|
+
const listOf = (v) => (Array.isArray(v) ? v : v == null ? [] : [v])
|
|
182
|
+
.map((x) => String(x).replace(/[\r\n]/g, ''))
|
|
183
|
+
.filter((x) => x !== '');
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Serialize a robots config into robots.txt text (#1564). An `app/robots.ts`
|
|
187
|
+
* may return this object directly; the server runs it through here. Newlines
|
|
188
|
+
* are stripped from every value so a data-derived path cannot inject a rule.
|
|
189
|
+
* @param {RobotsConfig} [config]
|
|
190
|
+
* @returns {string}
|
|
191
|
+
*/
|
|
192
|
+
export function robots(config = {}) {
|
|
193
|
+
const rules = config.rules == null ? [{ userAgent: '*', allow: '/' }]
|
|
194
|
+
: Array.isArray(config.rules) ? config.rules : [config.rules];
|
|
195
|
+
const blocks = [];
|
|
196
|
+
for (const rule of rules) {
|
|
197
|
+
if (!rule || typeof rule !== 'object') continue;
|
|
198
|
+
const agents = listOf(rule.userAgent);
|
|
199
|
+
const lines = (agents.length ? agents : ['*']).map((a) => `User-agent: ${a}`);
|
|
200
|
+
for (const a of listOf(rule.allow)) lines.push(`Allow: ${a}`);
|
|
201
|
+
for (const d of listOf(rule.disallow)) lines.push(`Disallow: ${d}`);
|
|
202
|
+
if (typeof rule.crawlDelay === 'number' && rule.crawlDelay >= 0) lines.push(`Crawl-delay: ${rule.crawlDelay}`);
|
|
203
|
+
blocks.push(lines.join('\n'));
|
|
204
|
+
}
|
|
205
|
+
const tail = [];
|
|
206
|
+
for (const h of listOf(config.host)) tail.push(`Host: ${h}`);
|
|
207
|
+
for (const s of listOf(config.sitemap)) tail.push(`Sitemap: ${s}`);
|
|
208
|
+
if (tail.length) blocks.push(tail.join('\n'));
|
|
209
|
+
return blocks.join('\n\n') + '\n';
|
|
210
|
+
}
|
package/src/ssr/head.js
CHANGED
|
@@ -8,6 +8,7 @@ import { publicEnvShim } from './env-shim.js';
|
|
|
8
8
|
import { clientRouterEnabled } from './client-router-flag.js';
|
|
9
9
|
import { embedScriptTag } from '../dev-embed.js';
|
|
10
10
|
import { devReloadState } from '../dev-reload-state.js';
|
|
11
|
+
import { openGraphPairs, twitterPairs, setMetadataImageRoutes } from './seo.js';
|
|
11
12
|
|
|
12
13
|
// Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
|
|
13
14
|
// Set at boot and on each route rebuild from the route table, the same shape
|
|
@@ -32,6 +33,9 @@ export function setMetadataIconRoutes(metadataRoutes) {
|
|
|
32
33
|
const stems = new Set();
|
|
33
34
|
for (const r of metadataRoutes || []) if (r && r.stem) stems.add(r.stem);
|
|
34
35
|
_metadataIconRoutes = { icon: stems.has('icon'), apple: stems.has('apple-icon') };
|
|
36
|
+
// The share-image routes (#1564) are recorded from the same table so one
|
|
37
|
+
// call keeps both in step on boot and on every rebuild.
|
|
38
|
+
setMetadataImageRoutes(metadataRoutes);
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -348,7 +352,8 @@ export function wrapHead(opts) {
|
|
|
348
352
|
// value looks like a relative URL (no scheme, no `//` prefix), resolve
|
|
349
353
|
// it. Otherwise return as-is. Used by og:image, twitter:image,
|
|
350
354
|
// alternates.canonical / languages / media.
|
|
351
|
-
const base =
|
|
355
|
+
const base = m.metadataBase instanceof URL ? m.metadataBase.toString()
|
|
356
|
+
: typeof m.metadataBase === 'string' ? m.metadataBase : '';
|
|
352
357
|
/** @param {unknown} v */
|
|
353
358
|
const absUrl = (v) => {
|
|
354
359
|
const s = String(v);
|
|
@@ -556,10 +561,11 @@ export function wrapHead(opts) {
|
|
|
556
561
|
}
|
|
557
562
|
}
|
|
558
563
|
|
|
564
|
+
// Open Graph: both the Next shape (`images` array / objects, camelCase
|
|
565
|
+
// `siteName`, article fields) and the raw `og:<key>` shape (#1564).
|
|
559
566
|
if (m.openGraph && typeof m.openGraph === 'object') {
|
|
560
|
-
for (const [
|
|
561
|
-
|
|
562
|
-
metaTags.push(`<meta property="og:${escapeAttr(k)}" content="${escapeAttr(out)}">`);
|
|
567
|
+
for (const [prop, value] of openGraphPairs(m.openGraph, absUrl)) {
|
|
568
|
+
metaTags.push(`<meta property="${escapeAttr(prop)}" content="${escapeAttr(value)}">`);
|
|
563
569
|
}
|
|
564
570
|
}
|
|
565
571
|
|
|
@@ -567,9 +573,8 @@ export function wrapHead(opts) {
|
|
|
567
573
|
// but won't upgrade to summary_large_image without an explicit
|
|
568
574
|
// twitter:card entry.
|
|
569
575
|
if (m.twitter && typeof m.twitter === 'object') {
|
|
570
|
-
for (const [
|
|
571
|
-
|
|
572
|
-
metaTags.push(`<meta name="twitter:${escapeAttr(k)}" content="${escapeAttr(out)}">`);
|
|
576
|
+
for (const [name, value] of twitterPairs(m.twitter, absUrl)) {
|
|
577
|
+
metaTags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(value)}">`);
|
|
573
578
|
}
|
|
574
579
|
}
|
|
575
580
|
|
package/src/ssr/render.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
cachedHtmlResponse, getNonce, htmlResponse, streamingHtmlResponse,
|
|
19
19
|
} from './responses.js';
|
|
20
20
|
import { devImportSpecifier } from '../dev-import.js';
|
|
21
|
+
import { applySeoDefaults } from './seo.js';
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -192,7 +193,11 @@ async function collectMetadata(route, ctx, dev) {
|
|
|
192
193
|
// ignore: metadata collection never fails the request
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
|
-
|
|
196
|
+
try {
|
|
197
|
+
return applySeoDefaults(meta, { url: String(ctx.url) });
|
|
198
|
+
} catch {
|
|
199
|
+
return meta;
|
|
200
|
+
}
|
|
196
201
|
}
|
|
197
202
|
|
|
198
203
|
/**
|
package/src/ssr/seo.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search and share defaults for page metadata (#1564).
|
|
3
|
+
*
|
|
4
|
+
* `applySeoDefaults()` runs once per page render, after the layout + page
|
|
5
|
+
* metadata has been merged, and fills in the tags every public page should
|
|
6
|
+
* carry when the author did not set them:
|
|
7
|
+
*
|
|
8
|
+
* - `metadataBase` from the `SITE_URL` env var (the app's public origin), so
|
|
9
|
+
* relative canonical / og:image / og:url values become absolute;
|
|
10
|
+
* - a canonical URL (site URL + pathname, no query) when a site URL is known;
|
|
11
|
+
* - `og:title` / `og:description` from `title` / `description`, `og:type`
|
|
12
|
+
* `website`, `og:url` from the canonical;
|
|
13
|
+
* - `og:image` / `twitter:image` from an `opengraph-image` / `twitter-image`
|
|
14
|
+
* metadata route (the nearest one above the page), Next's behaviour;
|
|
15
|
+
* - `twitter:card` (`summary_large_image` when there is an image).
|
|
16
|
+
*
|
|
17
|
+
* Every default yields to an explicit value, and `alternates.canonical: null`
|
|
18
|
+
* opts a page out of the canonical. The canonical NEVER falls back to the
|
|
19
|
+
* request origin: the forwarded host is client-influenced (see forwarded.js),
|
|
20
|
+
* so only a configured site URL is trusted to name the public address.
|
|
21
|
+
*
|
|
22
|
+
* Defaults are added only when the page has something to say (a title, a
|
|
23
|
+
* description, an image or a site URL), so an app with no metadata renders the
|
|
24
|
+
* same head it always did.
|
|
25
|
+
*/
|
|
26
|
+
import { basePath } from '../importmap.js';
|
|
27
|
+
import { withBasePath } from '../base-path.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The `opengraph-image` / `twitter-image` metadata routes, as
|
|
31
|
+
* `{ prefix, urlPath }` (prefix = the URL segment the route sits in). Set at
|
|
32
|
+
* boot and on every route rebuild, beside the icon routes in head.js.
|
|
33
|
+
* @type {{ og: Array<{ prefix: string, urlPath: string }>, tw: Array<{ prefix: string, urlPath: string }> }}
|
|
34
|
+
*/
|
|
35
|
+
let _imageRoutes = { og: [], tw: [] };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Record the image metadata routes the app defines.
|
|
39
|
+
* @param {Iterable<{ stem: string, urlPath: string }> | null | undefined} metadataRoutes
|
|
40
|
+
*/
|
|
41
|
+
export function setMetadataImageRoutes(metadataRoutes) {
|
|
42
|
+
/** @type {typeof _imageRoutes} */
|
|
43
|
+
const next = { og: [], tw: [] };
|
|
44
|
+
for (const r of metadataRoutes || []) {
|
|
45
|
+
if (!r || !r.urlPath) continue;
|
|
46
|
+
const list = r.stem === 'opengraph-image' ? next.og : r.stem === 'twitter-image' ? next.tw : null;
|
|
47
|
+
if (!list) continue;
|
|
48
|
+
const prefix = r.urlPath.slice(0, r.urlPath.lastIndexOf('/')) || '/';
|
|
49
|
+
list.push({ prefix, urlPath: r.urlPath });
|
|
50
|
+
}
|
|
51
|
+
// Deepest first, so the nearest route above a page wins.
|
|
52
|
+
next.og.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
53
|
+
next.tw.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
54
|
+
_imageRoutes = next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The nearest image route whose segment contains `pathname`.
|
|
59
|
+
* @param {Array<{ prefix: string, urlPath: string }>} list
|
|
60
|
+
* @param {string} pathname
|
|
61
|
+
*/
|
|
62
|
+
function nearestImageRoute(list, pathname) {
|
|
63
|
+
for (const r of list) {
|
|
64
|
+
if (r.prefix === '/' || pathname === r.prefix || pathname.startsWith(r.prefix + '/')) {
|
|
65
|
+
return withBasePath(r.urlPath, basePath());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The configured public origin of the app: `SITE_URL`, trimmed of a trailing
|
|
73
|
+
* slash, or '' when unset or not an http(s) URL.
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function siteUrlFromEnv() {
|
|
77
|
+
const raw = typeof process !== 'undefined' && process.env ? process.env.SITE_URL : '';
|
|
78
|
+
if (!raw) return '';
|
|
79
|
+
try {
|
|
80
|
+
const u = new URL(raw);
|
|
81
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
|
|
82
|
+
return u.toString().replace(/\/$/, '');
|
|
83
|
+
} catch {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `metadataBase` as a string (Next allows a URL object), or ''.
|
|
90
|
+
* @param {unknown} v
|
|
91
|
+
*/
|
|
92
|
+
function baseString(v) {
|
|
93
|
+
if (v instanceof URL) return v.toString();
|
|
94
|
+
if (typeof v === 'string') return v;
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {unknown} v */
|
|
99
|
+
const hasImage = (v) => v != null && v !== '' && !(Array.isArray(v) && v.length === 0);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fill in search and share defaults on merged page metadata. Pure apart from
|
|
103
|
+
* reading `SITE_URL` and the recorded image routes; never throws.
|
|
104
|
+
*
|
|
105
|
+
* @param {Record<string, any>} meta merged metadata (not mutated)
|
|
106
|
+
* @param {{ url: string }} ctx the page render context (`url` is the request URL)
|
|
107
|
+
* @returns {Record<string, any>}
|
|
108
|
+
*/
|
|
109
|
+
export function applySeoDefaults(meta, ctx) {
|
|
110
|
+
/** @type {Record<string, any>} */
|
|
111
|
+
const m = { ...meta };
|
|
112
|
+
let pathname = '/';
|
|
113
|
+
try {
|
|
114
|
+
pathname = new URL(ctx.url).pathname;
|
|
115
|
+
} catch {
|
|
116
|
+
// keep '/'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const base = baseString(m.metadataBase) || siteUrlFromEnv();
|
|
120
|
+
if (base) m.metadataBase = base;
|
|
121
|
+
else delete m.metadataBase;
|
|
122
|
+
|
|
123
|
+
// Canonical: explicit wins, `null` opts out, else site URL + pathname.
|
|
124
|
+
const alternates = m.alternates && typeof m.alternates === 'object' ? { ...m.alternates } : null;
|
|
125
|
+
if (alternates && alternates.canonical === null) {
|
|
126
|
+
delete alternates.canonical;
|
|
127
|
+
m.alternates = alternates;
|
|
128
|
+
} else if (base && (!alternates || alternates.canonical === undefined)) {
|
|
129
|
+
m.alternates = { ...(alternates || {}), canonical: withBasePath(pathname, basePath()) };
|
|
130
|
+
}
|
|
131
|
+
const canonical = m.alternates && m.alternates.canonical;
|
|
132
|
+
|
|
133
|
+
const title = typeof m.title === 'string' ? m.title : '';
|
|
134
|
+
const description = typeof m.description === 'string' ? m.description : '';
|
|
135
|
+
const ogImageRoute = nearestImageRoute(_imageRoutes.og, pathname);
|
|
136
|
+
const twImageRoute = nearestImageRoute(_imageRoutes.tw, pathname);
|
|
137
|
+
|
|
138
|
+
const og = m.openGraph && typeof m.openGraph === 'object' ? { ...m.openGraph } : null;
|
|
139
|
+
const tw = m.twitter && typeof m.twitter === 'object' ? { ...m.twitter } : null;
|
|
140
|
+
const ogHasImage = og && (hasImage(og.images) || hasImage(og.image));
|
|
141
|
+
const twHasImage = tw && (hasImage(tw.images) || hasImage(tw.image));
|
|
142
|
+
const worthIt = title || description || base || og || tw || ogImageRoute || twImageRoute;
|
|
143
|
+
if (!worthIt) return m;
|
|
144
|
+
|
|
145
|
+
const o = og || {};
|
|
146
|
+
if (o.title == null && title) o.title = title;
|
|
147
|
+
if (o.description == null && description) o.description = description;
|
|
148
|
+
if (o.url == null && canonical) o.url = canonical;
|
|
149
|
+
if (o.type == null) o.type = 'website';
|
|
150
|
+
if (!ogHasImage && ogImageRoute) o.images = [ogImageRoute];
|
|
151
|
+
m.openGraph = o;
|
|
152
|
+
|
|
153
|
+
const t = tw || {};
|
|
154
|
+
// Without a twitter image, X reads og:image on its own; only the card type
|
|
155
|
+
// needs saying, so the larger preview is used.
|
|
156
|
+
if (!twHasImage && twImageRoute) t.images = [twImageRoute];
|
|
157
|
+
if (t.card == null) {
|
|
158
|
+
const anyImage = hasImage(t.images) || hasImage(t.image) || hasImage(o.images) || hasImage(o.image);
|
|
159
|
+
t.card = anyImage ? 'summary_large_image' : 'summary';
|
|
160
|
+
}
|
|
161
|
+
m.twitter = t;
|
|
162
|
+
return m;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** camelCase -> snake_case for an Open Graph property (`siteName` -> `site_name`). */
|
|
166
|
+
const snake = (/** @type {string} */ k) => k.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
|
|
167
|
+
|
|
168
|
+
/** Open Graph article fields Next exposes in camelCase, emitted as `article:*`. */
|
|
169
|
+
const ARTICLE_KEYS = /** @type {Record<string, string>} */ ({
|
|
170
|
+
publishedTime: 'article:published_time',
|
|
171
|
+
modifiedTime: 'article:modified_time',
|
|
172
|
+
expirationTime: 'article:expiration_time',
|
|
173
|
+
authors: 'article:author',
|
|
174
|
+
section: 'article:section',
|
|
175
|
+
tags: 'article:tag',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** Media keys and the property they emit. */
|
|
179
|
+
const OG_MEDIA = /** @type {Record<string, string>} */ ({
|
|
180
|
+
images: 'og:image', image: 'og:image',
|
|
181
|
+
videos: 'og:video', video: 'og:video',
|
|
182
|
+
audio: 'og:audio',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Turn `metadata.openGraph` into `[property, value]` pairs. Accepts both the
|
|
187
|
+
* Next shape (`images` as a string, URL, object or array, camelCase keys,
|
|
188
|
+
* article fields) and the raw `og:<key>` shape (`image`, `site_name`,
|
|
189
|
+
* `'image:width'`).
|
|
190
|
+
*
|
|
191
|
+
* @param {Record<string, any>} og
|
|
192
|
+
* @param {(u: unknown) => string} absUrl
|
|
193
|
+
* @returns {Array<[string, string]>}
|
|
194
|
+
*/
|
|
195
|
+
export function openGraphPairs(og, absUrl) {
|
|
196
|
+
/** @type {Array<[string, string]>} */
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const [k, v] of Object.entries(og)) {
|
|
199
|
+
if (v == null || v === false || v === '') continue;
|
|
200
|
+
if (OG_MEDIA[k]) {
|
|
201
|
+
mediaPairs(out, OG_MEDIA[k], v, absUrl);
|
|
202
|
+
} else if (ARTICLE_KEYS[k]) {
|
|
203
|
+
for (const item of Array.isArray(v) ? v : [v]) out.push([ARTICLE_KEYS[k], item instanceof Date ? item.toISOString() : String(item)]);
|
|
204
|
+
} else if (k === 'url') {
|
|
205
|
+
out.push(['og:url', absUrl(v)]);
|
|
206
|
+
} else if (Array.isArray(v)) {
|
|
207
|
+
for (const item of v) out.push(['og:' + snake(k), String(item)]);
|
|
208
|
+
} else if (typeof v !== 'object' || v instanceof URL) {
|
|
209
|
+
out.push(['og:' + snake(k), String(v)]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {Array<[string, string]>} out
|
|
217
|
+
* @param {string} prop e.g. `og:image` / `twitter:image`
|
|
218
|
+
* @param {unknown} v string | URL | { url, width, height, alt, type, secureUrl } | array of those
|
|
219
|
+
* @param {(u: unknown) => string} absUrl
|
|
220
|
+
*/
|
|
221
|
+
function mediaPairs(out, prop, v, absUrl) {
|
|
222
|
+
for (const item of Array.isArray(v) ? v : [v]) {
|
|
223
|
+
if (item == null || item === '') continue;
|
|
224
|
+
if (typeof item === 'string' || item instanceof URL) {
|
|
225
|
+
out.push([prop, absUrl(item)]);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (typeof item !== 'object') continue;
|
|
229
|
+
const rec = /** @type {Record<string, unknown>} */ (item);
|
|
230
|
+
if (!rec.url) continue;
|
|
231
|
+
out.push([prop, absUrl(rec.url)]);
|
|
232
|
+
for (const [ik, iv] of Object.entries(rec)) {
|
|
233
|
+
if (ik === 'url' || iv == null || iv === '') continue;
|
|
234
|
+
const sub = ik === 'secureUrl' ? 'secure_url' : snake(ik);
|
|
235
|
+
out.push([`${prop}:${sub}`, sub === 'secure_url' ? absUrl(iv) : String(iv)]);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Turn `metadata.twitter` into `[name, value]` pairs (`images` -> repeated
|
|
242
|
+
* `twitter:image` + `twitter:image:alt`, `siteId` -> `twitter:site:id`).
|
|
243
|
+
*
|
|
244
|
+
* @param {Record<string, any>} tw
|
|
245
|
+
* @param {(u: unknown) => string} absUrl
|
|
246
|
+
* @returns {Array<[string, string]>}
|
|
247
|
+
*/
|
|
248
|
+
export function twitterPairs(tw, absUrl) {
|
|
249
|
+
/** @type {Array<[string, string]>} */
|
|
250
|
+
const out = [];
|
|
251
|
+
for (const [k, v] of Object.entries(tw)) {
|
|
252
|
+
if (v == null || v === false || v === '') continue;
|
|
253
|
+
if (k === 'images' || k === 'image') {
|
|
254
|
+
for (const item of Array.isArray(v) ? v : [v]) {
|
|
255
|
+
if (typeof item === 'string' || item instanceof URL) out.push(['twitter:image', absUrl(item)]);
|
|
256
|
+
else if (item && typeof item === 'object' && item.url) {
|
|
257
|
+
out.push(['twitter:image', absUrl(item.url)]);
|
|
258
|
+
if (item.alt) out.push(['twitter:image:alt', String(item.alt)]);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else if (k === 'siteId' || k === 'creatorId') {
|
|
262
|
+
out.push([`twitter:${k.slice(0, -2)}:id`, String(v)]);
|
|
263
|
+
} else if (typeof v !== 'object') {
|
|
264
|
+
out.push(['twitter:' + k, String(v)]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|