@jasonshimmy/vite-plugin-cer-app 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.github/workflows/publish.yml +56 -5
  2. package/CHANGELOG.md +4 -0
  3. package/commits.txt +1 -1
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.js +19 -5
  6. package/dist/cli/commands/build.js.map +1 -1
  7. package/dist/cli/commands/dev.js +1 -1
  8. package/dist/cli/commands/dev.js.map +1 -1
  9. package/dist/cli/commands/preview.d.ts.map +1 -1
  10. package/dist/cli/commands/preview.js +0 -1
  11. package/dist/cli/commands/preview.js.map +1 -1
  12. package/dist/plugin/build-ssg.d.ts.map +1 -1
  13. package/dist/plugin/build-ssg.js.map +1 -1
  14. package/dist/plugin/build-ssr.d.ts +10 -0
  15. package/dist/plugin/build-ssr.d.ts.map +1 -1
  16. package/dist/plugin/build-ssr.js +21 -8
  17. package/dist/plugin/build-ssr.js.map +1 -1
  18. package/dist/plugin/dev-server.d.ts.map +1 -1
  19. package/dist/plugin/dev-server.js +0 -2
  20. package/dist/plugin/dev-server.js.map +1 -1
  21. package/dist/plugin/dts-generator.d.ts +4 -4
  22. package/dist/plugin/dts-generator.d.ts.map +1 -1
  23. package/dist/plugin/dts-generator.js +39 -19
  24. package/dist/plugin/dts-generator.js.map +1 -1
  25. package/dist/plugin/generated-dir.d.ts +34 -0
  26. package/dist/plugin/generated-dir.d.ts.map +1 -0
  27. package/dist/plugin/generated-dir.js +94 -0
  28. package/dist/plugin/generated-dir.js.map +1 -0
  29. package/dist/plugin/index.d.ts.map +1 -1
  30. package/dist/plugin/index.js +27 -0
  31. package/dist/plugin/index.js.map +1 -1
  32. package/dist/plugin/path-utils.js.map +1 -1
  33. package/dist/plugin/virtual/loading.d.ts.map +1 -1
  34. package/dist/plugin/virtual/loading.js.map +1 -1
  35. package/dist/runtime/app-template.d.ts +8 -0
  36. package/dist/runtime/app-template.d.ts.map +1 -0
  37. package/dist/runtime/app-template.js +158 -0
  38. package/dist/runtime/app-template.js.map +1 -0
  39. package/docs/configuration.md +2 -2
  40. package/docs/rendering-modes.md +2 -2
  41. package/docs/routing.md +1 -1
  42. package/e2e/kitchen-sink/tsconfig.json +3 -0
  43. package/eslint.config.ts +22 -0
  44. package/package.json +6 -1
  45. package/src/__tests__/plugin/build-ssr.test.ts +24 -10
  46. package/src/__tests__/plugin/cer-app-plugin.test.ts +35 -0
  47. package/src/__tests__/plugin/dts-generator.test.ts +15 -6
  48. package/src/__tests__/plugin/generated-dir.test.ts +168 -0
  49. package/src/cli/commands/build.ts +19 -5
  50. package/src/cli/commands/dev.ts +2 -2
  51. package/src/cli/commands/preview.ts +7 -5
  52. package/src/plugin/build-ssg.ts +2 -2
  53. package/src/plugin/build-ssr.ts +22 -8
  54. package/src/plugin/dev-server.ts +4 -3
  55. package/src/plugin/dts-generator.ts +43 -19
  56. package/src/plugin/generated-dir.ts +102 -0
  57. package/src/plugin/index.ts +32 -1
  58. package/src/plugin/path-utils.ts +1 -1
  59. package/src/plugin/virtual/loading.ts +0 -1
  60. package/{e2e/kitchen-sink/app/app.ts → src/runtime/app-template.ts} +23 -7
  61. package/e2e/kitchen-sink/index.html +0 -12
@@ -6,11 +6,16 @@ import { resolve } from 'pathe'
6
6
  // The `buildSSR` function itself invokes Vite's `build` API which we don't
7
7
  // need to exercise in unit tests (it's an integration concern).
8
8
  vi.mock('vite', () => ({ build: vi.fn().mockResolvedValue(undefined) }))
9
+ vi.mock('../../plugin/generated-dir.js', () => ({
10
+ writeGeneratedDir: vi.fn(),
11
+ getGeneratedDir: vi.fn().mockReturnValue('/project/.cer'),
12
+ GENERATED_DIR_NAME: '.cer',
13
+ }))
9
14
  // Partial mock: keep the real readFileSync/existsSync but allow overrides in
10
15
  // individual describe blocks if needed.
