@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 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.80",
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",
@@ -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
- * Over-detection is safe (a top-level arrow whose body calls something, or a
324
- * pure top-level helper call, only ships). The accepted residual misses, all
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
- 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
+ }