@jasonshimmy/vite-plugin-cer-app 0.19.2 → 0.20.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 (115) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/commits.txt +2 -1
  3. package/dist/cli/commands/preview.d.ts.map +1 -1
  4. package/dist/cli/commands/preview.js +2 -0
  5. package/dist/cli/commands/preview.js.map +1 -1
  6. package/dist/cli/create/templates/spa/package.json.tpl +2 -2
  7. package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
  8. package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/plugin/build-ssg.d.ts.map +1 -1
  12. package/dist/plugin/build-ssg.js +11 -0
  13. package/dist/plugin/build-ssg.js.map +1 -1
  14. package/dist/plugin/content/emitter.d.ts +19 -0
  15. package/dist/plugin/content/emitter.d.ts.map +1 -0
  16. package/dist/plugin/content/emitter.js +42 -0
  17. package/dist/plugin/content/emitter.js.map +1 -0
  18. package/dist/plugin/content/index.d.ts +32 -0
  19. package/dist/plugin/content/index.d.ts.map +1 -0
  20. package/dist/plugin/content/index.js +199 -0
  21. package/dist/plugin/content/index.js.map +1 -0
  22. package/dist/plugin/content/parser.d.ts +18 -0
  23. package/dist/plugin/content/parser.d.ts.map +1 -0
  24. package/dist/plugin/content/parser.js +158 -0
  25. package/dist/plugin/content/parser.js.map +1 -0
  26. package/dist/plugin/content/path-utils.d.ts +19 -0
  27. package/dist/plugin/content/path-utils.d.ts.map +1 -0
  28. package/dist/plugin/content/path-utils.js +40 -0
  29. package/dist/plugin/content/path-utils.js.map +1 -0
  30. package/dist/plugin/content/scanner.d.ts +12 -0
  31. package/dist/plugin/content/scanner.d.ts.map +1 -0
  32. package/dist/plugin/content/scanner.js +18 -0
  33. package/dist/plugin/content/scanner.js.map +1 -0
  34. package/dist/plugin/content/search.d.ts +9 -0
  35. package/dist/plugin/content/search.d.ts.map +1 -0
  36. package/dist/plugin/content/search.js +24 -0
  37. package/dist/plugin/content/search.js.map +1 -0
  38. package/dist/plugin/dts-generator.d.ts.map +1 -1
  39. package/dist/plugin/dts-generator.js +10 -1
  40. package/dist/plugin/dts-generator.js.map +1 -1
  41. package/dist/plugin/index.d.ts.map +1 -1
  42. package/dist/plugin/index.js +4 -1
  43. package/dist/plugin/index.js.map +1 -1
  44. package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
  45. package/dist/plugin/transforms/auto-import.js +2 -0
  46. package/dist/plugin/transforms/auto-import.js.map +1 -1
  47. package/dist/runtime/composables/index.d.ts +3 -0
  48. package/dist/runtime/composables/index.d.ts.map +1 -1
  49. package/dist/runtime/composables/index.js +2 -0
  50. package/dist/runtime/composables/index.js.map +1 -1
  51. package/dist/runtime/composables/use-content-search.d.ts +49 -0
  52. package/dist/runtime/composables/use-content-search.d.ts.map +1 -0
  53. package/dist/runtime/composables/use-content-search.js +101 -0
  54. package/dist/runtime/composables/use-content-search.js.map +1 -0
  55. package/dist/runtime/composables/use-content.d.ts +51 -0
  56. package/dist/runtime/composables/use-content.d.ts.map +1 -0
  57. package/dist/runtime/composables/use-content.js +127 -0
  58. package/dist/runtime/composables/use-content.js.map +1 -0
  59. package/dist/runtime/content/client.d.ts +20 -0
  60. package/dist/runtime/content/client.d.ts.map +1 -0
  61. package/dist/runtime/content/client.js +163 -0
  62. package/dist/runtime/content/client.js.map +1 -0
  63. package/dist/types/config.d.ts +2 -0
  64. package/dist/types/config.d.ts.map +1 -1
  65. package/dist/types/config.js.map +1 -1
  66. package/dist/types/content.d.ts +63 -0
  67. package/dist/types/content.d.ts.map +1 -0
  68. package/dist/types/content.js +2 -0
  69. package/dist/types/content.js.map +1 -0
  70. package/docs/composables.md +115 -10
  71. package/docs/configuration.md +33 -0
  72. package/docs/content.md +436 -0
  73. package/e2e/cypress/e2e/content.cy.ts +228 -0
  74. package/e2e/kitchen-sink/app/pages/content-blog.ts +37 -0
  75. package/e2e/kitchen-sink/app/pages/content-doc.ts +42 -0
  76. package/e2e/kitchen-sink/app/pages/content-index.ts +39 -0
  77. package/e2e/kitchen-sink/app/pages/content-search.ts +35 -0
  78. package/e2e/kitchen-sink/cer.config.ts +1 -0
  79. package/e2e/kitchen-sink/content/blog/2026-04-01-hello.md +26 -0
  80. package/e2e/kitchen-sink/content/blog/2026-04-02-draft.md +10 -0
  81. package/e2e/kitchen-sink/content/blog/index.md +8 -0
  82. package/e2e/kitchen-sink/content/docs/getting-started.md +46 -0
  83. package/e2e/kitchen-sink/content/index.md +16 -0
  84. package/package.json +10 -7
  85. package/src/__tests__/plugin/build-ssg.test.ts +2 -1
  86. package/src/__tests__/plugin/content/emitter.test.ts +117 -0
  87. package/src/__tests__/plugin/content/loader.test.ts +162 -0
  88. package/src/__tests__/plugin/content/parser.test.ts +239 -0
  89. package/src/__tests__/plugin/content/path-utils.test.ts +53 -0
  90. package/src/__tests__/plugin/content/search.test.ts +119 -0
  91. package/src/__tests__/plugin/dts-generator.test.ts +39 -0
  92. package/src/__tests__/plugin/transforms/auto-import.test.ts +14 -0
  93. package/src/__tests__/runtime/use-content-search.test.ts +139 -0
  94. package/src/__tests__/runtime/use-content.test.ts +226 -0
  95. package/src/cli/commands/preview.ts +2 -0
  96. package/src/cli/create/templates/spa/package.json.tpl +2 -2
  97. package/src/cli/create/templates/ssg/package.json.tpl +2 -2
  98. package/src/cli/create/templates/ssr/package.json.tpl +2 -2
  99. package/src/index.ts +3 -0
  100. package/src/plugin/build-ssg.ts +12 -0
  101. package/src/plugin/content/emitter.ts +50 -0
  102. package/src/plugin/content/index.ts +236 -0
  103. package/src/plugin/content/parser.ts +192 -0
  104. package/src/plugin/content/path-utils.ts +47 -0
  105. package/src/plugin/content/scanner.ts +26 -0
  106. package/src/plugin/content/search.ts +28 -0
  107. package/src/plugin/dts-generator.ts +10 -1
  108. package/src/plugin/index.ts +6 -1
  109. package/src/plugin/transforms/auto-import.ts +2 -0
  110. package/src/runtime/composables/index.ts +3 -0
  111. package/src/runtime/composables/use-content-search.ts +121 -0
  112. package/src/runtime/composables/use-content.ts +146 -0
  113. package/src/runtime/content/client.ts +168 -0
  114. package/src/types/config.ts +2 -0
  115. package/src/types/content.ts +66 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for useContentSearch helpers.
