@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.
- package/.github/copilot-instructions.md +4 -2
- package/CHANGELOG.md +4 -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/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 +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +14 -6
- 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 +52 -11
- 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 +1 -1
- package/src/__tests__/cli/preview-isr.test.ts +246 -0
- package/src/__tests__/plugin/dts-generator.test.ts +20 -0
- 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/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 +14 -6
- package/src/types/config.ts +26 -0
- package/src/types/index.ts +1 -1
- 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
|
-
//
|
|
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) {
|
package/src/plugin/dev-server.ts
CHANGED
|
@@ -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
|
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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 — 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
|
}
|
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'
|