@pyreon/server 0.11.5 → 0.11.7

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.
@@ -1,6 +1,6 @@
1
- import type { ComponentFn, VNode } from "@pyreon/core"
2
- import { h } from "@pyreon/core"
3
- import { createHandler } from "../handler"
1
+ import type { ComponentFn, VNode } from '@pyreon/core'
2
+ import { h } from '@pyreon/core'
3
+ import { createHandler } from '../handler'
4
4
  import {
5
5
  buildClientEntryTag,
6
6
  buildScripts,
@@ -9,253 +9,253 @@ import {
9
9
  DEFAULT_TEMPLATE,
10
10
  processCompiledTemplate,
11
11
  processTemplate,
12
- } from "../html"
13
- import { island } from "../island"
14
- import type { Middleware } from "../middleware"
15
- import { prerender } from "../ssg"
12
+ } from '../html'
13
+ import { island } from '../island'
14
+ import type { Middleware } from '../middleware'
15
+ import { prerender } from '../ssg'
16
16
 
17
17
  // ─── HTML template ───────────────────────────────────────────────────────────
18
18
 
19
- describe("HTML template", () => {
20
- test("processTemplate replaces all placeholders", () => {
19
+ describe('HTML template', () => {
20
+ test('processTemplate replaces all placeholders', () => {
21
21
  const result = processTemplate(DEFAULT_TEMPLATE, {
22
- head: "<title>Test</title>",
23
- app: "<div>Hello</div>",
22
+ head: '<title>Test</title>',
23
+ app: '<div>Hello</div>',
24
24
  scripts: '<script type="module" src="/app.js"></script>',
25
25
  })
26
- expect(result).toContain("<title>Test</title>")
27
- expect(result).toContain("<div>Hello</div>")
26
+ expect(result).toContain('<title>Test</title>')
27
+ expect(result).toContain('<div>Hello</div>')
28
28
  expect(result).toContain('src="/app.js"')
29
- expect(result).not.toContain("<!--pyreon-head-->")
30
- expect(result).not.toContain("<!--pyreon-app-->")
31
- expect(result).not.toContain("<!--pyreon-scripts-->")
29
+ expect(result).not.toContain('<!--pyreon-head-->')
30
+ expect(result).not.toContain('<!--pyreon-app-->')
31
+ expect(result).not.toContain('<!--pyreon-scripts-->')
32
32
  })
33
33
 
34
- test("buildScripts emits loader data + client entry", () => {
35
- const scripts = buildScripts("/entry.js", { users: [{ id: 1 }] })
36
- expect(scripts).toContain("window.__PYREON_LOADER_DATA__=")
34
+ test('buildScripts emits loader data + client entry', () => {
35
+ const scripts = buildScripts('/entry.js', { users: [{ id: 1 }] })
36
+ expect(scripts).toContain('window.__PYREON_LOADER_DATA__=')
37
37
  expect(scripts).toContain('"users"')
38
38
  expect(scripts).toContain('src="/entry.js"')
39
39
  })
40
40
 
41
- test("buildScripts escapes </script> in JSON", () => {
42
- const scripts = buildScripts("/entry.js", { html: "</script><script>alert(1)" })
43
- expect(scripts).not.toContain("</script><script>")
44
- expect(scripts).toContain("<\\/script>")
41
+ test('buildScripts escapes </script> in JSON', () => {
42
+ const scripts = buildScripts('/entry.js', { html: '</script><script>alert(1)' })
43
+ expect(scripts).not.toContain('</script><script>')
44
+ expect(scripts).toContain('<\\/script>')
45
45
  })
46
46
 
47
- test("buildScripts omits inline data when no loaders", () => {
48
- const scripts = buildScripts("/entry.js", {})
49
- expect(scripts).not.toContain("__PYREON_LOADER_DATA__")
47
+ test('buildScripts omits inline data when no loaders', () => {
48
+ const scripts = buildScripts('/entry.js', {})
49
+ expect(scripts).not.toContain('__PYREON_LOADER_DATA__')
50
50
  expect(scripts).toContain('src="/entry.js"')
51
51
  })
52
52
 
53
- test("buildScripts with null loaderData only emits client entry", () => {
54
- const scripts = buildScripts("/entry.js", null)
55
- expect(scripts).not.toContain("__PYREON_LOADER_DATA__")
53
+ test('buildScripts with null loaderData only emits client entry', () => {
54
+ const scripts = buildScripts('/entry.js', null)
55
+ expect(scripts).not.toContain('__PYREON_LOADER_DATA__')
56
56
  expect(scripts).toContain('src="/entry.js"')
57
57
  })
58
58
 
59
- test("processTemplate works with custom template string", () => {
60
- const tpl = "<head><!--pyreon-head--></head><main><!--pyreon-app--></main><!--pyreon-scripts-->"
61
- const result = processTemplate(tpl, { head: "<title>X</title>", app: "APP", scripts: "JS" })
62
- expect(result).toBe("<head><title>X</title></head><main>APP</main>JS")
59
+ test('processTemplate works with custom template string', () => {
60
+ const tpl = '<head><!--pyreon-head--></head><main><!--pyreon-app--></main><!--pyreon-scripts-->'
61
+ const result = processTemplate(tpl, { head: '<title>X</title>', app: 'APP', scripts: 'JS' })
62
+ expect(result).toBe('<head><title>X</title></head><main>APP</main>JS')
63
63
  })
64
64
 
65
- test("DEFAULT_TEMPLATE contains all three placeholders", () => {
66
- expect(DEFAULT_TEMPLATE).toContain("<!--pyreon-head-->")
67
- expect(DEFAULT_TEMPLATE).toContain("<!--pyreon-app-->")
68
- expect(DEFAULT_TEMPLATE).toContain("<!--pyreon-scripts-->")
65
+ test('DEFAULT_TEMPLATE contains all three placeholders', () => {
66
+ expect(DEFAULT_TEMPLATE).toContain('<!--pyreon-head-->')
67
+ expect(DEFAULT_TEMPLATE).toContain('<!--pyreon-app-->')
68
+ expect(DEFAULT_TEMPLATE).toContain('<!--pyreon-scripts-->')
69
69
  })
70
70
  })
71
71
 
72
72
  // ─── SSR Handler ─────────────────────────────────────────────────────────────
73
73
 
74
- describe("createHandler", () => {
75
- const Home: ComponentFn = () => h("h1", null, "Home")
76
- const About: ComponentFn = () => h("h1", null, "About")
74
+ describe('createHandler', () => {
75
+ const Home: ComponentFn = () => h('h1', null, 'Home')
76
+ const About: ComponentFn = () => h('h1', null, 'About')
77
77
  const routes = [
78
- { path: "/", component: Home },
79
- { path: "/about", component: About },
78
+ { path: '/', component: Home },
79
+ { path: '/about', component: About },
80
80
  ]
81
81
 
82
- test("renders home page", async () => {
82
+ test('renders home page', async () => {
83
83
  const handler = createHandler({ App: Home, routes })
84
- const res = await handler(new Request("http://localhost/"))
84
+ const res = await handler(new Request('http://localhost/'))
85
85
  const html = await res.text()
86
86
  expect(res.status).toBe(200)
87
- expect(res.headers.get("Content-Type")).toContain("text/html")
88
- expect(html).toContain("<h1>Home</h1>")
89
- expect(html).toContain("<!DOCTYPE html>")
87
+ expect(res.headers.get('Content-Type')).toContain('text/html')
88
+ expect(html).toContain('<h1>Home</h1>')
89
+ expect(html).toContain('<!DOCTYPE html>')
90
90
  })
91
91
 
92
- test("renders about page with correct route", async () => {
93
- const App: ComponentFn = () => h("main", null, "app")
92
+ test('renders about page with correct route', async () => {
93
+ const App: ComponentFn = () => h('main', null, 'app')
94
94
  const handler = createHandler({ App, routes })
95
- const res = await handler(new Request("http://localhost/about"))
95
+ const res = await handler(new Request('http://localhost/about'))
96
96
  expect(res.status).toBe(200)
97
97
  })
98
98
 
99
- test("uses custom template", async () => {
99
+ test('uses custom template', async () => {
100
100
  const template =
101
- "<html><!--pyreon-head--><body><!--pyreon-app--><!--pyreon-scripts--></body></html>"
101
+ '<html><!--pyreon-head--><body><!--pyreon-app--><!--pyreon-scripts--></body></html>'
102
102
  const handler = createHandler({ App: Home, routes, template })
103
- const res = await handler(new Request("http://localhost/"))
103
+ const res = await handler(new Request('http://localhost/'))
104
104
  const html = await res.text()
105
- expect(html).toContain("<html>")
106
- expect(html).toContain("<h1>Home</h1>")
107
- expect(html).not.toContain("<!--pyreon-app-->")
105
+ expect(html).toContain('<html>')
106
+ expect(html).toContain('<h1>Home</h1>')
107
+ expect(html).not.toContain('<!--pyreon-app-->')
108
108
  })
109
109
 
110
- test("includes client entry script", async () => {
111
- const handler = createHandler({ App: Home, routes, clientEntry: "/dist/client.js" })
112
- const res = await handler(new Request("http://localhost/"))
110
+ test('includes client entry script', async () => {
111
+ const handler = createHandler({ App: Home, routes, clientEntry: '/dist/client.js' })
112
+ const res = await handler(new Request('http://localhost/'))
113
113
  const html = await res.text()
114
114
  expect(html).toContain('src="/dist/client.js"')
115
115
  })
116
116
 
117
- test("serializes loader data into HTML", async () => {
118
- const WithLoader: ComponentFn = () => h("div", null, "loaded")
117
+ test('serializes loader data into HTML', async () => {
118
+ const WithLoader: ComponentFn = () => h('div', null, 'loaded')
119
119
  const loaderRoutes = [
120
120
  {
121
- path: "/",
121
+ path: '/',
122
122
  component: WithLoader,
123
123
  loader: async () => ({ items: [1, 2, 3] }),
124
124
  },
125
125
  ]
126
126
  const handler = createHandler({ App: WithLoader, routes: loaderRoutes })
127
- const res = await handler(new Request("http://localhost/"))
127
+ const res = await handler(new Request('http://localhost/'))
128
128
  const html = await res.text()
129
- expect(html).toContain("__PYREON_LOADER_DATA__")
129
+ expect(html).toContain('__PYREON_LOADER_DATA__')
130
130
  expect(html).toContain('"items"')
131
131
  })
132
132
 
133
- test("returns 500 on render error", async () => {
133
+ test('returns 500 on render error', async () => {
134
134
  const BrokenApp: ComponentFn = () => {
135
- throw new Error("boom")
135
+ throw new Error('boom')
136
136
  }
137
137
  const handler = createHandler({ App: BrokenApp, routes })
138
- const res = await handler(new Request("http://localhost/"))
138
+ const res = await handler(new Request('http://localhost/'))
139
139
  expect(res.status).toBe(500)
140
- expect(await res.text()).toBe("Internal Server Error")
140
+ expect(await res.text()).toBe('Internal Server Error')
141
141
  })
142
142
 
143
- test("handles URL with query string", async () => {
143
+ test('handles URL with query string', async () => {
144
144
  const handler = createHandler({ App: Home, routes })
145
- const res = await handler(new Request("http://localhost/?foo=bar&baz=1"))
145
+ const res = await handler(new Request('http://localhost/?foo=bar&baz=1'))
146
146
  expect(res.status).toBe(200)
147
147
  const html = await res.text()
148
- expect(html).toContain("<h1>Home</h1>")
148
+ expect(html).toContain('<h1>Home</h1>')
149
149
  })
150
150
  })
151
151
 
152
152
  // ─── Stream mode ──────────────────────────────────────────────────────────────
153
153
 
154
- describe("createHandler — stream mode", () => {
155
- const Home: ComponentFn = () => h("h1", null, "Streamed")
156
- const routes = [{ path: "/", component: Home }]
154
+ describe('createHandler — stream mode', () => {
155
+ const Home: ComponentFn = () => h('h1', null, 'Streamed')
156
+ const routes = [{ path: '/', component: Home }]
157
157
 
158
- test("returns a streaming response", async () => {
159
- const handler = createHandler({ App: Home, routes, mode: "stream" })
160
- const res = await handler(new Request("http://localhost/"))
158
+ test('returns a streaming response', async () => {
159
+ const handler = createHandler({ App: Home, routes, mode: 'stream' })
160
+ const res = await handler(new Request('http://localhost/'))
161
161
  expect(res.status).toBe(200)
162
- expect(res.headers.get("Content-Type")).toContain("text/html")
162
+ expect(res.headers.get('Content-Type')).toContain('text/html')
163
163
  const html = await res.text()
164
- expect(html).toContain("<h1>Streamed</h1>")
164
+ expect(html).toContain('<h1>Streamed</h1>')
165
165
  })
166
166
 
167
- test("stream mode uses default template placeholders", async () => {
168
- const handler = createHandler({ App: Home, routes, mode: "stream" })
169
- const res = await handler(new Request("http://localhost/"))
167
+ test('stream mode uses default template placeholders', async () => {
168
+ const handler = createHandler({ App: Home, routes, mode: 'stream' })
169
+ const res = await handler(new Request('http://localhost/'))
170
170
  const html = await res.text()
171
171
  // Should contain the template shell
172
- expect(html).toContain("<!DOCTYPE html>")
173
- expect(html).toContain("</html>")
172
+ expect(html).toContain('<!DOCTYPE html>')
173
+ expect(html).toContain('</html>')
174
174
  // Script should be present
175
175
  expect(html).toContain('src="/src/entry-client.ts"')
176
176
  })
177
177
 
178
- test("stream mode with custom template", async () => {
178
+ test('stream mode with custom template', async () => {
179
179
  const template =
180
- "<html><!--pyreon-head--><body><!--pyreon-app--><!--pyreon-scripts--></body></html>"
181
- const handler = createHandler({ App: Home, routes, mode: "stream", template })
182
- const res = await handler(new Request("http://localhost/"))
180
+ '<html><!--pyreon-head--><body><!--pyreon-app--><!--pyreon-scripts--></body></html>'
181
+ const handler = createHandler({ App: Home, routes, mode: 'stream', template })
182
+ const res = await handler(new Request('http://localhost/'))
183
183
  const html = await res.text()
184
- expect(html).toContain("<h1>Streamed</h1>")
185
- expect(html).toContain("</body></html>")
184
+ expect(html).toContain('<h1>Streamed</h1>')
185
+ expect(html).toContain('</body></html>')
186
186
  })
187
187
 
188
- test("stream mode with custom client entry", async () => {
188
+ test('stream mode with custom client entry', async () => {
189
189
  const handler = createHandler({
190
190
  App: Home,
191
191
  routes,
192
- mode: "stream",
193
- clientEntry: "/dist/app.js",
192
+ mode: 'stream',
193
+ clientEntry: '/dist/app.js',
194
194
  })
195
- const res = await handler(new Request("http://localhost/"))
195
+ const res = await handler(new Request('http://localhost/'))
196
196
  const html = await res.text()
197
197
  expect(html).toContain('src="/dist/app.js"')
198
198
  })
199
199
 
200
- test("stream mode template without <!--pyreon-app--> throws", () => {
201
- const badTemplate = "<html><!--pyreon-head--><!--pyreon-scripts--></html>"
200
+ test('stream mode template without <!--pyreon-app--> throws', () => {
201
+ const badTemplate = '<html><!--pyreon-head--><!--pyreon-scripts--></html>'
202
202
  // Template validation happens at createHandler time (compile-time, not per-request)
203
203
  expect(() =>
204
- createHandler({ App: Home, routes, mode: "stream", template: badTemplate }),
205
- ).toThrow("Template must contain <!--pyreon-app-->")
204
+ createHandler({ App: Home, routes, mode: 'stream', template: badTemplate }),
205
+ ).toThrow('Template must contain <!--pyreon-app-->')
206
206
  })
207
207
 
208
- test("stream mode includes middleware-set headers", async () => {
208
+ test('stream mode includes middleware-set headers', async () => {
209
209
  const mw: Middleware = (ctx) => {
210
- ctx.headers.set("X-Custom", "test-value")
210
+ ctx.headers.set('X-Custom', 'test-value')
211
211
  }
212
- const handler = createHandler({ App: Home, routes, mode: "stream", middleware: [mw] })
213
- const res = await handler(new Request("http://localhost/"))
214
- expect(res.headers.get("X-Custom")).toBe("test-value")
212
+ const handler = createHandler({ App: Home, routes, mode: 'stream', middleware: [mw] })
213
+ const res = await handler(new Request('http://localhost/'))
214
+ expect(res.headers.get('X-Custom')).toBe('test-value')
215
215
  })
216
216
 
217
- test("stream mode middleware can short-circuit", async () => {
218
- const mw: Middleware = () => new Response("blocked", { status: 403 })
219
- const handler = createHandler({ App: Home, routes, mode: "stream", middleware: [mw] })
220
- const res = await handler(new Request("http://localhost/"))
217
+ test('stream mode middleware can short-circuit', async () => {
218
+ const mw: Middleware = () => new Response('blocked', { status: 403 })
219
+ const handler = createHandler({ App: Home, routes, mode: 'stream', middleware: [mw] })
220
+ const res = await handler(new Request('http://localhost/'))
221
221
  expect(res.status).toBe(403)
222
- expect(await res.text()).toBe("blocked")
222
+ expect(await res.text()).toBe('blocked')
223
223
  })
224
224
  })
225
225
 
226
226
  // ─── Middleware ───────────────────────────────────────────────────────────────
227
227
 
228
- describe("middleware", () => {
229
- const App: ComponentFn = () => h("div", null, "app")
230
- const routes = [{ path: "/", component: App }]
228
+ describe('middleware', () => {
229
+ const App: ComponentFn = () => h('div', null, 'app')
230
+ const routes = [{ path: '/', component: App }]
231
231
 
232
- test("middleware can short-circuit with a Response", async () => {
232
+ test('middleware can short-circuit with a Response', async () => {
233
233
  const authMiddleware: Middleware = (ctx) => {
234
- if (!ctx.req.headers.get("Authorization")) {
235
- return new Response("Unauthorized", { status: 401 })
234
+ if (!ctx.req.headers.get('Authorization')) {
235
+ return new Response('Unauthorized', { status: 401 })
236
236
  }
237
237
  }
238
238
  const handler = createHandler({ App, routes, middleware: [authMiddleware] })
239
239
 
240
- const noAuth = await handler(new Request("http://localhost/"))
240
+ const noAuth = await handler(new Request('http://localhost/'))
241
241
  expect(noAuth.status).toBe(401)
242
242
 
243
243
  const withAuth = await handler(
244
- new Request("http://localhost/", { headers: { Authorization: "Bearer token" } }),
244
+ new Request('http://localhost/', { headers: { Authorization: 'Bearer token' } }),
245
245
  )
246
246
  expect(withAuth.status).toBe(200)
247
247
  })
248
248
 
249
- test("middleware can set custom headers", async () => {
249
+ test('middleware can set custom headers', async () => {
250
250
  const cacheMiddleware: Middleware = (ctx) => {
251
- ctx.headers.set("Cache-Control", "max-age=3600")
251
+ ctx.headers.set('Cache-Control', 'max-age=3600')
252
252
  }
253
253
  const handler = createHandler({ App, routes, middleware: [cacheMiddleware] })
254
- const res = await handler(new Request("http://localhost/"))
255
- expect(res.headers.get("Cache-Control")).toBe("max-age=3600")
254
+ const res = await handler(new Request('http://localhost/'))
255
+ expect(res.headers.get('Cache-Control')).toBe('max-age=3600')
256
256
  })
257
257
 
258
- test("middleware chain runs in order", async () => {
258
+ test('middleware chain runs in order', async () => {
259
259
  const order: number[] = []
260
260
  const mw1: Middleware = () => {
261
261
  order.push(1)
@@ -267,28 +267,28 @@ describe("middleware", () => {
267
267
  order.push(3)
268
268
  }
269
269
  const handler = createHandler({ App, routes, middleware: [mw1, mw2, mw3] })
270
- await handler(new Request("http://localhost/"))
270
+ await handler(new Request('http://localhost/'))
271
271
  expect(order).toEqual([1, 2, 3])
272
272
  })
273
273
  })
274
274
 
275
275
  // ─── Stream mode error handling (handler.ts lines 175-178) ──────────────────
276
276
 
277
- describe("createHandler — stream mode error in rendering", () => {
278
- test("stream mode handles render error gracefully", async () => {
277
+ describe('createHandler — stream mode error in rendering', () => {
278
+ test('stream mode handles render error gracefully', async () => {
279
279
  let callCount = 0
280
280
  const BrokenApp: ComponentFn = () => {
281
281
  callCount++
282
- if (callCount > 0) throw new Error("render boom")
283
- return h("div", null, "ok")
282
+ if (callCount > 0) throw new Error('render boom')
283
+ return h('div', null, 'ok')
284
284
  }
285
- const routes = [{ path: "/", component: BrokenApp }]
286
- const handler = createHandler({ App: BrokenApp, routes, mode: "stream" })
285
+ const routes = [{ path: '/', component: BrokenApp }]
286
+ const handler = createHandler({ App: BrokenApp, routes, mode: 'stream' })
287
287
  // The stream mode should catch errors and emit an error script
288
288
  // Since renderToStream might throw synchronously, the handler might throw
289
289
  // or return a response depending on when the error occurs
290
290
  try {
291
- const res = await handler(new Request("http://localhost/"))
291
+ const res = await handler(new Request('http://localhost/'))
292
292
  const _html = await res.text()
293
293
  // If it returns a response, check it's still a valid response
294
294
  expect(res.status).toBeDefined()
@@ -300,9 +300,9 @@ describe("createHandler — stream mode error in rendering", () => {
300
300
 
301
301
  // ─── Middleware type exports ─────────────────────────────────────────────────
302
302
 
303
- describe("middleware types", () => {
304
- test("MiddlewareContext and Middleware types are importable", async () => {
305
- const mod = await import("../middleware")
303
+ describe('middleware types', () => {
304
+ test('MiddlewareContext and Middleware types are importable', async () => {
305
+ const mod = await import('../middleware')
306
306
  // Just verify the module can be imported — it's pure types
307
307
  expect(mod).toBeDefined()
308
308
  })
@@ -310,22 +310,22 @@ describe("middleware types", () => {
310
310
 
311
311
  // ─── Islands ─────────────────────────────────────────────────────────────────
312
312
 
313
- describe("island", () => {
314
- test("island() returns a function with island metadata", () => {
315
- const Counter = island(() => Promise.resolve({ default: () => h("div", null, "0") }), {
316
- name: "Counter",
313
+ describe('island', () => {
314
+ test('island() returns a function with island metadata', () => {
315
+ const Counter = island(() => Promise.resolve({ default: () => h('div', null, '0') }), {
316
+ name: 'Counter',
317
317
  })
318
- expect(typeof Counter).toBe("function")
318
+ expect(typeof Counter).toBe('function')
319
319
  expect((Counter as unknown as { __island: boolean }).__island).toBe(true)
320
- expect(Counter.name).toBe("Counter")
320
+ expect(Counter.name).toBe('Counter')
321
321
  })
322
322
 
323
- test("island() renders with <pyreon-island> wrapper during SSR", async () => {
323
+ test('island() renders with <pyreon-island> wrapper during SSR', async () => {
324
324
  const Inner: ComponentFn = (props) =>
325
- h("button", null, `Count: ${(props as Record<string, unknown>).initial}`)
325
+ h('button', null, `Count: ${(props as Record<string, unknown>).initial}`)
326
326
  const Counter = island<{ initial: number }>(() => Promise.resolve({ default: Inner }), {
327
- name: "Counter",
328
- hydrate: "idle",
327
+ name: 'Counter',
328
+ hydrate: 'idle',
329
329
  })
330
330
 
331
331
  // Simulate SSR by calling the async component
@@ -334,104 +334,104 @@ describe("island", () => {
334
334
  })
335
335
  expect(vnode).not.toBeNull()
336
336
  // The wrapper should be a pyreon-island element
337
- expect(vnode.type).toBe("pyreon-island")
338
- expect(vnode.props["data-component"]).toBe("Counter")
339
- expect(vnode.props["data-hydrate"]).toBe("idle")
340
- const parsedProps = JSON.parse(vnode.props["data-props"] as string)
337
+ expect(vnode.type).toBe('pyreon-island')
338
+ expect(vnode.props['data-component']).toBe('Counter')
339
+ expect(vnode.props['data-hydrate']).toBe('idle')
340
+ const parsedProps = JSON.parse(vnode.props['data-props'] as string)
341
341
  expect(parsedProps.initial).toBe(5)
342
342
  })
343
343
 
344
- test("island() strips non-serializable props", async () => {
345
- const Inner: ComponentFn = () => h("div", null)
346
- const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Widget" })
344
+ test('island() strips non-serializable props', async () => {
345
+ const Inner: ComponentFn = () => h('div', null)
346
+ const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Widget' })
347
347
 
348
348
  const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
349
- label: "hello",
349
+ label: 'hello',
350
350
  onClick: () => {},
351
- sym: Symbol("test"),
351
+ sym: Symbol('test'),
352
352
  nested: { a: 1 },
353
353
  })
354
- const parsedProps = JSON.parse(vnode.props["data-props"] as string)
355
- expect(parsedProps.label).toBe("hello")
354
+ const parsedProps = JSON.parse(vnode.props['data-props'] as string)
355
+ expect(parsedProps.label).toBe('hello')
356
356
  expect(parsedProps.onClick).toBeUndefined()
357
357
  expect(parsedProps.sym).toBeUndefined()
358
358
  expect(parsedProps.nested).toEqual({ a: 1 })
359
359
  })
360
360
 
361
- test("island() strips children prop from serialized props", async () => {
362
- const Inner: ComponentFn = () => h("div", null)
363
- const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Widget" })
361
+ test('island() strips children prop from serialized props', async () => {
362
+ const Inner: ComponentFn = () => h('div', null)
363
+ const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Widget' })
364
364
 
365
365
  const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
366
- title: "test",
367
- children: h("span", null, "child"),
366
+ title: 'test',
367
+ children: h('span', null, 'child'),
368
368
  })
369
- const parsedProps = JSON.parse(vnode.props["data-props"] as string)
370
- expect(parsedProps.title).toBe("test")
369
+ const parsedProps = JSON.parse(vnode.props['data-props'] as string)
370
+ expect(parsedProps.title).toBe('test')
371
371
  expect(parsedProps.children).toBeUndefined()
372
372
  })
373
373
 
374
- test("island() strips undefined values from serialized props", async () => {
375
- const Inner: ComponentFn = () => h("div", null)
376
- const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Widget" })
374
+ test('island() strips undefined values from serialized props', async () => {
375
+ const Inner: ComponentFn = () => h('div', null)
376
+ const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Widget' })
377
377
 
378
378
  const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
379
- present: "yes",
379
+ present: 'yes',
380
380
  missing: undefined,
381
381
  })
382
- const parsedProps = JSON.parse(vnode.props["data-props"] as string)
383
- expect(parsedProps.present).toBe("yes")
384
- expect("missing" in parsedProps).toBe(false)
382
+ const parsedProps = JSON.parse(vnode.props['data-props'] as string)
383
+ expect(parsedProps.present).toBe('yes')
384
+ expect('missing' in parsedProps).toBe(false)
385
385
  })
386
386
 
387
- test("island() resolves direct function module (not { default })", async () => {
388
- const Inner: ComponentFn = () => h("span", null, "direct")
389
- const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Direct" })
387
+ test('island() resolves direct function module (not { default })', async () => {
388
+ const Inner: ComponentFn = () => h('span', null, 'direct')
389
+ const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Direct' })
390
390
 
391
391
  const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
392
392
  {},
393
393
  )
394
- expect(vnode.type).toBe("pyreon-island")
395
- expect(vnode.props["data-component"]).toBe("Direct")
394
+ expect(vnode.type).toBe('pyreon-island')
395
+ expect(vnode.props['data-component']).toBe('Direct')
396
396
  })
397
397
 
398
398
  test("island() defaults hydrate to 'load'", () => {
399
- const Inner: ComponentFn = () => h("div", null)
400
- const Widget = island(() => Promise.resolve({ default: Inner }), { name: "NoHydrate" })
401
- expect((Widget as unknown as { hydrate: string }).hydrate).toBe("load")
399
+ const Inner: ComponentFn = () => h('div', null)
400
+ const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'NoHydrate' })
401
+ expect((Widget as unknown as { hydrate: string }).hydrate).toBe('load')
402
402
  })
403
403
 
404
- test("island() metadata properties are non-writable", () => {
405
- const Inner: ComponentFn = () => h("div", null)
404
+ test('island() metadata properties are non-writable', () => {
405
+ const Inner: ComponentFn = () => h('div', null)
406
406
  const Widget = island(() => Promise.resolve({ default: Inner }), {
407
- name: "Frozen",
408
- hydrate: "visible",
407
+ name: 'Frozen',
408
+ hydrate: 'visible',
409
409
  })
410
410
  const meta = Widget as unknown as { __island: boolean; hydrate: string }
411
411
  expect(meta.__island).toBe(true)
412
- expect(meta.hydrate).toBe("visible")
412
+ expect(meta.hydrate).toBe('visible')
413
413
  })
414
414
 
415
- test("island() serializes empty props as empty object", async () => {
416
- const Inner: ComponentFn = () => h("div", null)
417
- const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Empty" })
415
+ test('island() serializes empty props as empty object', async () => {
416
+ const Inner: ComponentFn = () => h('div', null)
417
+ const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Empty' })
418
418
 
419
419
  const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
420
420
  {},
421
421
  )
422
- expect(vnode.props["data-props"]).toBe("{}")
422
+ expect(vnode.props['data-props']).toBe('{}')
423
423
  })
424
424
  })
425
425
 
426
426
  // ─── SSG ─────────────────────────────────────────────────────────────────────
427
427
 
428
- describe("prerender", () => {
429
- test("generates HTML files for given paths", async () => {
430
- const Home: ComponentFn = () => h("h1", null, "Home")
431
- const About: ComponentFn = () => h("h1", null, "About")
428
+ describe('prerender', () => {
429
+ test('generates HTML files for given paths', async () => {
430
+ const Home: ComponentFn = () => h('h1', null, 'Home')
431
+ const About: ComponentFn = () => h('h1', null, 'About')
432
432
  const routes = [
433
- { path: "/", component: Home },
434
- { path: "/about", component: About },
433
+ { path: '/', component: Home },
434
+ { path: '/about', component: About },
435
435
  ]
436
436
  const handler = createHandler({ App: Home, routes })
437
437
 
@@ -440,7 +440,7 @@ describe("prerender", () => {
440
440
 
441
441
  const result = await prerender({
442
442
  handler,
443
- paths: ["/", "/about"],
443
+ paths: ['/', '/about'],
444
444
  outDir: tmpDir,
445
445
  })
446
446
 
@@ -449,11 +449,11 @@ describe("prerender", () => {
449
449
  expect(result.elapsed).toBeGreaterThanOrEqual(0)
450
450
 
451
451
  // Verify files exist
452
- const { readFile, rm } = await import("node:fs/promises")
453
- const indexHtml = await readFile(`${tmpDir}/index.html`, "utf-8")
454
- expect(indexHtml).toContain("<h1>Home</h1>")
452
+ const { readFile, rm } = await import('node:fs/promises')
453
+ const indexHtml = await readFile(`${tmpDir}/index.html`, 'utf-8')
454
+ expect(indexHtml).toContain('<h1>Home</h1>')
455
455
 
456
- const aboutStat = await import("node:fs").then((fs) =>
456
+ const aboutStat = await import('node:fs').then((fs) =>
457
457
  fs.existsSync(`${tmpDir}/about/index.html`),
458
458
  )
459
459
  expect(aboutStat).toBe(true)
@@ -462,114 +462,114 @@ describe("prerender", () => {
462
462
  await rm(tmpDir, { recursive: true, force: true })
463
463
  })
464
464
 
465
- test("onPage callback can skip pages", async () => {
466
- const App: ComponentFn = () => h("div", null)
467
- const handler = createHandler({ App, routes: [{ path: "/", component: App }] })
465
+ test('onPage callback can skip pages', async () => {
466
+ const App: ComponentFn = () => h('div', null)
467
+ const handler = createHandler({ App, routes: [{ path: '/', component: App }] })
468
468
 
469
469
  const tmpDir = `/tmp/pyreon-ssg-skip-${Date.now()}`
470
470
  const result = await prerender({
471
471
  handler,
472
- paths: ["/"],
472
+ paths: ['/'],
473
473
  outDir: tmpDir,
474
474
  onPage: () => false, // skip all pages
475
475
  })
476
476
 
477
477
  expect(result.pages).toBe(0)
478
478
 
479
- const { rm } = await import("node:fs/promises")
479
+ const { rm } = await import('node:fs/promises')
480
480
  await rm(tmpDir, { recursive: true, force: true })
481
481
  })
482
482
 
483
- test("paths can be an async function", async () => {
484
- const App: ComponentFn = () => h("div", null)
485
- const handler = createHandler({ App, routes: [{ path: "/", component: App }] })
483
+ test('paths can be an async function', async () => {
484
+ const App: ComponentFn = () => h('div', null)
485
+ const handler = createHandler({ App, routes: [{ path: '/', component: App }] })
486
486
 
487
487
  const tmpDir = `/tmp/pyreon-ssg-async-${Date.now()}`
488
488
  const result = await prerender({
489
489
  handler,
490
- paths: async () => ["/"],
490
+ paths: async () => ['/'],
491
491
  outDir: tmpDir,
492
492
  })
493
493
 
494
494
  expect(result.pages).toBe(1)
495
495
 
496
- const { rm } = await import("node:fs/promises")
496
+ const { rm } = await import('node:fs/promises')
497
497
  await rm(tmpDir, { recursive: true, force: true })
498
498
  })
499
499
 
500
- test("records errors for non-ok responses", async () => {
500
+ test('records errors for non-ok responses', async () => {
501
501
  // Handler that returns 404 for /missing
502
502
  const handler = async (req: Request) => {
503
503
  const url = new URL(req.url)
504
- if (url.pathname === "/missing") {
505
- return new Response("Not Found", { status: 404 })
504
+ if (url.pathname === '/missing') {
505
+ return new Response('Not Found', { status: 404 })
506
506
  }
507
- return new Response("<html>OK</html>", { status: 200 })
507
+ return new Response('<html>OK</html>', { status: 200 })
508
508
  }
509
509
 
510
510
  const tmpDir = `/tmp/pyreon-ssg-errors-${Date.now()}`
511
511
  const result = await prerender({
512
512
  handler,
513
- paths: ["/", "/missing"],
513
+ paths: ['/', '/missing'],
514
514
  outDir: tmpDir,
515
515
  })
516
516
 
517
517
  expect(result.pages).toBe(1)
518
518
  expect(result.errors).toHaveLength(1)
519
- expect(result.errors[0]?.path).toBe("/missing")
519
+ expect(result.errors[0]?.path).toBe('/missing')
520
520
 
521
- const { rm } = await import("node:fs/promises")
521
+ const { rm } = await import('node:fs/promises')
522
522
  await rm(tmpDir, { recursive: true, force: true })
523
523
  })
524
524
 
525
- test("records errors when handler throws", async () => {
525
+ test('records errors when handler throws', async () => {
526
526
  const handler = async (_req: Request) => {
527
- throw new Error("handler exploded")
527
+ throw new Error('handler exploded')
528
528
  }
529
529
 
530
530
  const tmpDir = `/tmp/pyreon-ssg-throw-${Date.now()}`
531
531
  const result = await prerender({
532
532
  handler,
533
- paths: ["/"],
533
+ paths: ['/'],
534
534
  outDir: tmpDir,
535
535
  })
536
536
 
537
537
  expect(result.pages).toBe(0)
538
538
  expect(result.errors).toHaveLength(1)
539
- expect(result.errors[0]?.path).toBe("/")
539
+ expect(result.errors[0]?.path).toBe('/')
540
540
  expect(result.errors[0]?.error).toBeInstanceOf(Error)
541
541
 
542
- const { rm } = await import("node:fs/promises")
542
+ const { rm } = await import('node:fs/promises')
543
543
  await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
544
544
  })
545
545
 
546
- test("handles .html path suffix", async () => {
547
- const handler = async (_req: Request) => new Response("<html>page</html>", { status: 200 })
546
+ test('handles .html path suffix', async () => {
547
+ const handler = async (_req: Request) => new Response('<html>page</html>', { status: 200 })
548
548
 
549
549
  const tmpDir = `/tmp/pyreon-ssg-html-${Date.now()}`
550
550
  const result = await prerender({
551
551
  handler,
552
- paths: ["/custom.html"],
552
+ paths: ['/custom.html'],
553
553
  outDir: tmpDir,
554
554
  })
555
555
 
556
556
  expect(result.pages).toBe(1)
557
557
 
558
- const { readFile, rm } = await import("node:fs/promises")
559
- const content = await readFile(`${tmpDir}/custom.html`, "utf-8")
560
- expect(content).toBe("<html>page</html>")
558
+ const { readFile, rm } = await import('node:fs/promises')
559
+ const content = await readFile(`${tmpDir}/custom.html`, 'utf-8')
560
+ expect(content).toBe('<html>page</html>')
561
561
 
562
562
  await rm(tmpDir, { recursive: true, force: true })
563
563
  })
564
564
 
565
- test("onPage callback receives path and html", async () => {
566
- const handler = async (_req: Request) => new Response("<html>content</html>", { status: 200 })
565
+ test('onPage callback receives path and html', async () => {
566
+ const handler = async (_req: Request) => new Response('<html>content</html>', { status: 200 })
567
567
 
568
568
  const received: { path: string; html: string }[] = []
569
569
  const tmpDir = `/tmp/pyreon-ssg-onpage-${Date.now()}`
570
570
  await prerender({
571
571
  handler,
572
- paths: ["/", "/about"],
572
+ paths: ['/', '/about'],
573
573
  outDir: tmpDir,
574
574
  onPage: (path, html) => {
575
575
  received.push({ path, html })
@@ -577,53 +577,53 @@ describe("prerender", () => {
577
577
  })
578
578
 
579
579
  expect(received).toHaveLength(2)
580
- expect(received.some((r) => r.path === "/")).toBe(true)
581
- expect(received.some((r) => r.path === "/about")).toBe(true)
582
- expect(received[0]?.html).toBe("<html>content</html>")
580
+ expect(received.some((r) => r.path === '/')).toBe(true)
581
+ expect(received.some((r) => r.path === '/about')).toBe(true)
582
+ expect(received[0]?.html).toBe('<html>content</html>')
583
583
 
584
- const { rm } = await import("node:fs/promises")
584
+ const { rm } = await import('node:fs/promises')
585
585
  await rm(tmpDir, { recursive: true, force: true })
586
586
  })
587
587
 
588
- test("uses custom origin", async () => {
589
- let receivedUrl = ""
588
+ test('uses custom origin', async () => {
589
+ let receivedUrl = ''
590
590
  const handler = async (req: Request) => {
591
591
  receivedUrl = req.url
592
- return new Response("<html></html>", { status: 200 })
592
+ return new Response('<html></html>', { status: 200 })
593
593
  }
594
594
 
595
595
  const tmpDir = `/tmp/pyreon-ssg-origin-${Date.now()}`
596
596
  await prerender({
597
597
  handler,
598
- paths: ["/test"],
598
+ paths: ['/test'],
599
599
  outDir: tmpDir,
600
- origin: "https://example.com",
600
+ origin: 'https://example.com',
601
601
  })
602
602
 
603
- expect(receivedUrl).toBe("https://example.com/test")
603
+ expect(receivedUrl).toBe('https://example.com/test')
604
604
 
605
- const { rm } = await import("node:fs/promises")
605
+ const { rm } = await import('node:fs/promises')
606
606
  await rm(tmpDir, { recursive: true, force: true })
607
607
  })
608
608
 
609
- test("paths as sync function", async () => {
610
- const handler = async (_req: Request) => new Response("<html></html>", { status: 200 })
609
+ test('paths as sync function', async () => {
610
+ const handler = async (_req: Request) => new Response('<html></html>', { status: 200 })
611
611
 
612
612
  const tmpDir = `/tmp/pyreon-ssg-sync-fn-${Date.now()}`
613
613
  const result = await prerender({
614
614
  handler,
615
- paths: () => ["/a", "/b"],
615
+ paths: () => ['/a', '/b'],
616
616
  outDir: tmpDir,
617
617
  })
618
618
 
619
619
  expect(result.pages).toBe(2)
620
620
 
621
- const { rm } = await import("node:fs/promises")
621
+ const { rm } = await import('node:fs/promises')
622
622
  await rm(tmpDir, { recursive: true, force: true })
623
623
  })
624
624
 
625
- test("batches more than 10 paths", async () => {
626
- const handler = async (_req: Request) => new Response("<html>ok</html>", { status: 200 })
625
+ test('batches more than 10 paths', async () => {
626
+ const handler = async (_req: Request) => new Response('<html>ok</html>', { status: 200 })
627
627
 
628
628
  const paths = Array.from({ length: 15 }, (_, i) => `/page-${i}`)
629
629
  const tmpDir = `/tmp/pyreon-ssg-batch-${Date.now()}`
@@ -636,53 +636,53 @@ describe("prerender", () => {
636
636
  expect(result.pages).toBe(15)
637
637
  expect(result.errors).toHaveLength(0)
638
638
 
639
- const { rm } = await import("node:fs/promises")
639
+ const { rm } = await import('node:fs/promises')
640
640
  await rm(tmpDir, { recursive: true, force: true })
641
641
  })
642
642
  })
643
643
 
644
644
  // ─── compileTemplate ─────────────────────────────────────────────────────────
645
645
 
646
- describe("compileTemplate", () => {
647
- test("splits template into 4 parts", () => {
646
+ describe('compileTemplate', () => {
647
+ test('splits template into 4 parts', () => {
648
648
  const compiled = compileTemplate(DEFAULT_TEMPLATE)
649
649
  expect(compiled.parts).toHaveLength(4)
650
650
  })
651
651
 
652
- test("throws when template is missing <!--pyreon-app-->", () => {
653
- expect(() => compileTemplate("<html><!--pyreon-head--><!--pyreon-scripts--></html>")).toThrow(
654
- "Template must contain <!--pyreon-app-->",
652
+ test('throws when template is missing <!--pyreon-app-->', () => {
653
+ expect(() => compileTemplate('<html><!--pyreon-head--><!--pyreon-scripts--></html>')).toThrow(
654
+ 'Template must contain <!--pyreon-app-->',
655
655
  )
656
656
  })
657
657
 
658
- test("handles template with all three placeholders in custom layout", () => {
658
+ test('handles template with all three placeholders in custom layout', () => {
659
659
  const tpl =
660
- "<head><!--pyreon-head--></head><main><!--pyreon-app--></main><footer><!--pyreon-scripts--></footer>"
660
+ '<head><!--pyreon-head--></head><main><!--pyreon-app--></main><footer><!--pyreon-scripts--></footer>'
661
661
  const compiled = compileTemplate(tpl)
662
662
  const result = processCompiledTemplate(compiled, {
663
- head: "<title>Hi</title>",
664
- app: "<div>App</div>",
665
- scripts: "<script></script>",
663
+ head: '<title>Hi</title>',
664
+ app: '<div>App</div>',
665
+ scripts: '<script></script>',
666
666
  })
667
667
  expect(result).toBe(
668
- "<head><title>Hi</title></head><main><div>App</div></main><footer><script></script></footer>",
668
+ '<head><title>Hi</title></head><main><div>App</div></main><footer><script></script></footer>',
669
669
  )
670
670
  })
671
671
 
672
- test("handles template without <!--pyreon-scripts--> placeholder", () => {
673
- const tpl = "<html><!--pyreon-head--><body><!--pyreon-app--></body></html>"
672
+ test('handles template without <!--pyreon-scripts--> placeholder', () => {
673
+ const tpl = '<html><!--pyreon-head--><body><!--pyreon-app--></body></html>'
674
674
  const compiled = compileTemplate(tpl)
675
- expect(compiled.parts[3]).toBe("") // after-scripts is empty
675
+ expect(compiled.parts[3]).toBe('') // after-scripts is empty
676
676
  })
677
677
  })
678
678
 
679
679
  // ─── processCompiledTemplate ─────────────────────────────────────────────────
680
680
 
681
- describe("processCompiledTemplate", () => {
682
- test("produces same result as processTemplate", () => {
681
+ describe('processCompiledTemplate', () => {
682
+ test('produces same result as processTemplate', () => {
683
683
  const data = {
684
- head: "<title>Test</title>",
685
- app: "<div>Hello</div>",
684
+ head: '<title>Test</title>',
685
+ app: '<div>Hello</div>',
686
686
  scripts: '<script type="module" src="/app.js"></script>',
687
687
  }
688
688
  const simple = processTemplate(DEFAULT_TEMPLATE, data)
@@ -691,109 +691,109 @@ describe("processCompiledTemplate", () => {
691
691
  expect(fast).toBe(simple)
692
692
  })
693
693
 
694
- test("works with empty data", () => {
694
+ test('works with empty data', () => {
695
695
  const compiled = compileTemplate(DEFAULT_TEMPLATE)
696
- const result = processCompiledTemplate(compiled, { head: "", app: "", scripts: "" })
697
- expect(result).not.toContain("<!--pyreon-head-->")
698
- expect(result).not.toContain("<!--pyreon-app-->")
699
- expect(result).not.toContain("<!--pyreon-scripts-->")
696
+ const result = processCompiledTemplate(compiled, { head: '', app: '', scripts: '' })
697
+ expect(result).not.toContain('<!--pyreon-head-->')
698
+ expect(result).not.toContain('<!--pyreon-app-->')
699
+ expect(result).not.toContain('<!--pyreon-scripts-->')
700
700
  })
701
701
  })
702
702
 
703
703
  // ─── buildClientEntryTag ─────────────────────────────────────────────────────
704
704
 
705
- describe("buildClientEntryTag", () => {
706
- test("emits a module script tag with src", () => {
707
- const tag = buildClientEntryTag("/dist/client.js")
705
+ describe('buildClientEntryTag', () => {
706
+ test('emits a module script tag with src', () => {
707
+ const tag = buildClientEntryTag('/dist/client.js')
708
708
  expect(tag).toBe('<script type="module" src="/dist/client.js"></script>')
709
709
  })
710
710
  })
711
711
 
712
712
  // ─── buildScriptsFast ────────────────────────────────────────────────────────
713
713
 
714
- describe("buildScriptsFast", () => {
715
- test("returns only client entry tag when no loader data", () => {
716
- const tag = buildClientEntryTag("/app.js")
714
+ describe('buildScriptsFast', () => {
715
+ test('returns only client entry tag when no loader data', () => {
716
+ const tag = buildClientEntryTag('/app.js')
717
717
  const result = buildScriptsFast(tag, null)
718
718
  expect(result).toBe(tag)
719
719
  })
720
720
 
721
- test("returns only client entry tag when loader data is empty object", () => {
722
- const tag = buildClientEntryTag("/app.js")
721
+ test('returns only client entry tag when loader data is empty object', () => {
722
+ const tag = buildClientEntryTag('/app.js')
723
723
  const result = buildScriptsFast(tag, {})
724
724
  expect(result).toBe(tag)
725
725
  })
726
726
 
727
- test("includes inline loader data when present", () => {
728
- const tag = buildClientEntryTag("/app.js")
727
+ test('includes inline loader data when present', () => {
728
+ const tag = buildClientEntryTag('/app.js')
729
729
  const result = buildScriptsFast(tag, { users: [1, 2] })
730
- expect(result).toContain("__PYREON_LOADER_DATA__")
730
+ expect(result).toContain('__PYREON_LOADER_DATA__')
731
731
  expect(result).toContain('"users"')
732
732
  expect(result).toContain(tag)
733
733
  })
734
734
 
735
- test("escapes </script> in loader data JSON", () => {
736
- const tag = buildClientEntryTag("/app.js")
737
- const result = buildScriptsFast(tag, { html: "</script>" })
738
- expect(result).not.toContain("</script><")
739
- expect(result).toContain("<\\/script>")
735
+ test('escapes </script> in loader data JSON', () => {
736
+ const tag = buildClientEntryTag('/app.js')
737
+ const result = buildScriptsFast(tag, { html: '</script>' })
738
+ expect(result).not.toContain('</script><')
739
+ expect(result).toContain('<\\/script>')
740
740
  })
741
741
  })
742
742
 
743
743
  // ─── Middleware chaining edge cases ──────────────────────────────────────────
744
744
 
745
- describe("middleware — edge cases", () => {
746
- const App: ComponentFn = () => h("div", null, "app")
747
- const routes = [{ path: "/", component: App }]
745
+ describe('middleware — edge cases', () => {
746
+ const App: ComponentFn = () => h('div', null, 'app')
747
+ const routes = [{ path: '/', component: App }]
748
748
 
749
- test("middleware can modify locals for downstream middleware", async () => {
749
+ test('middleware can modify locals for downstream middleware', async () => {
750
750
  const log: string[] = []
751
751
  const mw1: Middleware = (ctx) => {
752
- ctx.locals.user = "alice"
752
+ ctx.locals.user = 'alice'
753
753
  }
754
754
  const mw2: Middleware = (ctx) => {
755
755
  log.push(`user=${ctx.locals.user}`)
756
756
  }
757
757
  const handler = createHandler({ App, routes, middleware: [mw1, mw2] })
758
- await handler(new Request("http://localhost/"))
759
- expect(log).toEqual(["user=alice"])
758
+ await handler(new Request('http://localhost/'))
759
+ expect(log).toEqual(['user=alice'])
760
760
  })
761
761
 
762
- test("early short-circuit prevents later middleware from running", async () => {
762
+ test('early short-circuit prevents later middleware from running', async () => {
763
763
  const log: number[] = []
764
764
  const mw1: Middleware = () => {
765
765
  log.push(1)
766
- return new Response("blocked", { status: 403 })
766
+ return new Response('blocked', { status: 403 })
767
767
  }
768
768
  const mw2: Middleware = () => {
769
769
  log.push(2) // should never run
770
770
  }
771
771
  const handler = createHandler({ App, routes, middleware: [mw1, mw2] })
772
- const res = await handler(new Request("http://localhost/"))
772
+ const res = await handler(new Request('http://localhost/'))
773
773
  expect(res.status).toBe(403)
774
774
  expect(log).toEqual([1]) // mw2 never ran
775
775
  })
776
776
 
777
- test("async middleware is supported", async () => {
777
+ test('async middleware is supported', async () => {
778
778
  const mw: Middleware = async (ctx) => {
779
779
  await new Promise((r) => setTimeout(r, 1))
780
- ctx.headers.set("X-Async", "true")
780
+ ctx.headers.set('X-Async', 'true')
781
781
  }
782
782
  const handler = createHandler({ App, routes, middleware: [mw] })
783
- const res = await handler(new Request("http://localhost/"))
784
- expect(res.headers.get("X-Async")).toBe("true")
783
+ const res = await handler(new Request('http://localhost/'))
784
+ expect(res.headers.get('X-Async')).toBe('true')
785
785
  })
786
786
 
787
- test("middleware receives parsed URL and path", async () => {
788
- let receivedPath = ""
789
- let receivedSearch = ""
787
+ test('middleware receives parsed URL and path', async () => {
788
+ let receivedPath = ''
789
+ let receivedSearch = ''
790
790
  const mw: Middleware = (ctx) => {
791
791
  receivedPath = ctx.path
792
792
  receivedSearch = ctx.url.search
793
793
  }
794
794
  const handler = createHandler({ App, routes, middleware: [mw] })
795
- await handler(new Request("http://localhost/about?foo=bar"))
796
- expect(receivedPath).toBe("/about?foo=bar")
797
- expect(receivedSearch).toBe("?foo=bar")
795
+ await handler(new Request('http://localhost/about?foo=bar'))
796
+ expect(receivedPath).toBe('/about?foo=bar')
797
+ expect(receivedSearch).toBe('?foo=bar')
798
798
  })
799
799
  })