3
+ *
4
+ * Covers:
5
+ * - contentSearchIndexUrl() — correct URL with and without router.base
6
+ * - loadIndex() singleton — fetched and built only once per session
7
+ * - loadIndex() error path — fetch failure rejects cleanly; singleton reset allows retry
8
+ * - loadIndex() returns a searchable MiniSearch instance
9
+ *
10
+ * Note: The full useContentSearch() composable (query reactive state, stale-seq
11
+ * guard, useOnConnected pre-warm) requires a component context provided by the
12
+ * custom-elements-runtime and is exercised by the e2e suite in content.cy.ts.
13
+ */
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
15
+ import MiniSearch from 'minisearch'
16
+ import { buildSearchIndex } from '../../plugin/content/search.js'
17
+ import type { ContentItem } from '../../types/content.js'
18
+
19
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
20
+
21
+ const SAMPLE_ITEMS: ContentItem[] = [
22
+ {
23
+ _path: '/blog/hello',
24
+ _file: 'blog/hello.md',
25
+ _type: 'markdown',
26
+ title: 'Hello World',
27
+ description: 'First post about web components',
28
+ body: '<p>Hello</p>',
29
+ toc: [],
30
+ },
31
+ {
32
+ _path: '/docs/start',
33
+ _file: 'docs/start.md',
34
+ _type: 'markdown',
35
+ title: 'Getting Started',
36
+ description: 'How to get started',
37
+ body: '<p>Start</p>',
38
+ toc: [],
39
+ },
40
+ ]
41
+
42
+ /** Builds a serialised MiniSearch index from sample items (same format as search.ts). */
43
+ function buildIndex(): string {
44
+ return buildSearchIndex(SAMPLE_ITEMS)
45
+ }
46
+
47
+ // ─── contentSearchIndexUrl ────────────────────────────────────────────────────
48
+
49
+ describe('contentSearchIndexUrl', () => {
50
+ it('returns a URL ending with /_content/search-index.json', async () => {
51
+ const { contentSearchIndexUrl } = await import('../../runtime/content/client.js')
52
+ const url = contentSearchIndexUrl()
53
+ expect(url).toMatch(/\/_content\/search-index\.json$/)
54
+ })
55
+ })
56
+
57
+ // ─── loadIndex singleton ──────────────────────────────────────────────────────
58
+
59
+ describe('loadIndex', () => {
60
+ let originalFetch: typeof globalThis.fetch
61
+
62
+ beforeEach(async () => {
63
+ originalFetch = globalThis.fetch
64
+ // Reset the singleton before each test so tests are independent
65
+ const { resetIndexSingleton } = await import('../../runtime/composables/use-content-search.js')
66
+ resetIndexSingleton()
67
+ })
68
+
69
+ afterEach(() => {
70
+ globalThis.fetch = originalFetch
71
+ })
72
+
73
+ it('fetches the index only once despite multiple concurrent calls (singleton)', async () => {
74
+ const indexJson = buildIndex()
75
+ globalThis.fetch = vi.fn().mockResolvedValue({
76
+ ok: true,
77
+ text: () => Promise.resolve(indexJson),
78
+ } as unknown as Response)
79
+
80
+ const { loadIndex, resetIndexSingleton } = await import('../../runtime/composables/use-content-search.js')
81
+ resetIndexSingleton()
82
+
83
+ // Call loadIndex() three times concurrently — only one fetch should occur.
84
+ const [r1, r2, r3] = await Promise.all([loadIndex(), loadIndex(), loadIndex()])
85
+ expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(1)
86
+ // All calls must resolve to the same underlying index object
87
+ expect(r1).toBe(r2)
88
+ expect(r2).toBe(r3)
89
+ })
90
+
91
+ it('returns a MiniSearch-compatible index that can search by title', async () => {
92
+ const indexJson = buildIndex()
93
+ globalThis.fetch = vi.fn().mockResolvedValue({
94
+ ok: true,
95
+ text: () => Promise.resolve(indexJson),
96
+ } as unknown as Response)
97
+
98
+ const { loadIndex, resetIndexSingleton } = await import('../../runtime/composables/use-content-search.js')
99
+ resetIndexSingleton()
100
+
101
+ const index = await loadIndex() as ReturnType<typeof MiniSearch.loadJSON>
102
+ const results = index.search('Hello', { prefix: true }) as Array<{ _path: string }>
103
+ expect(results.some((r) => r._path === '/blog/hello')).toBe(true)
104
+ })
105
+
106
+ it('rejects when fetch fails (non-ok status)', async () => {
107
+ globalThis.fetch = vi.fn().mockResolvedValue({
108
+ ok: false,
109
+ status: 404,
110
+ } as unknown as Response)
111
+
112
+ const { loadIndex, resetIndexSingleton } = await import('../../runtime/composables/use-content-search.js')
113
+ resetIndexSingleton()
114
+
115
+ await expect(loadIndex()).rejects.toThrow()
116
+ })
117
+
118
+ it('allows retry after singleton reset following an error', async () => {
119
+ const { loadIndex, resetIndexSingleton } = await import('../../runtime/composables/use-content-search.js')
120
+
121
+ // First call fails
122
+ globalThis.fetch = vi.fn().mockResolvedValue({
123
+ ok: false,
124
+ status: 503,
125
+ } as unknown as Response)
126
+ resetIndexSingleton()
127
+ await expect(loadIndex()).rejects.toThrow()
128
+
129
+ // Reset and retry with a good response
130
+ resetIndexSingleton()
131
+ const indexJson = buildIndex()
132
+ globalThis.fetch = vi.fn().mockResolvedValue({
133
+ ok: true,
134
+ text: () => Promise.resolve(indexJson),
135
+ } as unknown as Response)
136
+ const index = await loadIndex() as ReturnType<typeof MiniSearch.loadJSON>
137
+ expect(index).toBeDefined()
138
+ })
139
+ })
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tests for queryContent() composable and QueryBuilder.
3
+ *
4
+ * Covers:
5
+ * - Server-side path: reads from globalThis.__CER_CONTENT_STORE__
6
+ * - .where() predicate filtering
7
+ * - .sortBy() sorting
8
+ * - .limit() and .skip() pagination
9
+ * - .find() returns ContentMeta[]
10
+ * - .count() returns number
11
+ * - .first() returns ContentItem (direct path fetch fast path)
12
+ * - .first() with filters (slow path through manifest)
13
+ * - prefix scoping
14
+ * - empty results
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
18
+ import type { ContentItem } from '../../types/content.js'
19
+
20
+ const g = globalThis as Record<string, unknown>
21
+
22
+ const STORE: ContentItem[] = [
23
+ {
24
+ _path: '/blog/hello',
25
+ _file: 'blog/hello.md',
26
+ _type: 'markdown',
27
+ title: 'Hello World',
28
+ description: 'First post',
29
+ date: '2026-04-03',
30
+ draft: false,
31
+ body: '<p>Hello</p>',
32
+ toc: [],
33
+ },
34
+ {
35
+ _path: '/blog/world',
36
+ _file: 'blog/world.md',
37
+ _type: 'markdown',
38
+ title: 'World Post',
39
+ description: 'Second post',
40
+ date: '2026-04-10',
41
+ draft: true,
42
+ body: '<p>World</p>',
43
+ toc: [],
44
+ },
45
+ {
46
+ _path: '/about',
47
+ _file: 'about.md',
48
+ _type: 'markdown',
49
+ title: 'About',
50
+ body: '<p>About</p>',
51
+ toc: [],
52
+ },
53
+ {
54
+ _path: '/blog',
55
+ _file: 'blog/index.md',
56
+ _type: 'markdown',
57
+ title: 'Blog',
58
+ body: '<p>Blog index</p>',
59
+ toc: [],
60
+ },
61
+ ]
62
+
63
+ function setup() {
64
+ g['__CER_CONTENT_STORE__'] = STORE
65
+ }
66
+
67
+ function teardown() {
68
+ delete g['__CER_CONTENT_STORE__']
69
+ }
70
+
71
+ describe('queryContent() — .find()', () => {
72
+ beforeEach(setup)
73
+ afterEach(teardown)
74
+
75
+ it('returns all items when no prefix', async () => {
76
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
77
+ const result = await queryContent().find()
78
+ expect(result).toHaveLength(STORE.length)
79
+ })
80
+
81
+ it('filters by prefix — exact match', async () => {
82
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
83
+ const result = await queryContent('/about').find()
84
+ expect(result).toHaveLength(1)
85
+ expect(result[0]._path).toBe('/about')
86
+ })
87
+
88
+ it('filters by prefix — includes children', async () => {
89
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
90
+ const result = await queryContent('/blog').find()
91
+ expect(result.map((r) => r._path).sort()).toEqual(['/blog', '/blog/hello', '/blog/world'])
92
+ })
93
+
94
+ it('.where() filters items', async () => {
95
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
96
+ const result = await queryContent('/blog').where((doc) => !doc.draft).find()
97
+ expect(result.every((r) => !r.draft)).toBe(true)
98
+ expect(result.some((r) => r._path === '/blog/world')).toBe(false)
99
+ })
100
+
101
+ it('.where() with date filter', async () => {
102
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
103
+ const result = await queryContent('/blog')
104
+ .where((doc) => typeof doc.date === 'string' && doc.date >= '2026-04-09')
105
+ .find()
106
+ expect(result).toHaveLength(1)
107
+ expect(result[0]._path).toBe('/blog/world')
108
+ })
109
+
110
+ it('.sortBy() ascending', async () => {
111
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
112
+ const result = await queryContent('/blog').sortBy('date').find()
113
+ const dates = result.filter((r) => r.date).map((r) => r.date as string)
114
+ expect(dates).toEqual([...dates].sort())
115
+ })
116
+
117
+ it('.sortBy() descending', async () => {
118
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
119
+ const result = await queryContent('/blog').sortBy('date', 'desc').find()
120
+ const dates = result.filter((r) => r.date).map((r) => r.date as string)
121
+ expect(dates).toEqual([...dates].sort().reverse())
122
+ })
123
+
124
+ it('.limit() caps results', async () => {
125
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
126
+ const result = await queryContent().limit(2).find()
127
+ expect(result).toHaveLength(2)
128
+ })
129
+
130
+ it('.skip() offsets results', async () => {
131
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
132
+ const all = await queryContent().find()
133
+ const skipped = await queryContent().skip(1).find()
134
+ expect(skipped).toHaveLength(all.length - 1)
135
+ expect(skipped[0]._path).toBe(all[1]._path)
136
+ })
137
+
138
+ it('chained .where().sortBy().limit()', async () => {
139
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
140
+ const result = await queryContent('/blog')
141
+ .where((doc) => !doc.draft)
142
+ .sortBy('date', 'desc')
143
+ .limit(1)
144
+ .find()
145
+ expect(result).toHaveLength(1)
146
+ })
147
+
148
+ it('returns ContentMeta — no body in results', async () => {
149
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
150
+ const result = await queryContent().find()
151
+ for (const item of result) {
152
+ expect('body' in item).toBe(false)
153
+ }
154
+ })
155
+
156
+ it('no prefix returns all items including those outside /blog', async () => {
157
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
158
+ const result = await queryContent().find()
159
+ expect(result.some((r) => r._path === '/about')).toBe(true)
160
+ })
161
+ })
162
+
163
+ describe('queryContent() — .count()', () => {
164
+ beforeEach(setup)
165
+ afterEach(teardown)
166
+
167
+ it('returns total count with no filters', async () => {
168
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
169
+ const count = await queryContent().count()
170
+ expect(count).toBe(STORE.length)
171
+ })
172
+
173
+ it('returns filtered count with .where()', async () => {
174
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
175
+ const count = await queryContent('/blog').where((doc) => !doc.draft).count()
176
+ // /blog index (no draft field) + /blog/hello (draft: false)
177
+ expect(count).toBe(2)
178
+ })
179
+ })
180
+
181
+ describe('queryContent() — .first()', () => {
182
+ beforeEach(setup)
183
+ afterEach(teardown)
184
+
185
+ it('fast path: fetches item directly by path', async () => {
186
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
187
+ const item = await queryContent('/about').first()
188
+ expect(item).not.toBeNull()
189
+ expect(item?._path).toBe('/about')
190
+ expect(item?.body).toBe('<p>About</p>')
191
+ })
192
+
193
+ it('fast path: returns null for unknown path', async () => {
194
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
195
+ const item = await queryContent('/does-not-exist').first()
196
+ expect(item).toBeNull()
197
+ })
198
+
199
+ it('slow path: applies .where() filter then fetches first match', async () => {
200
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
201
+ const item = await queryContent('/blog')
202
+ .where((doc) => !doc.draft)
203
+ .first()
204
+ expect(item).not.toBeNull()
205
+ expect(item?.draft).not.toBe(true)
206
+ })
207
+
208
+ it('returns full ContentItem with body and toc', async () => {
209
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
210
+ const item = await queryContent('/blog/hello').first()
211
+ expect(item?.body).toBeDefined()
212
+ expect(Array.isArray(item?.toc)).toBe(true)
213
+ })
214
+
215
+ it('returns null when no prefix is set and no store', async () => {
216
+ // Remove store temporarily
217
+ delete g['__CER_CONTENT_STORE__']
218
+ const { queryContent } = await import('../../runtime/composables/use-content.js')
219
+ // Without store, the client fetch path will run; since there's no real server, it may fail.
220
+ // Just verify it doesn't throw — result can be null.
221
+ const item = await queryContent('/about').first().catch(() => null)
222
+ expect(item === null || item === undefined || typeof item === 'object').toBe(true)
223
+ // Restore
224
+ g['__CER_CONTENT_STORE__'] = STORE
225
+ })
226
+ })
@@ -225,6 +225,8 @@ export function previewCommand(): Command {
225
225
  apiRoutes?: Array<{ path: string; handlers: Record<string, unknown> }>
226
226
  }
