Skip to content

Commit fdc35cb

Browse files
chrisbbreuerclaude
andcommitted
feat(router): layered pagesDirs so frameworks can ship default views
Apps that build on top of a framework (storefronts, dashboards, blogs) want to inherit a stack of default views — cart, checkout, orders, welcome emails — and override only the ones they care about. Until now the Router accepted exactly one `pagesDir`, so a project either took the framework defaults wholesale (by symlinking) or copied every default into its own views directory just to keep custom routes. This adds an ordered `pagesDirs` to RouterConfig. The Router scans each root in order, and the first match wins per pattern — so a user's `resources/views/cart.stx` shadows the framework's `storage/framework/defaults/resources/views/cart.stx`, and any default view the user hasn't customized still resolves through the second root. `bun-plugin/src/serve.ts` now passes ALL `patterns` through to the Router, instead of only the first one. The on-disk file walker (`discoverFiles`) already aggregated across all patterns, but the generated `.stx/routes.ts` manifest and the route-name type emitter were dropping every pattern after the first — so framework default pages worked sometimes but not predictably. Backwards compatible: `pagesDir` still works as before when set alone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8a6559c commit fdc35cb

4 files changed

Lines changed: 192 additions & 18 deletions

File tree

packages/bun-plugin/src/serve.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,11 +474,15 @@ export async function serve(options: ServeOptions): Promise<void> {
474474
pageMiddlewarePatterns.length = 0
475475
await Promise.all(files.map(f => detectPageMiddleware(f)))
476476

477-
// Generate route manifest and type declarations into .stx/
477+
// Generate route manifest and type declarations into .stx/.
478+
// Pass ALL patterns as a stack of page roots so frameworks can ship
479+
// default views (e.g. cart, checkout, orders) and apps can override
480+
// any of them by dropping a file with the same path into their own
481+
// `resources/views`. The first matching root wins per pattern.
478482
try {
479483
const { Router } = await import('stx-router')
480-
const pagesDir = patterns[0]?.replace(/\/$/, '') || 'pages'
481-
const router = new Router(process.cwd(), { pagesDir })
484+
const pagesDirs = patterns.map(p => p.replace(/\/$/, '')).filter(Boolean)
485+
const router = new Router(process.cwd(), { pagesDirs })
482486
console.log(`[stx] Generated ${router.routes.length} routes → .stx/routes.ts`)
483487
}
484488
catch (e) {

packages/router/src/file-router.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,29 +45,51 @@ function scanDirectory(dir: string, extensions: string[]): string[] {
4545
export class Router {
4646
routes: Route[]
4747
private pagesDir: string
48+
private pagesDirs: string[]
4849
private config: RouterConfig
4950

5051
constructor(baseDir: string, config: RouterConfig = {}) {
5152
this.config = config
52-
this.pagesDir = path.resolve(baseDir, config.pagesDir || 'pages')
53-
const extensions = config.extensions || ['.stx']
5453

55-
const files = scanDirectory(this.pagesDir, extensions)
54+
// Resolve the stack of page roots. `pagesDirs` wins when both are
55+
// set; otherwise fall back to the single-root `pagesDir`. Each
56+
// entry is resolved relative to `baseDir`.
57+
const rawDirs = (config.pagesDirs && config.pagesDirs.length > 0)
58+
? config.pagesDirs
59+
: [config.pagesDir || 'pages']
60+
this.pagesDirs = rawDirs.map(d => path.resolve(baseDir, d))
61+
// For backwards compatibility, `pagesDir` keeps pointing at the
62+
// first root — that's the user-facing root for codegen output.
63+
this.pagesDir = this.pagesDirs[0]
5664

57-
this.routes = files.map((filePath) => {
58-
const pattern = filePathToPattern(filePath, this.pagesDir)
59-
const { regex, params } = patternToRegex(pattern)
60-
const layouts = config.layouts !== false ? resolveLayoutChain(filePath, this.pagesDir) : []
65+
const extensions = config.extensions || ['.stx']
6166

62-
return {
63-
pattern,
64-
regex,
65-
params,
66-
filePath,
67-
isDynamic: params.length > 0,
68-
layout: layouts.length > 0 ? layouts[layouts.length - 1] : undefined,
67+
// Scan each root and bucket files by their resulting URL pattern.
68+
// Earlier roots win, so a user's `resources/views/cart.stx` shadows
69+
// the framework's `storage/framework/defaults/resources/views/cart.stx`.
70+
const seenPatterns = new Set<string>()
71+
this.routes = []
72+
for (const dir of this.pagesDirs) {
73+
const files = scanDirectory(dir, extensions)
74+
for (const filePath of files) {
75+
const pattern = filePathToPattern(filePath, dir)
76+
if (seenPatterns.has(pattern))
77+
continue
78+
seenPatterns.add(pattern)
79+
80+
const { regex, params } = patternToRegex(pattern)
81+
const layouts = config.layouts !== false ? resolveLayoutChain(filePath, dir) : []
82+
83+
this.routes.push({
84+
pattern,
85+
regex,
86+
params,
87+
filePath,
88+
isDynamic: params.length > 0,
89+
layout: layouts.length > 0 ? layouts[layouts.length - 1] : undefined,
90+
})
6991
}
70-
})
92+
}
7193

7294
// Sort: static before dynamic, more segments first
7395
this.routes.sort((a, b) => {

packages/router/src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,24 @@ export interface RouteMatch {
1515
}
1616

1717
export interface RouterConfig {
18+
/**
19+
* Single page root. Resolved relative to `baseDir`. Default: `'pages'`.
20+
*
21+
* Use `pagesDirs` instead when you want a stack of roots (e.g. user
22+
* views on top, framework defaults underneath).
23+
*/
1824
pagesDir?: string
25+
/**
26+
* Stack of page roots, scanned in order. The first match for any
27+
* given route pattern wins, so earlier entries override later ones.
28+
*
29+
* Useful for app frameworks that ship default views (cart, checkout,
30+
* orders) but want apps to override individual pages by dropping a
31+
* file with the same name into their own views directory.
32+
*
33+
* If both `pagesDir` and `pagesDirs` are set, `pagesDirs` takes precedence.
34+
*/
35+
pagesDirs?: string[]
1936
extensions?: string[]
2037
layouts?: boolean
2138
middleware?: boolean
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2+
import * as fs from 'node:fs'
3+
import * as os from 'node:os'
4+
import * as path from 'node:path'
5+
import { Router } from '../src/file-router'
6+
7+
/**
8+
* Layered page roots: the Router accepts an ordered stack of page
9+
* directories. Earlier roots shadow later ones for the same pattern,
10+
* which lets a framework ship default views (cart, checkout, orders)
11+
* and let apps override them just by dropping a file with the same
12+
* relative path into their own views directory.
13+
*/
14+
describe('Router with layered pagesDirs', () => {
15+
let baseDir: string
16+
let userDir: string
17+
let defaultsDir: string
18+
19+
beforeEach(() => {
20+
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stx-router-roots-'))
21+
userDir = path.join(baseDir, 'resources', 'views')
22+
defaultsDir = path.join(baseDir, 'storage', 'framework', 'defaults', 'resources', 'views')
23+
fs.mkdirSync(userDir, { recursive: true })
24+
fs.mkdirSync(path.join(defaultsDir, 'orders'), { recursive: true })
25+
})
26+
27+
afterEach(() => {
28+
fs.rmSync(baseDir, { recursive: true, force: true })
29+
})
30+
31+
function writeView(dir: string, relPath: string, body = '<html></html>'): string {
32+
const full = path.join(dir, relPath)
33+
fs.mkdirSync(path.dirname(full), { recursive: true })
34+
fs.writeFileSync(full, body)
35+
return full
36+
}
37+
38+
it('serves a default-only page from the second root', () => {
39+
writeView(userDir, 'index.stx')
40+
writeView(defaultsDir, 'cart.stx')
41+
writeView(defaultsDir, 'orders/[id].stx')
42+
43+
const router = new Router(baseDir, {
44+
pagesDirs: [
45+
path.relative(baseDir, userDir),
46+
path.relative(baseDir, defaultsDir),
47+
],
48+
})
49+
50+
const patterns = router.routes.map(r => r.pattern).sort()
51+
expect(patterns).toContain('/cart')
52+
expect(patterns).toContain('/orders/:id')
53+
expect(patterns).toContain('/')
54+
})
55+
56+
it('first root shadows the second for the same pattern', () => {
57+
const userIndex = writeView(userDir, 'index.stx', '<!-- user -->')
58+
writeView(defaultsDir, 'index.stx', '<!-- defaults -->')
59+
60+
const router = new Router(baseDir, {
61+
pagesDirs: [
62+
path.relative(baseDir, userDir),
63+
path.relative(baseDir, defaultsDir),
64+
],
65+
})
66+
67+
const indexRoute = router.routes.find(r => r.pattern === '/')
68+
expect(indexRoute).toBeDefined()
69+
expect(indexRoute!.filePath).toBe(userIndex)
70+
71+
// The shadowed default must NOT also be in the route list — that
72+
// would duplicate `/` and the wrong one might match first.
73+
const indexes = router.routes.filter(r => r.pattern === '/')
74+
expect(indexes.length).toBe(1)
75+
})
76+
77+
it('falls back to second root for patterns not present in the first', () => {
78+
writeView(userDir, 'index.stx')
79+
const defaultCart = writeView(defaultsDir, 'cart.stx', '<!-- defaults -->')
80+
81+
const router = new Router(baseDir, {
82+
pagesDirs: [
83+
path.relative(baseDir, userDir),
84+
path.relative(baseDir, defaultsDir),
85+
],
86+
})
87+
88+
const cartRoute = router.routes.find(r => r.pattern === '/cart')
89+
expect(cartRoute).toBeDefined()
90+
expect(cartRoute!.filePath).toBe(defaultCart)
91+
})
92+
93+
it('preserves backwards-compat single-root pagesDir', () => {
94+
writeView(userDir, 'about.stx')
95+
96+
const router = new Router(baseDir, {
97+
pagesDir: path.relative(baseDir, userDir),
98+
})
99+
100+
expect(router.routes.map(r => r.pattern)).toEqual(['/about'])
101+
})
102+
103+
it('matches /cart against a defaults-only view', () => {
104+
writeView(userDir, 'index.stx')
105+
writeView(defaultsDir, 'cart.stx')
106+
107+
const router = new Router(baseDir, {
108+
pagesDirs: [
109+
path.relative(baseDir, userDir),
110+
path.relative(baseDir, defaultsDir),
111+
],
112+
})
113+
114+
const match = router.match('/cart')
115+
expect(match).not.toBeNull()
116+
expect(match!.route.pattern).toBe('/cart')
117+
})
118+
119+
it('handles missing first root gracefully', () => {
120+
writeView(defaultsDir, 'cart.stx')
121+
122+
const router = new Router(baseDir, {
123+
pagesDirs: [
124+
'does/not/exist',
125+
path.relative(baseDir, defaultsDir),
126+
],
127+
})
128+
129+
expect(router.routes.map(r => r.pattern)).toContain('/cart')
130+
})
131+
})

0 commit comments

Comments
 (0)