@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,162 @@
1
+ /**
2
+ * Tests for cerContent() plugin factory helpers:
3
+ * - CONTENT_STORE_KEY constant
4
+ * - resolveContentDir()
5
+ * - loadContentStore()
6
+ *
7
+ * The Vite plugin hooks (buildStart, configureServer, closeBundle) are exercised
8
+ * by the e2e suite across all three build modes.
9
+ */
10
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
11
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'pathe'
14
+ import {
15
+ CONTENT_STORE_KEY,
16
+ resolveContentDir,
17
+ loadContentStore,
18
+ } from '../../../plugin/content/index.js'
19
+
20
+ let tmpDir: string
21
+ let contentDir: string
22
+
23
+ beforeAll(() => {
24
+ tmpDir = mkdtempSync(join(tmpdir(), 'cer-content-loader-'))
25
+ contentDir = join(tmpDir, 'content')
26
+ mkdirSync(contentDir, { recursive: true })
27
+ mkdirSync(join(contentDir, 'blog'), { recursive: true })
28
+ mkdirSync(join(contentDir, 'data'), { recursive: true })
29
+
30
+ writeFileSync(join(contentDir, 'index.md'), '---\ntitle: Home\ndescription: Welcome\n---\n\n# Home\n')
31
+ writeFileSync(join(contentDir, 'about.md'), '---\ntitle: About\ndate: 2026-04-01\n---\n\n# About\n')
32
+ writeFileSync(
33
+ join(contentDir, 'blog', '2026-04-03-hello.md'),
34
+ '---\ntitle: Hello World\ndate: 2026-04-03\ndraft: false\n---\n\nHello post!',
35
+ )
36
+ writeFileSync(
37
+ join(contentDir, 'blog', 'secret.md'),
38
+ '---\ntitle: Secret Draft\ndraft: true\n---\n\nSecret!',
39
+ )
40
+ writeFileSync(join(contentDir, 'data', 'products.json'), JSON.stringify([{ id: 1 }]))
41
+ })
42
+
43
+ afterAll(() => {
44
+ rmSync(tmpDir, { recursive: true, force: true })
45
+ })
46
+
47
+ // ─── CONTENT_STORE_KEY ────────────────────────────────────────────────────────
48
+
49
+ describe('CONTENT_STORE_KEY', () => {
50
+ it('equals __CER_CONTENT_STORE__', () => {
51
+ expect(CONTENT_STORE_KEY).toBe('__CER_CONTENT_STORE__')
52
+ })
53
+ })
54
+
55
+ // ─── resolveContentDir ────────────────────────────────────────────────────────
56
+
57
+ describe('resolveContentDir', () => {
58
+ it('uses "content" when no config is supplied', () => {
59
+ expect(resolveContentDir('/root')).toBe('/root/content')
60
+ })
61
+
62
+ it('uses "content" when config.dir is undefined', () => {
63
+ expect(resolveContentDir('/root', {})).toBe('/root/content')
64
+ })
65
+
66
+ it('uses the configured dir when provided', () => {
67
+ expect(resolveContentDir('/root', { dir: 'docs' })).toBe('/root/docs')
68
+ })
69
+
70
+ it('resolves relative to the project root, not the app source dir', () => {
71
+ // content/ is a sibling of app/, not a child of it
72
+ expect(resolveContentDir('/workspace/my-project', { dir: 'posts' })).toBe(
73
+ '/workspace/my-project/posts',
74
+ )
75
+ })
76
+ })
77
+
78
+ // ─── loadContentStore ─────────────────────────────────────────────────────────
79
+
80
+ describe('loadContentStore — nonexistent dir', () => {
81
+ it('returns empty array when contentDir does not exist', async () => {
82
+ const items = await loadContentStore('/path/does/not/exist', false, false)
83
+ expect(items).toEqual([])
84
+ })
85
+ })
86
+
87
+ describe('loadContentStore — dev mode (isProduction=false)', () => {
88
+ it('loads all files including drafts', async () => {
89
+ const items = await loadContentStore(contentDir, false, false)
90
+ const paths = items.map((i) => i._path).sort()
91
+ // Root, about, blog/hello, blog/secret, data/products
92
+ expect(paths).toContain('/')
93
+ expect(paths).toContain('/about')
94
+ expect(paths).toContain('/blog/hello')
95
+ expect(paths).toContain('/blog/secret')
96
+ expect(paths).toContain('/data/products')
97
+ })
98
+
99
+ it('includes draft items', async () => {
100
+ const items = await loadContentStore(contentDir, false, false)
101
+ const secret = items.find((i) => i._path === '/blog/secret')
102
+ expect(secret).toBeDefined()
103
+ expect(secret?.draft).toBe(true)
104
+ })
105
+
106
+ it('strips date prefix from slug', async () => {
107
+ const items = await loadContentStore(contentDir, false, false)
108
+ expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
109
+ expect(items.find((i) => i._path === '/blog/2026-04-03-hello')).toBeUndefined()
110
+ })
111
+
112
+ it('each item has required ContentItem fields', async () => {
113
+ const items = await loadContentStore(contentDir, false, false)
114
+ for (const item of items) {
115
+ expect(typeof item._path).toBe('string')
116
+ expect(typeof item._file).toBe('string')
117
+ expect(item._type === 'markdown' || item._type === 'json').toBe(true)
118
+ expect(typeof item.body).toBe('string')
119
+ expect(Array.isArray(item.toc)).toBe(true)
120
+ }
121
+ })
122
+
123
+ it('normalises date fields to strings (not Date objects)', async () => {
124
+ const items = await loadContentStore(contentDir, false, false)
125
+ const about = items.find((i) => i._path === '/about')
126
+ expect(about).toBeDefined()
127
+ expect(typeof about?.date).toBe('string')
128
+ expect(about?.date as string).toMatch(/^\d{4}-\d{2}-\d{2}$/)
129
+ })
130
+ })
131
+
132
+ describe('loadContentStore — production mode (isProduction=true, isDraft=false)', () => {
133
+ it('excludes draft items', async () => {
134
+ const items = await loadContentStore(contentDir, false, true)
135
+ const secret = items.find((i) => i._path === '/blog/secret')
136
+ expect(secret).toBeUndefined()
137
+ })
138
+
139
+ it('includes non-draft items', async () => {
140
+ const items = await loadContentStore(contentDir, false, true)
141
+ expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
142
+ expect(items.find((i) => i._path === '/about')).toBeDefined()
143
+ })
144
+ })
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
+ describe('loadContentStore — JSON files', () => {
155
+ it('includes JSON files with _type json', async () => {
156
+ const items = await loadContentStore(contentDir, false, false)
157
+ const products = items.find((i) => i._path === '/data/products')
158
+ expect(products).toBeDefined()
159
+ expect(products?._type).toBe('json')
160
+ expect(JSON.parse(products?.body ?? '[]')).toEqual([{ id: 1 }])
161
+ })
162
+ })
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'pathe'
5
+ import { parseContentFile } from '../../../plugin/content/parser.js'
6
+ import type { ContentFile } from '../../../plugin/content/scanner.js'
7
+
8
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
9
+
10
+ let tmpDir: string
11
+ let contentDir: string
12
+
13
+ beforeAll(() => {
14
+ tmpDir = mkdtempSync(join(tmpdir(), 'cer-content-parser-'))
15
+ contentDir = join(tmpDir, 'content')
16
+ mkdirSync(contentDir, { recursive: true })
17
+ mkdirSync(join(contentDir, 'blog'), { recursive: true })
18
+ mkdirSync(join(contentDir, 'data'), { recursive: true })
19
+
20
+ writeFileSync(join(contentDir, 'index.md'), `---
21
+ title: Home
22
+ description: Welcome home
23
+ ---
24
+
25
+ # Home
26
+
27
+ Body content.
28
+ `)
29
+
30
+ writeFileSync(join(contentDir, 'about.md'), `---
31
+ title: About
32
+ date: 2026-04-01
33
+ draft: false
34
+ tags: [web]
35
+ ---
36
+
37
+ # About
38
+
39
+ ## Section One
40
+
41
+ Content here.
42
+ `)
43
+
44
+ writeFileSync(join(contentDir, 'blog', 'hello.md'), `---
45
+ title: Hello World
46
+ description: My first post
47
+ date: 2026-04-03
48
+ ---
49
+
50
+ Intro paragraph.
51
+
52
+ <!-- more -->
53
+
54
+ Rest of the body.
55
+ `)
56
+
57
+ writeFileSync(join(contentDir, 'blog', 'no-excerpt.md'), `---
58
+ title: No Excerpt
59
+ ---
60
+
61
+ Just content, no more marker.
62
+ `)
63
+
64
+ writeFileSync(join(contentDir, 'data', 'products.json'), JSON.stringify([{ id: 1, name: 'Widget' }]))
65
+ })
66
+
67
+ function makeFile(filePath: string, ext: 'md' | 'json'): ContentFile {
68
+ return { filePath, ext }
69
+ }
70
+
71
+ // ─── Markdown parsing ─────────────────────────────────────────────────────────
72
+
73
+ describe('parseContentFile — Markdown', () => {
74
+ it('returns correct _path for index.md', () => {
75
+ const item = parseContentFile(makeFile(join(contentDir, 'index.md'), 'md'), contentDir)
76
+ expect(item._path).toBe('/')
77
+ })
78
+
79
+ it('sets _type to markdown', () => {
80
+ const item = parseContentFile(makeFile(join(contentDir, 'index.md'), 'md'), contentDir)
81
+ expect(item._type).toBe('markdown')
82
+ })
83
+
84
+ it('sets _file relative to contentDir', () => {
85
+ const item = parseContentFile(makeFile(join(contentDir, 'about.md'), 'md'), contentDir)
86
+ expect(item._file).toBe('about.md')
87
+ })
88
+
89
+ it('extracts frontmatter fields', () => {
90
+ const item = parseContentFile(makeFile(join(contentDir, 'about.md'), 'md'), contentDir)
91
+ expect(item.title).toBe('About')
92
+ // gray-matter parses YAML date strings as Date objects; verify date truthy and correct type
93
+ expect(item.date).toBeTruthy()
94
+ expect(item.draft).toBe(false)
95
+ expect(item.tags).toEqual(['web'])
96
+ })
97
+
98
+ it('renders Markdown body to HTML', () => {
99
+ const item = parseContentFile(makeFile(join(contentDir, 'index.md'), 'md'), contentDir)
100
+ expect(item.body).toContain('<h1')
101
+ expect(item.body).toContain('Home')
102
+ })
103
+
104
+ it('extracts headings into toc', () => {
105
+ const item = parseContentFile(makeFile(join(contentDir, 'about.md'), 'md'), contentDir)
106
+ expect(Array.isArray(item.toc)).toBe(true)
107
+ const h1 = item.toc.find((h) => h.depth === 1)
108
+ const h2 = item.toc.find((h) => h.depth === 2)
109
+ expect(h1).toBeDefined()
110
+ expect(h2).toBeDefined()
111
+ expect(h2?.text).toBe('Section One')
112
+ expect(h2?.id).toBe('section-one')
113
+ })
114
+
115
+ it('adds id attributes to headings in body HTML', () => {
116
+ const item = parseContentFile(makeFile(join(contentDir, 'about.md'), 'md'), contentDir)
117
+ expect(item.body).toContain('id="section-one"')
118
+ })
119
+
120
+ it('toc id matches heading id attribute in body', () => {
121
+ const item = parseContentFile(makeFile(join(contentDir, 'about.md'), 'md'), contentDir)
122
+ for (const h of item.toc) {
123
+ expect(item.body).toContain(`id="${h.id}"`)
124
+ }
125
+ })
126
+
127
+ it('sets excerpt when <!-- more --> marker is present', () => {
128
+ const item = parseContentFile(
129
+ makeFile(join(contentDir, 'blog', 'hello.md'), 'md'),
130
+ contentDir,
131
+ )
132
+ expect(item.excerpt).toBeDefined()
133
+ expect(item.excerpt).toContain('Intro paragraph')
134
+ expect(item.excerpt).not.toContain('Rest of the body')
135
+ })
136
+
137
+ it('excerpt is absent when no <!-- more --> marker', () => {
138
+ const item = parseContentFile(
139
+ makeFile(join(contentDir, 'blog', 'no-excerpt.md'), 'md'),
140
+ contentDir,
141
+ )
142
+ expect(item.excerpt).toBeUndefined()
143
+ })
144
+
145
+ it('body contains full content including text after <!-- more -->', () => {
146
+ const item = parseContentFile(
147
+ makeFile(join(contentDir, 'blog', 'hello.md'), 'md'),
148
+ contentDir,
149
+ )
150
+ expect(item.body).toContain('Intro paragraph')
151
+ expect(item.body).toContain('Rest of the body')
152
+ // The <!-- more --> marker itself must not appear in the rendered body HTML
153
+ expect(item.body).not.toContain('<!-- more -->')
154
+ })
155
+ })
156
+
157
+ // ─── JSON parsing ─────────────────────────────────────────────────────────────
158
+
159
+ describe('parseContentFile — JSON', () => {
160
+ it('sets _type to json', () => {
161
+ const item = parseContentFile(
162
+ makeFile(join(contentDir, 'data', 'products.json'), 'json'),
163
+ contentDir,
164
+ )
165
+ expect(item._type).toBe('json')
166
+ })
167
+
168
+ it('sets body to raw JSON string', () => {
169
+ const item = parseContentFile(
170
+ makeFile(join(contentDir, 'data', 'products.json'), 'json'),
171
+ contentDir,
172
+ )
173
+ expect(JSON.parse(item.body)).toEqual([{ id: 1, name: 'Widget' }])
174
+ })
175
+
176
+ it('sets toc to empty array', () => {
177
+ const item = parseContentFile(
178
+ makeFile(join(contentDir, 'data', 'products.json'), 'json'),
179
+ contentDir,
180
+ )
181
+ expect(item.toc).toEqual([])
182
+ })
183
+
184
+ it('sets correct _path for json file', () => {
185
+ const item = parseContentFile(
186
+ makeFile(join(contentDir, 'data', 'products.json'), 'json'),
187
+ contentDir,
188
+ )
189
+ expect(item._path).toBe('/data/products')
190
+ })
191
+
192
+ it('throws a descriptive error for invalid JSON', () => {
193
+ const badFile = join(contentDir, 'data', 'bad.json')
194
+ writeFileSync(badFile, '{not valid json}')
195
+ expect(() =>
196
+ parseContentFile(makeFile(badFile, 'json'), contentDir),
197
+ ).toThrow(/Invalid JSON/)
198
+ })
199
+ })
200
+
201
+ // ─── toContentMeta ────────────────────────────────────────────────────────────
202
+
203
+ describe('toContentMeta', () => {
204
+ it('strips _file, body, toc, excerpt from ContentItem', async () => {
205
+ const { toContentMeta } = await import('../../../plugin/content/parser.js')
206
+ const item = parseContentFile(
207
+ makeFile(join(contentDir, 'blog', 'hello.md'), 'md'),
208
+ contentDir,
209
+ )
210
+ const meta = toContentMeta(item)
211
+ expect('_file' in meta).toBe(false)
212
+ expect('body' in meta).toBe(false)
213
+ expect('toc' in meta).toBe(false)
214
+ expect('excerpt' in meta).toBe(false)
215
+ expect(meta._path).toBe('/blog/hello')
216
+ expect(meta.title).toBe('Hello World')
217
+ })
218
+ })
219
+
220
+ // ─── Date normalisation ───────────────────────────────────────────────────────
221
+
222
+ describe('parseContentFile — date normalisation', () => {
223
+ it('converts a gray-matter Date object to a YYYY-MM-DD string', () => {
224
+ // gray-matter parses `date: 2026-04-01` as a JS Date object.
225
+ // The parser must normalise it to a string so the in-memory server store and
226
+ // the client (after JSON round-trip) are consistent.
227
+ const item = parseContentFile(makeFile(join(contentDir, 'about.md'), 'md'), contentDir)
228
+ expect(typeof item.date).toBe('string')
229
+ expect(item.date as string).toMatch(/^\d{4}-\d{2}-\d{2}$/)
230
+ })
231
+
232
+ it('normalised date string is comparable with other ISO date strings', () => {
233
+ const item = parseContentFile(makeFile(join(contentDir, 'about.md'), 'md'), contentDir)
234
+ const date = item.date as string
235
+ // Verify that where-predicate comparisons work correctly post-normalisation
236
+ expect(date >= '2026-01-01').toBe(true)
237
+ expect(date < '2027-01-01').toBe(true)
238
+ })
239
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { fileToContentPath } from '../../../plugin/content/path-utils.js'
3
+
4
+ const DIR = '/project/app/content'
5
+
6
+ // ─── fileToContentPath ────────────────────────────────────────────────────────
7
+
8
+ describe('fileToContentPath', () => {
9
+ it('maps index.md to /', () => {
10
+ expect(fileToContentPath(`${DIR}/index.md`, DIR)).toBe('/')
11
+ })
12
+
13
+ it('maps about.md to /about', () => {
14
+ expect(fileToContentPath(`${DIR}/about.md`, DIR)).toBe('/about')
15
+ })
16
+
17
+ it('maps blog/index.md to /blog', () => {
18
+ expect(fileToContentPath(`${DIR}/blog/index.md`, DIR)).toBe('/blog')
19
+ })
20
+
21
+ it('maps blog/2026-04-03-hello.md to /blog/hello (strips date prefix)', () => {
22
+ expect(fileToContentPath(`${DIR}/blog/2026-04-03-hello.md`, DIR)).toBe('/blog/hello')
23
+ })
24
+
25
+ it('maps docs/getting-started.md to /docs/getting-started', () => {
26
+ expect(fileToContentPath(`${DIR}/docs/getting-started.md`, DIR)).toBe('/docs/getting-started')
27
+ })
28
+
29
+ it('maps data/products.json to /data/products', () => {
30
+ expect(fileToContentPath(`${DIR}/data/products.json`, DIR)).toBe('/data/products')
31
+ })
32
+
33
+ it('maps deeply nested file', () => {
34
+ expect(fileToContentPath(`${DIR}/a/b/c.md`, DIR)).toBe('/a/b/c')
35
+ })
36
+
37
+ it('does not strip date from non-last segments', () => {
38
+ expect(fileToContentPath(`${DIR}/2026-01-01-blog/hello.md`, DIR)).toBe('/2026-01-01-blog/hello')
39
+ })
40
+
41
+ it('handles .json extension', () => {
42
+ expect(fileToContentPath(`${DIR}/products.json`, DIR)).toBe('/products')
43
+ })
44
+
45
+ it('maps index-only path at root correctly', () => {
46
+ expect(fileToContentPath(`${DIR}/index.md`, DIR)).toBe('/')
47
+ })
48
+
49
+ it('strips date from root-level dated file', () => {
50
+ expect(fileToContentPath(`${DIR}/2026-04-03-hello.md`, DIR)).toBe('/hello')
51
+ })
52
+ })
53
+
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildSearchIndex } from '../../../plugin/content/search.js'
3
+ import MiniSearch from 'minisearch'
4
+ import type { ContentItem } from '../../../types/content.js'
5
+
6
+ const items: ContentItem[] = [
7
+ {
8
+ _path: '/blog/hello',
9
+ _file: 'blog/hello.md',
10
+ _type: 'markdown',
11
+ title: 'Hello World',
12
+ description: 'My first post about web components',
13
+ body: '<p>body</p>',
14
+ toc: [],
15
+ },
16
+ {
17
+ _path: '/docs/getting-started',
18
+ _file: 'docs/getting-started.md',
19
+ _type: 'markdown',
20
+ title: 'Getting Started',
21
+ description: 'How to get started quickly',
22
+ body: '<p>body</p>',
23
+ toc: [],
24
+ },
25
+ {
26
+ _path: '/about',
27
+ _file: 'about.md',
28
+ _type: 'markdown',
29
+ // No title — should be excluded from index
30
+ body: '<p>About</p>',
31
+ toc: [],
32
+ },
33
+ ]
34
+
35
+ describe('buildSearchIndex', () => {
36
+ it('returns a JSON string', () => {
37
+ const result = buildSearchIndex(items)
38
+ expect(typeof result).toBe('string')
39
+ expect(() => JSON.parse(result)).not.toThrow()
40
+ })
41
+
42
+ it('serialised index can be loaded by MiniSearch', () => {
43
+ const serialised = buildSearchIndex(items)
44
+ expect(() =>
45
+ MiniSearch.loadJSON(serialised, {
46
+ fields: ['title', 'description'],
47
+ storeFields: ['_path', 'title', 'description'],
48
+ idField: '_path',
49
+ }),
50
+ ).not.toThrow()
51
+ })
52
+
53
+ it('search finds items by title', () => {
54
+ const serialised = buildSearchIndex(items)
55
+ const index = MiniSearch.loadJSON(serialised, {
56
+ fields: ['title', 'description'],
57
+ storeFields: ['_path', 'title', 'description'],
58
+ idField: '_path',
59
+ })
60
+ const results = index.search('Hello')
61
+ expect(results.some((r) => r._path === '/blog/hello')).toBe(true)
62
+ })
63
+
64
+ it('search finds items by description', () => {
65
+ const serialised = buildSearchIndex(items)
66
+ const index = MiniSearch.loadJSON(serialised, {
67
+ fields: ['title', 'description'],
68
+ storeFields: ['_path', 'title', 'description'],
69
+ idField: '_path',
70
+ })
71
+ const results = index.search('quickly')
72
+ expect(results.some((r) => r._path === '/docs/getting-started')).toBe(true)
73
+ })
74
+
75
+ it('items without title are excluded from index', () => {
76
+ const serialised = buildSearchIndex(items)
77
+ const index = MiniSearch.loadJSON(serialised, {
78
+ fields: ['title', 'description'],
79
+ storeFields: ['_path', 'title', 'description'],
80
+ idField: '_path',
81
+ })
82
+ // Search for something that only appears in /about body (not indexed)
83
+ const results = index.search('About')
84
+ expect(results.some((r) => r._path === '/about')).toBe(false)
85
+ })
86
+
87
+ it('stored fields include _path, title, description', () => {
88
+ const serialised = buildSearchIndex(items)
89
+ const index = MiniSearch.loadJSON(serialised, {
90
+ fields: ['title', 'description'],
91
+ storeFields: ['_path', 'title', 'description'],
92
+ idField: '_path',
93
+ })
94
+ const results = index.search('web')
95
+ expect(results.length).toBeGreaterThan(0)
96
+ const r = results[0] as { _path: string; title: string; description?: string }
97
+ expect(r._path).toBeDefined()
98
+ expect(r.title).toBeDefined()
99
+ })
100
+
101
+ it('returns empty index string when no items have titles', () => {
102
+ const noTitleItems: ContentItem[] = [
103
+ {
104
+ _path: '/about',
105
+ _file: 'about.md',
106
+ _type: 'markdown',
107
+ body: '<p>About</p>',
108
+ toc: [],
109
+ },
110
+ ]
111
+ const serialised = buildSearchIndex(noTitleItems)
112
+ const index = MiniSearch.loadJSON(serialised, {
113
+ fields: ['title', 'description'],
114
+ storeFields: ['_path', 'title', 'description'],
115
+ idField: '_path',
116
+ })
117
+ expect(index.documentCount).toBe(0)
118
+ })
119
+ })
@@ -353,3 +353,42 @@ describe('writeAutoImportDts', () => {
353
353
  expect(writeFileSync).toHaveBeenCalledTimes(2)
354
354
  })
355
355
  })