227
227
  try {
228
+ // Expose the app root so the server bundle can resolve content files at runtime.
229
+ process.env.__CER_APP_ROOT__ = root
228
230
  serverMod = await import(pathToFileURL(serverBundle).href)
229
231
  } catch (err) {
230
232
  console.error('[cer-app] Failed to load server bundle:', err)
@@ -8,10 +8,10 @@
8
8
  "preview": "cer-app preview"
9
9
  },
10
10
  "dependencies": {
11
- "@jasonshimmy/custom-elements-runtime": "^3.7.1"
11
+ "@jasonshimmy/custom-elements-runtime": "^3.7.2"
12
12
  },
13
13
  "devDependencies": {
14
- "@jasonshimmy/vite-plugin-cer-app": "^0.19.2",
14
+ "@jasonshimmy/vite-plugin-cer-app": "^0.19.3",
15
15
  "typescript": "^5.9.3",
16
16
  "vite": "^8.0.3"
17
17
  }
@@ -9,10 +9,10 @@
9
9
  "preview": "cer-app preview"
10
10
  },
11
11
  "dependencies": {
12
- "@jasonshimmy/custom-elements-runtime": "^3.7.1"
12
+ "@jasonshimmy/custom-elements-runtime": "^3.7.2"
13
13
  },