11
16
  vi.mock('node:fs', async (importOriginal) => {
12
17
  const actual = await importOriginal<typeof import('node:fs')>()
13
- return { ...actual, existsSync: vi.fn().mockReturnValue(true) }
18
+ return { ...actual, existsSync: vi.fn().mockReturnValue(true), renameSync: vi.fn() }
14
19
  })
15
20
 
16
21
  import type { ResolvedCerConfig } from '../../plugin/dev-server.js'
@@ -202,24 +207,33 @@ describe('buildSSR — resolveClientEntry fallbacks', () => {
202
207
  })
203
208
 
204
209
  it('uses index.html when it exists', async () => {
205
- existsSyncMock.mockReturnValue(true) // index.html exists
210
+ existsSyncMock.mockImplementation((p: unknown) => String(p).endsWith('index.html'))
206
211
  await buildSSR(makeConfig())
207
- const clientInput = (buildMock.mock.calls[0][0] as any).build.rollupOptions.input
208
- expect(clientInput).toMatch(/index\.html$/)
212
+ const clientInput = (buildMock.mock.calls[0][0] as Record<string, unknown>)
213
+ expect(((clientInput.build as Record<string, unknown>).rollupOptions as Record<string, unknown>).input).toMatch(/(?<!\.cer\/)index\.html$/)
209
214
  })
210
215
 
