@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.
- package/.github/copilot-instructions.md +4 -2
- package/CHANGELOG.md +8 -0
- package/IMPLEMENTATION_PLAN.md +52 -10
- package/commits.txt +1 -1
- package/dist/cli/commands/preview-isr.d.ts +51 -0
- package/dist/cli/commands/preview-isr.d.ts.map +1 -0
- package/dist/cli/commands/preview-isr.js +104 -0
- package/dist/cli/commands/preview-isr.js.map +1 -0
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +65 -1
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/cli/create/templates/spa/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +4 -2
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/dev-server.d.ts +3 -0
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js.map +1 -1
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +8 -1
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +9 -1
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.js +2 -2
- package/dist/plugin/transforms/auto-import.js.map +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +95 -8
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/app-template.d.ts +1 -1
- package/dist/runtime/app-template.d.ts.map +1 -1
- package/dist/runtime/app-template.js +16 -4
- package/dist/runtime/app-template.js.map +1 -1
- package/dist/runtime/composables/index.d.ts +1 -0
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +1 -0
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-runtime-config.d.ts +32 -0
- package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -0
- package/dist/runtime/composables/use-runtime-config.js +41 -0
- package/dist/runtime/composables/use-runtime-config.js.map +1 -0
- package/dist/runtime/entry-server-template.d.ts +2 -2
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +50 -21
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/types/config.d.ts +24 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/page.d.ts +17 -0
- package/dist/types/page.d.ts.map +1 -1
- package/docs/composables.md +36 -0
- package/docs/configuration.md +52 -0
- package/docs/layouts.md +82 -0
- package/docs/rendering-modes.md +55 -14
- package/docs/routing.md +66 -0
- package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +100 -0
- package/e2e/kitchen-sink/app/layouts/admin.ts +13 -0
- package/e2e/kitchen-sink/app/pages/admin/_layout.ts +1 -0
- package/e2e/kitchen-sink/app/pages/admin/dashboard.ts +11 -0
- package/e2e/kitchen-sink/app/pages/blog/[slug].ts +1 -0
- package/e2e/kitchen-sink/app/pages/isr-test.ts +17 -0
- package/e2e/kitchen-sink/cer.config.ts +5 -0
- package/package.json +3 -3
- package/src/__tests__/cli/preview-isr.test.ts +246 -0
- package/src/__tests__/plugin/build-ssg-render.test.ts +46 -0
- package/src/__tests__/plugin/dts-generator.test.ts +20 -0
- package/src/__tests__/plugin/entry-server-template.test.ts +23 -5
- package/src/__tests__/plugin/resolve-config.test.ts +15 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
- package/src/__tests__/plugin/virtual/routes.test.ts +195 -0
- package/src/__tests__/runtime/use-runtime-config.test.ts +59 -0
- package/src/cli/commands/preview-isr.ts +139 -0
- package/src/cli/commands/preview.ts +71 -2
- package/src/cli/create/templates/spa/package.json.tpl +2 -2
- package/src/cli/create/templates/ssg/package.json.tpl +2 -2
- package/src/cli/create/templates/ssr/package.json.tpl +2 -2
- package/src/plugin/build-ssg.ts +4 -2
- package/src/plugin/dev-server.ts +1 -0
- package/src/plugin/dts-generator.ts +8 -1
- package/src/plugin/index.ts +11 -1
- package/src/plugin/transforms/auto-import.ts +2 -2
- package/src/plugin/virtual/routes.ts +106 -9
- package/src/runtime/app-template.ts +16 -4
- package/src/runtime/composables/index.ts +1 -0
- package/src/runtime/composables/use-runtime-config.ts +40 -0
- package/src/runtime/entry-server-template.ts +50 -21
- package/src/types/config.ts +26 -0
- package/src/types/index.ts +1 -1
- 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
|
|
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
|
|
85
|
-
//
|
|
86
|
-
const metaPerEntry: Array<{
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
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,
|
|
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 — 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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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>\${
|
|
211
|
+
const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
|
|
194
212
|
|
|
195
|
-
|
|
213
|
+
const merged = _clientTemplate
|
|
196
214
|
? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
|
|
197
215
|
: ssrHtml
|
|
198
216
|
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
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
|
|
package/src/types/config.ts
CHANGED
|
@@ -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 {
|
package/src/types/index.ts
CHANGED
|
@@ -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>> {
|