@webjsdev/server 0.8.79 → 0.8.81

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 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.79",
3
+ "version": "0.8.81",
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",
@@ -585,6 +585,12 @@ function __webjsReportVisibility(send) {
585
585
  });
586
586
  globalThis.__webjsDevActivity = activity;
587
587
  }
588
+ let __webjsFellBack = false;
589
+ function __webjsFallBack() {
590
+ if (__webjsFellBack) return;
591
+ __webjsFellBack = true;
592
+ __webjsDirectEvents();
593
+ }
588
594
  try {
589
595
  if (typeof SharedWorker !== 'undefined') {
590
596
  const w = new SharedWorker(${workerUrl});
@@ -593,13 +599,25 @@ try {
593
599
  if (m.type === 'reload') __webjsApplyReload(m.verdict);
594
600
  else if (m.type === 'webjs-error') __webjsApplyError(m.data);
595
601
  };
602
+ // The worker's SCRIPT can fail to load where the page itself loaded: a
603
+ // host that admits the page on a partitioned cookie, which Chromium does
604
+ // not send with a shared worker's script request from a third-party frame
605
+ // (a private preview embedded in a builder). That is an error event, not a
606
+ // throw, so without this the tab would get no reloads and no error
607
+ // overlay at all. Fall back to this tab's own EventSource, which the page's
608
+ // cookies do reach.
609
+ w.onerror = function () {
610
+ w.port.onmessage = null;
611
+ try { w.port.close(); } catch (_) { /* already gone */ }
612
+ __webjsFallBack();
613
+ };
596
614
  w.port.start();
597
- __webjsReportVisibility(function (msg) { w.port.postMessage(msg); });
615
+ __webjsReportVisibility(function (msg) { if (!__webjsFellBack) w.port.postMessage(msg); });
598
616
  } else {
599
- __webjsDirectEvents();
617
+ __webjsFallBack();
600
618
  }
601
619
  } catch (_) {
602
- __webjsDirectEvents();
620
+ __webjsFallBack();
603
621
  }
604
622
  `;
605
623
  }
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
- const mod = await import(devImportSpecifier(meta.file, dev));
367
- if (mod.default) {
368
- const result = await mod.default();
369
- // If the function returns a Response, use it directly.
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
- const urlPath = METADATA_URL_MAP[stem] || `/${stem}`;
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 = typeof m.metadataBase === 'string' ? m.metadataBase : '';
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 [k, v] of Object.entries(m.openGraph)) {
561
- const out = k === 'image' || k === 'url' ? absUrl(v) : String(v);
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 [k, v] of Object.entries(m.twitter)) {
571
- const out = k === 'image' ? absUrl(v) : String(v);
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
- return meta;
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
+ }