356
+
357
+ // ─── Content layer globals ────────────────────────────────────────────────────
358
+
359
+ describe('generateAutoImportDts — content layer globals', () => {
360
+ it('declares queryContent as a global', async () => {
361
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
362
+ expect(dts).toContain('queryContent')
363
+ })
364
+
365
+ it('declares useContentSearch as a global', async () => {
366
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
367
+ expect(dts).toContain('useContentSearch')
368
+ })
369
+
370
+ it('declares ContentItem type in global scope', async () => {
371
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
372
+ expect(dts).toContain('type ContentItem')
373
+ })
374
+
375
+ it('declares ContentMeta type in global scope', async () => {
376
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
377
+ expect(dts).toContain('type ContentMeta')
378
+ })
379
+
380
+ it('declares ContentHeading type in global scope', async () => {
381
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
382
+ expect(dts).toContain('type ContentHeading')
383
+ })
384
+
385
+ it('declares ContentSearchResult type in global scope', async () => {
386
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
387
+ expect(dts).toContain('type ContentSearchResult')
388
+ })
389
+
390
+ it('declares __CER_APP_CONFIG__ global var', async () => {
391
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
392
+ expect(dts).toContain('__CER_APP_CONFIG__')
393
+ })
394
+ })
@@ -346,6 +346,20 @@ describe('autoImportTransform — framework composable injection', () => {
346
346
  const count = result.split(`from ${FRAMEWORK_PKG}`).length - 1
347
347
  expect(count).toBe(1)
348
348
  })
349
+
350
+ it('injects queryContent import when queryContent is used', () => {
351
+ const code = "component('page-blog', () => { queryContent('/blog').find().then(posts => {}); return html`` })"
352
+ const result = autoImportTransform(code, '/project/app/pages/blog.ts', opts)!
353
+ expect(result).toContain('queryContent')
354
+ expect(result).toContain(`from ${FRAMEWORK_PKG}`)
355
+ })
356
+
357
+ it('injects useContentSearch import when useContentSearch is used', () => {
358
+ const code = "component('site-search', () => { const { query, results } = useContentSearch(); return html`` })"
359
+ const result = autoImportTransform(code, '/project/app/components/site-search.ts', opts)!
360
+ expect(result).toContain('useContentSearch')
361
+ expect(result).toContain(`from ${FRAMEWORK_PKG}`)
362
+ })
349
363
  })
350
364
 
351
365
  describe('autoImportTransform — server/middleware/ directory', () => {