@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,139 @@
1
+ /**
2
+ * ISR (Incremental Static Regeneration) helpers for the preview server.
3
+ *
4
+ * Extracted into their own module so they can be unit-tested independently
5
+ * from the HTTP server wiring in preview.ts.
6
+ */
7
+
8
+ import { Readable } from 'node:stream'
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+
11
+ // ─── Types ────────────────────────────────────────────────────────────────────
12
+
13
+ export interface IsrCacheEntry {
14
+ html: string
15
+ headers: Record<string, string>
16
+ statusCode: number
17
+ builtAt: number
18
+ revalidate: number
19
+ /** True while a background re-render is in flight (stale-while-revalidate). */
20
+ revalidating: boolean
21
+ }
22
+
23
+ export type IsrCacheStatus = 'HIT' | 'STALE' | 'MISS'
24
+
25
+ export type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
26
+
27
+ // ─── Route pattern matching ───────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Tests whether a route path pattern matches a URL path string.
31
+ * Normalises trailing slashes and supports `:param` and `:param*` (catch-all)
32
+ * segments using a simple regex conversion — no external dependencies needed.
33
+ *
34
+ * @example
35
+ * matchRoutePattern('/blog/:slug', '/blog/hello') // true
36
+ * matchRoutePattern('/:all*', '/any/deep/path') // true
37
+ * matchRoutePattern('/about', '/contact') // false
38
+ */
39
+ export function matchRoutePattern(pattern: string, urlPath: string): boolean {
40
+ const norm = (s: string): string => s.replace(/\/+$/, '') || '/'
41
+ if (norm(pattern) === norm(urlPath)) return true
42
+ const regexStr =
43
+ '^' +
44
+ norm(pattern)
45
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex special chars in static segments
46
+ .replace(/:[^/]+\*/g, '.*')
47
+ .replace(/:[^/]+/g, '[^/]+') +
48
+ '$'
49
+ return new RegExp(regexStr).test(norm(urlPath))
50
+ }
51
+
52
+ /**
53
+ * Looks up the `meta.ssg.revalidate` TTL (in seconds) for the route that best
54
+ * matches `urlPath`. Returns `null` when no route matches or none defines
55
+ * `revalidate`.
56
+ */
57
+ export function findRevalidate(
58
+ routes: Array<{ path: string; meta?: Record<string, unknown> }>,
59
+ urlPath: string,
60
+ ): number | null {
61
+ for (const route of routes) {
62
+ if (matchRoutePattern(route.path, urlPath)) {
63
+ const ssg = route.meta?.ssg as Record<string, unknown> | undefined
64
+ if (typeof ssg?.revalidate === 'number') return ssg.revalidate
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ // ─── Response capture ─────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Renders a URL path through `handler` using a synthetic IncomingMessage and a
74
+ * fake ServerResponse that captures the output in memory.
75
+ *
76
+ * Returns an `IsrCacheEntry` on success, or `null` if the handler throws.
77
+ */
78
+ export async function renderForIsr(
79
+ urlPath: string,
80
+ handler: SsrHandlerFn,
81
+ revalidate: number,
82
+ ): Promise<IsrCacheEntry | null> {
83
+ const req = Object.assign(new Readable({ read() {} }), {
84
+ url: urlPath,
85
+ method: 'GET',
86
+ headers: {},
87
+ socket: null,
88
+ }) as unknown as IncomingMessage
89
+
90
+ return new Promise<IsrCacheEntry | null>((resolve) => {
91
+ const chunks: Buffer[] = []
92
+ const headers: Record<string, string> = {}
93
+ let capturedStatus = 200
94
+
95
+ const fakeRes = {
96
+ get statusCode() { return capturedStatus },
97
+ set statusCode(v: number) { capturedStatus = v },
98
+ headersSent: false,
99
+ setHeader(name: string, value: string | string[]) {
100
+ headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value)
101
+ return this
102
+ },
103
+ getHeader(name: string) { return headers[name.toLowerCase()] },
104
+ write(chunk: string | Buffer) {
105
+ if (chunk != null) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
106
+ return true
107
+ },
108
+ end(chunk?: string | Buffer) {
109
+ if (chunk != null) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
110
+ const html = Buffer.concat(chunks).toString('utf-8')
111
+ resolve({ html, headers, statusCode: capturedStatus, builtAt: Date.now(), revalidate, revalidating: false })
112
+ return this
113
+ },
114
+ } as unknown as ServerResponse
115
+
116
+ // Use Promise.resolve().then() so synchronous throws in the handler are
117
+ // also caught by the .catch() handler.
118
+ Promise.resolve().then(() => handler(req, fakeRes)).catch(() => resolve(null))
119
+ })
120
+ }
121
+
122
+ // ─── Cache serving ────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Writes a cached ISR entry to the real HTTP response, forwarding all captured
126
+ * headers and setting the `X-Cache` diagnostic header.
127
+ */
128
+ export function serveFromIsrCache(
129
+ entry: IsrCacheEntry,
130
+ res: ServerResponse,
131
+ cacheStatus: IsrCacheStatus,
132
+ ): void {
133
+ for (const [name, value] of Object.entries(entry.headers)) {
134
+ res.setHeader(name, value)
135
+ }
136
+ res.setHeader('X-Cache', cacheStatus)
137
+ res.statusCode = entry.statusCode
138
+ res.end(entry.html)
139
+ }
@@ -3,6 +3,15 @@ import { createServer as createHttpServer, type IncomingMessage, type ServerResp
3
3
  import { createReadStream, existsSync, statSync } from 'node:fs'
4
4
  import { resolve, join, extname } from 'pathe'
5
5
  import { pathToFileURL } from 'node:url'
6
+ import {
7
+ type IsrCacheEntry,
8
+ type SsrHandlerFn,
9
+ findRevalidate,
10
+ renderForIsr,
11
+ serveFromIsrCache,
12
+ } from './preview-isr.js'
13
+
14
+ // ─── API route matching ───────────────────────────────────────────────────────
6
15
 
7
16
  /**
8
17
  * Matches an API route pattern (e.g. '/api/items/:id') against a URL path.
@@ -107,7 +116,6 @@ export function previewCommand(): Command {
107
116
  console.log('[cer-app] Starting SSR preview server...')
108
117
 
109
118
  // Load the server bundle
110
- type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
111
119
  let serverMod: {
112
120
  handler?: SsrHandlerFn
113
121
  default?: SsrHandlerFn
@@ -130,6 +138,15 @@ export function previewCommand(): Command {
130
138
  const apiRoutes: Array<{ path: string; handlers: Record<string, unknown> }> =
131
139
  Array.isArray(serverMod.apiRoutes) ? serverMod.apiRoutes : []
132
140
 
141
+ // Page routes exported by the server bundle (used for ISR revalidate lookup).
142
+ const pageRoutes: Array<{ path: string; meta?: Record<string, unknown> }> =
143
+ Array.isArray((serverMod as { routes?: unknown }).routes)
144
+ ? (serverMod as { routes: Array<{ path: string; meta?: Record<string, unknown> }> }).routes
145
+ : []
146
+
147
+ // ISR cache: path → cached render entry.
148
+ const isrCache = new Map<string, IsrCacheEntry>()
149
+
133
150
  const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
134
151
  const url = req.url ?? '/'
135
152
  const urlPath = url.split('?')[0]
@@ -183,7 +200,59 @@ export function previewCommand(): Command {
183
200
  if (served) return
184
201
  }
185
202
 
186
- // Fall through to SSR handler
203
+ // ISR: check whether this route has a revalidate TTL.
204
+ const revalidate = findRevalidate(pageRoutes, urlPath)
205
+ if (revalidate !== null) {
206
+ const cached = isrCache.get(urlPath)
207
+ const now = Date.now()
208
+
209
+ if (cached) {
210
+ const ageSeconds = (now - cached.builtAt) / 1000
211
+ if (ageSeconds < cached.revalidate) {
212
+ // Fresh — serve from cache.
213
+ serveFromIsrCache(cached, res, 'HIT')
214
+ return
215
+ }
216
+ // Stale — serve stale immediately, revalidate in background.
217
+ if (!cached.revalidating) {
218
+ cached.revalidating = true
219
+ serveFromIsrCache(cached, res, 'STALE')
220
+ const revalidateTimeout = setTimeout(() => {
221
+ if (cached) cached.revalidating = false
222
+ }, 30_000)
223
+ renderForIsr(urlPath, handler, revalidate).then((entry) => {
224
+ clearTimeout(revalidateTimeout)
225
+ if (entry) isrCache.set(urlPath, entry)
226
+ else if (cached) cached.revalidating = false
227
+ }).catch(() => {
228
+ clearTimeout(revalidateTimeout)
229
+ if (cached) cached.revalidating = false
230
+ })
231
+ return
232
+ }
233
+ // Already revalidating — serve stale without spawning another render.
234
+ serveFromIsrCache(cached, res, 'STALE')
235
+ return
236
+ }
237
+
238
+ // Cache miss — render, cache, then serve.
239
+ try {
240
+ const entry = await renderForIsr(urlPath, handler, revalidate)
241
+ if (entry) {
242
+ isrCache.set(urlPath, entry)
243
+ serveFromIsrCache(entry, res, 'HIT')
244
+ } else {
245
+ await handler(req, res)
246
+ }
247
+ } catch (err) {
248
+ console.error('[cer-app] ISR render error:', err)
249
+ res.statusCode = 500
250
+ res.end('Internal Server Error')
251
+ }
252
+ return
253
+ }
254
+
255
+ // Non-ISR: fall through to SSR handler directly.
187
256
  try {
188
257
  await handler(req, res)
189
258
  } catch (err) {
@@ -19,6 +19,7 @@ export interface ResolvedCerConfig {
19
19
  router: { base?: string; scrollToFragment?: boolean | object }
20
20
  jitCss: { content: string[]; extendedColors: boolean }
21
21
  autoImports: { components: boolean; composables: boolean; directives: boolean; runtime: boolean }
22
+ runtimeConfig: { public: Record<string, unknown> }
22
23
  }
23
24
 
24
25
  /**
@@ -76,7 +76,7 @@ const RUNTIME_GLOBALS = [
76
76
 
77
77
  const DIRECTIVE_GLOBALS = ['when', 'each', 'match', 'anchorBlock']
78
78
 
79
- const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject']
79
+ const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig']
80
80
 
81
81
  /**
82
82
  * Scans a composables directory and returns a map of export name → file path.
@@ -213,6 +213,13 @@ export async function generateVirtualModuleDts(
213
213
  lines.push(` export const errorTag: string | null`)
214
214
  lines.push(`}`)
215
215
  lines.push('')
216
+ lines.push(`declare module 'virtual:cer-app-config' {`)
217
+ lines.push(` import type { RuntimePublicConfig } from '@jasonshimmy/vite-plugin-cer-app/types'`)
218
+ lines.push(` export const appConfig: { mode: string; router: Record<string, unknown>; ssg: Record<string, unknown> }`)
219
+ lines.push(` export const runtimeConfig: { public: RuntimePublicConfig }`)
220
+ lines.push(` export default appConfig`)
221
+ lines.push(`}`)
222
+ lines.push('')
216
223
 
217
224
  return lines.join('\n')
218
225
  }
@@ -92,6 +92,9 @@ export function resolveConfig(userConfig: CerAppConfig, root: string = process.c
92
92
  directives: userConfig.autoImports?.directives ?? true,
93
93
  runtime: userConfig.autoImports?.runtime ?? true,
94
94
  },
95
+ runtimeConfig: {
96
+ public: userConfig.runtimeConfig?.public ?? {},
97
+ },
95
98
  }
96
99
  }
97
100
 
@@ -139,7 +142,14 @@ function generateAppConfigModule(config: ResolvedCerConfig): string {
139
142
  router: config.router,
140
143
  ssg: config.ssg,
141
144
  }
142
- return `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nexport const appConfig = ${JSON.stringify(exportedConfig, null, 2)}\nexport default appConfig\n`
145
+ const publicConfig = config.runtimeConfig.public
146
+ return (
147
+ `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\n` +
148
+ `export const appConfig = ${JSON.stringify(exportedConfig, null, 2)}\n` +
149
+ `export default appConfig\n` +
150
+ `\n` +
151
+ `export const runtimeConfig = { public: ${JSON.stringify(publicConfig, null, 2)} }\n`
152
+ )
143
153
  }
144
154
 
145
155
  /**
@@ -11,9 +11,9 @@ const RUNTIME_IMPORTS = `import { component, html, css, ref, computed, watch, wa
11
11
 
12
12
  const DIRECTIVE_IMPORTS = `import { when, each, match, anchorBlock } from '@jasonshimmy/custom-elements-runtime/directives';`
13
13
 
14
- const FRAMEWORK_IMPORTS = `import { useHead, usePageData, useInject } from '@jasonshimmy/vite-plugin-cer-app/composables';`
14
+ const FRAMEWORK_IMPORTS = `import { useHead, usePageData, useInject, useRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables';`
15
15
 
16
- const FRAMEWORK_IDENTIFIERS = ['useHead', 'usePageData', 'useInject']
16
+ const FRAMEWORK_IDENTIFIERS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig']
17
17
 
18
18
  const RUNTIME_IDENTIFIERS = [
19
19
  'component',
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { basename } from 'node:path'
2
+ import { basename, join, relative } from 'node:path'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { scanDirectory } from '../scanner.js'
5
5
  import { buildRouteEntry, sortRoutes } from '../path-utils.js'
@@ -35,6 +35,77 @@ function extractLayout(source: string): string | null {
35
35
  return match ? match[1] : null
36
36
  }
37
37
 
38
+ /**
39
+ * Extracts the `ssg.revalidate` number from a page file's source.
40
+ * Returns null when not declared.
41
+ *
42
+ * Matches patterns like:
43
+ * revalidate: 60
44
+ * revalidate: 3600
45
+ */
46
+ function extractRevalidate(source: string): number | null {
47
+ const match = source.match(/revalidate\s*:\s*(\d+)/)
48
+ return match ? parseInt(match[1], 10) : null
49
+ }
50
+
51
+ /**
52
+ * Extracts the `transition` value from a page file's source.
53
+ * Returns the transition name string, true (boolean), or null if absent.
54
+ *
55
+ * Matches patterns like:
56
+ * transition: 'fade'
57
+ * transition: true
58
+ */
59
+ function extractTransition(source: string): string | boolean | null {
60
+ const strMatch = source.match(/transition\s*:\s*['"]([^'"]+)['"]/)
61
+ if (strMatch) return strMatch[1]
62
+ const boolMatch = source.match(/transition\s*:\s*(true|false)/)
63
+ if (boolMatch) return boolMatch[1] === 'true'
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * Resolves the layout chain for a page by walking its ancestor directories
69
+ * inside pagesDir looking for `_layout.ts` files. Each `_layout.ts` must
70
+ * export a default string naming a layout in `app/layouts/`.
71
+ *
72
+ * Returns null when no nested layouts are found (single-layout path is used).
73
+ *
74
+ * Example:
75
+ * app/pages/admin/_layout.ts → export default 'minimal'
76
+ * app/pages/admin/users.ts → meta.layout: 'default' (or omitted)
77
+ * → layoutChain = ['default', 'minimal']
78
+ */
79
+ async function resolveLayoutChain(
80
+ filePath: string,
81
+ pagesDir: string,
82
+ outerLayout: string | null,
83
+ ): Promise<string[] | null> {
84
+ const rel = relative(pagesDir, filePath)
85
+ const parts = rel.split('/').slice(0, -1) // directory segments only
86
+
87
+ if (parts.length === 0) return null
88
+
89
+ const extras: string[] = []
90
+ let currentDir = pagesDir
91
+ for (const part of parts) {
92
+ currentDir = join(currentDir, part)
93
+ const layoutFile = join(currentDir, '_layout.ts')
94
+ if (existsSync(layoutFile)) {
95
+ try {
96
+ const src = await readFile(layoutFile, 'utf-8')
97
+ const match = src.match(/export\s+default\s+['"]([^'"]+)['"]/)
98
+ if (match) extras.push(match[1])
99
+ } catch (err) {
100
+ console.warn(`[cer-app] Could not read layout file "${layoutFile}":`, err)
101
+ }
102
+ }
103
+ }
104
+
105
+ if (extras.length === 0) return null
106
+ return [outerLayout ?? 'default', ...extras]
107
+ }
108
+
38
109
  /**
39
110
  * Generates the virtual:cer-routes module code.
40
111
  *
@@ -54,7 +125,9 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
54
125
  return `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nconst routes = []\nexport default routes\n`
55
126
  }
56
127
 
57
- const files = await scanDirectory('**/*.ts', pagesDir)
128
+ const allFiles = await scanDirectory('**/*.ts', pagesDir)
129
+ // Exclude _layout.ts files — they are directory-level layout config, not pages.
130
+ const files = allFiles.filter((f) => basename(f) !== '_layout.ts')
58
131
 
59
132
  if (files.length === 0) {
60
133
  return `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nconst routes = []\nexport default routes\n`
@@ -81,15 +154,29 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
81
154
 
82
155
  const sorted = sortRoutes(entries)
83
156
 
84
- // Read each file's source once to extract static metadata (middleware + layout)
85
- // without eagerly importing the module.
86
- const metaPerEntry: Array<{ middleware: string[]; layout: string | null }> = await Promise.all(
157
+ // Read each file's source once to extract static metadata without eagerly
158
+ // importing the module, then resolve any nested layout chains.
159
+ const metaPerEntry: Array<{
160
+ middleware: string[]
161
+ layout: string | null
162
+ layoutChain: string[] | null
163
+ revalidate: number | null
164
+ transition: string | boolean | null
165
+ }> = await Promise.all(
87
166
  sorted.map(async (entry) => {
88
167
  try {
89
168
  const src = await readFile(entry.filePath, 'utf-8')
90
- return { middleware: extractMiddleware(src), layout: extractLayout(src) }
169
+ const layout = extractLayout(src)
170
+ const layoutChain = await resolveLayoutChain(entry.filePath, pagesDir, layout)
171
+ return {
172
+ middleware: extractMiddleware(src),
173
+ layout,
174
+ layoutChain,
175
+ revalidate: extractRevalidate(src),
176
+ transition: extractTransition(src),
177
+ }
91
178
  } catch {
92
- return { middleware: [], layout: null }
179
+ return { middleware: [], layout: null, layoutChain: null, revalidate: null, transition: null }
93
180
  }
94
181
  }),
95
182
  )
@@ -98,7 +185,7 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
98
185
 
99
186
  // Build routes array with lazy load() functions for code splitting.
100
187
  const routeItems = sorted.map((entry, i) => {
101
- const { middleware: mw, layout } = metaPerEntry[i]
188
+ const { middleware: mw, layout, layoutChain, revalidate, transition } = metaPerEntry[i]
102
189
  const filePath = JSON.stringify(entry.filePath)
103
190
  const tagName = JSON.stringify(entry.tagName)
104
191
  const routePath = JSON.stringify(entry.routePath)
@@ -111,7 +198,17 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
111
198
 
112
199
  // Build meta object — only emit fields that are set
113
200
  const metaFields: string[] = []
114
- if (layout !== null) metaFields.push(`layout: ${JSON.stringify(layout)}`)
201
+ if (layoutChain !== null) {
202
+ metaFields.push(`layoutChain: ${JSON.stringify(layoutChain)}`)
203
+ } else if (layout !== null) {
204
+ metaFields.push(`layout: ${JSON.stringify(layout)}`)
205
+ }
206
+ if (revalidate !== null) {
207
+ metaFields.push(`ssg: { revalidate: ${revalidate} }`)
208
+ }
209
+ if (transition !== null) {
210
+ metaFields.push(`transition: ${JSON.stringify(transition)}`)
211
+ }
115
212
  const metaStr = metaFields.length > 0 ? ` meta: { ${metaFields.join(', ')} },\n` : ''
116
213
 
117
214
  if (mw.length === 0) {
@@ -16,6 +16,7 @@ import layouts from 'virtual:cer-layouts'
16
16
  import plugins from 'virtual:cer-plugins'
17
17
  import { hasLoading, loadingTag } from 'virtual:cer-loading'
18
18
  import { hasError, errorTag } from 'virtual:cer-error'
19
+ import { runtimeConfig } from 'virtual:cer-app-config'
19
20
  import {
20
21
  component,
21
22
  ref,
@@ -26,9 +27,11 @@ import {
26
27
  } from '@jasonshimmy/custom-elements-runtime'
27
28
  import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
28
29
  import { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'
30
+ import { initRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
29
31
 
30
32
  registerBuiltinComponents()
31
33
  enableJITCSS()
34
+ initRuntimeConfig(runtimeConfig)
32
35
 
33
36
  const router = initRouter({ routes })
34
37
 
@@ -112,12 +115,21 @@ component('cer-layout-view', () => {
112
115
 
113
116
  const matched = router.matchRoute(current.value.path)
114
117
  const routeMeta = matched?.route?.meta
115
- const layoutName = routeMeta?.layout ?? 'default'
116
- const layoutTag = layouts[layoutName]
117
118
  const routerView = { tag: 'router-view', props: {}, children: [] }
118
119
 
119
- if (layoutTag) return { tag: layoutTag, props: {}, children: [routerView] }
120
- return routerView
120
+ // Support nested layout chains: meta.layoutChain = ['default', 'admin']
121
+ // renders <layout-default><layout-admin><router-view/></layout-admin></layout-default>
122
+ const chain = routeMeta?.layoutChain
123
+ ? routeMeta.layoutChain
124
+ : [routeMeta?.layout ?? 'default']
125
+
126
+ // Build nested vnodes from innermost to outermost.
127
+ let vnode = routerView
128
+ for (let i = chain.length - 1; i >= 0; i--) {
129
+ const tag = layouts[chain[i]]
130
+ if (tag) vnode = { tag, props: {}, children: [vnode] }
131
+ }
132
+ return vnode
121
133
  })
122
134
 
123
135
  for (const plugin of plugins) {
@@ -2,3 +2,4 @@ export { useHead, beginHeadCollection, endHeadCollection, serializeHeadTags } fr
2
2
  export type { HeadInput } from './use-head.js'
3
3
  export { usePageData } from './use-page-data.js'
4
4
  export { useInject } from './use-inject.js'
5
+ export { useRuntimeConfig, initRuntimeConfig } from './use-runtime-config.js'
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Returns the public runtime configuration set in `cer.config.ts` under
3
+ * `runtimeConfig.public`. Available on both server and client.
4
+ *
5
+ * Values are baked in at build time from `virtual:cer-app-config`, so only
6
+ * static/env-var values should be placed here. For truly dynamic config,
7
+ * use a loader or API route.
8
+ *
9
+ * @example
10
+ * // cer.config.ts
11
+ * export default defineConfig({
12
+ * runtimeConfig: {
13
+ * public: { apiBase: process.env.VITE_API_BASE ?? '/api' },
14
+ * },
15
+ * })
16
+ *
17
+ * // app/pages/index.ts
18
+ * const config = useRuntimeConfig()
19
+ * fetch(config.public.apiBase + '/posts')
20
+ */
21
+ export function useRuntimeConfig(): { public: Record<string, unknown> } {
22
+ // Dynamic import resolved at runtime — avoids a static circular dependency
23
+ // between the composable and the virtual module.
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ const mod = (globalThis as any).__cerRuntimeConfig
26
+ if (mod) return mod as { public: Record<string, unknown> }
27
+
28
+ // Fallback: empty config (e.g. in test environments without the virtual module).
29
+ return { public: {} }
30
+ }
31
+
32
+ /**
33
+ * Called once during app bootstrap to store the resolved runtimeConfig on
34
+ * globalThis so useRuntimeConfig() can access it synchronously in any context
35
+ * (component render, composable, server handler).
36
+ */
37
+ export function initRuntimeConfig(config: { public: Record<string, unknown> }): void {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ ;(globalThis as any).__cerRuntimeConfig = config
40
+ }
@@ -21,13 +21,15 @@ import routes from 'virtual:cer-routes'
21
21
  import layouts from 'virtual:cer-layouts'
22
22
  import plugins from 'virtual:cer-plugins'
23
23
  import apiRoutes from 'virtual:cer-server-api'
24
+ import { runtimeConfig } from 'virtual:cer-app-config'
24
25
  import { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'
25
26
  import { registerEntityMap, renderToStringWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
26
27
  import entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'
27
28
  import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
28
- import { beginHeadCollection, endHeadCollection, serializeHeadTags } from '@jasonshimmy/vite-plugin-cer-app/composables'
29
+ import { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
29
30
 
30
31
  registerBuiltinComponents()
32
+ initRuntimeConfig(runtimeConfig)
31
33
 
32
34
  // Pre-load the full HTML entity map so named entities like &mdash; decode
33
35
  // correctly during SSR. Without this the bundled runtime falls back to a
@@ -130,8 +132,6 @@ const _prepareRequest = async (req) => {
130
132
  const router = initRouter({ routes, initialUrl: req.url ?? '/' })
131
133
  const current = router.getCurrent()
132
134
  const { route, params } = router.matchRoute(current.path)
133
- const layoutName = route?.meta?.layout ?? 'default'
134
- const layoutTag = layouts[layoutName]
135
135
 
136
136
  // Pre-load the page module so we can embed the component tag directly.
137
137
  // This avoids the async router-view (which injects content via script tags
@@ -160,9 +160,17 @@ const _prepareRequest = async (req) => {
160
160
  }
161
161
  }
162
162
 
163
- const vnode = layoutTag
164
- ? { tag: layoutTag, props: {}, children: [pageVnode] }
165
- : pageVnode
163
+ // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.
164
+ const chain = route?.meta?.layoutChain
165
+ ? route.meta.layoutChain
166
+ : [route?.meta?.layout ?? 'default']
167
+
168
+ // Wrap pageVnode in the layout chain from innermost to outermost.
169
+ let vnode = pageVnode
170
+ for (let i = chain.length - 1; i >= 0; i--) {
171
+ const tag = layouts[chain[i]]
172
+ if (tag) vnode = { tag, props: {}, children: [vnode] }
173
+ }
166
174
 
167
175
  return { vnode, router, head }
168
176
  }
@@ -18,6 +18,26 @@ export interface AutoImportsConfig {
18
18
  runtime?: boolean
19
19
  }
20
20
 
21
+ export interface RuntimePublicConfig {
22
+ [key: string]: unknown
23
+ }
24
+
25
+ export interface RuntimeConfig {
26
+ /**
27
+ * Public runtime config — available on both server and client via
28
+ * `useRuntimeConfig().public`. Values are serialized into the virtual module
29
+ * at build time, so only use static/env-var values here.
30
+ *
31
+ * @example
32
+ * runtimeConfig: {
33
+ * public: {
34
+ * apiBase: process.env.VITE_API_BASE ?? 'https://api.example.com',
35
+ * }
36
+ * }
37
+ */
38
+ public?: RuntimePublicConfig
39
+ }
40
+
21
41
  export interface CerAppConfig {
22
42
  mode?: 'spa' | 'ssr' | 'ssg'
23
43
  srcDir?: string // defaults to 'app'
@@ -26,6 +46,12 @@ export interface CerAppConfig {
26
46
  jitCss?: JitCssConfig
27
47
  autoImports?: AutoImportsConfig
28
48
  port?: number
49
+ /**
50
+ * Runtime configuration accessible via `useRuntimeConfig()`.
51
+ * Only `public` values are exposed to the client; keep secrets
52
+ * out of `public`.
53
+ */
54
+ runtimeConfig?: RuntimeConfig
29
55
  }
30
56
 
31
57
  export function defineConfig(config: CerAppConfig): CerAppConfig {
@@ -1,4 +1,4 @@
1
- export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig } from './config.js'
1
+ export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig, RuntimeConfig, RuntimePublicConfig } from './config.js'
2
2
  export { defineConfig } from './config.js'
3
3
  export type { HydrateStrategy, SsgPathsContext, PageSsgConfig, PageMeta, PageLoaderContext, PageLoader } from './page.js'
4
4
  export type { ApiRequest, ApiResponse, ApiHandler, ApiContext } from './api.js'