@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.
- package/CHANGELOG.md +9 -0
- package/commits.txt +2 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +2 -0
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/cli/create/templates/spa/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +11 -0
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/content/emitter.d.ts +19 -0
- package/dist/plugin/content/emitter.d.ts.map +1 -0
- package/dist/plugin/content/emitter.js +42 -0
- package/dist/plugin/content/emitter.js.map +1 -0
- package/dist/plugin/content/index.d.ts +32 -0
- package/dist/plugin/content/index.d.ts.map +1 -0
- package/dist/plugin/content/index.js +199 -0
- package/dist/plugin/content/index.js.map +1 -0
- package/dist/plugin/content/parser.d.ts +18 -0
- package/dist/plugin/content/parser.d.ts.map +1 -0
- package/dist/plugin/content/parser.js +158 -0
- package/dist/plugin/content/parser.js.map +1 -0
- package/dist/plugin/content/path-utils.d.ts +19 -0
- package/dist/plugin/content/path-utils.d.ts.map +1 -0
- package/dist/plugin/content/path-utils.js +40 -0
- package/dist/plugin/content/path-utils.js.map +1 -0
- package/dist/plugin/content/scanner.d.ts +12 -0
- package/dist/plugin/content/scanner.d.ts.map +1 -0
- package/dist/plugin/content/scanner.js +18 -0
- package/dist/plugin/content/scanner.js.map +1 -0
- package/dist/plugin/content/search.d.ts +9 -0
- package/dist/plugin/content/search.d.ts.map +1 -0
- package/dist/plugin/content/search.js +24 -0
- package/dist/plugin/content/search.js.map +1 -0
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +10 -1
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +4 -1
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
- package/dist/plugin/transforms/auto-import.js +2 -0
- package/dist/plugin/transforms/auto-import.js.map +1 -1
- package/dist/runtime/composables/index.d.ts +3 -0
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +2 -0
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-content-search.d.ts +49 -0
- package/dist/runtime/composables/use-content-search.d.ts.map +1 -0
- package/dist/runtime/composables/use-content-search.js +101 -0
- package/dist/runtime/composables/use-content-search.js.map +1 -0
- package/dist/runtime/composables/use-content.d.ts +51 -0
- package/dist/runtime/composables/use-content.d.ts.map +1 -0
- package/dist/runtime/composables/use-content.js +127 -0
- package/dist/runtime/composables/use-content.js.map +1 -0
- package/dist/runtime/content/client.d.ts +20 -0
- package/dist/runtime/content/client.d.ts.map +1 -0
- package/dist/runtime/content/client.js +163 -0
- package/dist/runtime/content/client.js.map +1 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/content.d.ts +63 -0
- package/dist/types/content.d.ts.map +1 -0
- package/dist/types/content.js +2 -0
- package/dist/types/content.js.map +1 -0
- package/docs/composables.md +115 -10
- package/docs/configuration.md +33 -0
- package/docs/content.md +436 -0
- package/e2e/cypress/e2e/content.cy.ts +228 -0
- package/e2e/kitchen-sink/app/pages/content-blog.ts +37 -0
- package/e2e/kitchen-sink/app/pages/content-doc.ts +42 -0
- package/e2e/kitchen-sink/app/pages/content-index.ts +39 -0
- package/e2e/kitchen-sink/app/pages/content-search.ts +35 -0
- package/e2e/kitchen-sink/cer.config.ts +1 -0
- package/e2e/kitchen-sink/content/blog/2026-04-01-hello.md +26 -0
- package/e2e/kitchen-sink/content/blog/2026-04-02-draft.md +10 -0
- package/e2e/kitchen-sink/content/blog/index.md +8 -0
- package/e2e/kitchen-sink/content/docs/getting-started.md +46 -0
- package/e2e/kitchen-sink/content/index.md +16 -0
- package/package.json +10 -7
- package/src/__tests__/plugin/build-ssg.test.ts +2 -1
- package/src/__tests__/plugin/content/emitter.test.ts +117 -0
- package/src/__tests__/plugin/content/loader.test.ts +162 -0
- package/src/__tests__/plugin/content/parser.test.ts +239 -0
- package/src/__tests__/plugin/content/path-utils.test.ts +53 -0
- package/src/__tests__/plugin/content/search.test.ts +119 -0
- package/src/__tests__/plugin/dts-generator.test.ts +39 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +14 -0
- package/src/__tests__/runtime/use-content-search.test.ts +139 -0
- package/src/__tests__/runtime/use-content.test.ts +226 -0
- package/src/cli/commands/preview.ts +2 -0
- package/src/cli/create/templates/spa/package.json.tpl +2 -2
- package/src/cli/create/templates/ssg/package.json.tpl +2 -2
- package/src/cli/create/templates/ssr/package.json.tpl +2 -2
- package/src/index.ts +3 -0
- package/src/plugin/build-ssg.ts +12 -0
- package/src/plugin/content/emitter.ts +50 -0
- package/src/plugin/content/index.ts +236 -0
- package/src/plugin/content/parser.ts +192 -0
- package/src/plugin/content/path-utils.ts +47 -0
- package/src/plugin/content/scanner.ts +26 -0
- package/src/plugin/content/search.ts +28 -0
- package/src/plugin/dts-generator.ts +10 -1
- package/src/plugin/index.ts +6 -1
- package/src/plugin/transforms/auto-import.ts +2 -0
- package/src/runtime/composables/index.ts +3 -0
- package/src/runtime/composables/use-content-search.ts +121 -0
- package/src/runtime/composables/use-content.ts +146 -0
- package/src/runtime/content/client.ts +168 -0
- package/src/types/config.ts +2 -0
- 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,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.
|
|
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.
|
|
96
|
-
"@types/node": "^25.
|
|
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.
|
|
99
|
-
"eslint": "^10.
|
|
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.
|
|
105
|
-
"vite": "^8.0.
|
|
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
|
-
|
|
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
|
+
})
|