@jasonshimmy/vite-plugin-cer-app 0.4.6 → 0.6.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 (93) hide show
  1. package/.github/copilot-instructions.md +4 -2
  2. package/CHANGELOG.md +8 -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/cli/create/templates/spa/package.json.tpl +2 -2
  13. package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
  14. package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
  15. package/dist/plugin/build-ssg.d.ts.map +1 -1
  16. package/dist/plugin/build-ssg.js +4 -2
  17. package/dist/plugin/build-ssg.js.map +1 -1
  18. package/dist/plugin/dev-server.d.ts +3 -0
  19. package/dist/plugin/dev-server.d.ts.map +1 -1
  20. package/dist/plugin/dev-server.js.map +1 -1
  21. package/dist/plugin/dts-generator.d.ts.map +1 -1
  22. package/dist/plugin/dts-generator.js +8 -1
  23. package/dist/plugin/dts-generator.js.map +1 -1
  24. package/dist/plugin/index.d.ts.map +1 -1
  25. package/dist/plugin/index.js +9 -1
  26. package/dist/plugin/index.js.map +1 -1
  27. package/dist/plugin/transforms/auto-import.js +2 -2
  28. package/dist/plugin/transforms/auto-import.js.map +1 -1
  29. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  30. package/dist/plugin/virtual/routes.js +95 -8
  31. package/dist/plugin/virtual/routes.js.map +1 -1
  32. package/dist/runtime/app-template.d.ts +1 -1
  33. package/dist/runtime/app-template.d.ts.map +1 -1
  34. package/dist/runtime/app-template.js +16 -4
  35. package/dist/runtime/app-template.js.map +1 -1
  36. package/dist/runtime/composables/index.d.ts +1 -0
  37. package/dist/runtime/composables/index.d.ts.map +1 -1
  38. package/dist/runtime/composables/index.js +1 -0
  39. package/dist/runtime/composables/index.js.map +1 -1
  40. package/dist/runtime/composables/use-runtime-config.d.ts +32 -0
  41. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -0
  42. package/dist/runtime/composables/use-runtime-config.js +41 -0
  43. package/dist/runtime/composables/use-runtime-config.js.map +1 -0
  44. package/dist/runtime/entry-server-template.d.ts +2 -2
  45. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  46. package/dist/runtime/entry-server-template.js +50 -21
  47. package/dist/runtime/entry-server-template.js.map +1 -1
  48. package/dist/types/config.d.ts +24 -0
  49. package/dist/types/config.d.ts.map +1 -1
  50. package/dist/types/config.js.map +1 -1
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/types/page.d.ts +17 -0
  54. package/dist/types/page.d.ts.map +1 -1
  55. package/docs/composables.md +36 -0
  56. package/docs/configuration.md +52 -0
  57. package/docs/layouts.md +82 -0
  58. package/docs/rendering-modes.md +55 -14
  59. package/docs/routing.md +66 -0
  60. package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +100 -0
  61. package/e2e/kitchen-sink/app/layouts/admin.ts +13 -0
  62. package/e2e/kitchen-sink/app/pages/admin/_layout.ts +1 -0
  63. package/e2e/kitchen-sink/app/pages/admin/dashboard.ts +11 -0
  64. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +1 -0
  65. package/e2e/kitchen-sink/app/pages/isr-test.ts +17 -0
  66. package/e2e/kitchen-sink/cer.config.ts +5 -0
  67. package/package.json +3 -3
  68. package/src/__tests__/cli/preview-isr.test.ts +246 -0
  69. package/src/__tests__/plugin/build-ssg-render.test.ts +46 -0
  70. package/src/__tests__/plugin/dts-generator.test.ts +20 -0
  71. package/src/__tests__/plugin/entry-server-template.test.ts +23 -5
  72. package/src/__tests__/plugin/resolve-config.test.ts +15 -0
  73. package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
  74. package/src/__tests__/plugin/virtual/routes.test.ts +195 -0
  75. package/src/__tests__/runtime/use-runtime-config.test.ts +59 -0
  76. package/src/cli/commands/preview-isr.ts +139 -0
  77. package/src/cli/commands/preview.ts +71 -2
  78. package/src/cli/create/templates/spa/package.json.tpl +2 -2
  79. package/src/cli/create/templates/ssg/package.json.tpl +2 -2
  80. package/src/cli/create/templates/ssr/package.json.tpl +2 -2
  81. package/src/plugin/build-ssg.ts +4 -2
  82. package/src/plugin/dev-server.ts +1 -0
  83. package/src/plugin/dts-generator.ts +8 -1
  84. package/src/plugin/index.ts +11 -1
  85. package/src/plugin/transforms/auto-import.ts +2 -2
  86. package/src/plugin/virtual/routes.ts +106 -9
  87. package/src/runtime/app-template.ts +16 -4
  88. package/src/runtime/composables/index.ts +1 -0
  89. package/src/runtime/composables/use-runtime-config.ts +40 -0
  90. package/src/runtime/entry-server-template.ts +50 -21
  91. package/src/types/config.ts +26 -0
  92. package/src/types/index.ts +1 -1
  93. package/src/types/page.ts +17 -0
@@ -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
+ }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Key features:
9
9
  * - AsyncLocalStorage for race-condition-free concurrent renders (SSG concurrency > 1)
