diff --git a/src/index.tsx b/src/index.tsx
index 1039cd1..1fb92d1 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -176,14 +176,18 @@ export class Hype<
})
// file based routing
- this.on('GET', ['/', '/:page'], async c => {
- const pageName = (c.req.param('page') ?? 'index').replace('.', '')
- if (pageName.startsWith('_')) return render404(c)
+ this.on('GET', ['/', '/:page{.+}'], async c => {
+ const pageName = (c.req.param('page') ?? 'index').replace(/\.\./g, '')
+ if (pageName.split('/').some(s => s.startsWith('_'))) return render404(c)
- const path = join(process.cwd(), `./src/pages/${pageName}.tsx`)
-
- if (!(await Bun.file(path).exists()))
- return render404(c)
+ // try pageName.tsx, then pageName/index.tsx
+ const base = join(process.cwd(), './src/pages')
+ let path = join(base, `${pageName}.tsx`)
+ if (!(await Bun.file(path).exists())) {
+ path = join(base, `${pageName}/index.tsx`)
+ if (!(await Bun.file(path).exists()))
+ return render404(c)
+ }
let Layout = defaultLayout
const layoutPath = join(process.cwd(), `./src/pages/_layout.tsx`)
diff --git a/src/tests/pages-routing.test.ts b/src/tests/pages-routing.test.ts
new file mode 100644
index 0000000..0cc2f7f
--- /dev/null
+++ b/src/tests/pages-routing.test.ts
@@ -0,0 +1,137 @@
+import { test, expect, beforeAll, afterAll } from "bun:test"
+import { Hype } from "../index.tsx"
+import { join } from "path"
+import { mkdirSync, writeFileSync, rmSync } from "fs"
+
+// PWD is set to testRoot, so pages resolve at testRoot/src/pages/
+const testRoot = join(process.cwd(), "src/tests/_test_root")
+const pagesDir = join(testRoot, "src/pages")
+
+beforeAll(() => {
+ mkdirSync(join(pagesDir, "editor"), { recursive: true })
+ mkdirSync(join(pagesDir, "deep/nested"), { recursive: true })
+
+ writeFileSync(join(pagesDir, "index.tsx"), `export default
Home
`)
+ writeFileSync(join(pagesDir, "about.tsx"), `export default About
`)
+ writeFileSync(join(pagesDir, "editor/spell.tsx"), `export default Spell Editor
`)
+ writeFileSync(join(pagesDir, "deep/nested/page.tsx"), `export default Deep Page
`)
+ writeFileSync(join(pagesDir, "editor/index.tsx"), `export default Editor Index
`)
+ writeFileSync(join(pagesDir, "_private.tsx"), `export default Private
`)
+ writeFileSync(join(pagesDir, "editor/_secret.tsx"), `export default Secret
`)
+})
+
+afterAll(() => {
+ rmSync(testRoot, { recursive: true, force: true })
+})
+
+test("single-segment page route", async () => {
+ const origPwd = process.env.PWD
+ process.env.PWD = testRoot
+ const app = new Hype({ layout: false, logging: false, prettyHTML: false })
+ const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
+
+ const res = await fetch(`http://localhost:${server.port}/about`)
+ const text = await res.text()
+ expect(res.status).toBe(200)
+ expect(text).toContain("About")
+
+ server.stop(true)
+ process.env.PWD = origPwd
+})
+
+test("nested page route (editor/spell)", async () => {
+ const origPwd = process.env.PWD
+ process.env.PWD = testRoot
+ const app = new Hype({ layout: false, logging: false, prettyHTML: false })
+ const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
+
+ const res = await fetch(`http://localhost:${server.port}/editor/spell`)
+ const text = await res.text()
+ expect(res.status).toBe(200)
+ expect(text).toContain("Spell Editor")
+
+ server.stop(true)
+ process.env.PWD = origPwd
+})
+
+test("deeply nested page route", async () => {
+ const origPwd = process.env.PWD
+ process.env.PWD = testRoot
+ const app = new Hype({ layout: false, logging: false, prettyHTML: false })
+ const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
+
+ const res = await fetch(`http://localhost:${server.port}/deep/nested/page`)
+ const text = await res.text()
+ expect(res.status).toBe(200)
+ expect(text).toContain("Deep Page")
+
+ server.stop(true)
+ process.env.PWD = origPwd
+})
+
+test("subdirectory index.tsx fallback", async () => {
+ const origPwd = process.env.PWD
+ process.env.PWD = testRoot
+ const app = new Hype({ layout: false, logging: false, prettyHTML: false })
+ const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
+
+ const res = await fetch(`http://localhost:${server.port}/editor`)
+ const text = await res.text()
+ expect(res.status).toBe(200)
+ expect(text).toContain("Editor Index")
+
+ server.stop(true)
+ process.env.PWD = origPwd
+})
+
+test("underscore-prefixed files return 404", async () => {
+ const origPwd = process.env.PWD
+ process.env.PWD = testRoot
+ const app = new Hype({ layout: false, logging: false, prettyHTML: false })
+ const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
+
+ const res = await fetch(`http://localhost:${server.port}/_private`)
+ expect(res.status).toBe(404)
+
+ server.stop(true)
+ process.env.PWD = origPwd
+})
+
+test("underscore-prefixed files in subdirectories return 404", async () => {
+ const origPwd = process.env.PWD
+ process.env.PWD = testRoot
+ const app = new Hype({ layout: false, logging: false, prettyHTML: false })
+ const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
+
+ const res = await fetch(`http://localhost:${server.port}/editor/_secret`)
+ expect(res.status).toBe(404)
+
+ server.stop(true)
+ process.env.PWD = origPwd
+})
+
+test("nonexistent page returns 404", async () => {
+ const origPwd = process.env.PWD
+ process.env.PWD = testRoot
+ const app = new Hype({ layout: false, logging: false, prettyHTML: false })
+ const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
+
+ const res = await fetch(`http://localhost:${server.port}/does/not/exist`)
+ expect(res.status).toBe(404)
+
+ server.stop(true)
+ process.env.PWD = origPwd
+})
+
+test("path traversal is blocked", async () => {
+ const origPwd = process.env.PWD
+ process.env.PWD = testRoot
+ const app = new Hype({ layout: false, logging: false, prettyHTML: false })
+ const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
+
+ const res = await fetch(`http://localhost:${server.port}/../../../etc/passwd`)
+ expect(res.status).toBe(404)
+
+ server.stop(true)
+ process.env.PWD = origPwd
+})