@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,228 @@
1
+ /**
2
+ * Content layer e2e tests — exercises queryContent() and useContentSearch().
3
+ *
4
+ * Pages under test:
5
+ * /content-index — queryContent().find() (all content)
6
+ * /content-blog — queryContent('/blog').find() (blog prefix, draft exclusion)
7
+ * /content-doc — queryContent('/docs/getting-started').first() (body + TOC)
8
+ * /content-search — useContentSearch() (MiniSearch, client-side)
9
+ */
10
+
11
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
12
+
13
+ // ─── /content-index ───────────────────────────────────────────────────────────
14
+
15
+ describe('Content index — queryContent().find()', () => {
16
+ if (mode !== 'spa') {
17
+ it('pre-renders content items in initial HTML (SSR/SSG)', () => {
18
+ cy.request('/content-index').then((response) => {
19
+ expect(response.body).to.include('Welcome')
20
+ expect(response.body).to.include('Hello World')
21
+ })
22
+ })
23
+ }
24
+
25
+ it('renders at least 3 content items after hydration', () => {
26
+ cy.visit('/content-index')
27
+ cy.get('[data-cy=content-list]').should('exist')
28
+ cy.get('[data-cy=content-item]', { timeout: 8000 }).should('have.length.at.least', 3)
29
+ })
30
+
31
+ it('shows total item count', () => {
32
+ cy.visit('/content-index')
33
+ // Use .should() so Cypress retries until the text updates (handles
34
+ // the case where the client-side fetch updates the count after hydration).
35
+ cy.get('[data-cy=content-total]', { timeout: 8000 })
36
+ .invoke('text')
37
+ .should((text) => {
38
+ const n = parseInt(text.replace(/\D/g, ''), 10)
39
+ expect(n).to.be.at.least(3)
40
+ })
41
+ })
42
+
43
+ it('each item has a data-path attribute', () => {
44
+ cy.visit('/content-index')
45
+ cy.get('[data-cy=content-item]', { timeout: 8000 }).first().should('have.attr', 'data-path')
46
+ })
47
+
48
+ it('root content item appears with title "Welcome"', () => {
49
+ cy.visit('/content-index')
50
+ cy.get('[data-cy=content-item][data-path="/"]', { timeout: 8000 }).should('exist')
51
+ cy.get('[data-cy=content-item][data-path="/"] [data-cy=content-item-title]').should('contain', 'Welcome')
52
+ })
53
+ })
54
+
55
+ // ─── /content-blog ────────────────────────────────────────────────────────────
56
+
57
+ describe('Content blog listing — queryContent("/blog").find()', () => {
58
+ if (mode !== 'spa') {
59
+ it('pre-renders blog posts in initial HTML (SSR/SSG)', () => {
60
+ cy.request('/content-blog').then((response) => {
61
+ expect(response.body).to.include('Hello World')
62
+ })
63
+ })
64
+
65
+ it('does not pre-render draft post in initial HTML (SSR/SSG)', () => {
66
+ cy.request('/content-blog').then((response) => {
67
+ expect(response.body).not.to.include('Draft Post')
68
+ })
69
+ })
70
+ }
71
+
72
+ it('renders blog posts after hydration', () => {
73
+ cy.visit('/content-blog')
74
+ cy.get('[data-cy=content-blog-list]').should('exist')
75
+ cy.get('[data-cy=content-blog-item]', { timeout: 8000 }).should('have.length.at.least', 1)
76
+ })
77
+
78
+ it('shows Hello World post', () => {
79
+ cy.visit('/content-blog')
80
+ cy.get('[data-cy=content-blog-title]', { timeout: 8000 }).first().should('contain', 'Hello World')
81
+ })
82
+
83
+ it('does not show draft post', () => {
84
+ cy.visit('/content-blog')
85
+ cy.get('[data-cy=content-blog-list]', { timeout: 8000 })
86
+ cy.get('[data-cy=content-blog-item][data-path="/blog/2026-04-02-draft"]').should('not.exist')
87
+ cy.get('[data-cy=content-blog-title]').each(($el) => {
88
+ expect($el.text()).not.to.include('Draft Post')
89
+ })
90
+ })
91
+
92
+ it('blog items have data-path starting with /blog', () => {
93
+ cy.visit('/content-blog')
94
+ cy.get('[data-cy=content-blog-item]', { timeout: 8000 }).each(($el) => {
95
+ const path = $el.attr('data-path') ?? ''
96
+ expect(path).to.match(/^\/blog/)
97
+ })
98
+ })
99
+ })
100
+
101
+ // ─── /content-doc ─────────────────────────────────────────────────────────────
102
+
103
+ describe('Content doc — queryContent("/docs/getting-started").first()', () => {
104
+ if (mode !== 'spa') {
105
+ it('pre-renders doc title in initial HTML (SSR/SSG)', () => {
106
+ cy.request('/content-doc').then((response) => {
107
+ expect(response.body).to.include('Getting Started')
108
+ })
109
+ })
110
+
111
+ it('pre-renders heading "Installation" in initial HTML (SSR/SSG)', () => {
112
+ cy.request('/content-doc').then((response) => {
113
+ expect(response.body).to.include('Installation')
114
+ })
115
+ })
116
+ }
117
+
118
+ it('renders doc title after hydration', () => {
119
+ cy.visit('/content-doc')
120
+ cy.get('[data-cy=content-doc-title]', { timeout: 8000 }).should('contain', 'Getting Started')
121
+ })
122
+
123
+ it('renders doc description', () => {
124
+ cy.visit('/content-doc')
125
+ cy.get('[data-cy=content-doc-desc]', { timeout: 8000 }).should('contain', 'install')
126
+ })
127
+
128
+ it('renders TOC with at least 2 entries', () => {
129
+ cy.visit('/content-doc')
130
+ cy.get('[data-cy=content-doc-toc-item]', { timeout: 8000 }).should('have.length.at.least', 2)
131
+ })
132
+
133
+ it('TOC links have href with # anchor', () => {
134
+ cy.visit('/content-doc')
135
+ cy.get('[data-cy=content-doc-toc-link]', { timeout: 8000 }).each(($el) => {
136
+ const href = $el.attr('href') ?? ''
137
+ expect(href).to.match(/^#/)
138
+ })
139
+ })
140
+
141
+ it('TOC has Installation entry', () => {
142
+ cy.visit('/content-doc')
143
+ cy.get('[data-cy=content-doc-toc-item]', { timeout: 8000 }).contains('Installation')
144
+ })
145
+
146
+ it('body contains rendered HTML with heading ids', () => {
147
+ cy.visit('/content-doc')
148
+ cy.get('[data-cy=content-doc-body]', { timeout: 8000 }).first().within(() => {
149
+ cy.get('h2[id]').should('have.length.at.least', 1)
150
+ })
151
+ })
152
+
153
+ it('body contains "Installation" heading', () => {
154
+ cy.visit('/content-doc')
155
+ cy.get('[data-cy=content-doc-body]', { timeout: 8000 }).contains('h2', 'Installation')
156
+ })
157
+ })
158
+
159
+ // ─── /content-search ──────────────────────────────────────────────────────────
160
+
161
+ // Helper: set the search input value and fire the input event.
162
+ // We navigate directly to the shadow root of page-content-search to get
163
+ // exactly one element (the JS-hydrated input with the @input listener).
164
+ function setSearchQuery(value: string) {
165
+ cy.get('cer-layout-view').shadow().find('page-content-search').shadow()
166
+ .find('[data-cy=content-search-input]')
167
+ .invoke('val', value)
168
+ .trigger('input', { force: true })
169
+ }
170
+
171
+ describe('Content search — useContentSearch()', () => {
172
+ // Before each search test, intercept the pre-built search index so we can
173
+ // wait for the component's useOnConnected pre-warm to complete (signals full
174
+ // hydration). useContentSearch loads /_content/search-index.json (not the
175
+ // manifest) via MiniSearch.loadJSON.
176
+ beforeEach(() => {
177
+ cy.intercept('GET', '/_content/search-index.json').as('searchIndex')
178
+ })
179
+
180
+ it('renders search input', () => {
181
+ cy.visit('/content-search')
182
+ cy.get('[data-cy=content-search-input]').should('exist')
183
+ })
184
+
185
+ it('shows no results before typing 2 chars', () => {
186
+ cy.visit('/content-search')
187
+ cy.wait('@searchIndex')
188
+ setSearchQuery('H')
189
+ cy.get('[data-cy=content-search-result]').should('not.exist')
190
+ })
191
+
192
+ it('shows results after typing a 2-char query', () => {
193
+ cy.visit('/content-search')
194
+ cy.wait('@searchIndex')
195
+ setSearchQuery('He')
196
+ cy.get('[data-cy=content-search-result]', { timeout: 8000 }).should('have.length.at.least', 1)
197
+ })
198
+
199
+ it('searching "Hello" finds Hello World post', () => {
200
+ cy.visit('/content-search')
201
+ cy.wait('@searchIndex')
202
+ setSearchQuery('Hello')
203
+ cy.get('[data-cy=content-search-result]', { timeout: 8000 }).should('contain', 'Hello World')
204
+ })
205
+
206
+ it('searching "Getting" finds Getting Started doc', () => {
207
+ cy.visit('/content-search')
208
+ cy.wait('@searchIndex')
209
+ setSearchQuery('Getting')
210
+ cy.get('[data-cy=content-search-result]', { timeout: 8000 }).should('contain', 'Getting Started')
211
+ })
212
+
213
+ it('result items have data-path attribute', () => {
214
+ cy.visit('/content-search')
215
+ cy.wait('@searchIndex')
216
+ setSearchQuery('Hello')
217
+ cy.get('[data-cy=content-search-result]', { timeout: 8000 }).first().should('have.attr', 'data-path')
218
+ })
219
+
220
+ it('clearing query clears results', () => {
221
+ cy.visit('/content-search')
222
+ cy.wait('@searchIndex')
223
+ setSearchQuery('Hello')
224
+ cy.get('[data-cy=content-search-result]', { timeout: 8000 }).should('have.length.at.least', 1)
225
+ setSearchQuery('')
226
+ cy.get('[data-cy=content-search-result]').should('not.exist')
227
+ })
228
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Content blog listing page — exercises `queryContent('/blog').find()`.
3
+ * Filters to blog prefix, verifies draft exclusion, and renders titles.
4
+ * Route: /content-blog
5
+ */
6
+
7
+ component('page-content-blog', () => {
8
+ useHead({ title: 'Content Blog — Kitchen Sink' })
9
+
10
+ const ssrData = usePageData<{ posts: ContentMeta[] }>()
11
+ const posts = ref<ContentMeta[]>(ssrData?.posts ?? [])
12
+
13
+ useOnConnected(async () => {
14
+ if (ssrData) return // already hydrated
15
+ posts.value = await queryContent('/blog').find()
16
+ })
17
+
18
+ return html`
19
+ <div>
20
+ <h1 data-cy="content-blog-heading">Blog Posts</h1>
21
+ <ul data-cy="content-blog-list">
22
+ ${posts.value.map(post => html`
23
+ <li data-cy="content-blog-item" data-path="${post._path}">
24
+ <strong data-cy="content-blog-title">${post.title ?? post._path}</strong>
25
+ ${post.description ? html`<p data-cy="content-blog-desc">${post.description}</p>` : ''}
26
+ </li>
27
+ `)}
28
+ </ul>
29
+ ${posts.value.length === 0 ? html`<p data-cy="content-blog-empty">No posts found.</p>` : ''}
30
+ </div>
31
+ `
32
+ })
33
+
34
+ export const loader = async () => {
35
+ const posts = await queryContent('/blog').find()
36
+ return { posts }
37
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Content doc page — exercises `queryContent('/docs/getting-started').first()`.
3
+ * Verifies full body, TOC headings with id attributes, and excerpt.
4
+ * Route: /content-doc
5
+ */
6
+
7
+ component('page-content-doc', () => {
8
+ useHead({ title: 'Content Doc — Kitchen Sink' })
9
+
10
+ const ssrData = usePageData<{ doc: ContentItem | null }>()
11
+ const doc = ref<ContentItem | null>(ssrData?.doc ?? null)
12
+
13
+ useOnConnected(async () => {
14
+ if (ssrData) return // already hydrated
15
+ doc.value = await queryContent('/docs/getting-started').first()
16
+ })
17
+
18
+ return html`
19
+ <div>
20
+ <h1 data-cy="content-doc-heading">Doc Viewer</h1>
21
+ ${doc.value ? html`
22
+ <h2 data-cy="content-doc-title">${doc.value.title}</h2>
23
+ <p data-cy="content-doc-desc">${doc.value.description}</p>
24
+ <nav data-cy="content-doc-toc">
25
+ <ul>
26
+ ${(doc.value.toc ?? []).map(h => html`
27
+ <li data-cy="content-doc-toc-item" data-depth="${h.depth}">
28
+ <a href="#${h.id}" data-cy="content-doc-toc-link">${h.text}</a>
29
+ </li>
30
+ `)}
31
+ </ul>
32
+ </nav>
33
+ <div data-cy="content-doc-body">${unsafeHTML(doc.value.body)}</div>
34
+ ` : html`<p data-cy="content-doc-missing">Document not found.</p>`}
35
+ </div>
36
+ `
37
+ })
38
+
39
+ export const loader = async () => {
40
+ const doc = await queryContent('/docs/getting-started').first()
41
+ return { doc }
42
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Content index page — exercises `queryContent().find()` with a page loader.
3
+ * Route: /content-index
4
+ */
5
+
6
+ component('page-content-index', () => {
7
+ useHead({ title: 'Content Index — Kitchen Sink' })
8
+
9
+ const ssrData = usePageData<{ items: ContentMeta[]; total: number }>()
10
+ const items = ref<ContentMeta[]>(ssrData?.items ?? [])
11
+ const total = ref<number>(ssrData?.total ?? 0)
12
+
13
+ useOnConnected(async () => {
14
+ if (ssrData) return // already hydrated — skip client fetch
15
+ const all = await queryContent().find()
16
+ items.value = all
17
+ total.value = all.length
18
+ })
19
+
20
+ return html`
21
+ <div>
22
+ <h1 data-cy="content-index-heading">All Content</h1>
23
+ <p data-cy="content-total">Total items: <strong>${total.value}</strong></p>
24
+ <ul data-cy="content-list">
25
+ ${items.value.map(item => html`
26
+ <li data-cy="content-item" data-path="${item._path}">
27
+ <strong data-cy="content-item-title">${item.title ?? item._path}</strong>
28
+ ${item.description ? html`<span data-cy="content-item-desc"> — ${item.description}</span>` : ''}
29
+ </li>
30
+ `)}
31
+ </ul>
32
+ </div>
33
+ `
34
+ })
35
+
36
+ export const loader = async () => {
37
+ const all = await queryContent().find()
38
+ return { items: all, total: all.length }
39
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Content search page — exercises `useContentSearch()`.
3
+ * Typing 2+ characters into the search box triggers MiniSearch results.
4
+ * Route: /content-search
5
+ */
6
+
7
+ component('page-content-search', () => {
8
+ useHead({ title: 'Content Search — Kitchen Sink' })
9
+
10
+ const { query, results } = useContentSearch()
11
+
12
+ return html`
13
+ <div>
14
+ <h1 data-cy="content-search-heading">Content Search</h1>
15
+ <input
16
+ type="search"
17
+ placeholder="Search content…"
18
+ data-cy="content-search-input"
19
+ .value="${query.value}"
20
+ @input="${(e: Event) => { query.value = (e.target as HTMLInputElement).value }}"
21
+ />
22
+ <ul data-cy="content-search-results">
23
+ ${results.value.map(r => html`
24
+ <li data-cy="content-search-result" data-path="${r._path}">
25
+ <a href="${r._path}" data-cy="content-search-link">${r.title ?? r._path}</a>
26
+ ${r.description ? html`<p data-cy="content-search-desc">${r.description}</p>` : ''}
27
+ </li>
28
+ `)}
29
+ </ul>
30
+ ${results.value.length === 0 && query.value.length >= 2 ? html`
31
+ <p data-cy="content-search-empty">No results for "${query.value}".</p>
32
+ ` : ''}
33
+ </div>
34
+ `
35
+ })
@@ -5,6 +5,7 @@ const LOG_FILE = '/tmp/cer-hooks-test.log'
5
5
 
6
6
  // Kitchen sink configuration — mode is overridden by --mode CLI flag
7
7
  export default {
8
+ content: {},
8
9
  ssg: { routes: 'auto', concurrency: 2 },
9
10
  autoImports: { runtime: true, components: true, composables: true },
10
11
  runtimeConfig: {
@@ -0,0 +1,26 @@
1
+ ---
2
+ title: Hello World
3
+ description: The first content blog post — tests excerpt and TOC extraction.
4
+ date: 2026-04-01
5
+ draft: false
6
+ ---
7
+
8
+ # Hello World
9
+
10
+ Welcome to the content layer.
11
+
12
+ <!-- more -->
13
+
14
+ ## Introduction
15
+
16
+ This post exercises the `<!-- more -->` excerpt boundary.
17
+
18
+ ## Features
19
+
20
+ - Parsed frontmatter
21
+ - TOC extraction
22
+ - Excerpt support
23
+
24
+ ## Conclusion
25
+
26
+ That's all for now.
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: Draft Post
3
+ description: This post is a draft and should be excluded from production builds.
4
+ date: 2026-04-02
5
+ draft: true
6
+ ---
7
+
8
+ # Draft Post
9
+
10
+ This post should never appear in production because `draft: true`.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Blog
3
+ description: All blog posts — used as a listing page in content tests.
4
+ ---
5
+
6
+ # Blog
7
+
8
+ Browse all posts below.
@@ -0,0 +1,46 @@
1
+ ---
2
+ title: Getting Started
3
+ description: How to install and configure the content layer — tests TOC depth and heading ids.
4
+ ---
5
+
6
+ # Getting Started
7
+
8
+ This guide walks you through installation and basic usage.
9
+
10
+ ## Installation
11
+
12
+ Run the following command:
13
+
14
+ ```sh
15
+ npm install @jasonshimmy/vite-plugin-cer-app
16
+ ```
17
+
18
+ ## Configuration
19
+
20
+ Add the `content` option to `cer.config.ts`:
21
+
22
+ ```ts
23
+ export default defineConfig({
24
+ content: {},
25
+ })
26
+ ```
27
+
28
+ ### Options
29
+
30
+ | Option | Default | Description |
31
+ |---|---|---|
32
+ | `dir` | `'content'` | Content directory relative to `app/`. |
33
+ | `drafts` | `false` | Include draft items in production builds. |
34
+
35
+ ## Usage
36
+
37
+ Use `queryContent()` in any page or loader:
38
+
39
+ ```ts
40
+ const posts = await queryContent('/blog').find()
41
+ ```
42
+
43
+ ## Next Steps
44
+
45
+ - Read the [full docs](https://example.com)
46
+ - Join the community
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: Welcome
3
+ description: Kitchen sink home content page — verifies root content query.
4
+ ---
5
+
6
+ # Welcome
7
+
8
+ This is the **root content** item (`_path: '/'`).
9
+
10
+ <!-- more -->
11
+
12
+ Below the excerpt boundary.
13
+
14
+ - Item A
15
+ - Item B
16
+ - Item C
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -88,21 +88,24 @@
88
88
  "dependencies": {
89
89
  "commander": "^14.0.3",
90
90
  "fast-glob": "^3.3.3",
91
+ "gray-matter": "^4.0.3",
91
92
  "magic-string": "^0.30.21",
93
+ "marked": "^18.0.0",
94
+ "minisearch": "^7.2.0",
92
95
  "pathe": "^2.0.3"
93
96
  },
94
97
  "devDependencies": {
95
- "@jasonshimmy/custom-elements-runtime": "^3.7.1",
96
- "@types/node": "^25.5.0",
98
+ "@jasonshimmy/custom-elements-runtime": "^3.7.4",
99
+ "@types/node": "^25.6.0",
97
100
  "@vitest/coverage-v8": "^4.1.2",
98
- "cypress": "^15.13.0",
99
- "eslint": "^10.1.0",
101
+ "cypress": "^15.13.1",
102
+ "eslint": "^10.2.0",
100
103
  "happy-dom": "^20.8.9",
101
104
  "jiti": "^2.6.1",
102
105
  "start-server-and-test": "^3.0.0",
103
106
  "typescript": "^5.9.3",
104
- "typescript-eslint": "^8.57.2",
105
- "vite": "^8.0.3",
107
+ "typescript-eslint": "^8.58.1",
108
+ "vite": "^8.0.8",
106
109
  "vitest": "^4.1.0"
107
110
  },
108
111
  "publishConfig": {
@@ -137,7 +137,8 @@ describe('buildSSG — path collection', () => {
137
137
  it('calls fg when pagesDir exists and no explicit routes', async () => {
138
138
  vi.mocked(existsSync).mockReturnValue(true)
139
139
  await buildSSG(makeConfig())
140
- expect(fg).toHaveBeenCalledTimes(1)
140
+ // fg is called once for page discovery and once by loadContentStore (content scan)
141
+ expect(fg).toHaveBeenCalledTimes(2)
141
142
  })
142
143
 
143
144
  it('skips Vite dev server when all discovered pages are static', async () => {
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { contentPathToFile, emitContentFiles } from '../../../plugin/content/emitter.js'
3
+ import { mkdtempSync, readFileSync, existsSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'pathe'
6
+ import type { ContentItem } from '../../../types/content.js'
7
+
8
+ // ─── contentPathToFile ────────────────────────────────────────────────────────
9
+
10
+ describe('contentPathToFile', () => {
11
+ it('maps / to index.json', () => {
12
+ expect(contentPathToFile('/')).toBe('index.json')
13
+ })
14
+
15
+ it('maps /about to about.json', () => {
16
+ expect(contentPathToFile('/about')).toBe('about.json')
17
+ })
18
+
19
+ it('maps /blog/hello to blog/hello.json', () => {
20
+ expect(contentPathToFile('/blog/hello')).toBe('blog/hello.json')
21
+ })
22
+
23
+ it('maps /blog to blog.json', () => {
24
+ expect(contentPathToFile('/blog')).toBe('blog.json')
25
+ })
26
+
27
+ it('maps /docs/getting-started to docs/getting-started.json', () => {
28
+ expect(contentPathToFile('/docs/getting-started')).toBe('docs/getting-started.json')
29
+ })
30
+ })
31
+
32
+ // ─── emitContentFiles ─────────────────────────────────────────────────────────
33
+
34
+ describe('emitContentFiles', () => {
35
+ const items: ContentItem[] = [
36
+ {
37
+ _path: '/',
38
+ _file: 'index.md',
39
+ _type: 'markdown',
40
+ title: 'Home',
41
+ description: 'Welcome',
42
+ body: '<h1>Home</h1>',
43
+ toc: [],
44
+ },
45
+ {
46
+ _path: '/blog/hello',
47
+ _file: 'blog/hello.md',
48
+ _type: 'markdown',
49
+ title: 'Hello',
50
+ description: 'First post',
51
+ date: '2026-04-03',
52
+ draft: false,
53
+ body: '<h1>Hello</h1>',
54
+ toc: [{ depth: 1, id: 'hello', text: 'Hello' }],
55
+ excerpt: '<p>Intro</p>',
56
+ },
57
+ ]
58
+
59
+ it('writes manifest.json with lean ContentMeta (no body/toc/excerpt/_file)', () => {
60
+ const outDir = mkdtempSync(join(tmpdir(), 'cer-emitter-'))
61
+ emitContentFiles(items, outDir, '{}')
62
+ const manifest = JSON.parse(readFileSync(join(outDir, '_content/manifest.json'), 'utf-8'))
63
+ expect(Array.isArray(manifest)).toBe(true)
64
+ expect(manifest).toHaveLength(2)
65
+ const root = manifest.find((m: { _path: string }) => m._path === '/')
66
+ expect(root.title).toBe('Home')
67
+ expect('body' in root).toBe(false)
68
+ expect('toc' in root).toBe(false)
69
+ expect('excerpt' in root).toBe(false)
70
+ expect('_file' in root).toBe(false)
71
+ })
72
+
73
+ it('writes search-index.json with provided content', () => {
74
+ const outDir = mkdtempSync(join(tmpdir(), 'cer-emitter-'))
75
+ emitContentFiles(items, outDir, '{"test":true}')
76
+ const idx = JSON.parse(readFileSync(join(outDir, '_content/search-index.json'), 'utf-8'))
77
+ expect(idx).toEqual({ test: true })
78
+ })
79
+
80
+ it('writes root document to _content/index.json', () => {
81
+ const outDir = mkdtempSync(join(tmpdir(), 'cer-emitter-'))
82
+ emitContentFiles(items, outDir, '{}')
83
+ expect(existsSync(join(outDir, '_content/index.json'))).toBe(true)
84
+ const doc = JSON.parse(readFileSync(join(outDir, '_content/index.json'), 'utf-8'))
85
+ expect(doc._path).toBe('/')
86
+ expect(doc.body).toBe('<h1>Home</h1>')
87
+ })
88
+
89
+ it('writes nested document to correct subdirectory', () => {
90
+ const outDir = mkdtempSync(join(tmpdir(), 'cer-emitter-'))
91
+ emitContentFiles(items, outDir, '{}')
92
+ const docPath = join(outDir, '_content/blog/hello.json')
93
+ expect(existsSync(docPath)).toBe(true)
94
+ const doc = JSON.parse(readFileSync(docPath, 'utf-8'))
95
+ expect(doc._path).toBe('/blog/hello')
96
+ expect(doc.toc).toEqual([{ depth: 1, id: 'hello', text: 'Hello' }])
97
+ expect(doc.excerpt).toBe('<p>Intro</p>')
98
+ })
99
+
100
+ it('coexisting blog.json and blog/ dir is allowed', () => {
101
+ const items2: ContentItem[] = [
102
+ ...items,
103
+ {
104
+ _path: '/blog',
105
+ _file: 'blog/index.md',
106
+ _type: 'markdown',
107
+ title: 'Blog',
108
+ body: '<p>Blog</p>',
109
+ toc: [],
110
+ },
111
+ ]
112
+ const outDir = mkdtempSync(join(tmpdir(), 'cer-emitter-'))
113
+ emitContentFiles(items2, outDir, '{}')
114
+ expect(existsSync(join(outDir, '_content/blog.json'))).toBe(true)
115
+ expect(existsSync(join(outDir, '_content/blog/hello.json'))).toBe(true)
116
+ })
117
+ })