14
14
  "devDependencies": {
15
- "@jasonshimmy/vite-plugin-cer-app": "^0.19.2",
15
+ "@jasonshimmy/vite-plugin-cer-app": "^0.19.3",
16
16
  "typescript": "^5.9.3",
17
17
  "vite": "^8.0.3"
18
18
  }
@@ -8,10 +8,10 @@
8
8
  "preview": "cer-app preview --ssr"
9
9
  },
10
10
  "dependencies": {
11
- "@jasonshimmy/custom-elements-runtime": "^3.7.1"
11
+ "@jasonshimmy/custom-elements-runtime": "^3.7.2"
12
12
  },
13
13
  "devDependencies": {
14
- "@jasonshimmy/vite-plugin-cer-app": "^0.19.2",
14
+ "@jasonshimmy/vite-plugin-cer-app": "^0.19.3",
15
15
  "typescript": "^5.9.3",
16
16
  "vite": "^8.0.3"
17
17
  }
package/src/index.ts CHANGED
@@ -15,3 +15,6 @@ export type { UseFetchOptions, UseFetchReturn, UseFetchResult, UseFetchReactiveR
15
15
 
16
16
  // Re-export resolved config type for use in build scripts
17
17
  export type { ResolvedCerConfig } from './plugin/dev-server.js'
18
+
19
+ // Content layer types
20
+ export type { ContentItem, ContentMeta, ContentHeading, ContentSearchResult, CerContentConfig } from './types/content.js'
@@ -5,6 +5,7 @@ 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'
8
9
  import fg from 'fast-glob'
9
10
 
10
11
  interface SsgManifest {
@@ -249,6 +250,17 @@ export async function buildSSG(
249
250
  const paths = await collectSsgPaths(config, viteUserConfig)
250
251
  console.log(`[cer-app] Found ${paths.length} path(s) to generate:`, paths)
251
252
 
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
+
252
264
  // Step 3+4: Render and write paths with bounded concurrency.
253
265
  // The server bundle uses per-request router instances (initRouter returns the
254
266
  // router; the factory passes it to createStreamingSSRHandler as { vnode, router })
@@ -0,0 +1,50 @@
1
+ import { writeFileSync, mkdirSync } from 'node:fs'
2
+ import { join, dirname } from 'pathe'
3
+ import type { ContentItem, ContentMeta } from '../../types/content.js'
4
+ import { toContentMeta } from './parser.js'
5
+
6
+ /**
7
+ * Converts a `_path` to the relative file path under `_content/`.
8
+ *
9
+ * Special case: `_path === '/'` writes to `_content/index.json` rather than
10
+ * `_content/.json` (which would be invalid on most filesystems and confusing).
11
+ */
12
+ export function contentPathToFile(path: string): string {
13
+ if (path === '/') return 'index.json'
14
+ // Remove leading slash, append .json
15
+ return path.slice(1) + '.json'
16
+ }
17
+
18
+ /**
19
+ * Writes all content output files to `<outDir>/_content/`:
20
+ *
21
+ * - `manifest.json` — `ContentMeta[]` (no body, no toc, no excerpt)
22
+ * - `search-index.json` — serialised MiniSearch index string
23
+ * - `[path].json` — full `ContentItem` per document
24
+ *
25
+ * `router.base` is NOT prepended here — that is a client-side fetch concern.
26
+ */
27
+ export function emitContentFiles(
28
+ items: ContentItem[],
29
+ outDir: string,
30
+ searchIndexJson: string,
31
+ ): void {
32
+ const contentDir = join(outDir, '_content')
33
+ mkdirSync(contentDir, { recursive: true })
34
+
35
+ // manifest.json — lean metadata only
36
+ const manifest: ContentMeta[] = items.map(toContentMeta)
37
+ writeFileSync(join(contentDir, 'manifest.json'), JSON.stringify(manifest), 'utf-8')
38
+
39
+ // search-index.json
40
+ writeFileSync(join(contentDir, 'search-index.json'), searchIndexJson, 'utf-8')
41
+
42
+ // Per-document full JSON files
43
+ for (const item of items) {
44
+ const relFile = contentPathToFile(item._path)
45
+ const absFile = join(contentDir, relFile)
46
+ // Ensure subdirectory exists (e.g. _content/blog/ for _path: /blog/hello)
47
+ mkdirSync(dirname(absFile), { recursive: true })
48
+ writeFileSync(absFile, JSON.stringify(item), 'utf-8')
49
+ }
50
+ }