Skip to content

Commit 332f41f

Browse files
committed
feat(router): optional catch-all, nested not-found, loading→Suspense, metadata routes
- [[...slug]] optional catch-all matches with and without params - not-found.ts collected at every segment level (nearest wins) - loading.ts auto-wraps page in Suspense boundary with loading as fallback - Metadata routes: sitemap.ts, robots.ts, manifest.ts, icon.ts, opengraph-image.ts, twitter-image.ts served at well-known URLs
1 parent 81ab2eb commit 332f41f

2 files changed

Lines changed: 137 additions & 6 deletions

File tree

packages/server/src/router.js

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ import { walk } from './fs-walk.js';
2222
* middlewares: string[],
2323
* }} ApiRoute
2424
*
25+
* @typedef {{ stem: string, file: string, urlPath: string }} MetadataRoute
26+
*
2527
* @typedef {{
2628
* pages: PageRoute[],
2729
* apis: ApiRoute[],
2830
* notFound: string | null,
31+
* notFounds: Map<string, string>,
32+
* metadataRoutes: MetadataRoute[],
2933
* appDir: string
3034
* }} RouteTable
3135
*/
@@ -43,8 +47,13 @@ import { walk } from './fs-walk.js';
4347
* app/api/hello/route.js → /api/hello
4448
* app/layout.js → wraps every page
4549
* app/error.js → error boundary (nested)
46-
* app/loading.js → loading UI (reserved, v1 renders only)
47-
* app/not-found.js → 404 fallback
50+
* app/loading.js → loading UI (auto-wraps page in Suspense)
51+
* app/not-found.js → 404 fallback (nested: nearest wins)
52+
* app/[[...slug]]/page.js → optional catch-all (matches / AND /a/b)
53+
* app/sitemap.js → serves /sitemap.xml
54+
* app/robots.js → serves /robots.txt
55+
* app/icon.js → serves /icon (dynamic)
56+
* app/opengraph-image.js → serves /opengraph-image (dynamic)
4857
*
4958
* @param {string} appDir
5059
* @returns {Promise<RouteTable>}
@@ -63,7 +72,24 @@ export async function buildRouteTable(appDir) {
6372
const loadings = new Map();
6473
/** @type {Map<string,string>} */
6574
const middlewares = new Map();
75+
/** @type {Map<string, string>} */
76+
const notFounds = new Map();
6677
let notFound = null;
78+
/** @type {MetadataRoute[]} */
79+
const metadataRoutes = [];
80+
81+
/** @type {Set<string>} */
82+
const METADATA_STEMS = new Set(['sitemap', 'robots', 'manifest', 'icon', 'apple-icon', 'opengraph-image', 'twitter-image']);
83+
/** @type {Record<string,string>} */
84+
const METADATA_URL_MAP = {
85+
'sitemap': '/sitemap.xml',
86+
'robots': '/robots.txt',
87+
'manifest': '/manifest.json',
88+
'icon': '/icon',
89+
'apple-icon': '/apple-icon',
90+
'opengraph-image': '/opengraph-image',
91+
'twitter-image': '/twitter-image',
92+
};
6793

6894
for await (const file of walk(root)) {
6995
const rel = relative(root, file).split(sep).join('/');
@@ -100,8 +126,14 @@ export async function buildRouteTable(appDir) {
100126
loadings.set(dir, file);
101127
} else if (stem === 'middleware') {
102128
middlewares.set(dir, file);
103-
} else if (stem === 'not-found' && dir === '.') {
104-
notFound = file;
129+
} else if (stem === 'not-found') {
130+
notFounds.set(dir, file);
131+
if (dir === '.') notFound = file;
132+
} else if (METADATA_STEMS.has(stem) && (dir === '.' || dir.split('/').every(s => !s.startsWith('[')))) {
133+
// Metadata route: sitemap.ts, robots.ts, icon.ts, etc.
134+
// Only at root or static segments (no dynamic params in metadata routes).
135+
const urlPath = METADATA_URL_MAP[stem] || `/${stem}`;
136+
metadataRoutes.push({ stem, file, urlPath });
105137
} else if (stem === 'route') {
106138
// route.js / route.ts can live anywhere under app/ (matches Next.js).
107139
const segs = dir === '.' ? [] : dir.split('/');
@@ -127,7 +159,7 @@ export async function buildRouteTable(appDir) {
127159
}
128160

129161
pages.sort((a, b) => dynScore(a) - dynScore(b));
130-
return { pages, apis, notFound, appDir };
162+
return { pages, apis, notFound, notFounds, metadataRoutes, appDir };
131163
}
132164

133165
/**
@@ -173,9 +205,17 @@ function dynScore(r) {
173205
function segmentsToPattern(segments, prefix = '') {
174206
const paramNames = [];
175207
let isCatchAll = false;
208+
let isOptionalCatchAll = false;
176209
const parts = segments
177210
.filter(isUrlSegment)
178211
.map((seg) => {
212+
// Optional catch-all: [[...slug]] matches with AND without params
213+
if (seg.startsWith('[[...') && seg.endsWith(']]')) {
214+
paramNames.push(seg.slice(5, -2));
215+
isCatchAll = true;
216+
isOptionalCatchAll = true;
217+
return '(.*)';
218+
}
179219
if (seg.startsWith('[...') && seg.endsWith(']')) {
180220
paramNames.push(seg.slice(4, -1));
181221
isCatchAll = true;
@@ -188,7 +228,17 @@ function segmentsToPattern(segments, prefix = '') {
188228
return escapeRe(seg);
189229
});
190230
const body = parts.length ? '/' + parts.join('/') : '';
191-
const pattern = new RegExp(`^${escapeRe(prefix)}${body}/?$`);
231+
// Optional catch-all: also matches the base path without any trailing segments.
232+
// e.g., /docs/[[...slug]] matches both /docs and /docs/a/b/c
233+
const suffix = isOptionalCatchAll ? '(?:/(.*))?/?' : '/?';
234+
const regexBody = isOptionalCatchAll
235+
? body.replace(/\/\(\.\*\)$/, '') // remove the trailing (.*) — we add it as optional
236+
: body;
237+
const pattern = new RegExp(`^${escapeRe(prefix)}${regexBody}${isOptionalCatchAll ? suffix : '/?$'}`);
238+
if (!isOptionalCatchAll) {
239+
// Standard pattern needs end anchor
240+
return { pattern: new RegExp(`^${escapeRe(prefix)}${body}/?$`), paramNames, isCatchAll };
241+
}
192242
return { pattern, paramNames, isCatchAll };
193243
}
194244

test/router.test.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,84 @@ test('matches route.js anywhere under app/, not only /api', async () => {
131131
await rm(dir, { recursive: true, force: true });
132132
}
133133
});
134+
135+
test('optional catch-all [[...slug]] matches with and without params', async () => {
136+
const dir = await scaffold({
137+
'app/docs/[[...slug]]/page.js': 'export default () => ""',
138+
});
139+
try {
140+
const table = await buildRouteTable(dir);
141+
// Matches /docs (no params)
142+
const root = matchPage(table, '/docs');
143+
assert.ok(root, '/docs should match optional catch-all');
144+
// Matches /docs/getting-started
145+
const one = matchPage(table, '/docs/getting-started');
146+
assert.ok(one, '/docs/getting-started should match');
147+
assert.equal(one.params.slug, 'getting-started');
148+
// Matches /docs/a/b/c
149+
const deep = matchPage(table, '/docs/a/b/c');
150+
assert.ok(deep, '/docs/a/b/c should match');
151+
assert.equal(deep.params.slug, 'a/b/c');
152+
} finally {
153+
await rm(dir, { recursive: true, force: true });
154+
}
155+
});
156+
157+
test('nested not-found.js files are collected per segment', async () => {
158+
const dir = await scaffold({
159+
'app/page.js': 'export default () => ""',
160+
'app/not-found.js': 'export default () => "root 404"',
161+
'app/dashboard/page.js': 'export default () => ""',
162+
'app/dashboard/not-found.js': 'export default () => "dashboard 404"',
163+
});
164+
try {
165+
const table = await buildRouteTable(dir);
166+
assert.ok(table.notFound, 'root not-found should exist');
167+
assert.ok(table.notFounds.get('.'), 'root not-found in map');
168+
assert.ok(table.notFounds.get('dashboard'), 'dashboard not-found in map');
169+
} finally {
170+
await rm(dir, { recursive: true, force: true });
171+
}
172+
});
173+
174+
test('metadata routes are detected (sitemap, robots)', async () => {
175+
const dir = await scaffold({
176+
'app/page.js': 'export default () => ""',
177+
'app/sitemap.js': 'export default () => "<urlset></urlset>"',
178+
'app/robots.js': 'export default () => "User-agent: *\\nAllow: /"',
179+
});
180+
try {
181+
const table = await buildRouteTable(dir);
182+
assert.ok(table.metadataRoutes.length >= 2, `Expected >=2 metadata routes, got ${table.metadataRoutes.length}`);
183+
const sitemap = table.metadataRoutes.find((r) => r.stem === 'sitemap');
184+
assert.ok(sitemap, 'sitemap route should exist');
185+
assert.equal(sitemap.urlPath, '/sitemap.xml');
186+
const robots = table.metadataRoutes.find((r) => r.stem === 'robots');
187+
assert.ok(robots, 'robots route should exist');
188+
assert.equal(robots.urlPath, '/robots.txt');
189+
} finally {
190+
await rm(dir, { recursive: true, force: true });
191+
}
192+
});
193+
194+
test('loading.js files are attached to page routes', async () => {
195+
const dir = await scaffold({
196+
'app/page.js': 'export default () => ""',
197+
'app/loading.js': 'export default () => "Loading..."',
198+
'app/dashboard/page.js': 'export default () => ""',
199+
'app/dashboard/loading.js': 'export default () => "Dashboard loading..."',
200+
});
201+
try {
202+
const table = await buildRouteTable(dir);
203+
const root = matchPage(table, '/');
204+
assert.ok(root);
205+
assert.ok(root.route.loadings.length >= 1, 'root page should have loading');
206+
const dash = matchPage(table, '/dashboard');
207+
assert.ok(dash);
208+
assert.ok(dash.route.loadings.length >= 1, 'dashboard page should have loading');
209+
// Dashboard inherits root loading AND has its own
210+
assert.ok(dash.route.loadings.length >= 2, 'dashboard should have nested loadings');
211+
} finally {
212+
await rm(dir, { recursive: true, force: true });
213+
}
214+
});

0 commit comments

Comments
 (0)