@jasonshimmy/vite-plugin-cer-app 0.4.6 → 0.5.0

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.
Files changed (81) hide show
  1. package/.github/copilot-instructions.md +4 -2
  2. package/CHANGELOG.md +4 -0
  3. package/IMPLEMENTATION_PLAN.md +52 -10
  4. package/commits.txt +1 -1
  5. package/dist/cli/commands/preview-isr.d.ts +51 -0
  6. package/dist/cli/commands/preview-isr.d.ts.map +1 -0
  7. package/dist/cli/commands/preview-isr.js +104 -0
  8. package/dist/cli/commands/preview-isr.js.map +1 -0
  9. package/dist/cli/commands/preview.d.ts.map +1 -1
  10. package/dist/cli/commands/preview.js +65 -1
  11. package/dist/cli/commands/preview.js.map +1 -1
  12. package/dist/plugin/dev-server.d.ts +3 -0
  13. package/dist/plugin/dev-server.d.ts.map +1 -1
  14. package/dist/plugin/dev-server.js.map +1 -1
  15. package/dist/plugin/dts-generator.d.ts.map +1 -1
  16. package/dist/plugin/dts-generator.js +8 -1
  17. package/dist/plugin/dts-generator.js.map +1 -1
  18. package/dist/plugin/index.d.ts.map +1 -1
  19. package/dist/plugin/index.js +9 -1
  20. package/dist/plugin/index.js.map +1 -1
  21. package/dist/plugin/transforms/auto-import.js +2 -2
  22. package/dist/plugin/transforms/auto-import.js.map +1 -1
  23. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  24. package/dist/plugin/virtual/routes.js +95 -8
  25. package/dist/plugin/virtual/routes.js.map +1 -1
  26. package/dist/runtime/app-template.d.ts +1 -1
  27. package/dist/runtime/app-template.d.ts.map +1 -1
  28. package/dist/runtime/app-template.js +16 -4
  29. package/dist/runtime/app-template.js.map +1 -1
  30. package/dist/runtime/composables/index.d.ts +1 -0
  31. package/dist/runtime/composables/index.d.ts.map +1 -1
  32. package/dist/runtime/composables/index.js +1 -0
  33. package/dist/runtime/composables/index.js.map +1 -1
  34. package/dist/runtime/composables/use-runtime-config.d.ts +32 -0
  35. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -0
  36. package/dist/runtime/composables/use-runtime-config.js +41 -0
  37. package/dist/runtime/composables/use-runtime-config.js.map +1 -0
  38. package/dist/runtime/entry-server-template.d.ts +1 -1
  39. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  40. package/dist/runtime/entry-server-template.js +14 -6
  41. package/dist/runtime/entry-server-template.js.map +1 -1
  42. package/dist/types/config.d.ts +24 -0
  43. package/dist/types/config.d.ts.map +1 -1
  44. package/dist/types/config.js.map +1 -1
  45. package/dist/types/index.d.ts +1 -1
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/types/page.d.ts +17 -0
  48. package/dist/types/page.d.ts.map +1 -1
  49. package/docs/composables.md +36 -0
  50. package/docs/configuration.md +52 -0
  51. package/docs/layouts.md +82 -0
  52. package/docs/rendering-modes.md +52 -11
  53. package/docs/routing.md +66 -0
  54. package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +100 -0
  55. package/e2e/kitchen-sink/app/layouts/admin.ts +13 -0
  56. package/e2e/kitchen-sink/app/pages/admin/_layout.ts +1 -0
  57. package/e2e/kitchen-sink/app/pages/admin/dashboard.ts +11 -0
  58. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +1 -0
  59. package/e2e/kitchen-sink/app/pages/isr-test.ts +17 -0
  60. package/e2e/kitchen-sink/cer.config.ts +5 -0
  61. package/package.json +1 -1
  62. package/src/__tests__/cli/preview-isr.test.ts +246 -0
  63. package/src/__tests__/plugin/dts-generator.test.ts +20 -0
  64. package/src/__tests__/plugin/resolve-config.test.ts +15 -0
  65. package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
  66. package/src/__tests__/plugin/virtual/routes.test.ts +195 -0
  67. package/src/__tests__/runtime/use-runtime-config.test.ts +59 -0
  68. package/src/cli/commands/preview-isr.ts +139 -0
  69. package/src/cli/commands/preview.ts +71 -2
  70. package/src/plugin/dev-server.ts +1 -0
  71. package/src/plugin/dts-generator.ts +8 -1
  72. package/src/plugin/index.ts +11 -1
  73. package/src/plugin/transforms/auto-import.ts +2 -2
  74. package/src/plugin/virtual/routes.ts +106 -9
  75. package/src/runtime/app-template.ts +16 -4
  76. package/src/runtime/composables/index.ts +1 -0
  77. package/src/runtime/composables/use-runtime-config.ts +40 -0
  78. package/src/runtime/entry-server-template.ts +14 -6
  79. package/src/types/config.ts +26 -0
  80. package/src/types/index.ts +1 -1
  81. package/src/types/page.ts +17 -0
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import {
4
+ matchRoutePattern,
5
+ findRevalidate,
6
+ renderForIsr,
7
+ serveFromIsrCache,
8
+ type IsrCacheEntry,
9
+ } from '../../cli/commands/preview-isr.js'
10
+
11
+ // ─── matchRoutePattern ────────────────────────────────────────────────────────
12
+
13
+ describe('matchRoutePattern', () => {
14
+ it('matches identical paths', () => {
15
+ expect(matchRoutePattern('/about', '/about')).toBe(true)
16
+ })
17
+
18
+ it('matches root path', () => {
19
+ expect(matchRoutePattern('/', '/')).toBe(true)
20
+ })
21
+
22
+ it('does not match different static paths', () => {
23
+ expect(matchRoutePattern('/about', '/contact')).toBe(false)
24
+ })
25
+
26
+ it('matches :param segments', () => {
27
+ expect(matchRoutePattern('/blog/:slug', '/blog/hello-world')).toBe(true)
28
+ })
29
+
30
+ it('does not match when param segment is missing', () => {
31
+ expect(matchRoutePattern('/blog/:slug', '/blog')).toBe(false)
32
+ })
33
+
34
+ it('matches :param* catch-all against single segment', () => {
35
+ expect(matchRoutePattern('/:all*', '/about')).toBe(true)
36
+ })
37
+
38
+ it('matches :param* catch-all against multi-segment path', () => {
39
+ expect(matchRoutePattern('/:all*', '/deeply/nested/path')).toBe(true)
40
+ })
41
+
42
+ it('matches :param* catch-all against root', () => {
43
+ expect(matchRoutePattern('/:all*', '/')).toBe(true)
44
+ })
45
+
46
+ it('does not match longer path against shorter static pattern', () => {
47
+ expect(matchRoutePattern('/blog', '/blog/extra')).toBe(false)
48
+ })
49
+
50
+ it('handles trailing slashes gracefully', () => {
51
+ expect(matchRoutePattern('/about/', '/about')).toBe(true)
52
+ expect(matchRoutePattern('/about', '/about/')).toBe(true)
53
+ })
54
+
55
+ it('matches multiple :param segments', () => {
56
+ expect(matchRoutePattern('/users/:id/posts/:postId', '/users/42/posts/99')).toBe(true)
57
+ })
58
+
59
+ it('does not match paths that differ only by a regex wildcard character (dot-safety)', () => {
60
+ // Without escaping, '/api.v1' regex would be '^/apixv1$' which matches '/apixv1'.
61
+ // With escaping, the dot is literal and '/apixv1' must not match '/api.v1'.
62
+ expect(matchRoutePattern('/api.v1/users', '/apixv1/users')).toBe(false)
63
+ })
64
+
65
+ it('matches route with literal dot in static segment', () => {
66
+ expect(matchRoutePattern('/api.v1/users', '/api.v1/users')).toBe(true)
67
+ })
68
+ })
69
+
70
+ // ─── findRevalidate ───────────────────────────────────────────────────────────
71
+
72
+ describe('findRevalidate', () => {
73
+ it('returns null for an empty routes array', () => {
74
+ expect(findRevalidate([], '/about')).toBeNull()
75
+ })
76
+
77
+ it('returns null when no route matches', () => {
78
+ const routes = [{ path: '/contact', meta: { ssg: { revalidate: 60 } } }]
79
+ expect(findRevalidate(routes, '/about')).toBeNull()
80
+ })
81
+
82
+ it('returns null when matched route has no meta', () => {
83
+ const routes = [{ path: '/about' }]
84
+ expect(findRevalidate(routes, '/about')).toBeNull()
85
+ })
86
+
87
+ it('returns null when matched route has no ssg.revalidate', () => {
88
+ const routes = [{ path: '/about', meta: { layout: 'default' } }]
89
+ expect(findRevalidate(routes, '/about')).toBeNull()
90
+ })
91
+
92
+ it('returns revalidate value for a matching static route', () => {
93
+ const routes = [{ path: '/about', meta: { ssg: { revalidate: 60 } } }]
94
+ expect(findRevalidate(routes, '/about')).toBe(60)
95
+ })
96
+
97
+ it('returns revalidate value for a matching dynamic route', () => {
98
+ const routes = [{ path: '/blog/:slug', meta: { ssg: { revalidate: 300 } } }]
99
+ expect(findRevalidate(routes, '/blog/hello')).toBe(300)
100
+ })
101
+
102
+ it('returns revalidate value for a catch-all route', () => {
103
+ const routes = [{ path: '/:all*', meta: { ssg: { revalidate: 120 } } }]
104
+ expect(findRevalidate(routes, '/some/unmatched/path')).toBe(120)
105
+ })
106
+
107
+ it('picks the first matching route when multiple match', () => {
108
+ const routes = [
109
+ { path: '/blog/:slug', meta: { ssg: { revalidate: 300 } } },
110
+ { path: '/:all*', meta: { ssg: { revalidate: 60 } } },
111
+ ]
112
+ // /blog/:slug matches first
113
+ expect(findRevalidate(routes, '/blog/post')).toBe(300)
114
+ })
115
+
116
+ it('ignores non-numeric revalidate values', () => {
117
+ const routes = [{ path: '/about', meta: { ssg: { revalidate: 'invalid' } } }]
118
+ expect(findRevalidate(routes, '/about')).toBeNull()
119
+ })
120
+ })
121
+
122
+ // ─── renderForIsr ─────────────────────────────────────────────────────────────
123
+
124
+ describe('renderForIsr', () => {
125
+ it('captures HTML from the handler', async () => {
126
+ const handler = (_req: IncomingMessage, res: ServerResponse) => {
127
+ res.setHeader('Content-Type', 'text/html')
128
+ res.end('<html>hello</html>')
129
+ }
130
+ const entry = await renderForIsr('/about', handler, 60)
131
+ expect(entry).not.toBeNull()
132
+ expect(entry!.html).toBe('<html>hello</html>')
133
+ })
134
+
135
+ it('captures status code from the handler', async () => {
136
+ const handler = (_req: IncomingMessage, res: ServerResponse) => {
137
+ res.statusCode = 404
138
+ res.end('Not Found')
139
+ }
140
+ const entry = await renderForIsr('/missing', handler, 30)
141
+ expect(entry!.statusCode).toBe(404)
142
+ })
143
+
144
+ it('captures response headers from the handler', async () => {
145
+ const handler = (_req: IncomingMessage, res: ServerResponse) => {
146
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
147
+ res.end('<html/>')
148
+ }
149
+ const entry = await renderForIsr('/about', handler, 60)
150
+ expect(entry!.headers['content-type']).toBe('text/html; charset=utf-8')
151
+ })
152
+
153
+ it('sets revalidate to the provided TTL', async () => {
154
+ const handler = (_req: IncomingMessage, res: ServerResponse) => { res.end('ok') }
155
+ const entry = await renderForIsr('/about', handler, 300)
156
+ expect(entry!.revalidate).toBe(300)
157
+ })
158
+
159
+ it('sets revalidating to false on the returned entry', async () => {
160
+ const handler = (_req: IncomingMessage, res: ServerResponse) => { res.end('ok') }
161
+ const entry = await renderForIsr('/about', handler, 60)
162
+ expect(entry!.revalidating).toBe(false)
163
+ })
164
+
165
+ it('returns null when the handler throws', async () => {
166
+ const handler = () => { throw new Error('boom') }
167
+ const entry = await renderForIsr('/about', handler as SsrHandlerFn, 60)
168
+ expect(entry).toBeNull()
169
+ })
170
+
171
+ it('passes the correct URL to the synthetic request', async () => {
172
+ let capturedUrl = ''
173
+ const handler = (req: IncomingMessage, res: ServerResponse) => {
174
+ capturedUrl = req.url ?? ''
175
+ res.end('ok')
176
+ }
177
+ await renderForIsr('/blog/hello', handler, 60)
178
+ expect(capturedUrl).toBe('/blog/hello')
179
+ })
180
+ })
181
+
182
+ type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
183
+
184
+ // ─── serveFromIsrCache ────────────────────────────────────────────────────────
185
+
186
+ describe('serveFromIsrCache', () => {
187
+ function makeFakeRes() {
188
+ const headers: Record<string, string> = {}
189
+ let statusCode = 200
190
+ let body = ''
191
+ const res = {
192
+ get statusCode() { return statusCode },
193
+ set statusCode(v: number) { statusCode = v },
194
+ setHeader: vi.fn((name: string, value: string) => { headers[name] = value }),
195
+ end: vi.fn((chunk: string) => { body = chunk }),
196
+ _headers: headers,
197
+ _body: () => body,
198
+ _status: () => statusCode,
199
+ }
200
+ return res
201
+ }
202
+
203
+ function makeEntry(overrides: Partial<IsrCacheEntry> = {}): IsrCacheEntry {
204
+ return {
205
+ html: '<html>cached</html>',
206
+ headers: { 'content-type': 'text/html' },
207
+ statusCode: 200,
208
+ builtAt: Date.now(),
209
+ revalidate: 60,
210
+ revalidating: false,
211
+ ...overrides,
212
+ }
213
+ }
214
+
215
+ it('writes the cached HTML to the response', () => {
216
+ const res = makeFakeRes()
217
+ serveFromIsrCache(makeEntry(), res as unknown as ServerResponse, 'HIT')
218
+ expect(res.end).toHaveBeenCalledWith('<html>cached</html>')
219
+ })
220
+
221
+ it('sets X-Cache: HIT header', () => {
222
+ const res = makeFakeRes()
223
+ serveFromIsrCache(makeEntry(), res as unknown as ServerResponse, 'HIT')
224
+ expect(res.setHeader).toHaveBeenCalledWith('X-Cache', 'HIT')
225
+ })
226
+
227
+ it('sets X-Cache: STALE header', () => {
228
+ const res = makeFakeRes()
229
+ serveFromIsrCache(makeEntry(), res as unknown as ServerResponse, 'STALE')
230
+ expect(res.setHeader).toHaveBeenCalledWith('X-Cache', 'STALE')
231
+ })
232
+
233
+ it('forwards cached headers to the response', () => {
234
+ const entry = makeEntry({ headers: { 'content-type': 'text/html; charset=utf-8' } })
235
+ const res = makeFakeRes()
236
+ serveFromIsrCache(entry, res as unknown as ServerResponse, 'HIT')
237
+ expect(res.setHeader).toHaveBeenCalledWith('content-type', 'text/html; charset=utf-8')
238
+ })
239
+
240
+ it('sets the status code from the cache entry', () => {
241
+ const entry = makeEntry({ statusCode: 404 })
242
+ const res = makeFakeRes()
243
+ serveFromIsrCache(entry, res as unknown as ServerResponse, 'HIT')
244
+ expect(res._status()).toBe(404)
245
+ })
246
+ })
@@ -168,6 +168,11 @@ describe('generateAutoImportDts', () => {
168
168
  expect(dts).toContain("const useInject: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useInject']")
169
169
  })
