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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -0
  3. package/commits.txt +1 -1
  4. package/dist/cli/create/index.js +7 -3
  5. package/dist/cli/create/index.js.map +1 -1
  6. package/dist/cli/create/templates/spa/.gitignore.tpl +25 -0
  7. package/dist/cli/create/templates/spa/index.html.tpl +1 -1
  8. package/dist/cli/create/templates/ssg/.gitignore.tpl +25 -0
  9. package/dist/cli/create/templates/ssg/index.html.tpl +1 -1
  10. package/dist/cli/create/templates/ssr/.gitignore.tpl +25 -0
  11. package/dist/cli/create/templates/ssr/cer.config.ts.tpl +0 -1
  12. package/dist/cli/create/templates/ssr/index.html.tpl +1 -1
  13. package/dist/plugin/build-ssr.d.ts.map +1 -1
  14. package/dist/plugin/build-ssr.js +18 -0
  15. package/dist/plugin/build-ssr.js.map +1 -1
  16. package/dist/plugin/dev-server.d.ts +0 -1
  17. package/dist/plugin/dev-server.d.ts.map +1 -1
  18. package/dist/plugin/dts-generator.js +1 -1
  19. package/dist/plugin/dts-generator.js.map +1 -1
  20. package/dist/plugin/generated-dir.d.ts +5 -11
  21. package/dist/plugin/generated-dir.d.ts.map +1 -1
  22. package/dist/plugin/generated-dir.js +43 -31
  23. package/dist/plugin/generated-dir.js.map +1 -1
  24. package/dist/plugin/index.d.ts.map +1 -1
  25. package/dist/plugin/index.js +9 -1
  26. package/dist/plugin/index.js.map +1 -1
  27. package/dist/plugin/transforms/auto-import.js +2 -2
  28. package/dist/plugin/transforms/auto-import.js.map +1 -1
  29. package/dist/runtime/app-template.d.ts +5 -4
  30. package/dist/runtime/app-template.d.ts.map +1 -1
  31. package/dist/runtime/app-template.js +6 -5
  32. package/dist/runtime/app-template.js.map +1 -1
  33. package/dist/runtime/composables/index.d.ts +1 -0
  34. package/dist/runtime/composables/index.d.ts.map +1 -1
  35. package/dist/runtime/composables/index.js +1 -0
  36. package/dist/runtime/composables/index.js.map +1 -1
  37. package/dist/runtime/composables/use-inject.d.ts +29 -0
  38. package/dist/runtime/composables/use-inject.d.ts.map +1 -0
  39. package/dist/runtime/composables/use-inject.js +48 -0
  40. package/dist/runtime/composables/use-inject.js.map +1 -0
  41. package/dist/runtime/entry-server-template.d.ts +1 -1
  42. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  43. package/dist/runtime/entry-server-template.js +20 -0
  44. package/dist/runtime/entry-server-template.js.map +1 -1
  45. package/dist/types/config.d.ts +0 -1
  46. package/dist/types/config.d.ts.map +1 -1
  47. package/dist/types/config.js.map +1 -1
  48. package/docs/cli.md +1 -1
  49. package/docs/composables.md +37 -0
  50. package/docs/configuration.md +2 -11
  51. package/docs/getting-started.md +2 -100
  52. package/docs/plugins.md +23 -15
  53. package/docs/rendering-modes.md +3 -4
  54. package/docs/testing.md +3 -3
  55. package/e2e/kitchen-sink/app/pages/(auth)/protected.ts +1 -5
  56. package/e2e/kitchen-sink/cer-auto-imports.d.ts +1 -0
  57. package/package.json +1 -1
  58. package/src/__tests__/plugin/build-ssr.test.ts +10 -0
  59. package/src/__tests__/plugin/cer-app-plugin.test.ts +15 -0
  60. package/src/__tests__/plugin/dev-server.test.ts +1 -1
  61. package/src/__tests__/plugin/dts-generator.test.ts +5 -0
  62. package/src/__tests__/plugin/entry-server-template.test.ts +24 -0
  63. package/src/__tests__/plugin/generated-dir.test.ts +8 -39
  64. package/src/__tests__/plugin/resolve-config.test.ts +0 -5
  65. package/src/__tests__/plugin/transforms/auto-import.test.ts +7 -0
  66. package/src/__tests__/runtime/use-inject-client.test.ts +67 -0
  67. package/src/__tests__/runtime/use-inject.test.ts +66 -0
  68. package/src/__tests__/types/config.test.ts +1 -1
  69. package/src/cli/create/index.ts +12 -8
  70. package/src/cli/create/templates/spa/.gitignore.tpl +25 -0
  71. package/src/cli/create/templates/spa/index.html.tpl +1 -1
  72. package/src/cli/create/templates/ssg/.gitignore.tpl +25 -0
  73. package/src/cli/create/templates/ssg/index.html.tpl +1 -1
  74. package/src/cli/create/templates/ssr/.gitignore.tpl +25 -0
  75. package/src/cli/create/templates/ssr/cer.config.ts.tpl +0 -1
  76. package/src/cli/create/templates/ssr/index.html.tpl +1 -1
  77. package/src/plugin/build-ssr.ts +18 -0
  78. package/src/plugin/dev-server.ts +1 -1
  79. package/src/plugin/dts-generator.ts +1 -1
  80. package/src/plugin/generated-dir.ts +44 -31
  81. package/src/plugin/index.ts +9 -1
  82. package/src/plugin/transforms/auto-import.ts +2 -2
  83. package/src/runtime/app-template.ts +6 -5
  84. package/src/runtime/composables/index.ts +1 -0
  85. package/src/runtime/composables/use-inject.ts +49 -0
  86. package/src/runtime/entry-server-template.ts +20 -0
  87. package/src/types/config.ts +0 -1
  88. package/dist/cli/create/templates/spa/app/app.ts.tpl +0 -93
  89. package/dist/cli/create/templates/ssg/app/app.ts.tpl +0 -97
  90. package/dist/cli/create/templates/ssr/app/app.ts.tpl +0 -97
  91. package/src/cli/create/templates/spa/app/app.ts.tpl +0 -93
  92. package/src/cli/create/templates/ssg/app/app.ts.tpl +0 -97
  93. package/src/cli/create/templates/ssr/app/app.ts.tpl +0 -97
