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