10
- * - Declarative Shadow DOM via renderToStringWithJITCSSDSD (always on)
10
+ * - Declarative Shadow DOM via renderToStreamWithJITCSSDSD (always on, streamed)
11
11
  * - useHead() support via beginHeadCollection / endHeadCollection
12
12
  * - DSD polyfill injected at end of <body> after client-template merge
13
13
  */
@@ -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
- import { registerEntityMap, renderToStringWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
26
+ import { registerEntityMap, renderToStreamWithJITCSSDSD, 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
  }
@@ -172,38 +180,59 @@ export const handler = async (req, res) => {
172
180
  const { vnode, router, head } = await _prepareRequest(req)
173
181
 
174
182
  // Begin collecting useHead() calls made during the synchronous render pass.
183
+ // IMPORTANT: the stream's start() function runs synchronously on construction,
184
+ // so ALL useHead() calls happen before the stream object is returned. We must
185
+ // call endHeadCollection() immediately — before any await — to avoid a race
186
+ // window where a concurrent request (e.g. SSG concurrency > 1) resets the
187
+ // shared globalThis collector while this handler is suspended at an await.
175
188
  beginHeadCollection()
176
189
 
177
190
  // dsdPolyfill: false — we inject the polyfill manually after merging so it
178
191
  // lands at the end of <body>, not inside <cer-layout-view> light DOM where
179
192
  // scripts may not execute.
180
- const { htmlWithStyles } = renderToStringWithJITCSSDSD(vnode, {
181
- dsdPolyfill: false,
182
- router,
183
- })
193
+ // The first chunk from the stream is the full synchronous render. Subsequent
194
+ // chunks are async component swap scripts streamed as they resolve.
195
+ const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })
184
196
 
185
- // Collect and serialize any useHead() calls from the rendered components.
197
+ // Collect head tags synchronously — all useHead() calls have already fired
198
+ // inside the stream constructor's start() before it returned.
186
199
  const headTags = serializeHeadTags(endHeadCollection())
187
200
 
201
+ const reader = stream.getReader()
202
+
203
+ // Read the first (synchronous) chunk.
204
+ const { value: firstChunk = '' } = await reader.read()
205
+
188
206
  // Merge loader data script + useHead() tags into the document head.
189
207
  const headContent = [head, headTags].filter(Boolean).join('\\n')
190
208
 
191
209
  // Wrap the rendered body in a full HTML document and inject the head additions
192
210
  // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
193
- const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${htmlWithStyles}</body></html>\`
211
+ const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
194
212
 
195
- let finalHtml = _clientTemplate
213
+ const merged = _clientTemplate
196
214
  ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
197
215
  : ssrHtml
198
216
 
199
- // Inject DSD polyfill at end of <body>, outside <cer-layout-view>, so the
200
- // browser runs it after parsing the declarative shadow roots.
201
- finalHtml = finalHtml.includes('</body>')
202
- ? finalHtml.replace('</body>', DSD_POLYFILL_SCRIPT + '</body>')
203
- : finalHtml + DSD_POLYFILL_SCRIPT
217
+ // Split at </body> so async swap scripts and the DSD polyfill can be streamed
218
+ // in before the document is closed.
219
+ const bodyCloseIdx = merged.lastIndexOf('</body>')
220
+ const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged
221
+ const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''
204
222
 
205
223
  res.setHeader('Content-Type', 'text/html; charset=utf-8')
206
- res.end(finalHtml)
224
+ res.setHeader('Transfer-Encoding', 'chunked')
225
+ res.write(beforeBodyClose)
226
+
227
+ // Stream async component swap scripts through as-is.
228
+ while (true) {
229
+ const { value, done } = await reader.read()
230
+ if (done) break
231
+ res.write(value)
232
+ }
233
+
234
+ // Inject DSD polyfill immediately before </body>, then close the document.
235
+ res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)
207
236
  })
208
237
  }
209
238
 
@@ -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'
package/src/types/page.ts CHANGED
@@ -8,6 +8,14 @@ export interface SsgPathsContext {
8
8
 
9
9
  export interface PageSsgConfig {
10
10
  paths?: () => Promise<SsgPathsContext[]> | SsgPathsContext[]
11
+ /**
12
+ * Seconds before a cached SSR response is stale and should be re-rendered.
13
+ * Enables Incremental Static Regeneration (ISR) in the preview server and
14
+ * any production adapter that reads `meta.ssg.revalidate`.
15
+ *
16
+ * @example export const meta = { ssg: { revalidate: 60 } }
17
+ */
18
+ revalidate?: number
11
19
  }
12
20
 
13
21
  export interface PageMeta {
@@ -15,6 +23,15 @@ export interface PageMeta {
15
23
  middleware?: string[] // named middleware files from app/middleware/
16
24
  hydrate?: HydrateStrategy
17
25
  ssg?: PageSsgConfig
26
+ /**
27
+ * CSS transition name applied to the page during route changes.
28
+ * Set to `true` to use the default 'page' transition name.
29
+ * The framework adds/removes `[data-transition="<name>"]` on the root element
30
+ * so you can target it with CSS animations.
31
+ *
32
+ * @example export const meta = { transition: 'fade' }
33
+ */
34
+ transition?: string | boolean
18
35
  }
19
36
 
20
37
  export interface PageLoaderContext<P extends Record<string, string> = Record<string, string>> {