@@ -83,7 +83,7 @@ dist/
83
83
 
84
84
  ```ts
85
85
  export const handler: (req, res) => void // main request handler
86
- export { apiRoutes, middleware, plugins, layouts, routes }
86
+ export { apiRoutes, plugins, layouts }
87
87
  export default handler
88
88
  ```
89
89
 
@@ -93,8 +93,7 @@ export default handler
93
93
  export default defineConfig({
94
94
  mode: 'ssr',
95
95
  ssr: {
96
- dsd: true, // Declarative Shadow DOM (eliminates FOUC)
97
- streaming: false, // true = stream response; false = buffer full HTML
96
+ dsd: true, // Declarative Shadow DOM (eliminates FOUC)
98
97
  },
99
98
  })
100
99
  ```
@@ -242,7 +241,7 @@ Any CDN or static host. Upload the entire `dist/` directory (excluding `dist/ser
242
241
  | Dynamic routes | Yes | Yes | Requires `ssg.paths` |
243
242
  | API routes | Separate deploy | Same process | Separate deploy |
244
243
  | `useHead()` SSR injection | No | Yes | Yes |
245
- | Streaming | No | Optional | No |
244
+ | Streaming | No | No | No |
246
245
 
247
246
  ---
248
247
 
package/docs/testing.md CHANGED
@@ -223,12 +223,12 @@ Use in a page:
223
223
  ```ts
224
224
  // app/pages/index.ts
225
225
  component('page-index', () => {
226
- const greeting = inject('greeting')
226
+ const greeting = useInject<string>('greeting')
227
227
  return html`<p>${greeting}</p>`
228
228
  })
229
229
  ```
230
230
 
231
- **Expected:** Page shows the injected string.
231
+ **Expected:** Page shows the injected string in all three modes (SPA, SSR, SSG). `useInject` is auto-imported — no explicit import needed.
232
232
 
233
233
  ---
234
234
 
@@ -428,7 +428,7 @@ cat dist/items/1/index.html # should contain "Item 1"
428
428
 
429
429
  ## 14. Run the automated test suite
