@@ -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) {
173205function 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
0 commit comments