211
- it('falls back to entry-client.ts when index.html is absent', async () => {
216
+ it('falls back to .cer/index.html when root index.html is absent', async () => {
217
+ existsSyncMock.mockImplementation((p: unknown) =>
218
+ String(p).endsWith('.cer/index.html'),
219
+ )
220
+ await buildSSR(makeConfig())
221
+ const clientInput = (buildMock.mock.calls[0][0] as Record<string, unknown>)
222
+ expect(((clientInput.build as Record<string, unknown>).rollupOptions as Record<string, unknown>).input).toMatch(/\.cer\/index\.html$/)
223
+ })
224
+
225
+ it('falls back to entry-client.ts when no index.html exists', async () => {
212
226
  existsSyncMock.mockImplementation((p: unknown) => String(p).endsWith('entry-client.ts'))
213
227
  await buildSSR(makeConfig())
214
- const clientInput = (buildMock.mock.calls[0][0] as any).build.rollupOptions.input
215
- expect(clientInput).toMatch(/entry-client\.ts$/)
228
+ const clientInput = (buildMock.mock.calls[0][0] as Record<string, unknown>)
229
+ expect(((clientInput.build as Record<string, unknown>).rollupOptions as Record<string, unknown>).input).toMatch(/entry-client\.ts$/)
216
230
  })
217
231
 
218
- it('falls back to app.ts when neither index.html nor entry-client.ts exist', async () => {
232
+ it('falls back to app.ts when nothing else exists', async () => {
219
233
  existsSyncMock.mockReturnValue(false)
220
234
  await buildSSR(makeConfig())
221
- const clientInput = (buildMock.mock.calls[0][0] as any).build.rollupOptions.input
222
- expect(clientInput).toMatch(/app\.ts$/)
235
+ const clientInput = (buildMock.mock.calls[0][0] as Record<string, unknown>)
236
+ expect(((clientInput.build as Record<string, unknown>).rollupOptions as Record<string, unknown>).input).toMatch(/app\.ts$/)
223
237
  })
224
238
  })
225
239
 
@@ -3,6 +3,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'
3
3
  vi.mock('@jasonshimmy/custom-elements-runtime/vite-plugin', () => ({
4
4
  cerPlugin: vi.fn().mockReturnValue([{ name: 'cer-runtime-plugin' }]),
5
5
  }))
6
+ vi.mock('node:fs', async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import('node:fs')>()
8
+ return { ...actual, existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn().mockReturnValue('') }
9
+ })
6
10
  vi.mock('../../plugin/dev-server.js', () => ({
7
11
  configureCerDevServer: vi.fn().mockResolvedValue(undefined),
8
12
  }))
@@ -15,6 +19,11 @@ vi.mock('../../plugin/dts-generator.js', () => ({
15
19
  writeAutoImportDts: vi.fn().mockResolvedValue(undefined),
16
20
  writeTsconfigPaths: vi.fn(),
17
21
  }))
22
+ vi.mock('../../plugin/generated-dir.js', () => ({
23
+ writeGeneratedDir: vi.fn(),
24
+ getGeneratedDir: vi.fn().mockReturnValue('/project/.cer'),
25
+ GENERATED_DIR_NAME: '.cer',
26
+ }))
18
27
  vi.mock('../../plugin/virtual/routes.js', () => ({ generateRoutesCode: vi.fn().mockResolvedValue('// routes') }))
19
28
  vi.mock('../../plugin/virtual/layouts.js', () => ({ generateLayoutsCode: vi.fn().mockResolvedValue('// layouts') }))
20
29
  vi.mock('../../plugin/virtual/components.js', () => ({ generateComponentsCode: vi.fn().mockResolvedValue('// components') }))
@@ -263,6 +272,16 @@ describe('cerApp plugin — transform hook', () => {
263
272
  })
264
273
 
265
274
  describe('cerApp plugin — buildStart hook', () => {
275
+ it('calls writeGeneratedDir on build start', async () => {
276
+ const { writeGeneratedDir } = await import('../../plugin/generated-dir.js')
277
+ vi.mocked(writeGeneratedDir).mockClear()
278
+ const plugin = getCerPlugin()
279
+ plugin.config({ root: '/project' }, { command: 'build', mode: 'production' })
280
+ plugin.configResolved(FAKE_RESOLVED)
281
+ await plugin.buildStart()
282
+ expect(writeGeneratedDir).toHaveBeenCalledTimes(1)
283
+ })
284
+
266
285
  it('calls scanComposableExports on build start', async () => {
267
286
  const { scanComposableExports } = await import('../../plugin/dts-generator.js')
268
287
  vi.mocked(scanComposableExports).mockClear()
@@ -295,6 +314,22 @@ describe('cerApp plugin — buildStart hook', () => {
295
314
  })
296
315
 
297
316
  describe('cerApp plugin — configureServer hook', () => {
317
+ it('calls writeGeneratedDir on server configure', async () => {
318
+ const { writeGeneratedDir } = await import('../../plugin/generated-dir.js')
319
+ vi.mocked(writeGeneratedDir).mockClear()
320
+ const plugin = getCerPlugin()
321
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
322
+ plugin.configResolved(FAKE_RESOLVED)
323
+ const mockServer = {
324
+ watcher: { on: vi.fn() },
325
+ moduleGraph: { getModuleById: vi.fn().mockReturnValue(null), invalidateModule: vi.fn() },
326
+ ws: { send: vi.fn() },
327
+ middlewares: { use: vi.fn() },
328
+ }
329
+ await plugin.configureServer(mockServer)
330
+ expect(writeGeneratedDir).toHaveBeenCalledTimes(1)
331
+ })
332
+
298
333
  it('calls scanComposableExports on server configure', async () => {
299
334
  const { scanComposableExports } = await import('../../plugin/dts-generator.js')
300
335
  vi.mocked(scanComposableExports).mockClear()
@@ -6,10 +6,12 @@ vi.mock('node:fs', async (importOriginal) => {
6
6
  ...actual,
7
7
  existsSync: vi.fn().mockReturnValue(false),
8
8
  writeFileSync: vi.fn(),
9
+ mkdirSync: vi.fn(),
9
10
  readFileSync: vi.fn().mockReturnValue(''),
10
11
  }
11
12
  })
12
13
  vi.mock('../../plugin/scanner.js', () => ({ scanDirectory: vi.fn().mockResolvedValue([]) }))
14
+ vi.mock('../../plugin/generated-dir.js', () => ({ GENERATED_DIR_NAME: '.cer' }))
13
15
 
14
16
  import { existsSync, writeFileSync, readFileSync } from 'node:fs'
15
17
  import { scanDirectory } from '../../plugin/scanner.js'
@@ -32,10 +34,10 @@ beforeEach(() => {
32
34
  })
33
35
 
34
36
  describe('writeTsconfigPaths', () => {
35
- it('writes cer-tsconfig.json to the root directory', () => {
37
+ it('writes tsconfig.json to the .cer directory', () => {
36
38
  writeTsconfigPaths(ROOT, `${ROOT}/app`)
37
39
  expect(writeFileSync).toHaveBeenCalledWith(
38
- `${ROOT}/cer-tsconfig.json`,
40
+ `${ROOT}/.cer/tsconfig.json`,
39
41
  expect.any(String),
40
42
  'utf-8',
41
43
  )
@@ -65,6 +67,13 @@ describe('writeTsconfigPaths', () => {
65
67
  const json = JSON.parse(content)
66
68
  expect(json).toHaveProperty('compilerOptions.paths')
67
69
  })
70
+
71
+ it('includes project source directories in include array', () => {
72
+ writeTsconfigPaths(ROOT, `${ROOT}/app`)
73
+ const content = vi.mocked(writeFileSync).mock.calls[0][1] as string
74
+ const json = JSON.parse(content) as { include?: string[] }
75
+ expect(Array.isArray(json.include)).toBe(true)
76
+ })
68
77
  })
69
78
 
70
79
  describe('scanComposableExports', () => {
@@ -227,16 +236,16 @@ describe('generateVirtualModuleDts', () => {
227
236
  })
228
237
 
229
238
  describe('writeAutoImportDts', () => {
230
- it('writes cer-auto-imports.d.ts to root', async () => {
239
+ it('writes auto-imports.d.ts to .cer/', async () => {
231
240
  await writeAutoImportDts(ROOT, COMPOSABLES_DIR)
232
241
  const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
233
- expect(paths.some(p => p.includes('cer-auto-imports.d.ts'))).toBe(true)
242
+ expect(paths.some(p => p.includes('.cer/auto-imports.d.ts'))).toBe(true)
234
243
  })
235
244
 
236
- it('writes cer-env.d.ts to root', async () => {
245
+ it('writes env.d.ts to .cer/', async () => {
237
246
  await writeAutoImportDts(ROOT, COMPOSABLES_DIR)
238
247
  const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
239
- expect(paths.some(p => p.includes('cer-env.d.ts'))).toBe(true)
248
+ expect(paths.some(p => p.includes('.cer/env.d.ts'))).toBe(true)
240
249
  })
241
250
 
242
251
  it('writes exactly two files', async () => {
@@ -0,0 +1,168 @@
1
+ import { vi, describe, it, expect, beforeEach } from 'vitest'
2
+
3
+ vi.mock('node:fs', async (importOriginal) => {
4
+ const actual = await importOriginal<typeof import('node:fs')>()
5
+ return {
6
+ ...actual,
7
+ existsSync: vi.fn().mockReturnValue(false),
8
+ writeFileSync: vi.fn(),
9
+ mkdirSync: vi.fn(),
10
+ readFileSync: vi.fn().mockReturnValue(''),
11
+ appendFileSync: vi.fn(),
12
+ }
13
+ })
14
+ vi.mock('../../runtime/app-template.js', () => ({ APP_ENTRY_TEMPLATE: '// app template' }))
15
+
16
+ import { existsSync, writeFileSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs'
17
+ import {
18
+ GENERATED_DIR_NAME,
19
+ getGeneratedDir,
20
+ resolveAppEntry,
21
+ resolveHtmlEntry,
22
+ generateDefaultHtml,
23
+ writeGeneratedDir,
24
+ } from '../../plugin/generated-dir.js'
25
+
26
+ const ROOT = '/project'
27
+ const mockConfig = {
28
+ root: ROOT,
29
+ srcDir: `${ROOT}/app`,
30
+ } as Parameters<typeof writeGeneratedDir>[0]
31
+
32
+ beforeEach(() => {
33
+ vi.mocked(existsSync).mockReturnValue(false)
34
+ vi.mocked(writeFileSync).mockClear()
35
+ vi.mocked(mkdirSync).mockClear()
36
+ vi.mocked(readFileSync).mockReturnValue('')
37
+ vi.mocked(appendFileSync).mockClear()
38
+ })
39
+
40
+ describe('GENERATED_DIR_NAME', () => {
41
+ it('is .cer', () => {
42
+ expect(GENERATED_DIR_NAME).toBe('.cer')
43
+ })
44
+ })
45
+
46
+ describe('getGeneratedDir', () => {
47
+ it('returns <root>/.cer', () => {
48
+ expect(getGeneratedDir(ROOT)).toBe(`${ROOT}/.cer`)
49
+ })
50
+ })
51
+
52
+ describe('resolveAppEntry', () => {
53
+ it('returns user app/app.ts when it exists', () => {
54
+ vi.mocked(existsSync).mockReturnValue(true)
55
+ expect(resolveAppEntry(mockConfig)).toBe(`${ROOT}/app/app.ts`)
56
+ })
57
+
58
+ it('returns .cer/app.ts when user app/app.ts is absent', () => {
59
+ vi.mocked(existsSync).mockReturnValue(false)
60
+ expect(resolveAppEntry(mockConfig)).toBe(`${ROOT}/.cer/app.ts`)
61
+ })
62
+ })
63
+
64
+ describe('resolveHtmlEntry', () => {
65
+ it('returns user index.html when it exists', () => {
66
+ vi.mocked(existsSync).mockReturnValue(true)
67
+ expect(resolveHtmlEntry(mockConfig)).toBe(`${ROOT}/index.html`)
68
+ })
69
+
70
+ it('returns .cer/index.html when user index.html is absent', () => {
71
+ vi.mocked(existsSync).mockReturnValue(false)
72
+ expect(resolveHtmlEntry(mockConfig)).toBe(`${ROOT}/.cer/index.html`)
73
+ })
74
+ })
75
+
76
+ describe('generateDefaultHtml', () => {
77
+ it('references /app/app.ts when user entry exists', () => {
78
+ vi.mocked(existsSync).mockReturnValue(true)
79
+ const html = generateDefaultHtml(mockConfig)
80
+ expect(html).toContain('/app/app.ts')
81
+ expect(html).not.toContain('/.cer/app.ts')
82
+ })
83
+
84
+ it('references /.cer/app.ts when user entry is absent', () => {
85
+ vi.mocked(existsSync).mockReturnValue(false)
86
+ const html = generateDefaultHtml(mockConfig)
87
+ expect(html).toContain('/.cer/app.ts')
88
+ })
89
+
90
+ it('includes <cer-layout-view> mount point', () => {
91
+ const html = generateDefaultHtml(mockConfig)
92
+ expect(html).toContain('<cer-layout-view>')
93
+ })
94
+
95
+ it('is valid HTML with doctype', () => {
96
+ const html = generateDefaultHtml(mockConfig)
97
+ expect(html).toContain('<!DOCTYPE html>')
98
+ })
99
+ })
100
+
101
+ describe('writeGeneratedDir', () => {
102
+ it('creates the .cer directory when absent', () => {
103
+ // existsSync returns false for everything → dir is created
104
+ writeGeneratedDir(mockConfig)
105
+ expect(mkdirSync).toHaveBeenCalledWith(`${ROOT}/.cer`, { recursive: true })
106
+ })
107
+
108
+ it('does not re-create the directory when it already exists', () => {
109
+ // existsSync returns true → dir already present
110
+ vi.mocked(existsSync).mockReturnValue(true)
111
+ writeGeneratedDir(mockConfig)
112
+ expect(mkdirSync).not.toHaveBeenCalled()
113
+ })
114
+
115
+ it('writes .cer/app.ts when app/app.ts does not exist', () => {
116
+ // Only the .cer dir check needs to return true to skip mkdirSync — but we
117
+ // want the user entry check to return false. Use a counter.
118
+ let callCount = 0
119
+ vi.mocked(existsSync).mockImplementation(() => {
120
+ callCount++
121
+ // First call: .cer/ dir → true (already exists, skip mkdir)
122
+ // Second call: app/app.ts → false (absent, write template)
123
+ // Subsequent calls: false (no .gitignore)
124
+ return callCount === 1
125
+ })
126
+ writeGeneratedDir(mockConfig)
127
+ const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
128
+ expect(paths.some(p => p.endsWith('/.cer/app.ts'))).toBe(true)
129
+ })
130
+
131
+ it('skips writing .cer/app.ts when app/app.ts exists', () => {
132
+ // existsSync always returns true — dir exists, user entry exists
133
+ vi.mocked(existsSync).mockReturnValue(true)
134
+ writeGeneratedDir(mockConfig)
135
+ const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
136
+ expect(paths.some(p => p.endsWith('/.cer/app.ts'))).toBe(false)
137
+ })
138
+
139
+ it('always writes .cer/index.html', () => {
140
+ writeGeneratedDir(mockConfig)
141
+ const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
142
+ expect(paths.some(p => p.endsWith('/.cer/index.html'))).toBe(true)
143
+ })
144
+
145
+ it('creates .gitignore when absent', () => {
146
+ // existsSync returns false → .cer/ dir created, app.ts written, .gitignore created
147
+ writeGeneratedDir(mockConfig)
148
+ const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
149
+ expect(paths.some(p => p.endsWith('/.gitignore'))).toBe(true)
150
+ })
151
+
152
+ it('appends .cer/ to existing .gitignore that does not contain it', () => {
153
+ // .gitignore exists (readFileSync returns '' — no .cer/ entry)
154
+ vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.gitignore'))
155
+ vi.mocked(readFileSync).mockReturnValue('node_modules/\ndist/\n')
156
+ writeGeneratedDir(mockConfig)
157
+ expect(appendFileSync).toHaveBeenCalled()
158
+ const appendArg = vi.mocked(appendFileSync).mock.calls[0][1] as string
159
+ expect(appendArg).toContain('.cer/')
160
+ })
161
+
162
+ it('does not append to .gitignore when .cer/ is already present', () => {
163
+ vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith('.gitignore'))
164
+ vi.mocked(readFileSync).mockReturnValue('node_modules/\n.cer/\ndist/\n')
165
+ writeGeneratedDir(mockConfig)
166
+ expect(appendFileSync).not.toHaveBeenCalled()
167
+ })
168
+ })
@@ -1,11 +1,12 @@
1
1
  import { Command } from 'commander'
2
2
  import { build } from 'vite'
3
- import { resolve } from 'pathe'
3
+ import { resolve, join } from 'pathe'
4
4
  import { pathToFileURL } from 'node:url'
5
- import { existsSync } from 'node:fs'
5
+ import { existsSync, renameSync } from 'node:fs'
6
6
  import { cerApp, resolveConfig } from '../../plugin/index.js'
7
- import { buildSSR } from '../../plugin/build-ssr.js'
7
+ import { buildSSR, resolveClientEntry } from '../../plugin/build-ssr.js'
8
8
  import { buildSSG } from '../../plugin/build-ssg.js'
9
+ import { writeGeneratedDir } from '../../plugin/generated-dir.js'
9
10
  import type { CerAppConfig } from '../../types/config.js'
10
11
 
11
12
  async function loadCerConfig(root: string): Promise<CerAppConfig> {
@@ -73,15 +74,28 @@ export function buildCommand(): Command {
73
74
 
74
75
  switch (config.mode) {
75
76
  case 'spa': {
76
- // Standard Vite build single-page app
77
+ // Write .cer/ files BEFORE resolveClientEntry checks for .cer/index.html.
78
+ writeGeneratedDir(config)
79
+ const spaEntry = resolveClientEntry(config)
80
+ const spaOutDir = resolve(root, 'dist')
77
81
  await build({
78
82
  root,
79
83
  plugins: cerApp(userConfig),
80
84
  build: {
81
- outDir: resolve(root, 'dist'),
85
+ outDir: spaOutDir,
86
+ rollupOptions: { input: spaEntry },
82
87
  },
83
88
  })
89
+ // If the entry was .cer/index.html, Vite outputs it as dist/.cer/index.html.
90
+ // Rename it to dist/index.html so the preview server can find it.
91
+ const generatedHtmlOut = join(spaOutDir, '.cer/index.html')
92
+ const rootHtmlOut = join(spaOutDir, 'index.html')
93
+ if (existsSync(generatedHtmlOut) && !existsSync(rootHtmlOut)) {
94
+ renameSync(generatedHtmlOut, rootHtmlOut)
95
+ }
84
96
  console.log('[cer-app] SPA build complete.')
97
+ // Force exit: Vite HTML builds may keep Node timers alive.
98
+ process.exit(0)
85
99
  break
86
100
  }
87
101
 
@@ -3,7 +3,7 @@ import { createServer } from 'vite'
3
3
  import { resolve } from 'pathe'
4
4
  import { pathToFileURL } from 'node:url'
5
5
  import { existsSync } from 'node:fs'
6
- import { cerApp, resolveConfig } from '../../plugin/index.js'
6
+ import { cerApp } from '../../plugin/index.js'
7
7
  import type { CerAppConfig } from '../../types/config.js'
8
8
 
9
9
  /**
@@ -28,7 +28,7 @@ async function loadCerConfig(root: string): Promise<CerAppConfig> {
28
28
  try {
29
29
  // Use Vite's build to transpile TS config at runtime
30
30
  const { build } = await import('vite')
31
- const result = await build({
31
+ await build({
32
32
  build: {
33
33
  lib: {
34
34
  entry: filePath,
@@ -107,9 +107,10 @@ export function previewCommand(): Command {
107
107
  console.log('[cer-app] Starting SSR preview server...')
108
108
 
109
109
  // Load the server bundle
110
+ type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
110
111
  let serverMod: {
111
- handler?: Function
112
- default?: Function
112
+ handler?: SsrHandlerFn
113
+ default?: SsrHandlerFn
113
114
  apiRoutes?: Array<{ path: string; handlers: Record<string, unknown> }>
114
115
  }
115
116
  try {
@@ -151,10 +152,11 @@ export function previewCommand(): Command {
151
152
  }
152
153
  augRes.status = function (code) { this.statusCode = code; return this }
153
154
 
155
+ type ApiHandlerFn = (req: typeof augReq, res: typeof augRes) => void | Promise<void>
154
156
  const handlerFn =
155
- (route.handlers[method.toLowerCase()] as Function | undefined) ??
156
- (route.handlers[method.toUpperCase()] as Function | undefined) ??
157
- (route.handlers['default'] as Function | undefined)
157
+ (route.handlers[method.toLowerCase()] as ApiHandlerFn | undefined) ??
158
+ (route.handlers[method.toUpperCase()] as ApiHandlerFn | undefined) ??
159
+ (route.handlers['default'] as ApiHandlerFn | undefined)
158
160
 
159
161
  if (typeof handlerFn === 'function') {
160
162
  try {
@@ -1,7 +1,7 @@
1
1
  import { writeFile, mkdir } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { join } from 'pathe'
4
- import { createServer, build, type UserConfig } from 'vite'
4
+ 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'
@@ -138,7 +138,7 @@ async function renderPath(
138
138
  setHeader: () => {},
139
139
  end: (body: string) => resolve(body),
140
140
  }
141
- ;(handlerFn as Function)(mockReq, mockRes).catch(reject)
141
+ ;(handlerFn as (req: unknown, res: unknown) => Promise<void>)(mockReq, mockRes).catch(reject)
142
142
  })
143
143
  }
144
144
 
@@ -1,22 +1,23 @@
1
1
  import { build, type UserConfig } from 'vite'
2
2
  import { join, resolve } from 'pathe'
3
- import { existsSync } from 'node:fs'
3
+ import { existsSync, renameSync } from 'node:fs'
4
4
  import type { ResolvedCerConfig } from './dev-server.js'
5
+ import { getGeneratedDir, writeGeneratedDir } from './generated-dir.js'
5
6
 
6
7
  /**
7
8
  * Resolves the client build entry point for an SSR/SSG build.
8
9
  *
9
10
  * Priority order:
10
- * 1. `index.html` at the project root — preferred because Vite writes a
11
- * processed `index.html` to `dist/client/` with correct asset references,
12
- * which `renderPath` then uses as the shell template for SSG pages.
13
- * 2. `app/entry-client.ts` — fallback for projects that handle HTML injection
14
- * themselves (e.g. a custom Express server).
15
- * 3. `app/app.ts` — last resort (same bundle, no DSD hydration preamble).
11
+ * 1. `index.html` at the project root — consumer-provided HTML shell.
12
+ * 2. `.cer/index.html` auto-generated HTML shell (Nuxt-style magic).
13
+ * 3. `app/entry-client.ts` fallback for projects that manage HTML externally.
14
+ * 4. `app/app.ts` — last resort (same bundle, no DSD hydration preamble).
16
15
  */
17
- function resolveClientEntry(config: ResolvedCerConfig): string {
16
+ export function resolveClientEntry(config: ResolvedCerConfig): string {
18
17
  const indexHtml = resolve(config.root, 'index.html')
19
18
  if (existsSync(indexHtml)) return indexHtml
19
+ const cerIndexHtml = join(getGeneratedDir(config.root), 'index.html')
20
+ if (existsSync(cerIndexHtml)) return cerIndexHtml
20
21
  const entryClient = resolve(config.srcDir, 'entry-client.ts')
21
22
  if (existsSync(entryClient)) return entryClient
22
23
  return resolve(config.srcDir, 'app.ts')
@@ -225,6 +226,10 @@ export async function buildSSR(
225
226
  const clientOutDir = join(config.root, 'dist/client')
226
227
  const serverOutDir = join(config.root, 'dist/server')
227
228
 
229
+ // Write .cer/ generated files BEFORE resolving the client entry so that
230
+ // .cer/index.html is on disk when resolveClientEntry checks for it.
231
+ writeGeneratedDir(config)
232
+
228
233
  // Resolve the client entry — index.html is preferred so Vite writes a
229
234
  // processed index.html to dist/client/ for use as the SSG shell template.
230
235
  const clientEntry = resolveClientEntry(config)
@@ -244,6 +249,15 @@ export async function buildSSR(
244
249
  },
245
250
  })
246
251
 
252
+ // If the client entry was .cer/index.html, Vite outputs it as
253
+ // dist/client/.cer/index.html (preserving relative path). The SSR server
254
+ // template expects dist/client/index.html, so rename it into place.
255
+ const generatedHtmlOut = join(clientOutDir, '.cer/index.html')
256
+ const rootHtmlOut = join(clientOutDir, 'index.html')
257
+ if (existsSync(generatedHtmlOut) && !existsSync(rootHtmlOut)) {
258
+ renameSync(generatedHtmlOut, rootHtmlOut)
259
+ }
260
+
247
261
  // Generate server entry source inline via a virtual plugin
248
262
  const serverEntryCode = generateServerEntryCode()
249
263
  const VIRTUAL_SERVER_ENTRY = 'virtual:cer-server-entry'
@@ -186,11 +186,12 @@ export function configureCerDevServer(
186
186
 
187
187
  // Try to find a handler for the HTTP method. Exports may be GET/POST (uppercase)
188
188
  // or get/post (lowercase); try both plus a 'default' fallback.
189
+ type RouteHandlerFn = (req: typeof augmentedReq, res: typeof augmentedRes) => void | Promise<void>
189
190
  const handlerKey = method.toLowerCase()
190
191
  const handler =
191
- (route.handlers[handlerKey] as Function | undefined) ??
192
- (route.handlers[method.toUpperCase()] as Function | undefined) ??
193
- (route.handlers['default'] as Function | undefined)
192
+ (route.handlers[handlerKey] as RouteHandlerFn | undefined) ??
193
+ (route.handlers[method.toUpperCase()] as RouteHandlerFn | undefined) ??
194
+ (route.handlers['default'] as RouteHandlerFn | undefined)
194
195
 
195
196
  if (typeof handler === 'function') {
196
197
  try {
@@ -1,27 +1,46 @@
1
- import { writeFileSync } from 'node:fs'
2
- import { readFileSync, existsSync } from 'node:fs'
1
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
2
+ import { readFileSync } from 'node:fs'
3
3
  import { join, relative } from 'pathe'
4
4
  import { scanDirectory } from './scanner.js'
5
+ import { GENERATED_DIR_NAME } from './generated-dir.js'
5
6
 
6
7
  /**
7
- * Writes `cer-tsconfig.json` to the project root containing path aliases for
8
- * the `~/` prefix. Users extend it from their `tsconfig.json`:
9
- * { "extends": "./cer-tsconfig.json" }
8
+ * Writes `.cer/tsconfig.json` containing path aliases for the `~/` prefix
9
+ * plus include/exclude entries so the consumer's `tsconfig.json` only needs:
10
+ * { "extends": "./.cer/tsconfig.json" }
10
11
  */
11
12
  export function writeTsconfigPaths(root: string, srcDir: string): void {
12
- const rel = './' + relative(root, srcDir).replace(/\\/g, '/')
13
+ const cerDir = join(root, GENERATED_DIR_NAME)
14
+ if (!existsSync(cerDir)) {
15
+ mkdirSync(cerDir, { recursive: true })
16
+ }
17
+
18
+ // Paths are relative to .cer/ inside the project root, so prefix with ../
19
+ const srcRel = '../' + relative(root, srcDir).replace(/\\/g, '/')
13
20
  const paths: Record<string, string[]> = {
14
- '~/*': [`${rel}/*`],
15
- '~/pages/*': [`${rel}/pages/*`],
16
- '~/layouts/*': [`${rel}/layouts/*`],
17
- '~/components/*': [`${rel}/components/*`],
18
- '~/composables/*': [`${rel}/composables/*`],
19
- '~/plugins/*': [`${rel}/plugins/*`],
20
- '~/middleware/*': [`${rel}/middleware/*`],
21
- '~/assets/*': [`${rel}/assets/*`],
21
+ '~/*': [`${srcRel}/*`],
22
+ '~/pages/*': [`${srcRel}/pages/*`],
23
+ '~/layouts/*': [`${srcRel}/layouts/*`],
24
+ '~/components/*': [`${srcRel}/components/*`],
25
+ '~/composables/*': [`${srcRel}/composables/*`],
26
+ '~/plugins/*': [`${srcRel}/plugins/*`],
27
+ '~/middleware/*': [`${srcRel}/middleware/*`],
28
+ '~/assets/*': [`${srcRel}/assets/*`],
29
+ }
30
+
31
+ const tsconfig = {
32
+ compilerOptions: { paths },
33
+ include: [
34
+ '../app/**/*.ts',
35
+ '../server/**/*.ts',
36
+ './**/*.ts',
37
+ './**/*.d.ts',
38
+ ],
39
+ exclude: ['../node_modules', '../dist'],
22
40
  }
23
- const content = JSON.stringify({ compilerOptions: { paths } }, null, 2) + '\n'
24
- writeFileSync(join(root, 'cer-tsconfig.json'), content, 'utf-8')
41
+
42
+ const content = JSON.stringify(tsconfig, null, 2) + '\n'
43
+ writeFileSync(join(cerDir, 'tsconfig.json'), content, 'utf-8')
25
44
  }
26
45
 
27
46
  const RUNTIME_GLOBALS = [
@@ -199,16 +218,21 @@ export async function generateVirtualModuleDts(
199
218
  }
200
219
 
201
220
  /**
202
- * Writes both `cer-auto-imports.d.ts` and `cer-env.d.ts` to the project root.
221
+ * Writes `auto-imports.d.ts` and `env.d.ts` to `.cer/` inside the project root.
203
222
  */
204
223
  export async function writeAutoImportDts(
205
224
  root: string,
206
225
  composablesDir: string,
207
226
  composableExports?: Map<string, string>,
208
227
  ): Promise<void> {
228
+ const cerDir = join(root, GENERATED_DIR_NAME)
229
+ if (!existsSync(cerDir)) {
230
+ mkdirSync(cerDir, { recursive: true })
231
+ }
232
+
209
233
  const scanned = composableExports ?? await scanComposableExports(composablesDir)
210
234
  const autoImportsContent = await generateAutoImportDts(root, composablesDir, scanned)
211
235
  const envContent = await generateVirtualModuleDts(root, composablesDir, scanned)
212
- writeFileSync(join(root, 'cer-auto-imports.d.ts'), autoImportsContent, 'utf-8')
213
- writeFileSync(join(root, 'cer-env.d.ts'), envContent, 'utf-8')
236
+ writeFileSync(join(cerDir, 'auto-imports.d.ts'), autoImportsContent, 'utf-8')
237
+ writeFileSync(join(cerDir, 'env.d.ts'), envContent, 'utf-8')
214
238
  }