430
430
 
431
- The framework ships with 211 unit and integration tests:
431
+ The framework ships with unit and integration tests. Run them with:
432
432
 
433
433
  ```sh
434
434
  cd /path/to/@jasonshimmy/vite-plugin-cer-app
@@ -1,9 +1,5 @@
1
1
  component('page-protected', () => {
2
- // inject() works for SSR and client-side navigations; fall back to the
3
- // globalThis store for SSG where router-view loads the chunk before
4
- // cer-layout-view has had a chance to call provide().
5
- const appProvides = (globalThis as any).__cerPluginProvides as Map<string, unknown> | undefined
6
- const greeting = inject<string>('ks-greeting') ?? appProvides?.get('ks-greeting') as string | undefined ?? 'No greeting'
2
+ const greeting = useInject<string>('ks-greeting', 'No greeting')
7
3
 
8
4
  return html`
9
5
  <div>
@@ -40,6 +40,7 @@ declare global {
40
40
 
41
41
  const useHead: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useHead']
42
42
  const usePageData: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['usePageData']
43
+ const useInject: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useInject']
43
44
 
44
45
  const useKsCounter: typeof import('./app/composables/useKsCounter')['useKsCounter']
45
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -133,6 +133,16 @@ describe('build-ssr generateServerEntryCode (template content)', () => {
133
133
  it('sets Content-Type header on response', () => {
134
134
  expect(src).toContain('text/html; charset=utf-8')
135
135
  })
136
+
137
+ it('template initializes plugins and sets globalThis.__cerPluginProvides', () => {
138
+ expect(src).toContain('__cerPluginProvides')
139
+ expect(src).toContain('_pluginProvides')
140
+ expect(src).toContain('_pluginsReady')
141
+ })
142
+
143
+ it('template awaits _pluginsReady before handling each request', () => {
144
+ expect(src).toContain('await _pluginsReady')
145
+ })
136
146
  })
137
147
 
138
148
  describe('buildSSR', () => {
@@ -37,6 +37,7 @@ vi.mock('../../plugin/virtual/error.js', () => ({ generateErrorCode: vi.fn().moc
37
37
  vi.mock('../../plugin/transforms/auto-import.js', () => ({ autoImportTransform: vi.fn().mockReturnValue(null) }))
38
38
 
39
39
  import { cerApp } from '../../plugin/index.js'
40
+ import { APP_ENTRY_TEMPLATE } from '../../runtime/app-template.js'
40
41
 
41
42
 
42
43
  type TestPlugin = {
@@ -142,6 +143,12 @@ describe('cerApp plugin — resolveId hook', () => {
142
143
  expect(plugin.resolveId('virtual:cer-error')).toBe('\0virtual:cer-error')
143
144
  })
144
145
 
146
+ it('resolves /.cer/app.ts to \\0cer-app-entry (virtual, bypasses fs security)', () => {
147
+ const plugin = getCerPlugin()
148
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
149
+ expect(plugin.resolveId('/.cer/app.ts')).toBe('\0cer-app-entry')
150
+ })
151
+
145
152
  it('returns undefined for unknown ids', () => {
146
153
  const plugin = getCerPlugin()
147
154
  plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
@@ -150,6 +157,14 @@ describe('cerApp plugin — resolveId hook', () => {
150
157
  })
151
158
 
152
159
  describe('cerApp plugin — load hook', () => {
160
+ it('loads \\0cer-app-entry with APP_ENTRY_TEMPLATE content', async () => {
161
+ const plugin = getCerPlugin()
162
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
163
+ plugin.configResolved(FAKE_RESOLVED)
164
+ const result = await plugin.load('\0cer-app-entry')
165
+ expect(result).toBe(APP_ENTRY_TEMPLATE)
166
+ })
167
+
153
168
  it('returns null for unknown resolved ids', async () => {
154
169
  const plugin = getCerPlugin()
155
170
  plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
@@ -48,7 +48,7 @@ function makeConfig(overrides: Partial<ResolvedCerConfig> = {}): ResolvedCerConf
48
48
  serverApiDir: '/project/server/api',
49
49
  serverMiddlewareDir: '/project/server/middleware',
50
50
  port: 3000,
51
- ssr: { dsd: true, streaming: false },
51
+ ssr: { dsd: true },
52
52
  ssg: { routes: 'auto', concurrency: 2, fallback: false },
53
53
  router: {},
54
54
  jitCss: { content: [], extendedColors: false },
@@ -163,6 +163,11 @@ describe('generateAutoImportDts', () => {
163
163
  expect(dts).toContain("const usePageData: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['usePageData']")
164
164
  })
165
165
 
166
+ it('declares useInject as a framework global', async () => {
167
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
168
+ expect(dts).toContain("const useInject: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useInject']")
169
+ })
170
+
166
171
  it('declares when directive as a global', async () => {
167
172
  const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
168
173
  expect(dts).toContain("const when: typeof import('@jasonshimmy/custom-elements-runtime/directives')['when']")
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'pathe'
4
+
5
+ const src = readFileSync(
6
+ resolve(import.meta.dirname, '../../runtime/entry-server-template.ts'),
7
+ 'utf-8',
8
+ )
9
+
10
+ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
11
+ it('template imports plugins from virtual:cer-plugins', () => {
12
+ expect(src).toContain('virtual:cer-plugins')
13
+ })
14
+
15
+ it('template initializes plugins and sets globalThis.__cerPluginProvides', () => {
16
+ expect(src).toContain('__cerPluginProvides')
17
+ expect(src).toContain('_pluginProvides')
18
+ expect(src).toContain('_pluginsReady')
19
+ })
20
+
21
+ it('template awaits _pluginsReady before handling each request', () => {
22
+ expect(src).toContain('await _pluginsReady')
23
+ })
24
+ })
@@ -17,7 +17,6 @@ import { existsSync, writeFileSync, mkdirSync, readFileSync, appendFileSync } fr
17
17
  import {
18
18
  GENERATED_DIR_NAME,
19
19
  getGeneratedDir,
20
- resolveAppEntry,
21
20
  resolveHtmlEntry,
22
21
  generateDefaultHtml,
23
22
  writeGeneratedDir,
@@ -49,18 +48,6 @@ describe('getGeneratedDir', () => {
49
48
  })
50
49
  })
51
50
 
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
51
  describe('resolveHtmlEntry', () => {
65
52
  it('returns user index.html when it exists', () => {
66
53
  vi.mocked(existsSync).mockReturnValue(true)
@@ -74,26 +61,19 @@ describe('resolveHtmlEntry', () => {
74
61
  })
75
62
 
76
63
  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)
64
+ it('always references /.cer/app.ts', () => {
65
+ const html = generateDefaultHtml()
87
66
  expect(html).toContain('/.cer/app.ts')
67
+ expect(html).not.toContain('/app/app.ts')
88
68
  })
89
69
 
90
70
  it('includes <cer-layout-view> mount point', () => {
91
- const html = generateDefaultHtml(mockConfig)
71
+ const html = generateDefaultHtml()
92
72
  expect(html).toContain('<cer-layout-view>')
93
73
  })
94
74
 
95
75
  it('is valid HTML with doctype', () => {
96
- const html = generateDefaultHtml(mockConfig)
76
+ const html = generateDefaultHtml()
97
77
  expect(html).toContain('<!DOCTYPE html>')
98
78
  })
99
79
  })
@@ -112,28 +92,17 @@ describe('writeGeneratedDir', () => {
112
92
  expect(mkdirSync).not.toHaveBeenCalled()
113
93
  })
114
94
 
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
- })
95
+ it('always writes .cer/app.ts', () => {
126
96
  writeGeneratedDir(mockConfig)
127
97
  const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
128
98
  expect(paths.some(p => p.endsWith('/.cer/app.ts'))).toBe(true)
129
99
  })
130
100
 
131
- it('skips writing .cer/app.ts when app/app.ts exists', () => {
132
- // existsSync always returns true — dir exists, user entry exists
101
+ it('always writes .cer/app.ts even when .cer/ dir already exists', () => {
133
102
  vi.mocked(existsSync).mockReturnValue(true)
134
103
  writeGeneratedDir(mockConfig)
135
104
  const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
136
- expect(paths.some(p => p.endsWith('/.cer/app.ts'))).toBe(false)
105
+ expect(paths.some(p => p.endsWith('/.cer/app.ts'))).toBe(true)
137
106
  })
138
107
 
139
108
  it('always writes .cer/index.html', () => {
@@ -84,11 +84,6 @@ describe('resolveConfig', () => {
84
84
  expect(cfg.ssr.dsd).toBe(false)
85
85
  })
86
86
 
87
- it('defaults ssr.streaming to false', () => {
88
- const cfg = resolveConfig({}, ROOT)
89
- expect(cfg.ssr.streaming).toBe(false)
90
- })
91
-
92
87
  it('defaults ssg.routes to "auto"', () => {
93
88
  const cfg = resolveConfig({}, ROOT)
94
89
  expect(cfg.ssg.routes).toBe('auto')
@@ -221,6 +221,13 @@ describe('autoImportTransform — framework composable injection', () => {
221
221
  expect(count).toBe(1)
222
222
  })
223
223
 
224
+ it('injects useInject import when useInject is used', () => {
225
+ const code = "component('page-about', () => { const svc = useInject('my-service'); return html`<div></div>` })"
226
+ const result = autoImportTransform(code, '/project/app/pages/about.ts', opts)!
227
+ expect(result).toContain(`from ${FRAMEWORK_PKG}`)
228
+ expect(result).toContain('useInject')
229
+ })
230
+
224
231
  it('injects usePageData for root-level convention files (loading.ts, error.ts)', () => {
225
232
  const code = "component('page-loading', () => { const d = usePageData(); return html`<div></div>` })"
226
233
  const result = autoImportTransform(code, '/project/app/loading.ts', opts)!
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ *
4
+ * Tests for useInject — client-side path.
5
+ *
6
+ * Runs in happy-dom so `document` is defined, putting useInject() on the
7
+ * client branch. inject() from the runtime is mocked since it requires a
8
+ * live component context that doesn't exist in unit tests.
9
+ */
10
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
11
+
12
+ vi.mock('@jasonshimmy/custom-elements-runtime', () => ({
13
+ inject: vi.fn(),
14
+ }))
15
+
16
+ import { inject } from '@jasonshimmy/custom-elements-runtime'
17
+ import { useInject } from '../../runtime/composables/use-inject.js'
18
+
19
+ const _g = globalThis as Record<string, unknown>
20
+
21
+ describe('useInject — client-side', () => {
22
+ beforeEach(() => {
23
+ delete _g['__cerPluginProvides']
24
+ vi.mocked(inject).mockReturnValue(undefined)
25
+ })
26
+
27
+ afterEach(() => {
28
+ delete _g['__cerPluginProvides']
29
+ })
30
+
31
+ it('returns the value from inject() when the component context has it', () => {
32
+ vi.mocked(inject).mockReturnValue('injected-value')
33
+ expect(useInject('my-key')).toBe('injected-value')
34
+ })
35
+
36
+ it('falls back to __cerPluginProvides when inject() returns undefined', () => {
37
+ _g['__cerPluginProvides'] = new Map([['my-key', 'plugin-provided']])
38
+ expect(useInject('my-key')).toBe('plugin-provided')
39
+ })
40
+
41
+ it('inject() result takes priority over __cerPluginProvides', () => {
42
+ vi.mocked(inject).mockReturnValue('inject-wins')
43
+ _g['__cerPluginProvides'] = new Map([['my-key', 'global-value']])
44
+ expect(useInject('my-key')).toBe('inject-wins')
45
+ })
46
+
47
+ it('returns defaultValue when inject() and provides both miss', () => {
48
+ expect(useInject('missing', 'default')).toBe('default')
49
+ })
50
+
51
+ it('returns undefined when inject() and provides both miss with no defaultValue', () => {
52
+ expect(useInject('missing')).toBeUndefined()
53
+ })
54
+
55
+ it('returns defaultValue when inject() returns undefined and key is absent from provides', () => {
56
+ _g['__cerPluginProvides'] = new Map()
57
+ expect(useInject('missing', 42)).toBe(42)
58
+ })
59
+
60
+ it('is generic and preserves the typed shape from inject()', () => {
61
+ interface Service { call(): string }
62
+ const svc: Service = { call: () => 'ok' }
63
+ vi.mocked(inject).mockReturnValue(svc)
64
+ const result = useInject<Service>('svc')
65
+ expect(result?.call()).toBe('ok')
66
+ })
67
+ })
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tests for useInject — server-side (SSR/SSG) path.
3
+ *
4
+ * This file runs in the default 'node' environment where `document` is
5
+ * undefined, so useInject() always takes the SSR branch: reading from
6
+ * globalThis.__cerPluginProvides.
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
9
+ import { useInject } from '../../runtime/composables/use-inject.js'
10
+
11
+ const _g = globalThis as Record<string, unknown>
12
+
13
+ describe('useInject — server-side (SSR/SSG)', () => {
14
+ beforeEach(() => {
15
+ delete _g['__cerPluginProvides']
16
+ })
17
+
18
+ afterEach(() => {
19
+ delete _g['__cerPluginProvides']
20
+ })
21
+
22
+ it('returns value from __cerPluginProvides when key is present', () => {
23
+ _g['__cerPluginProvides'] = new Map([['my-service', { greet: () => 'hello' }]])
24
+ const result = useInject<{ greet(): string }>('my-service')
25
+ expect(typeof result?.greet).toBe('function')
26
+ expect(result?.greet()).toBe('hello')
27
+ })
28
+
29
+ it('returns defaultValue when key is absent from the provides map', () => {
30
+ _g['__cerPluginProvides'] = new Map()
31
+ expect(useInject('missing', 'fallback')).toBe('fallback')
32
+ })
33
+
34
+ it('returns undefined when key is absent and no defaultValue is given', () => {
35
+ _g['__cerPluginProvides'] = new Map()
36
+ expect(useInject('missing')).toBeUndefined()
37
+ })
38
+
39
+ it('returns undefined when __cerPluginProvides is not set at all', () => {
40
+ expect(useInject('my-service')).toBeUndefined()
41
+ })
42
+
43
+ it('returns defaultValue when __cerPluginProvides is not set and defaultValue is given', () => {
44
+ expect(useInject('my-service', 'default')).toBe('default')
45
+ })
46
+
47
+ it('is generic and preserves the typed shape', () => {
48
+ interface Store { count: number }
49
+ const store: Store = { count: 42 }
50
+ _g['__cerPluginProvides'] = new Map([['store', store]])
51
+ const result = useInject<Store>('store')
52
+ expect(result?.count).toBe(42)
53
+ })
54
+
55
+ it('is safe to call multiple times — does not consume the value', () => {
56
+ _g['__cerPluginProvides'] = new Map([['key', 'value']])
57
+ expect(useInject('key')).toBe('value')
58
+ expect(useInject('key')).toBe('value')
59
+ })
60
+
61
+ it('supports multiple keys independently', () => {
62
+ _g['__cerPluginProvides'] = new Map([['a', 1], ['b', 2]])
63
+ expect(useInject('a')).toBe(1)
64
+ expect(useInject('b')).toBe(2)
65
+ })
66
+ })
@@ -12,7 +12,7 @@ describe('defineConfig', () => {
12
12
  })
13
13
 
14
14
  it('preserves nested ssr config', () => {
15
- const config = { ssr: { dsd: false, streaming: true } }
15
+ const config = { ssr: { dsd: false } }
16
16
  expect(defineConfig(config)).toEqual(config)
17
17
  })
18
18
 
@@ -179,6 +179,10 @@ async function generateInlineTemplate(
179
179
  ): Promise<void> {
180
180
  await mkdir(join(targetDir, 'app/pages'), { recursive: true })
181
181
  await mkdir(join(targetDir, 'app/layouts'), { recursive: true })
182
+ await mkdir(join(targetDir, 'app/components'), { recursive: true })
183
+ await mkdir(join(targetDir, 'app/composables'), { recursive: true })
184
+ await mkdir(join(targetDir, 'app/plugins'), { recursive: true })
185
+ await mkdir(join(targetDir, 'app/middleware'), { recursive: true })
182
186
 
183
187
  // package.json
184
188
  await writeFile(
@@ -215,13 +219,6 @@ async function generateInlineTemplate(
215
219
  'utf-8',
216
220
  )
217
221
 
218
- // app/app.ts
219
- await writeFile(
220
- join(targetDir, 'app/app.ts'),
221
- `import '@jasonshimmy/custom-elements-runtime/css'\nimport 'virtual:cer-jit-css'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport { hasLoading, loadingTag } from 'virtual:cer-loading'\nimport { hasError, errorTag } from 'virtual:cer-error'\nimport { component, ref, provide, useOnConnected, useOnDisconnected, registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'\n\nregisterBuiltinComponents()\nenableJITCSS()\n\nconst router = initRouter({ routes })\n\nconst isNavigating = ref(false)\nconst currentError = ref(null)\n;(globalThis as any).resetError = () => { currentError.value = null; router.replace(router.getCurrent().path) }\nconst _push = router.push.bind(router)\nconst _replace = router.replace.bind(router)\nrouter.push = async (path) => { isNavigating.value = true; currentError.value = null; try { await _push(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\nrouter.replace = async (path) => { isNavigating.value = true; currentError.value = null; try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\n\nconst _pluginProvides = new Map()\n;(globalThis as any).__cerPluginProvides = _pluginProvides\n\ncomponent('cer-layout-view', () => {\n for (const [key, value] of _pluginProvides) { provide(key, value) }\n const current = ref(router.getCurrent())\n let unsub: (() => void) | undefined\n useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })\n useOnDisconnected(() => { unsub?.(); unsub = undefined })\n if (currentError.value !== null) {\n if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }\n return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }\n }\n if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }\n const matched = router.matchRoute(current.value.path)\n const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'\n const layoutTag = (layouts as Record<string, string>)[layoutName]\n const routerView = { tag: 'router-view', props: {}, children: [] }\n return layoutTag ? { tag: layoutTag, props: {}, children: [routerView] } : routerView\n})\n\nfor (const plugin of plugins ?? []) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })\n }\n}\n\nif (typeof window !== 'undefined') {\n const _initMatch = router.matchRoute(window.location.pathname)\n if (_initMatch?.route?.load) {\n try { await _initMatch.route.load() } catch { /* non-fatal */ }\n }\n}\n\nif (typeof window !== 'undefined') {\n await _replace(window.location.pathname + window.location.search + window.location.hash)\n delete (globalThis as any).__CER_DATA__\n}\n\nexport { router }\n`,
222
- 'utf-8',
223
- )
224
-
225
222
  // app/pages/index.ts
226
223
  await writeFile(
227
224
  join(targetDir, 'app/pages/index.ts'),
@@ -236,10 +233,17 @@ async function generateInlineTemplate(
236
233
  'utf-8',
237
234
  )
238
235
 
236
+ // .gitignore
237
+ await writeFile(
238
+ join(targetDir, '.gitignore'),
239
+ `# Dependencies\nnode_modules/\n\n# Build output\ndist/\n\n# CER App generated directory\n.cer/\n\n# Environment variables\n.env.local\n.env.*.local\n\n# Editor\n.vscode/\n.idea/\n*.suo\n*.sw?\n\n# OS\n.DS_Store\nThumbs.db\n\n# Logs\n*.log\n`,
240
+ 'utf-8',
241
+ )
242
+
239
243
  // index.html
240
244
  await writeFile(
241
245
  join(targetDir, 'index.html'),
242
- `<!DOCTYPE html>\n<html lang="en">\n <head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>${projectName}</title>\n </head>\n <body>\n <cer-layout-view></cer-layout-view>\n <script type="module" src="/app/app.ts"></script>\n </body>\n</html>\n`,
246
+ `<!DOCTYPE html>\n<html lang="en">\n <head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>${projectName}</title>\n </head>\n <body>\n <cer-layout-view></cer-layout-view>\n <script type="module" src="/.cer/app.ts"></script>\n </body>\n</html>\n`,
243
247
  'utf-8',
244
248
  )
245
249
  }
@@ -0,0 +1,25 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+
7
+ # CER App generated directory
8
+ .cer/
9
+
10
+ # Environment variables
11
+ .env.local
12
+ .env.*.local
13
+
14
+ # Editor
15
+ .vscode/
16
+ .idea/
17
+ *.suo
18
+ *.sw?
19
+
20
+ # OS
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # Logs
25
+ *.log
@@ -7,6 +7,6 @@
7
7
  </head>
8
8
  <body>
9
9
  <cer-layout-view></cer-layout-view>
10
- <script type="module" src="/app/app.ts"></script>
10
+ <script type="module" src="/.cer/app.ts"></script>
11
11
  </body>
12
12
  </html>
@@ -0,0 +1,25 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+
7
+ # CER App generated directory
8
+ .cer/
9
+
10
+ # Environment variables
11
+ .env.local
12
+ .env.*.local
13
+
14
+ # Editor
15
+ .vscode/
16
+ .idea/
17
+ *.suo
18
+ *.sw?
19
+
20
+ # OS
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # Logs
25
+ *.log
@@ -7,6 +7,6 @@
7
7
  </head>
8
8
  <body>
9
9
  <cer-layout-view></cer-layout-view>
10
- <script type="module" src="/app/app.ts"></script>
10
+ <script type="module" src="/.cer/app.ts"></script>
11
11
  </body>
12
12
  </html>
@@ -0,0 +1,25 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+
7
+ # CER App generated directory
8
+ .cer/
9
+
10
+ # Environment variables
11
+ .env.local
12
+ .env.*.local
13
+
14
+ # Editor
15
+ .vscode/
16
+ .idea/
17
+ *.suo
18
+ *.sw?
19
+
20
+ # OS
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # Logs
25
+ *.log
@@ -4,7 +4,6 @@ export default defineConfig({
4
4
  mode: 'ssr',
5
5
  ssr: {
6
6
  dsd: true,
7
- streaming: true,
8
7
  },
9
8
  autoImports: { components: true, composables: true, directives: true, runtime: true },
10
9
  })
@@ -7,6 +7,6 @@
7
7
  </head>
8
8
  <body>
9
9
  <cer-layout-view></cer-layout-view>
10
- <script type="module" src="/app/app.ts"></script>
10
+ <script type="module" src="/.cer/app.ts"></script>
11
11
  </body>
12
12
  </html>
@@ -50,6 +50,23 @@ registerBuiltinComponents()
50
50
  // minimal set (&lt;, &gt;, &amp; …) and re-escapes everything else.
51
51
  registerEntityMap(entitiesJson)
52
52
 
53
+ // Run plugins once at server startup so their provide() values are available
54
+ // to useInject() during every SSR/SSG render pass.
55
+ const _pluginProvides = new Map()
56
+ ;(globalThis).__cerPluginProvides = _pluginProvides
57
+ const _pluginsReady = (async () => {
58
+ const _bootstrapRouter = initRouter({ routes })
59
+ for (const plugin of plugins) {
60
+ if (plugin && typeof plugin.setup === 'function') {
61
+ await plugin.setup({
62
+ router: _bootstrapRouter,
63
+ provide: (key, value) => _pluginProvides.set(key, value),
64
+ config: {},
65
+ })
66
+ }
67
+ }
68
+ })()
69
+
53
70
  // Load the Vite-built client index.html (dist/client/index.html) so every SSR
54
71
  // response includes the client-side scripts needed for hydration and routing.
55
72
  // The server bundle lives at dist/server/server.js, so ../client resolves correctly.
@@ -119,6 +136,7 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
119
136
  // synchronously right before renderToStringWithJITCSS — guaranteeing that
120
137
  // concurrent renders (SSG concurrency > 1) never race on a shared global.
121
138
  const _prepareRequest = async (req) => {
139
+ await _pluginsReady
122
140
  const router = initRouter({ routes, initialUrl: req.url ?? '/' })
123
141
  const current = router.getCurrent()
124
142
  const { route, params } = router.matchRoute(current.path)
@@ -15,7 +15,7 @@ export interface ResolvedCerConfig {
15
15
  serverApiDir: string
16
16
  serverMiddlewareDir: string
17
17
  port: number
18
- ssr: { dsd: boolean; streaming: boolean }
18
+ ssr: { dsd: boolean }
19
19
  ssg: { routes: 'auto' | string[]; concurrency: number; fallback: boolean }
20
20
  router: { base?: string; scrollToFragment?: boolean | object }
21
21
  jitCss: { content: string[]; extendedColors: boolean }