170
170
 
171
+ it('declares useRuntimeConfig as a framework global', async () => {
172
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
173
+ expect(dts).toContain("const useRuntimeConfig: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useRuntimeConfig']")
174
+ })
175
+
171
176
  it('declares when directive as a global', async () => {
172
177
  const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
173
178
  expect(dts).toContain("const when: typeof import('@jasonshimmy/custom-elements-runtime/directives')['when']")
@@ -233,6 +238,21 @@ describe('generateVirtualModuleDts', () => {
233
238
  expect(dts).toContain('errorTag')
234
239
  })
235
240
 
241
+ it('declares virtual:cer-app-config module', async () => {
242
+ const dts = await generateVirtualModuleDts(ROOT, COMPOSABLES_DIR)
243
+ expect(dts).toContain("declare module 'virtual:cer-app-config'")
244
+ })
245
+
246
+ it('declares runtimeConfig export in virtual:cer-app-config', async () => {
247
+ const dts = await generateVirtualModuleDts(ROOT, COMPOSABLES_DIR)
248
+ expect(dts).toContain('runtimeConfig')
249
+ })
250
+
251
+ it('declares RuntimePublicConfig in virtual:cer-app-config', async () => {
252
+ const dts = await generateVirtualModuleDts(ROOT, COMPOSABLES_DIR)
253
+ expect(dts).toContain('RuntimePublicConfig')
254
+ })
255
+
236
256
  it('includes user composable re-exports in virtual:cer-composables', async () => {
237
257
  const exports = new Map([['useMyThing', `${ROOT}/app/composables/my-thing.ts`]])
238
258
  const dts = await generateVirtualModuleDts(ROOT, COMPOSABLES_DIR, exports)
@@ -140,4 +140,19 @@ describe('resolveConfig', () => {
140
140
  const cfg = resolveConfig({}, ROOT)
141
141
  expect(cfg.root).toBe(ROOT)
142
142
  })
143
+
144
+ it('defaults runtimeConfig.public to an empty object', () => {
145
+ const cfg = resolveConfig({}, ROOT)
146
+ expect(cfg.runtimeConfig.public).toEqual({})
147
+ })
148
+
149
+ it('passes runtimeConfig.public values through', () => {
150
+ const cfg = resolveConfig({ runtimeConfig: { public: { apiBase: '/api', version: '1' } } }, ROOT)
151
+ expect(cfg.runtimeConfig.public).toEqual({ apiBase: '/api', version: '1' })
152
+ })
153
+
154
+ it('preserves runtimeConfig.public when other runtimeConfig fields are omitted', () => {
155
+ const cfg = resolveConfig({ runtimeConfig: { public: { foo: 42 } } }, ROOT)
156
+ expect(cfg.runtimeConfig.public.foo).toBe(42)
157
+ })
143
158
  })
@@ -233,6 +233,22 @@ describe('autoImportTransform — framework composable injection', () => {
233
233
  const result = autoImportTransform(code, '/project/app/loading.ts', opts)!
234
234
  expect(result).toContain('usePageData')
235
235
  })
236
+
237
+ it('injects useRuntimeConfig import when useRuntimeConfig is used', () => {
238
+ const code = "component('page-dashboard', () => { const cfg = useRuntimeConfig(); return html`<div></div>` })"
239
+ const result = autoImportTransform(code, '/project/app/pages/dashboard.ts', opts)!
240
+ expect(result).toContain(`from ${FRAMEWORK_PKG}`)
241
+ expect(result).toContain('useRuntimeConfig')
242
+ })
243
+
244
+ it('injects useRuntimeConfig alongside other framework composables', () => {
245
+ const code = "component('page-x', () => { useHead({ title: 'x' }); const cfg = useRuntimeConfig(); return html`<div></div>` })"
246
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', opts)!
247
+ expect(result).toContain('useHead')
248
+ expect(result).toContain('useRuntimeConfig')
249
+ const count = result.split(`from ${FRAMEWORK_PKG}`).length - 1
250
+ expect(count).toBe(1)
251
+ })
236
252
  })
