@jasonshimmy/vite-plugin-cer-app 0.20.2 → 0.20.4
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/CHANGELOG.md +8 -0
- package/commits.txt +1 -2
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +5 -0
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +0 -11
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/content/index.d.ts +5 -4
- package/dist/plugin/content/index.d.ts.map +1 -1
- package/dist/plugin/content/index.js +9 -11
- package/dist/plugin/content/index.js.map +1 -1
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js +40 -2
- 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 +9 -1
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +14 -4
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/virtual/plugins.d.ts +7 -1
- package/dist/plugin/virtual/plugins.d.ts.map +1 -1
- package/dist/plugin/virtual/plugins.js +12 -2
- package/dist/plugin/virtual/plugins.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 +9 -3
- package/dist/runtime/app-template.js.map +1 -1
- package/dist/runtime/composables/use-page-data.d.ts +7 -5
- package/dist/runtime/composables/use-page-data.d.ts.map +1 -1
- package/dist/runtime/composables/use-page-data.js +7 -5
- package/dist/runtime/composables/use-page-data.js.map +1 -1
- 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 +8 -2
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/docs/content.md +2 -13
- package/e2e/cypress/e2e/preview-hardening.cy.ts +42 -33
- package/e2e/cypress/e2e/use-page-data.cy.ts +122 -0
- package/e2e/kitchen-sink/app/pages/blog/[slug].ts +4 -0
- package/e2e/kitchen-sink/app/pages/blog/index.ts +5 -0
- package/package.json +5 -2
- package/src/__tests__/plugin/build-ssg.test.ts +2 -2
- package/src/__tests__/plugin/content/loader.test.ts +19 -27
- package/src/__tests__/plugin/entry-server-template.test.ts +4 -1
- package/src/__tests__/plugin/virtual/plugins.test.ts +45 -0
- package/src/__tests__/runtime/app-template.test.ts +11 -2
- package/src/cli/commands/dev.ts +5 -0
- package/src/plugin/build-ssg.ts +0 -12
- package/src/plugin/content/index.ts +8 -11
- package/src/plugin/dev-server.ts +37 -2
- package/src/plugin/dts-generator.ts +7 -1
- package/src/plugin/index.ts +14 -4
- package/src/plugin/virtual/plugins.ts +12 -2
- package/src/runtime/app-template.ts +9 -3
- package/src/runtime/composables/use-page-data.ts +7 -5
- package/src/runtime/entry-server-template.ts +8 -2
|
@@ -9,6 +9,10 @@ component('page-blog', () => {
|
|
|
9
9
|
|
|
10
10
|
const ssrData = usePageData<{ posts: Post[] }>()
|
|
11
11
|
const posts = ref<Post[]>(ssrData?.posts ?? [])
|
|
12
|
+
// Captured once at element-creation time (during the hydration re-render).
|
|
13
|
+
// 'ssr' proves usePageData() was non-null — the queueMicrotask timing fix works.
|
|
14
|
+
// 'client' means __CER_DATA__ was deleted before setup ran (regression).
|
|
15
|
+
const dataSource = ssrData ? 'ssr' : 'client'
|
|
12
16
|
|
|
13
17
|
useOnConnected(async () => {
|
|
14
18
|
if (ssrData) return // already hydrated — skip client fetch
|
|
@@ -28,6 +32,7 @@ component('page-blog', () => {
|
|
|
28
32
|
<div>
|
|
29
33
|
<h1 data-cy="blog-heading">Blog</h1>
|
|
30
34
|
<p>Posts are loaded via a page <strong>loader</strong> (SSR/SSG) or client-side fetch (SPA).</p>
|
|
35
|
+
<span data-cy="blog-data-source" hidden>${dataSource}</span>
|
|
31
36
|
<ul data-cy="blog-list">
|
|
32
37
|
${posts.value.map(post => html`
|
|
33
38
|
<li data-cy="blog-item">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jasonshimmy/vite-plugin-cer-app",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.4",
|
|
4
4
|
"description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -72,13 +72,16 @@
|
|
|
72
72
|
"e2e:serve:spa": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4174",
|
|
73
73
|
"e2e:serve:ssr": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4175 --ssr",
|
|
74
74
|
"e2e:serve:ssg": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4176",
|
|
75
|
+
"e2e:serve:dev": "node dist/cli/index.js dev --root e2e/kitchen-sink --port 4177 --mode ssr",
|
|
75
76
|
"e2e:run:spa": "cypress run --config baseUrl=http://localhost:4174 --env mode=spa",
|
|
76
77
|
"e2e:run:ssr": "cypress run --config baseUrl=http://localhost:4175 --env mode=ssr",
|
|
77
78
|
"e2e:run:ssg": "cypress run --config baseUrl=http://localhost:4176 --env mode=ssg",
|
|
79
|
+
"e2e:run:dev": "cypress run --config baseUrl=http://localhost:4177 --env mode=dev",
|
|
78
80
|
"e2e:spa": "npm run e2e:build:spa && start-server-and-test e2e:serve:spa http://localhost:4174 e2e:run:spa",
|
|
79
81
|
"e2e:ssr": "npm run e2e:build:ssr && start-server-and-test e2e:serve:ssr http://localhost:4175 e2e:run:ssr",
|
|
80
82
|
"e2e:ssg": "npm run e2e:build:ssg && start-server-and-test e2e:serve:ssg http://localhost:4176 e2e:run:ssg",
|
|
81
|
-
"e2e": "npm run e2e:
|
|
83
|
+
"e2e:dev": "npm run e2e:clean && start-server-and-test e2e:serve:dev http://localhost:4177 e2e:run:dev",
|
|
84
|
+
"e2e": "npm run e2e:ssr && npm run e2e:ssg && npm run e2e:spa && npm run e2e:dev",
|
|
82
85
|
"cypress:open": "cypress open"
|
|
83
86
|
},
|
|
84
87
|
"peerDependencies": {
|
|
@@ -137,8 +137,8 @@ describe('buildSSG — path collection', () => {
|
|
|
137
137
|
it('calls fg when pagesDir exists and no explicit routes', async () => {
|
|
138
138
|
vi.mocked(existsSync).mockReturnValue(true)
|
|
139
139
|
await buildSSG(makeConfig())
|
|
140
|
-
// fg is called once for page discovery
|
|
141
|
-
expect(fg).toHaveBeenCalledTimes(
|
|
140
|
+
// fg is called once for page discovery
|
|
141
|
+
expect(fg).toHaveBeenCalledTimes(1)
|
|
142
142
|
})
|
|
143
143
|
|
|
144
144
|
it('skips Vite dev server when all discovered pages are static', async () => {
|
|
@@ -79,38 +79,37 @@ describe('resolveContentDir', () => {
|
|
|
79
79
|
|
|
80
80
|
describe('loadContentStore — nonexistent dir', () => {
|
|
81
81
|
it('returns empty array when contentDir does not exist', async () => {
|
|
82
|
-
const items = await loadContentStore('/path/does/not/exist', false
|
|
82
|
+
const items = await loadContentStore('/path/does/not/exist', false)
|
|
83
83
|
expect(items).toEqual([])
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
-
describe('loadContentStore —
|
|
88
|
-
it('loads all files
|
|
89
|
-
const items = await loadContentStore(contentDir, false
|
|
87
|
+
describe('loadContentStore — drafts excluded by default (includeDrafts=false)', () => {
|
|
88
|
+
it('loads all non-draft files', async () => {
|
|
89
|
+
const items = await loadContentStore(contentDir, false)
|
|
90
90
|
const paths = items.map((i) => i._path).sort()
|
|
91
|
-
// Root, about, blog/hello
|
|
91
|
+
// Root, about, blog/hello (not secret — it is a draft), data/products
|
|
92
92
|
expect(paths).toContain('/')
|
|
93
93
|
expect(paths).toContain('/about')
|
|
94
94
|
expect(paths).toContain('/blog/hello')
|
|
95
|
-
expect(paths).toContain('/blog/secret')
|
|
95
|
+
expect(paths).not.toContain('/blog/secret')
|
|
96
96
|
expect(paths).toContain('/data/products')
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
-
it('
|
|
100
|
-
const items = await loadContentStore(contentDir, false
|
|
99
|
+
it('excludes draft items', async () => {
|
|
100
|
+
const items = await loadContentStore(contentDir, false)
|
|
101
101
|
const secret = items.find((i) => i._path === '/blog/secret')
|
|
102
|
-
expect(secret).
|
|
103
|
-
expect(secret?.draft).toBe(true)
|
|
102
|
+
expect(secret).toBeUndefined()
|
|
104
103
|
})
|
|
105
104
|
|
|
106
105
|
it('strips date prefix from slug', async () => {
|
|
107
|
-
const items = await loadContentStore(contentDir, false
|
|
106
|
+
const items = await loadContentStore(contentDir, false)
|
|
108
107
|
expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
|
|
109
108
|
expect(items.find((i) => i._path === '/blog/2026-04-03-hello')).toBeUndefined()
|
|
110
109
|
})
|
|
111
110
|
|
|
112
111
|
it('each item has required ContentItem fields', async () => {
|
|
113
|
-
const items = await loadContentStore(contentDir, false
|
|
112
|
+
const items = await loadContentStore(contentDir, false)
|
|
114
113
|
for (const item of items) {
|
|
115
114
|
expect(typeof item._path).toBe('string')
|
|
116
115
|
expect(typeof item._file).toBe('string')
|
|
@@ -121,7 +120,7 @@ describe('loadContentStore — dev mode (isProduction=false)', () => {
|
|
|
121
120
|
})
|
|
122
121
|
|
|
123
122
|
it('normalises date fields to strings (not Date objects)', async () => {
|
|
124
|
-
const items = await loadContentStore(contentDir, false
|
|
123
|
+
const items = await loadContentStore(contentDir, false)
|
|
125
124
|
const about = items.find((i) => i._path === '/about')
|
|
126
125
|
expect(about).toBeDefined()
|
|
127
126
|
expect(typeof about?.date).toBe('string')
|
|
@@ -129,31 +128,24 @@ describe('loadContentStore — dev mode (isProduction=false)', () => {
|
|
|
129
128
|
})
|
|
130
129
|
})
|
|
131
130
|
|
|
132
|
-
describe('loadContentStore —
|
|
133
|
-
it('
|
|
134
|
-
const items = await loadContentStore(contentDir,
|
|
131
|
+
describe('loadContentStore — drafts included (includeDrafts=true)', () => {
|
|
132
|
+
it('includes draft items when includeDrafts=true', async () => {
|
|
133
|
+
const items = await loadContentStore(contentDir, true)
|
|
135
134
|
const secret = items.find((i) => i._path === '/blog/secret')
|
|
136
|
-
expect(secret).
|
|
135
|
+
expect(secret).toBeDefined()
|
|
136
|
+
expect(secret?.draft).toBe(true)
|
|
137
137
|
})
|
|
138
138
|
|
|
139
139
|
it('includes non-draft items', async () => {
|
|
140
|
-
const items = await loadContentStore(contentDir,
|
|
140
|
+
const items = await loadContentStore(contentDir, true)
|
|
141
141
|
expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
|
|
142
142
|
expect(items.find((i) => i._path === '/about')).toBeDefined()
|
|
143
143
|
})
|
|
144
144
|
})
|
|
145
145
|
|
|
146
|
-
describe('loadContentStore — production mode with drafts enabled (isDraft=true)', () => {
|
|
147
|
-
it('includes draft items when isDraft=true in production', async () => {
|
|
148
|
-
const items = await loadContentStore(contentDir, true, true)
|
|
149
|
-
const secret = items.find((i) => i._path === '/blog/secret')
|
|
150
|
-
expect(secret).toBeDefined()
|
|
151
|
-
})
|
|
152
|
-
})
|
|
153
|
-
|
|
154
146
|
describe('loadContentStore — JSON files', () => {
|
|
155
147
|
it('includes JSON files with _type json', async () => {
|
|
156
|
-
const items = await loadContentStore(contentDir, false
|
|
148
|
+
const items = await loadContentStore(contentDir, false)
|
|
157
149
|
const products = items.find((i) => i._path === '/data/products')
|
|
158
150
|
expect(products).toBeDefined()
|
|
159
151
|
expect(products?._type).toBe('json')
|
|
@@ -117,7 +117,10 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
|
|
|
117
117
|
})
|
|
118
118
|
|
|
119
119
|
it('merges SSR html with client template when available', () => {
|
|
120
|
-
|
|
120
|
+
// Dev mode: per-request global takes precedence over module-level _clientTemplate
|
|
121
|
+
expect(src).toContain('_resolvedClientTemplate')
|
|
122
|
+
expect(src).toContain('(globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate')
|
|
123
|
+
expect(src).toContain('_mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)')
|
|
121
124
|
})
|
|
122
125
|
|
|
123
126
|
it('exports handler as both named and default export', () => {
|
|
@@ -81,4 +81,49 @@ describe('generatePluginsCode', () => {
|
|
|
81
81
|
expect(code).toContain('export const plugins')
|
|
82
82
|
expect(code).toContain('export default plugins')
|
|
83
83
|
})
|
|
84
|
+
|
|
85
|
+
// ─── .client.ts convention ────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
it('includes .client.ts plugins by default (client-side)', async () => {
|
|
88
|
+
vi.mocked(scanDirectory).mockResolvedValue([
|
|
89
|
+
`${PLUGINS}/auth.ts`,
|
|
90
|
+
`${PLUGINS}/cer-material.client.ts`,
|
|
91
|
+
])
|
|
92
|
+
const code = await generatePluginsCode(PLUGINS)
|
|
93
|
+
expect(code).toContain('cer-material.client.ts')
|
|
94
|
+
expect(code).toContain('auth.ts')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('excludes .client.ts plugins when serverSafeOnly is true', async () => {
|
|
98
|
+
vi.mocked(scanDirectory).mockResolvedValue([
|
|
99
|
+
`${PLUGINS}/auth.ts`,
|
|
100
|
+
`${PLUGINS}/cer-material.client.ts`,
|
|
101
|
+
])
|
|
102
|
+
const code = await generatePluginsCode(PLUGINS, true)
|
|
103
|
+
expect(code).not.toContain('cer-material.client.ts')
|
|
104
|
+
expect(code).toContain('auth.ts')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns empty plugins array when all plugins are .client.ts and serverSafeOnly is true', async () => {
|
|
108
|
+
vi.mocked(scanDirectory).mockResolvedValue([
|
|
109
|
+
`${PLUGINS}/cer-material.client.ts`,
|
|
110
|
+
])
|
|
111
|
+
const code = await generatePluginsCode(PLUGINS, true)
|
|
112
|
+
expect(code).toContain('export const plugins = []')
|
|
113
|
+
expect(code).not.toContain('cer-material.client.ts')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('preserves non-.client.ts plugins in server-safe mode', async () => {
|
|
117
|
+
vi.mocked(scanDirectory).mockResolvedValue([
|
|
118
|
+
`${PLUGINS}/01.analytics.ts`,
|
|
119
|
+
`${PLUGINS}/02.fonts.client.ts`,
|
|
120
|
+
`${PLUGINS}/03.auth.ts`,
|
|
121
|
+
])
|
|
122
|
+
const code = await generatePluginsCode(PLUGINS, true)
|
|
123
|
+
expect(code).toContain('01.analytics.ts')
|
|
124
|
+
expect(code).toContain('03.auth.ts')
|
|
125
|
+
expect(code).not.toContain('02.fonts.client.ts')
|
|
126
|
+
// Two plugins remain: _p0 and _p1
|
|
127
|
+
expect(code).toContain('export const plugins = [_p0, _p1]')
|
|
128
|
+
})
|
|
84
129
|
})
|
|
@@ -79,12 +79,21 @@ describe('APP_ENTRY_TEMPLATE — meta.hydrate', () => {
|
|
|
79
79
|
expect(APP_ENTRY_TEMPLATE).toContain('__cerRouter')
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
it('_doHydrate
|
|
83
|
-
// delete must
|
|
82
|
+
it('_doHydrate defers __CER_DATA__ deletion via queueMicrotask after navigation', () => {
|
|
83
|
+
// The delete must happen inside a queueMicrotask callback so that
|
|
84
|
+
// cer-layout-view's reactive re-render (queued by the router subscription)
|
|
85
|
+
// runs BEFORE the data is cleared. A synchronous delete would remove the
|
|
86
|
+
// data before the scheduled render can read it, causing usePageData() to
|
|
87
|
+
// always return null on initial SSR/SSG page load.
|
|
84
88
|
const doHydrateStart = APP_ENTRY_TEMPLATE.indexOf('const _doHydrate')
|
|
85
89
|
const doHydrateEnd = APP_ENTRY_TEMPLATE.indexOf('\n }', doHydrateStart)
|
|
86
90
|
const doHydrateBlock = APP_ENTRY_TEMPLATE.slice(doHydrateStart, doHydrateEnd)
|
|
91
|
+
expect(doHydrateBlock).toContain('queueMicrotask')
|
|
87
92
|
expect(doHydrateBlock).toContain('delete (globalThis).__CER_DATA__')
|
|
93
|
+
// The delete must be INSIDE a queueMicrotask callback, not inline
|
|
94
|
+
const microtaskIdx = doHydrateBlock.indexOf('queueMicrotask')
|
|
95
|
+
const deleteIdx = doHydrateBlock.indexOf('delete (globalThis).__CER_DATA__')
|
|
96
|
+
expect(deleteIdx).toBeGreaterThan(microtaskIdx)
|
|
88
97
|
})
|
|
89
98
|
})
|
|
90
99
|
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -77,9 +77,14 @@ export function devCommand(): Command {
|
|
|
77
77
|
.option('-p, --port <port>', 'Port to listen on', '3000')
|
|
78
78
|
.option('--host <host>', 'Host to bind to', 'localhost')
|
|
79
79
|
.option('--root <root>', 'Project root directory', process.cwd())
|
|
80
|
+
.option('--mode <mode>', 'Dev mode: spa, ssr, or ssg (overrides cer.config.ts)')
|
|
80
81
|
.action(async (options) => {
|
|
81
82
|
const root = resolve(options.root)
|
|
82
83
|
const userConfig = await loadCerConfig(root)
|
|
84
|
+
// CLI --mode flag overrides config file (mirrors build command behaviour)
|
|
85
|
+
if (options.mode) {
|
|
86
|
+
userConfig.mode = options.mode as 'spa' | 'ssr' | 'ssg'
|
|
87
|
+
}
|
|
83
88
|
const port = options.port ? parseInt(options.port, 10) : (userConfig.port ?? 3000)
|
|
84
89
|
|
|
85
90
|
console.log('[cer-app] Starting dev server...')
|
package/src/plugin/build-ssg.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { createServer, type UserConfig } from 'vite'
|
|
|
5
5
|
import type { ResolvedCerConfig } from './dev-server.js'
|
|
6
6
|
import { buildSSR } from './build-ssr.js'
|
|
7
7
|
import { buildRouteEntry } from './path-utils.js'
|
|
8
|
-
import { CONTENT_STORE_KEY, loadContentStore, resolveContentDir } from './content/index.js'
|
|
9
8
|
import fg from 'fast-glob'
|
|
10
9
|
|
|
11
10
|
interface SsgManifest {
|
|
@@ -250,17 +249,6 @@ export async function buildSSG(
|
|
|
250
249
|
const paths = await collectSsgPaths(config, viteUserConfig)
|
|
251
250
|
console.log(`[cer-app] Found ${paths.length} path(s) to generate:`, paths)
|
|
252
251
|
|
|
253
|
-
// Restore the in-memory content store to the production (no-draft) content.
|
|
254
|
-
// collectSsgPaths may spin up a Vite dev server (watchMode=true) whose
|
|
255
|
-
// buildStart hook overwrites globalThis.__CER_CONTENT_STORE__ with drafts
|
|
256
|
-
// included. Re-running loadContentStore with isProduction=true corrects this
|
|
257
|
-
// so that every renderPath call sees only published content.
|
|
258
|
-
{
|
|
259
|
-
const contentDir = resolveContentDir(config.root)
|
|
260
|
-
const productionItems = await loadContentStore(contentDir, false, true)
|
|
261
|
-
;(globalThis as Record<string, unknown>)[CONTENT_STORE_KEY] = productionItems
|
|
262
|
-
}
|
|
263
|
-
|
|
264
252
|
// Step 3+4: Render and write paths with bounded concurrency.
|
|
265
253
|
// The server bundle uses per-request router instances (initRouter returns the
|
|
266
254
|
// router; the factory passes it to createStreamingSSRHandler as { vnode, router })
|
|
@@ -24,14 +24,14 @@ export function resolveContentDir(root: string, contentConfig?: CerContentConfig
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Loads all content files from `contentDir`, parses them concurrently, and
|
|
27
|
-
* returns the full `ContentItem[]`. Excludes drafts
|
|
28
|
-
*
|
|
29
|
-
* which is significantly faster than sequential `readFileSync` at
|
|
27
|
+
* returns the full `ContentItem[]`. Excludes drafts unless `includeDrafts: true`
|
|
28
|
+
* is set in the content config. Uses async I/O + `Promise.all` for concurrent
|
|
29
|
+
* disk reads, which is significantly faster than sequential `readFileSync` at
|
|
30
|
+
* 10k+ pages.
|
|
30
31
|
*/
|
|
31
32
|
export async function loadContentStore(
|
|
32
33
|
contentDir: string,
|
|
33
34
|
isDraft: boolean,
|
|
34
|
-
isProduction: boolean,
|
|
35
35
|
): Promise<ContentItem[]> {
|
|
36
36
|
if (!existsSync(contentDir)) return []
|
|
37
37
|
|
|
@@ -41,8 +41,8 @@ export async function loadContentStore(
|
|
|
41
41
|
files.map(async (file) => {
|
|
42
42
|
try {
|
|
43
43
|
const item = await parseContentFileAsync(file, contentDir)
|
|
44
|
-
// Skip drafts
|
|
45
|
-
if (
|
|
44
|
+
// Skip drafts unless the user explicitly opted in via drafts: true
|
|
45
|
+
if (!isDraft && item.draft === true) return null
|
|
46
46
|
return item
|
|
47
47
|
} catch (err) {
|
|
48
48
|
// Warn and skip unparseable / invalid files so one bad file does not
|
|
@@ -175,8 +175,7 @@ export function cerContent(
|
|
|
175
175
|
},
|
|
176
176
|
|
|
177
177
|
async buildStart() {
|
|
178
|
-
const
|
|
179
|
-
const items = await loadContentStore(_resolvedContentDir, includeDrafts, isProduction)
|
|
178
|
+
const items = await loadContentStore(_resolvedContentDir, includeDrafts)
|
|
180
179
|
const g = globalThis as Record<string, unknown>
|
|
181
180
|
g[CONTENT_STORE_KEY] = items
|
|
182
181
|
},
|
|
@@ -225,9 +224,7 @@ export function cerContent(
|
|
|
225
224
|
}
|
|
226
225
|
|
|
227
226
|
async function refreshStore(contentDir: string, includeDrafts: boolean): Promise<void> {
|
|
228
|
-
|
|
229
|
-
// remain visible, matching the initial buildStart behaviour in dev mode.
|
|
230
|
-
const items = await loadContentStore(contentDir, includeDrafts, false)
|
|
227
|
+
const items = await loadContentStore(contentDir, includeDrafts)
|
|
231
228
|
const g = globalThis as Record<string, unknown>
|
|
232
229
|
g[CONTENT_STORE_KEY] = items
|
|
233
230
|
// Invalidate the dev middleware caches so the next request rebuilds manifest
|
package/src/plugin/dev-server.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ViteDevServer } from 'vite'
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
3
5
|
import { join } from 'pathe'
|
|
4
6
|
import { getGeneratedDir } from './generated-dir.js'
|
|
5
7
|
|
|
@@ -246,7 +248,7 @@ export function configureCerDevServer(
|
|
|
246
248
|
const acceptsHtml =
|
|
247
249
|
(req.headers['accept'] ?? '').includes('text/html') ||
|
|
248
250
|
url === '/' ||
|
|
249
|
-
(!url.includes('.') && !url.startsWith('/api/'))
|
|
251
|
+
(!url.includes('.') && !url.startsWith('/api/') && !url.startsWith('/@'))
|
|
250
252
|
|
|
251
253
|
if (acceptsHtml) {
|
|
252
254
|
// Check per-route render mode — skip SSR for 'spa' routes.
|
|
@@ -257,6 +259,20 @@ export function configureCerDevServer(
|
|
|
257
259
|
for (const route of pageRoutes) {
|
|
258
260
|
if (_matchDevRoute(route.path, urlPathOnly)) {
|
|
259
261
|
if (route.meta?.render === 'spa') {
|
|
262
|
+
// In SSR/SSG dev mode, a route with render:'spa' should be served as
|
|
263
|
+
// the SPA shell (no server rendering). Serve .cer/index.html so the
|
|
264
|
+
// client bundle boots and handles the route client-side.
|
|
265
|
+
const _userHtml = resolve(config.root, 'index.html')
|
|
266
|
+
const _cerHtml = join(getGeneratedDir(config.root), 'index.html')
|
|
267
|
+
const _spaSrcPath = existsSync(_userHtml) ? _userHtml : _cerHtml
|
|
268
|
+
if (existsSync(_spaSrcPath)) {
|
|
269
|
+
const rawHtml = readFileSync(_spaSrcPath, 'utf-8')
|
|
270
|
+
const transformed = await server.transformIndexHtml(url, rawHtml)
|
|
271
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
272
|
+
res.statusCode = 200
|
|
273
|
+
res.end(transformed)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
260
276
|
next()
|
|
261
277
|
return
|
|
262
278
|
}
|
|
@@ -276,7 +292,26 @@ export function configureCerDevServer(
|
|
|
276
292
|
ssrEntry.handler ?? ssrEntry.default?.handler
|
|
277
293
|
|
|
278
294
|
if (typeof handler === 'function') {
|
|
279
|
-
|
|
295
|
+
// In dev mode _clientTemplate inside entry-server.ts is null because
|
|
296
|
+
// the dist/client/index.html path doesn't exist. Set the global that
|
|
297
|
+
// the handler reads per-request so the SSR response includes the
|
|
298
|
+
// Vite client scripts (/@vite/client, HMR, module imports for app.ts).
|
|
299
|
+
const _userIndexPath = resolve(config.root, 'index.html')
|
|
300
|
+
const _genIndexPath = join(getGeneratedDir(config.root), 'index.html')
|
|
301
|
+
const _rawHtml = existsSync(_userIndexPath)
|
|
302
|
+
? readFileSync(_userIndexPath, 'utf-8')
|
|
303
|
+
: existsSync(_genIndexPath)
|
|
304
|
+
? readFileSync(_genIndexPath, 'utf-8')
|
|
305
|
+
: null
|
|
306
|
+
if (_rawHtml) {
|
|
307
|
+
;(globalThis as Record<string, unknown>).__CER_CLIENT_TEMPLATE__ =
|
|
308
|
+
await server.transformIndexHtml(url, _rawHtml)
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
await handler(req, res)
|
|
312
|
+
} finally {
|
|
313
|
+
;(globalThis as Record<string, unknown>).__CER_CLIENT_TEMPLATE__ = undefined
|
|
314
|
+
}
|
|
280
315
|
return
|
|
281
316
|
}
|
|
282
317
|
|
|
@@ -40,7 +40,13 @@ export function writeTsconfigPaths(root: string, srcDir: string): void {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const content = JSON.stringify(tsconfig, null, 2) + '\n'
|
|
43
|
-
|
|
43
|
+
const tsconfigPath = join(cerDir, 'tsconfig.json')
|
|
44
|
+
// Skip write when content is unchanged to avoid triggering Vite's tsconfig
|
|
45
|
+
// watcher, which would cause an unnecessary server restart on every dev start.
|
|
46
|
+
try {
|
|
47
|
+
if (existsSync(tsconfigPath) && readFileSync(tsconfigPath, 'utf-8') === content) return
|
|
48
|
+
} catch { /* proceed to write */ }
|
|
49
|
+
writeFileSync(tsconfigPath, content, 'utf-8')
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
const RUNTIME_GLOBALS = [
|
package/src/plugin/index.ts
CHANGED
|
@@ -123,7 +123,7 @@ async function generateVirtualModule(
|
|
|
123
123
|
case RESOLVED_IDS.composables:
|
|
124
124
|
return generateComposablesCode(config.composablesDir)
|
|
125
125
|
case RESOLVED_IDS.plugins:
|
|
126
|
-
return generatePluginsCode(config.pluginsDir)
|
|
126
|
+
return generatePluginsCode(config.pluginsDir, ssr)
|
|
127
127
|
case RESOLVED_IDS.middleware:
|
|
128
128
|
return generateMiddlewareCode(config.middlewareDir)
|
|
129
129
|
case RESOLVED_IDS.serverApi:
|
|
@@ -307,9 +307,12 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
|
|
|
307
307
|
if (!allResolved.includes(id)) return null
|
|
308
308
|
|
|
309
309
|
const ssr = options?.ssr ?? false
|
|
310
|
-
// For virtual:cer-app-config the SSR and client
|
|
311
|
-
//
|
|
312
|
-
|
|
310
|
+
// For virtual:cer-app-config and virtual:cer-plugins the SSR and client
|
|
311
|
+
// variants differ: app-config includes private defaults in SSR; plugins
|
|
312
|
+
// excludes .client.ts files in SSR. Use separate cache keys for both.
|
|
313
|
+
const cacheKey = (id === RESOLVED_IDS.appConfig || id === RESOLVED_IDS.plugins)
|
|
314
|
+
? `${id}:${ssr ? 'ssr' : 'client'}`
|
|
315
|
+
: id
|
|
313
316
|
|
|
314
317
|
// Return from cache if available
|
|
315
318
|
if (moduleCache.has(cacheKey)) {
|
|
@@ -362,6 +365,13 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
|
|
|
362
365
|
if (!existsSync(userHtml)) {
|
|
363
366
|
const cerHtmlPath = join(getGeneratedDir(config.root), 'index.html')
|
|
364
367
|
server.middlewares.use(async (req, res, next) => {
|
|
368
|
+
// In SSR/SSG mode, HTML requests must fall through to configureCerDevServer
|
|
369
|
+
// so the SSR handler can run loaders and inject __CER_DATA__ into the response.
|
|
370
|
+
// Only SPA mode (no server rendering) should serve the raw SPA shell here.
|
|
371
|
+
if (config.mode === 'ssr' || config.mode === 'ssg') {
|
|
372
|
+
next()
|
|
373
|
+
return
|
|
374
|
+
}
|
|
365
375
|
const url = (req as { url?: string }).url ?? '/'
|
|
366
376
|
const isHtmlRequest =
|
|
367
377
|
url === '/' ||
|
|
@@ -5,8 +5,14 @@ import { sortPluginFiles } from '../path-utils.js'
|
|
|
5
5
|
/**
|
|
6
6
|
* Generates the virtual:cer-plugins module code.
|
|
7
7
|
* Scans app/plugins/ and produces an ordered array of plugin imports.
|
|
8
|
+
*
|
|
9
|
+
* @param pluginsDir - Absolute path to app/plugins/
|
|
10
|
+
* @param serverSafeOnly - When true, excludes files ending in `.client.ts`.
|
|
11
|
+
* These are client-only plugins (CSS injections, browser component libraries,
|
|
12
|
+
* etc.) that must not be imported in the SSR server entry. Naming convention:
|
|
13
|
+
* `app/plugins/my-plugin.client.ts` → excluded from the server bundle.
|
|
8
14
|
*/
|
|
9
|
-
export async function generatePluginsCode(pluginsDir: string): Promise<string> {
|
|
15
|
+
export async function generatePluginsCode(pluginsDir: string, serverSafeOnly = false): Promise<string> {
|
|
10
16
|
if (!existsSync(pluginsDir)) {
|
|
11
17
|
return `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nexport const plugins = []\nexport default plugins\n`
|
|
12
18
|
}
|
|
@@ -18,10 +24,14 @@ export async function generatePluginsCode(pluginsDir: string): Promise<string> {
|
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
const sorted = sortPluginFiles(files)
|
|
27
|
+
// In server-safe mode, exclude `.client.ts` plugins — they are browser-only
|
|
28
|
+
// (CSS imports, client component libraries, etc.) and must not be loaded
|
|
29
|
+
// server-side. Rename the file to `*.client.ts` to mark it as client-only.
|
|
30
|
+
const filtered = serverSafeOnly ? sorted.filter(f => !f.endsWith('.client.ts')) : sorted
|
|
21
31
|
const lines: string[] = ['// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app', '']
|
|
22
32
|
|
|
23
33
|
const aliases: string[] = []
|
|
24
|
-
|
|
34
|
+
filtered.forEach((file, i) => {
|
|
25
35
|
const alias = `_p${i}`
|
|
26
36
|
aliases.push(alias)
|
|
27
37
|
lines.push(`import ${alias} from ${JSON.stringify(file)}`)
|
|
@@ -287,9 +287,15 @@ if (typeof window !== 'undefined') {
|
|
|
287
287
|
// the initial paint — the loading component must not flash over pre-rendered content.
|
|
288
288
|
await _replace(_initPath)
|
|
289
289
|
}
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
290
|
+
// Defer the data cleanup by one microtask so that cer-layout-view's reactive
|
|
291
|
+
// re-render — scheduled via queueMicrotask by the reactive system when the
|
|
292
|
+
// router fires its subscribers during _replace — can still read __CER_DATA__.
|
|
293
|
+
// Clearing synchronously here would remove the data before the queued render
|
|
294
|
+
// runs, causing usePageData() to return null on the initial page load.
|
|
295
|
+
// Subsequent navigations via router.push / router.replace each delete
|
|
296
|
+
// __CER_DATA__ themselves before calling _loadPageForPath, so this deferred
|
|
297
|
+
// cleanup only matters for the very first hydration render.
|
|
298
|
+
queueMicrotask(() => { delete (globalThis).__CER_DATA__ })
|
|
293
299
|
}
|
|
294
300
|
|
|
295
301
|
if (_hydrateStrategy === 'idle') {
|
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
* component renders with real data in the initial SSR/SSG HTML.
|
|
11
11
|
*
|
|
12
12
|
* 2. **Client hydration** — the server also serializes the data as
|
|
13
|
-
* `window.__CER_DATA__` in the page `<head>`.
|
|
14
|
-
*
|
|
15
|
-
* component instantiation `usePageData()` returns that value so the client
|
|
13
|
+
* `window.__CER_DATA__` in the page `<head>`. On first component
|
|
14
|
+
* instantiation `usePageData()` returns that value so the client
|
|
16
15
|
* starts with the correct state without an extra network round-trip.
|
|
17
16
|
*
|
|
18
|
-
* The client-side value is cleared
|
|
19
|
-
*
|
|
17
|
+
* The client-side value is cleared via `queueMicrotask` in `_doHydrate`
|
|
18
|
+
* (app-template) after the initial router navigation completes — deferred by
|
|
19
|
+
* one microtask so the queued reactive re-render can still read the data
|
|
20
|
+
* before it is removed. Subsequent navigations via `router.push` / `router.replace`
|
|
21
|
+
* each clear `__CER_DATA__` synchronously before loading the next page's data.
|
|
20
22
|
*
|
|
21
23
|
* @returns The serialized loader result, or `null` if no SSR data is present.
|
|
22
24
|
*
|
|
@@ -394,8 +394,14 @@ export const handler = async (req, res) => {
|
|
|
394
394
|
// (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
|
|
395
395
|
const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
|
|
396
396
|
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
// In dev mode the module-level _clientTemplate is null (only the
|
|
398
|
+
// production dist/client/index.html path is searched at init time).
|
|
399
|
+
// The dev server sets (globalThis).__CER_CLIENT_TEMPLATE__ per-request
|
|
400
|
+
// after running server.transformIndexHtml so the Vite client scripts
|
|
401
|
+
// (/@vite/client, HMR) are included in every SSR response.
|
|
402
|
+
const _resolvedClientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate
|
|
403
|
+
const merged = _resolvedClientTemplate
|
|
404
|
+
? _mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)
|
|
399
405
|
: ssrHtml
|
|
400
406
|
|
|
401
407
|
// Split at </body> so async swap scripts and the DSD polyfill can be streamed
|