237
253
 
238
254
  // ─── Composable import injection ─────────────────────────────────────────────
@@ -200,3 +200,198 @@ describe('generateRoutesCode — 404.ts convention', () => {
200
200
  expect(idIdx).toBeLessThan(allIdx)
201
201
  })
202
202
  })
203
+
204
+ // ─── _layout.ts filtering ─────────────────────────────────────────────────────
205
+
206
+ describe('generateRoutesCode — _layout.ts filtering', () => {
207
+ beforeEach(() => {
208
+ vi.mocked(existsSync).mockReturnValue(true)
209
+ vi.mocked(scanDirectory).mockResolvedValue([])
210
+ vi.mocked(readFile).mockResolvedValue('' as never)
211
+ })
212
+
213
+ it('excludes _layout.ts files from the generated routes', async () => {
214
+ vi.mocked(scanDirectory).mockResolvedValue([
215
+ `${PAGES}/admin/index.ts`,
216
+ `${PAGES}/admin/_layout.ts`,
217
+ ])
218
+ const code = await generateRoutesCode(PAGES)
219
+ expect(code).not.toContain('_layout')
220
+ expect(code).toContain('/admin')
221
+ })
222
+
223
+ it('generates a route for non-underscore files in the same directory', async () => {
224
+ vi.mocked(scanDirectory).mockResolvedValue([
225
+ `${PAGES}/admin/users.ts`,
226
+ `${PAGES}/admin/_layout.ts`,
227
+ ])
228
+ const code = await generateRoutesCode(PAGES)
229
+ expect(code).toContain('/admin/users')
230
+ expect(code).not.toContain('_layout')
231
+ })
232
+ })
233
+
234
+ // ─── meta.revalidate ─────────────────────────────────────────────────────────
235
+
236
+ describe('generateRoutesCode — meta.ssg.revalidate', () => {
237
+ beforeEach(() => {
238
+ vi.mocked(existsSync).mockReturnValue(true)
239
+ vi.mocked(scanDirectory).mockResolvedValue([])
240
+ vi.mocked(readFile).mockResolvedValue('' as never)
241
+ })
242
+
243
+ it('emits meta.ssg.revalidate when declared in page source', async () => {
244
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/blog.ts`])
245
+ vi.mocked(readFile).mockResolvedValue(
246
+ `export const meta = { ssg: { revalidate: 60 } }` as never,
247
+ )
248
+ const code = await generateRoutesCode(PAGES)
249
+ expect(code).toContain('ssg: { revalidate: 60 }')
250
+ })
251
+
252
+ it('emits larger revalidate values correctly', async () => {
253
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/products.ts`])
254
+ vi.mocked(readFile).mockResolvedValue(
255
+ `export const meta = { ssg: { revalidate: 3600 } }` as never,
256
+ )
257
+ const code = await generateRoutesCode(PAGES)
258
+ expect(code).toContain('ssg: { revalidate: 3600 }')
259
+ })
260
+
261
+ it('omits ssg meta when no revalidate is declared', async () => {
262
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
263
+ vi.mocked(readFile).mockResolvedValue(
264
+ `component('page-about', () => html\`<h1>About</h1>\`)` as never,
265
+ )
266
+ const code = await generateRoutesCode(PAGES)
267
+ expect(code).not.toContain('revalidate')
268
+ })
269
+
270
+ it('can combine revalidate with layout in meta', async () => {
271
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/blog.ts`])
272
+ vi.mocked(readFile).mockResolvedValue(
273
+ `export const meta = { layout: 'minimal', ssg: { revalidate: 120 } }` as never,
274
+ )
275
+ const code = await generateRoutesCode(PAGES)
276
+ expect(code).toContain('layout: "minimal"')
277
+ expect(code).toContain('ssg: { revalidate: 120 }')
278
+ })
279
+ })
280
+
281
+ // ─── meta.transition ─────────────────────────────────────────────────────────
282
+
283
+ describe('generateRoutesCode — meta.transition', () => {
284
+ beforeEach(() => {
285
+ vi.mocked(existsSync).mockReturnValue(true)
286
+ vi.mocked(scanDirectory).mockResolvedValue([])
287
+ vi.mocked(readFile).mockResolvedValue('' as never)
288
+ })
289
+
290
+ it('emits meta.transition string when declared', async () => {
291
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
292
+ vi.mocked(readFile).mockResolvedValue(
293
+ `export const meta = { transition: 'fade' }` as never,
294
+ )
295
+ const code = await generateRoutesCode(PAGES)
296
+ expect(code).toContain('transition: "fade"')
297
+ })
298
+
299
+ it('emits meta.transition true (boolean) when declared', async () => {
300
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
301
+ vi.mocked(readFile).mockResolvedValue(
302
+ `export const meta = { transition: true }` as never,
303
+ )
304
+ const code = await generateRoutesCode(PAGES)
305
+ expect(code).toContain('transition: true')
306
+ })
307
+
308
+ it('omits transition meta when not declared', async () => {
309
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
310
+ vi.mocked(readFile).mockResolvedValue(
311
+ `component('page-about', () => html\`<h1>About</h1>\`)` as never,
312
+ )
313
+ const code = await generateRoutesCode(PAGES)
314
+ expect(code).not.toContain('transition')
315
+ })
316
+
317
+ it('emits meta.transition false (boolean) when declared', async () => {
318
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
319
+ vi.mocked(readFile).mockResolvedValue(
320
+ `export const meta = { transition: false }` as never,
321
+ )
322
+ const code = await generateRoutesCode(PAGES)
323
+ expect(code).toContain('transition: false')
324
+ })
325
+
326
+ it('can combine transition with middleware', async () => {
327
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
328
+ vi.mocked(readFile).mockResolvedValue(
329
+ `export const meta = { transition: 'slide', middleware: ['auth'] }` as never,
330
+ )
331
+ const code = await generateRoutesCode(PAGES)
332
+ expect(code).toContain('transition: "slide"')
333
+ expect(code).toContain('beforeEnter')
334
+ expect(code).toContain('"auth"')
335
+ })
336
+ })
337
+
338
+ // ─── nested layouts (layoutChain) ────────────────────────────────────────────
339
+
340
+ describe('generateRoutesCode — layoutChain (nested layouts)', () => {
341
+ beforeEach(() => {
342
+ vi.mocked(existsSync).mockReturnValue(true)
343
+ vi.mocked(scanDirectory).mockResolvedValue([])
344
+ vi.mocked(readFile).mockResolvedValue('' as never)
345
+ })
346
+
347
+ it('emits meta.layoutChain when a _layout.ts exists in ancestor directory', async () => {
348
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/admin/users.ts`])
349
+ vi.mocked(readFile).mockImplementation(async (path: unknown) => {
350
+ if (String(path).endsWith('_layout.ts')) return 'export default \'minimal\''
351
+ return ''
352
+ })
353
+ const code = await generateRoutesCode(PAGES)
354
+ expect(code).toContain('layoutChain:')
355
+ expect(code).toContain('"default"')
356
+ expect(code).toContain('"minimal"')
357
+ })
358
+
359
+ it('uses outerLayout from page meta when layoutChain is emitted', async () => {
360
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/admin/users.ts`])
361
+ vi.mocked(readFile).mockImplementation(async (path: unknown) => {
362
+ const p = String(path)
363
+ if (p.endsWith('_layout.ts')) return 'export default \'sidebar\''
364
+ // page source has its own layout
365
+ return `export const meta = { layout: 'clean' }`
366
+ })
367
+ const code = await generateRoutesCode(PAGES)
368
+ expect(code).toContain('layoutChain:')
369
+ expect(code).toContain('"clean"')
370
+ expect(code).toContain('"sidebar"')
371
+ // Should not emit a plain meta.layout — the chain replaces it
372
+ expect(code).not.toContain('layout: "clean"')
373
+ })
374
+
375
+ it('omits layoutChain for top-level pages with no _layout.ts', async () => {
376
+ vi.mocked(existsSync).mockImplementation((p: unknown) => {
377
+ // Return false for any _layout.ts path so no chain is resolved
378
+ if (String(p).endsWith('_layout.ts')) return false
379
+ return true
380
+ })
381
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
382
+ vi.mocked(readFile).mockResolvedValue(
383
+ `export const meta = { layout: 'default' }` as never,
384
+ )
385
+ const code = await generateRoutesCode(PAGES)
386
+ expect(code).not.toContain('layoutChain')
387
+ expect(code).toContain('layout: "default"')
388
+ })
389
+
390
+ it('omits layoutChain for top-level pages even when existsSync returns true', async () => {
391
+ // top-level page has no subdirectory segments, so resolveLayoutChain returns null immediately
392
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/index.ts`])
393
+ vi.mocked(readFile).mockResolvedValue('' as never)
394
+ const code = await generateRoutesCode(PAGES)
395
+ expect(code).not.toContain('layoutChain')
396
+ })
397
+ })
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { useRuntimeConfig, initRuntimeConfig } from '../../runtime/composables/use-runtime-config.js'
3
+
4
+ beforeEach(() => {
5
+ // Reset global state between tests
6
+ delete (globalThis as Record<string, unknown>).__cerRuntimeConfig
7
+ })
8
+
9
+ describe('initRuntimeConfig', () => {
10
+ it('stores the config on globalThis', () => {
11
+ initRuntimeConfig({ public: { apiBase: '/api' } })
12
+ expect((globalThis as Record<string, unknown>).__cerRuntimeConfig).toEqual({ public: { apiBase: '/api' } })
13
+ })
14
+
15
+ it('overwrites a previous config', () => {
16
+ initRuntimeConfig({ public: { apiBase: '/v1' } })
17
+ initRuntimeConfig({ public: { apiBase: '/v2' } })
18
+ const stored = (globalThis as Record<string, unknown>).__cerRuntimeConfig as { public: Record<string, unknown> }
19
+ expect(stored.public.apiBase).toBe('/v2')
20
+ })
21
+ })
22
+
23
+ describe('useRuntimeConfig', () => {
24
+ it('returns empty public config when not initialized', () => {
25
+ const config = useRuntimeConfig()
26
+ expect(config.public).toEqual({})
27
+ })
28
+
29
+ it('returns the config set by initRuntimeConfig', () => {
30
+ initRuntimeConfig({ public: { apiBase: '/api' } })
31
+ const config = useRuntimeConfig()
32
+ expect(config.public.apiBase).toBe('/api')
33
+ })
34
+
35
+ it('returns the full public config object', () => {
36
+ initRuntimeConfig({ public: { apiBase: '/api', version: '1.0', debug: false } })
37
+ const config = useRuntimeConfig()
38
+ expect(config.public).toEqual({ apiBase: '/api', version: '1.0', debug: false })
39
+ })
40
+
41
+ it('returns a reference to the stored config (not a copy)', () => {
42
+ const stored = { public: { apiBase: '/api' } }
43
+ initRuntimeConfig(stored)
44
+ const config = useRuntimeConfig()
45
+ expect(config).toBe(stored)
46
+ })
47
+
48
+ it('reflects updates when initRuntimeConfig is called again', () => {
49
+ initRuntimeConfig({ public: { key: 'first' } })
50
+ initRuntimeConfig({ public: { key: 'second' } })
51
+ expect(useRuntimeConfig().public.key).toBe('second')
52
+ })
53
+
54
+ it('handles empty public config', () => {
55
+ initRuntimeConfig({ public: {} })
56
+ const config = useRuntimeConfig()
57
+ expect(config.public).toEqual({})
58
+ })
59
+ })