@jasonshimmy/vite-plugin-cer-app 0.23.0 → 0.23.2
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 +8 -0
- package/commits.txt +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +37 -4
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/plugin/build-ssg.d.ts +4 -2
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +55 -5
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/dev-server.d.ts +2 -0
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js.map +1 -1
- package/dist/plugin/generated-dir.js +1 -1
- package/dist/plugin/generated-dir.js.map +1 -1
- package/dist/plugin/html-post-process.d.ts +29 -0
- package/dist/plugin/html-post-process.d.ts.map +1 -0
- package/dist/plugin/html-post-process.js +88 -0
- package/dist/plugin/html-post-process.js.map +1 -0
- package/dist/plugin/index.d.ts +9 -0
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +30 -2
- package/dist/plugin/index.js.map +1 -1
- package/dist/runtime/app-template.d.ts +1 -4
- package/dist/runtime/app-template.d.ts.map +1 -1
- package/dist/runtime/app-template.js +14 -13
- package/dist/runtime/app-template.js.map +1 -1
- package/dist/runtime/composables/use-content-search.d.ts +3 -0
- package/dist/runtime/composables/use-content-search.d.ts.map +1 -1
- package/dist/runtime/composables/use-content-search.js +60 -18
- package/dist/runtime/composables/use-content-search.js.map +1 -1
- package/dist/runtime/composables/use-head.d.ts.map +1 -1
- package/dist/runtime/composables/use-head.js +30 -6
- package/dist/runtime/composables/use-head.js.map +1 -1
- package/dist/types/config.d.ts +8 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/docs/configuration.md +29 -0
- package/docs/content.md +7 -5
- package/e2e/cypress/e2e/content.cy.ts +57 -0
- package/package.json +1 -1
- package/src/__tests__/plugin/app-template.test.ts +72 -18
- package/src/__tests__/plugin/html-post-process.test.ts +146 -0
- package/src/__tests__/plugin/resolve-config.test.ts +33 -0
- package/src/__tests__/runtime/app-template.test.ts +10 -0
- package/src/__tests__/runtime/use-content-search-composable.test.ts +405 -0
- package/src/__tests__/runtime/use-content-search.test.ts +28 -6
- package/src/__tests__/runtime/use-head.test.ts +45 -0
- package/src/cli/commands/preview.ts +38 -4
- package/src/plugin/build-ssg.ts +72 -5
- package/src/plugin/dev-server.ts +2 -0
- package/src/plugin/generated-dir.ts +1 -1
- package/src/plugin/html-post-process.ts +96 -0
- package/src/plugin/index.ts +33 -2
- package/src/runtime/app-template.ts +14 -17
- package/src/runtime/composables/use-content-search.ts +76 -17
- package/src/runtime/composables/use-head.ts +28 -6
- package/src/types/config.ts +8 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the useContentSearch() composable.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Initial state — empty query, empty results, loading=false
|
|
6
|
+
* - loading state — true immediately on typing, false after results arrive
|
|
7
|
+
* - Debounce timing — results are withheld until 200 ms after the last keystroke
|
|
8
|
+
* - Timer cancellation — a new query before 200 ms resets the debounce clock
|
|
9
|
+
* - Empty-query path — clears loading + results immediately, cancels pending timer
|
|
10
|
+
* - Disconnect cleanup — pending timer is cancelled and loading reset on unmount
|
|
11
|
+
* - Results content — correct items returned for each query
|
|
12
|
+
*
|
|
13
|
+
* @jasonshimmy/custom-elements-runtime is mocked so these tests run without a
|
|
14
|
+
* real DOM or component context. The watch() callback is available immediately
|
|
15
|
+
* after useContentSearch() is called (it is registered during the render-body
|
|
16
|
+
* call, not inside useOnConnected), so no triggerConnected() is needed before
|
|
17
|
+
* simulateType().
|
|
18
|
+
*
|
|
19
|
+
* Note: verifying that debounced input triggers exactly one loadIndex() call
|
|
20
|
+
* is an end-to-end concern exercised in content.cy.ts — the seq-stale guard
|
|
21
|
+
* discards duplicate search results even when debounce is absent, making the
|
|
22
|
+
* count indistinguishable at the unit level.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
25
|
+
import { buildSearchIndex } from '../../plugin/content/search.js'
|
|
26
|
+
import type { ContentItem } from '../../types/content.js'
|
|
27
|
+
|
|
28
|
+
// ─── Sample content ───────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const ITEMS: ContentItem[] = [
|
|
31
|
+
{
|
|
32
|
+
_path: '/blog/hello',
|
|
33
|
+
_file: 'blog/hello.md',
|
|
34
|
+
_type: 'markdown',
|
|
35
|
+
title: 'Hello World',
|
|
36
|
+
description: 'A post about hello',
|
|
37
|
+
body: '<p>Hello</p>',
|
|
38
|
+
toc: [],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
_path: '/docs/start',
|
|
42
|
+
_file: 'docs/start.md',
|
|
43
|
+
_type: 'markdown',
|
|
44
|
+
title: 'Getting Started',
|
|
45
|
+
description: 'How to get started',
|
|
46
|
+
body: '<p>Start</p>',
|
|
47
|
+
toc: [],
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
// ─── Runtime mock ─────────────────────────────────────────────────────────────
|
|
52
|
+
//
|
|
53
|
+
// The mock simulates just enough of the runtime for these unit tests:
|
|
54
|
+
// _currentMockContext — fresh plain object each test; receives _cerSearchDebounce
|
|
55
|
+
// _connectedCallbacks — useOnConnected callbacks (pre-warm only in new implementation)
|
|
56
|
+
// _disconnectedCallbacks — useOnDisconnected callbacks (timer cleanup)
|
|
57
|
+
// _watchCallback — the single watch(query, cb) handler registered during render
|
|
58
|
+
|
|
59
|
+
let _currentMockContext: Record<string, unknown> = {}
|
|
60
|
+
let _connectedCallbacks: Array<() => void> = []
|
|
61
|
+
let _disconnectedCallbacks: Array<() => void> = []
|
|
62
|
+
let _watchCallback: ((q: string) => void) | null = null
|
|
63
|
+
|
|
64
|
+
vi.mock('@jasonshimmy/custom-elements-runtime', () => ({
|
|
65
|
+
// Run the factory immediately; getCurrentComponentContext() returns the mock context.
|
|
66
|
+
createComposable: (fn: () => unknown) => () => fn(),
|
|
67
|
+
// Minimal reactive ref: plain object with getter/setter.
|
|
68
|
+
ref: (initial: unknown) => {
|
|
69
|
+
let _val = initial
|
|
70
|
+
return {
|
|
71
|
+
get value() { return _val },
|
|
72
|
+
set value(v: unknown) { _val = v },
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
// Capture the single watch() callback registered by the composable.
|
|
76
|
+
watch: (_state: unknown, cb: (val: string) => void) => {
|
|
77
|
+
_watchCallback = cb
|
|
78
|
+
},
|
|
79
|
+
// Capture useOnConnected callbacks for manual triggering (pre-warm only).
|
|
80
|
+
useOnConnected: (cb: () => void) => {
|
|
81
|
+
_connectedCallbacks.push(cb)
|
|
82
|
+
},
|
|
83
|
+
// Capture useOnDisconnected callbacks for manual triggering.
|
|
84
|
+
useOnDisconnected: (cb: () => void) => {
|
|
85
|
+
_disconnectedCallbacks.push(cb)
|
|
86
|
+
},
|
|
87
|
+
// Return the fresh mock context so getDebounceState() can attach _cerSearchDebounce.
|
|
88
|
+
getCurrentComponentContext: () => _currentMockContext,
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
// Static imports resolve AFTER vi.mock hoisting, so the mock is in place when
|
|
92
|
+
// use-content-search.js's module-level createComposable() call executes.
|
|
93
|
+
import { useContentSearch, resetIndexSingleton } from '../../runtime/composables/use-content-search.js'
|
|
94
|
+
import type { UseContentSearchReturn } from '../../runtime/composables/use-content-search.js'
|
|
95
|
+
|
|
96
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** Flush all useOnConnected callbacks (simulates component mount). */
|
|
99
|
+
function triggerConnected(): void {
|
|
100
|
+
for (const cb of _connectedCallbacks) cb()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Flush all useOnDisconnected callbacks (simulates component unmount). */
|
|
104
|
+
function triggerDisconnected(): void {
|
|
105
|
+
for (const cb of _disconnectedCallbacks) cb()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Simulate the user typing a value into the search input. */
|
|
109
|
+
function simulateType(q: string): void {
|
|
110
|
+
if (!_watchCallback) throw new Error('watch callback not registered — did you call useContentSearch()?')
|
|
111
|
+
_watchCallback(q)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Convenience typed accessor
|
|
115
|
+
type Ref<T> = { value: T }
|
|
116
|
+
|
|
117
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe('useContentSearch() composable', () => {
|
|
120
|
+
let result: UseContentSearchReturn
|
|
121
|
+
let originalFetch: typeof globalThis.fetch
|
|
122
|
+
|
|
123
|
+
beforeEach(async () => {
|
|
124
|
+
// Fresh context object for each test so _cerSearchDebounce doesn't bleed
|
|
125
|
+
_currentMockContext = {}
|
|
126
|
+
_connectedCallbacks = []
|
|
127
|
+
_disconnectedCallbacks = []
|
|
128
|
+
_watchCallback = null
|
|
129
|
+
|
|
130
|
+
// Each test gets a fresh index singleton so loadIndex() re-fetches.
|
|
131
|
+
resetIndexSingleton()
|
|
132
|
+
|
|
133
|
+
originalFetch = globalThis.fetch
|
|
134
|
+
const indexJson = buildSearchIndex(ITEMS)
|
|
135
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
136
|
+
ok: true,
|
|
137
|
+
text: () => Promise.resolve(indexJson),
|
|
138
|
+
} as unknown as Response)
|
|
139
|
+
|
|
140
|
+
// Calling useContentSearch() runs the factory, which calls watch() and
|
|
141
|
+
// registers useOnConnected/useOnDisconnected callbacks synchronously.
|
|
142
|
+
result = useContentSearch()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
globalThis.fetch = originalFetch
|
|
147
|
+
vi.useRealTimers()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ─── Shape ─────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
it('returns query, results, and loading refs', () => {
|
|
153
|
+
expect(result).toHaveProperty('query')
|
|
154
|
+
expect(result).toHaveProperty('results')
|
|
155
|
+
expect(result).toHaveProperty('loading')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('initialises with empty query, empty results, and loading=false', () => {
|
|
159
|
+
expect((result.query as Ref<string>).value).toBe('')
|
|
160
|
+
expect((result.results as Ref<unknown[]>).value).toEqual([])
|
|
161
|
+
expect((result.loading as Ref<boolean>).value).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('registers a watch callback during the render call (not after mount)', () => {
|
|
165
|
+
// The watch() is in the render body: available immediately after useContentSearch()
|
|
166
|
+
expect(_watchCallback).not.toBeNull()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('attaches _cerSearchDebounce to the component context', () => {
|
|
170
|
+
expect(_currentMockContext).toHaveProperty('_cerSearchDebounce')
|
|
171
|
+
const state = _currentMockContext['_cerSearchDebounce'] as { seq: number; timer: unknown }
|
|
172
|
+
expect(state.seq).toBe(0)
|
|
173
|
+
expect(state.timer).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('registers a useOnDisconnected callback for timer cleanup', () => {
|
|
177
|
+
expect(_disconnectedCallbacks).toHaveLength(1)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// ─── loading state ─────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
it('sets loading=true immediately when a non-empty query is set', () => {
|
|
183
|
+
vi.useFakeTimers()
|
|
184
|
+
simulateType('Hello')
|
|
185
|
+
expect((result.loading as Ref<boolean>).value).toBe(true)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('keeps loading=true while the debounce timer is pending', () => {
|
|
189
|
+
vi.useFakeTimers()
|
|
190
|
+
simulateType('Hello')
|
|
191
|
+
vi.advanceTimersByTime(100) // 100 ms < 200 ms debounce
|
|
192
|
+
expect((result.loading as Ref<boolean>).value).toBe(true)
|
|
193
|
+
expect((result.results as Ref<unknown[]>).value).toEqual([])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('sets loading=false once results arrive after the debounce window', async () => {
|
|
197
|
+
vi.useFakeTimers()
|
|
198
|
+
simulateType('Hello')
|
|
199
|
+
await vi.runAllTimersAsync()
|
|
200
|
+
expect((result.loading as Ref<boolean>).value).toBe(false)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('clears loading immediately when query is reset to empty string', () => {
|
|
204
|
+
vi.useFakeTimers()
|
|
205
|
+
simulateType('Hello')
|
|
206
|
+
expect((result.loading as Ref<boolean>).value).toBe(true)
|
|
207
|
+
simulateType('')
|
|
208
|
+
expect((result.loading as Ref<boolean>).value).toBe(false)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// ─── Debounce timing ───────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
it('withholds results until 200 ms after the last keystroke', () => {
|
|
214
|
+
vi.useFakeTimers()
|
|
215
|
+
simulateType('Hello')
|
|
216
|
+
vi.advanceTimersByTime(199) // 1 ms before debounce fires
|
|
217
|
+
expect((result.results as Ref<unknown[]>).value).toEqual([])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('delivers results after the 200 ms debounce window elapses', async () => {
|
|
221
|
+
vi.useFakeTimers()
|
|
222
|
+
simulateType('Hello')
|
|
223
|
+
await vi.runAllTimersAsync()
|
|
224
|
+
expect((result.results as Ref<unknown[]>).value.length).toBeGreaterThan(0)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('resets the debounce clock when a new query arrives before 200 ms', async () => {
|
|
228
|
+
vi.useFakeTimers()
|
|
229
|
+
|
|
230
|
+
simulateType('Getting') // timer-A starts at t=0
|
|
231
|
+
vi.advanceTimersByTime(100) // t=100 — timer-A still pending (100 < 200)
|
|
232
|
+
simulateType('Hello') // cancels timer-A, timer-B starts at t=100
|
|
233
|
+
vi.advanceTimersByTime(100) // t=200 — only 100 ms since timer-B started; still pending
|
|
234
|
+
|
|
235
|
+
// No results yet — timer-B hasn't fired
|
|
236
|
+
expect((result.results as Ref<unknown[]>).value).toEqual([])
|
|
237
|
+
|
|
238
|
+
await vi.runAllTimersAsync() // timer-B fires at t=300; search runs with 'Hello'
|
|
239
|
+
|
|
240
|
+
const paths = (result.results as Ref<{ _path: string }[]>).value.map(r => r._path)
|
|
241
|
+
expect(paths).toContain('/blog/hello') // 'Hello' prefix matched Hello World
|
|
242
|
+
expect(paths).not.toContain('/docs/start') // 'Getting' timer was cancelled
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('cancels the pending timer and prevents results when query is cleared mid-debounce', async () => {
|
|
246
|
+
vi.useFakeTimers()
|
|
247
|
+
simulateType('Hello') // debounce timer starts
|
|
248
|
+
vi.advanceTimersByTime(100) // partway through window
|
|
249
|
+
simulateType('') // clears timer; loading + results reset immediately
|
|
250
|
+
|
|
251
|
+
expect((result.loading as Ref<boolean>).value).toBe(false)
|
|
252
|
+
expect((result.results as Ref<unknown[]>).value).toEqual([])
|
|
253
|
+
|
|
254
|
+
await vi.runAllTimersAsync() // advance remaining time — no timer should fire
|
|
255
|
+
expect((result.results as Ref<unknown[]>).value).toEqual([]) // still empty
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// ─── Disconnect cleanup ────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
it('cancels the pending timer on disconnect', async () => {
|
|
261
|
+
vi.useFakeTimers()
|
|
262
|
+
simulateType('Hello')
|
|
263
|
+
vi.advanceTimersByTime(100) // timer is pending
|
|
264
|
+
|
|
265
|
+
triggerDisconnected()
|
|
266
|
+
|
|
267
|
+
// Timer should be cancelled — advancing past the debounce window produces no results
|
|
268
|
+
await vi.runAllTimersAsync()
|
|
269
|
+
expect((result.results as Ref<unknown[]>).value).toEqual([])
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('resets loading to false on disconnect', () => {
|
|
273
|
+
vi.useFakeTimers()
|
|
274
|
+
simulateType('Hello')
|
|
275
|
+
expect((result.loading as Ref<boolean>).value).toBe(true)
|
|
276
|
+
|
|
277
|
+
triggerDisconnected()
|
|
278
|
+
expect((result.loading as Ref<boolean>).value).toBe(false)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// ─── Result shape and content ──────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
it('result items include _path and title', async () => {
|
|
284
|
+
vi.useFakeTimers()
|
|
285
|
+
simulateType('Hello')
|
|
286
|
+
await vi.runAllTimersAsync()
|
|
287
|
+
const first = (result.results as Ref<Record<string, unknown>[]>).value[0]
|
|
288
|
+
expect(first).toHaveProperty('_path')
|
|
289
|
+
expect(first).toHaveProperty('title')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('searching "Hello" returns Hello World and not Getting Started', async () => {
|
|
293
|
+
vi.useFakeTimers()
|
|
294
|
+
simulateType('Hello')
|
|
295
|
+
await vi.runAllTimersAsync()
|
|
296
|
+
const paths = (result.results as Ref<{ _path: string }[]>).value.map(r => r._path)
|
|
297
|
+
expect(paths).toContain('/blog/hello')
|
|
298
|
+
expect(paths).not.toContain('/docs/start')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('searching "Getting" returns Getting Started and not Hello World', async () => {
|
|
302
|
+
vi.useFakeTimers()
|
|
303
|
+
simulateType('Getting')
|
|
304
|
+
await vi.runAllTimersAsync()
|
|
305
|
+
const paths = (result.results as Ref<{ _path: string }[]>).value.map(r => r._path)
|
|
306
|
+
expect(paths).toContain('/docs/start')
|
|
307
|
+
expect(paths).not.toContain('/blog/hello')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('clears results immediately when query is reset from non-empty to empty', async () => {
|
|
311
|
+
vi.useFakeTimers()
|
|
312
|
+
simulateType('Hello')
|
|
313
|
+
await vi.runAllTimersAsync()
|
|
314
|
+
expect((result.results as Ref<unknown[]>).value.length).toBeGreaterThan(0)
|
|
315
|
+
|
|
316
|
+
simulateType('')
|
|
317
|
+
expect((result.results as Ref<unknown[]>).value).toEqual([])
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// ─── Pre-warm ──────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
it('registers a useOnConnected callback for index pre-warming', () => {
|
|
323
|
+
expect(_connectedCallbacks).toHaveLength(1)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('pre-warms the index on mount (triggers a fetch)', async () => {
|
|
327
|
+
triggerConnected()
|
|
328
|
+
// fetch is called by the pre-warm (loadIndex inside useOnConnected)
|
|
329
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// ─── Re-render stability ───────────────────────────────────────────────────
|
|
333
|
+
//
|
|
334
|
+
// The core fix: _seq and _timer live on the component context, not in local
|
|
335
|
+
// factory-body variables. These tests confirm that calling the factory a second
|
|
336
|
+
// time (simulating a component re-render) reuses the same state object rather
|
|
337
|
+
// than resetting it, so a timer set during one render can be cancelled by the
|
|
338
|
+
// new watcher registered on the next render.
|
|
339
|
+
|
|
340
|
+
it('reuses the same debounce state object when the factory runs again with the same context', () => {
|
|
341
|
+
// First render already called useContentSearch() in beforeEach.
|
|
342
|
+
const state1 = _currentMockContext['_cerSearchDebounce']
|
|
343
|
+
expect(state1).toBeDefined()
|
|
344
|
+
|
|
345
|
+
// Simulate re-render: reset captured callbacks, call factory again.
|
|
346
|
+
_connectedCallbacks = []
|
|
347
|
+
_disconnectedCallbacks = []
|
|
348
|
+
_watchCallback = null
|
|
349
|
+
useContentSearch()
|
|
350
|
+
|
|
351
|
+
const state2 = _currentMockContext['_cerSearchDebounce']
|
|
352
|
+
|
|
353
|
+
// Must be the identical object — not re-created.
|
|
354
|
+
expect(state2).toBe(state1)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('a new keystroke after re-render cancels the timer that was set in the previous render', () => {
|
|
358
|
+
vi.useFakeTimers()
|
|
359
|
+
|
|
360
|
+
// First render (done in beforeEach). Type something → timer-A starts.
|
|
361
|
+
simulateType('Getting')
|
|
362
|
+
const state = _currentMockContext['_cerSearchDebounce'] as {
|
|
363
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
364
|
+
seq: number
|
|
365
|
+
}
|
|
366
|
+
const timerA = state.timer
|
|
367
|
+
expect(timerA).not.toBeNull()
|
|
368
|
+
|
|
369
|
+
// Simulate re-render: new watch callback registered, same context state.
|
|
370
|
+
_watchCallback = null
|
|
371
|
+
useContentSearch()
|
|
372
|
+
|
|
373
|
+
// The new watcher fires. It reads state.timer (still timerA) and cancels it,
|
|
374
|
+
// then starts timer-B.
|
|
375
|
+
simulateType('Hello')
|
|
376
|
+
|
|
377
|
+
expect(state.timer).not.toBeNull()
|
|
378
|
+
expect(state.timer).not.toBe(timerA) // timer-A was replaced by timer-B
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('seq counter is not reset to 0 when the factory runs again (shared state)', async () => {
|
|
382
|
+
vi.useFakeTimers()
|
|
383
|
+
|
|
384
|
+
// First render: type something, let the debounce fire.
|
|
385
|
+
simulateType('Getting')
|
|
386
|
+
await vi.runAllTimersAsync() // timer fires → seq incremented to 1
|
|
387
|
+
|
|
388
|
+
const state = _currentMockContext['_cerSearchDebounce'] as { seq: number }
|
|
389
|
+
expect(state.seq).toBe(1)
|
|
390
|
+
|
|
391
|
+
// Simulate re-render: fresh callbacks, same context.
|
|
392
|
+
_connectedCallbacks = []
|
|
393
|
+
_disconnectedCallbacks = []
|
|
394
|
+
_watchCallback = null
|
|
395
|
+
useContentSearch()
|
|
396
|
+
|
|
397
|
+
// Type again on the new watcher and let it fire.
|
|
398
|
+
simulateType('Hello')
|
|
399
|
+
await vi.runAllTimersAsync() // seq incremented to 2
|
|
400
|
+
|
|
401
|
+
// If state were re-initialised on re-render, seq would be 1 again.
|
|
402
|
+
// Shared state means it continues from where it left off.
|
|
403
|
+
expect(state.seq).toBe(2)
|
|
404
|
+
})
|
|
405
|
+
})
|
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
* - loadIndex() error path — fetch failure rejects cleanly; singleton reset allows retry
|
|
8
8
|
* - loadIndex() returns a searchable MiniSearch instance
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* clears results + increments the seq counter to cancel any in-flight search.
|
|
10
|
+
* The full useContentSearch() composable (debounce, loading state, stale-seq
|
|
11
|
+
* guard) is tested in use-content-search-composable.test.ts, which mocks the
|
|
12
|
+
* runtime to exercise the watch callback and fake-timer debounce logic directly.
|
|
13
|
+
* End-to-end behaviour is covered by content.cy.ts.
|
|
15
14
|
*/
|
|
16
15
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
17
16
|
import MiniSearch from 'minisearch'
|
|
@@ -101,7 +100,7 @@ describe('loadIndex', () => {
|
|
|
101
100
|
resetIndexSingleton()
|
|
102
101
|
|
|
103
102
|
const index = await loadIndex() as ReturnType<typeof MiniSearch.loadJSON>
|
|
104
|
-
const results = index.search('Hello', { prefix: true }) as Array<{ _path: string }>
|
|
103
|
+
const results = index.search('Hello', { prefix: true }) as unknown as Array<{ _path: string }>
|
|
105
104
|
expect(results.some((r) => r._path === '/blog/hello')).toBe(true)
|
|
106
105
|
})
|
|
107
106
|
|
|
@@ -138,4 +137,27 @@ describe('loadIndex', () => {
|
|
|
138
137
|
const index = await loadIndex() as ReturnType<typeof MiniSearch.loadJSON>
|
|
139
138
|
expect(index).toBeDefined()
|
|
140
139
|
})
|
|
140
|
+
|
|
141
|
+
it('automatically retries after a fetch failure without manual singleton reset', async () => {
|
|
142
|
+
const { loadIndex, resetIndexSingleton } = await import('../../runtime/composables/use-content-search.js')
|
|
143
|
+
resetIndexSingleton()
|
|
144
|
+
|
|
145
|
+
// First call fails — singleton should be cleared automatically
|
|
146
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
147
|
+
ok: false,
|
|
148
|
+
status: 503,
|
|
149
|
+
} as unknown as Response)
|
|
150
|
+
await expect(loadIndex()).rejects.toThrow()
|
|
151
|
+
|
|
152
|
+
// Second call without resetIndexSingleton — should retry and succeed
|
|
153
|
+
const indexJson = buildIndex()
|
|
154
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
155
|
+
ok: true,
|
|
156
|
+
text: () => Promise.resolve(indexJson),
|
|
157
|
+
} as unknown as Response)
|
|
158
|
+
const index = await loadIndex() as ReturnType<typeof MiniSearch.loadJSON>
|
|
159
|
+
expect(index).toBeDefined()
|
|
160
|
+
// Confirm the new singleton is cached (second call reuses it)
|
|
161
|
+
expect(await loadIndex()).toBe(index)
|
|
162
|
+
})
|
|
141
163
|
})
|
|
@@ -215,6 +215,29 @@ describe('useHead — client-side DOM updates', () => {
|
|
|
215
215
|
expect(links.length).toBe(1)
|
|
216
216
|
})
|
|
217
217
|
|
|
218
|
+
it('updates canonical href in-place when URL changes (no duplicate)', () => {
|
|
219
|
+
useHead({ link: [{ rel: 'canonical', href: 'https://example.com/page-a' }] })
|
|
220
|
+
useHead({ link: [{ rel: 'canonical', href: 'https://example.com/page-b' }] })
|
|
221
|
+
const links = document.querySelectorAll('link[rel="canonical"]')
|
|
222
|
+
expect(links.length).toBe(1)
|
|
223
|
+
expect(links[0].getAttribute('href')).toBe('https://example.com/page-b')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('updates icon link in-place when href changes (no duplicate)', () => {
|
|
227
|
+
useHead({ link: [{ rel: 'icon', href: '/favicon.ico' }] })
|
|
228
|
+
useHead({ link: [{ rel: 'icon', href: '/favicon.svg' }] })
|
|
229
|
+
const links = document.querySelectorAll('link[rel="icon"]')
|
|
230
|
+
expect(links.length).toBe(1)
|
|
231
|
+
expect(links[0].getAttribute('href')).toBe('/favicon.svg')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('does NOT deduplicate stylesheet links by rel alone (multiple hrefs allowed)', () => {
|
|
235
|
+
useHead({ link: [{ rel: 'stylesheet', href: '/a.css' }] })
|
|
236
|
+
useHead({ link: [{ rel: 'stylesheet', href: '/b.css' }] })
|
|
237
|
+
const links = document.querySelectorAll('link[rel="stylesheet"]')
|
|
238
|
+
expect(links.length).toBe(2)
|
|
239
|
+
})
|
|
240
|
+
|
|
218
241
|
it('adds a script tag with src', () => {
|
|
219
242
|
useHead({ script: [{ src: '/analytics.js' }] })
|
|
220
243
|
const script = document.querySelector('script[src="/analytics.js"]')
|
|
@@ -263,4 +286,26 @@ describe('useHead — client-side DOM updates', () => {
|
|
|
263
286
|
expect(scripts.length).toBe(1)
|
|
264
287
|
expect(scripts[0].textContent).toBe('window.x = 2')
|
|
265
288
|
})
|
|
289
|
+
|
|
290
|
+
it('updates existing application/ld+json content in-place (no duplicate)', () => {
|
|
291
|
+
useHead({ script: [{ type: 'application/ld+json', innerHTML: '{"@type":"WebPage","name":"A"}' }] })
|
|
292
|
+
useHead({ script: [{ type: 'application/ld+json', innerHTML: '{"@type":"WebPage","name":"B"}' }] })
|
|
293
|
+
const scripts = document.querySelectorAll('script[type="application/ld+json"]')
|
|
294
|
+
expect(scripts.length).toBe(1)
|
|
295
|
+
expect(scripts[0].textContent).toBe('{"@type":"WebPage","name":"B"}')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('creates application/ld+json script when none exists', () => {
|
|
299
|
+
useHead({ script: [{ type: 'application/ld+json', innerHTML: '{"@type":"WebPage"}' }] })
|
|
300
|
+
const scripts = document.querySelectorAll('script[type="application/ld+json"]')
|
|
301
|
+
expect(scripts.length).toBe(1)
|
|
302
|
+
expect(scripts[0].textContent).toBe('{"@type":"WebPage"}')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('does NOT deduplicate non-ld+json inline scripts', () => {
|
|
306
|
+
useHead({ script: [{ innerHTML: 'window.a = 1' }] })
|
|
307
|
+
useHead({ script: [{ innerHTML: 'window.b = 2' }] })
|
|
308
|
+
const scripts = document.querySelectorAll('script:not([src]):not([type])')
|
|
309
|
+
expect(scripts.length).toBe(2)
|
|
310
|
+
})
|
|
266
311
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
3
3
|
import { createReadStream, existsSync, statSync } from 'node:fs'
|
|
4
|
+
import { createGzip } from 'node:zlib'
|
|
4
5
|
import { resolve, join, extname } from 'pathe'
|
|
5
6
|
import { pathToFileURL } from 'node:url'
|
|
6
7
|
import {
|
|
@@ -108,6 +109,22 @@ function getMimeType(filePath: string): string {
|
|
|
108
109
|
return MIME_TYPES[ext] ?? 'application/octet-stream'
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// MIME types that benefit from gzip compression. Binary formats (woff2, images)
|
|
113
|
+
// are already compressed and should not be re-compressed.
|
|
114
|
+
const GZIP_TYPES = new Set([
|
|
115
|
+
'text/html; charset=utf-8',
|
|
116
|
+
'application/javascript; charset=utf-8',
|
|
117
|
+
'text/css; charset=utf-8',
|
|
118
|
+
'application/json; charset=utf-8',
|
|
119
|
+
'image/svg+xml',
|
|
120
|
+
'application/json',
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
function acceptsGzip(req: IncomingMessage): boolean {
|
|
124
|
+
const ae = req.headers['accept-encoding'] ?? ''
|
|
125
|
+
return ae.includes('gzip')
|
|
126
|
+
}
|
|
127
|
+
|
|
111
128
|
/**
|
|
112
129
|
* Returns the appropriate Cache-Control header value for a file.
|
|
113
130
|
* Vite content-hashes assets placed in the /assets/ directory, so they
|
|
@@ -129,6 +146,8 @@ function setSecurityHeaders(res: ServerResponse): void {
|
|
|
129
146
|
|
|
130
147
|
/**
|
|
131
148
|
* Serves a static file from the dist directory.
|
|
149
|
+
* Applies gzip compression for compressible text types when the client
|
|
150
|
+
* signals support via the Accept-Encoding request header.
|
|
132
151
|
* Returns true if the file was served, false otherwise.
|
|
133
152
|
*/
|
|
134
153
|
function serveStaticFile(
|
|
@@ -161,10 +180,18 @@ function serveStaticFile(
|
|
|
161
180
|
}
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
|
|
183
|
+
const mimeType = getMimeType(filePath)
|
|
184
|
+
res.setHeader('Content-Type', mimeType)
|
|
165
185
|
res.setHeader('Cache-Control', getCacheControl(filePath))
|
|
166
186
|
setSecurityHeaders(res)
|
|
167
|
-
|
|
187
|
+
|
|
188
|
+
const stream = createReadStream(filePath)
|
|
189
|
+
if (GZIP_TYPES.has(mimeType) && acceptsGzip(req)) {
|
|
190
|
+
res.setHeader('Content-Encoding', 'gzip')
|
|
191
|
+
stream.pipe(createGzip()).pipe(res)
|
|
192
|
+
} else {
|
|
193
|
+
stream.pipe(res)
|
|
194
|
+
}
|
|
168
195
|
return true
|
|
169
196
|
}
|
|
170
197
|
|
|
@@ -463,9 +490,16 @@ export function previewCommand(): Command {
|
|
|
463
490
|
isPathBounded(clientDist, urlPath) &&
|
|
464
491
|
existsSync(assetPath) && !statSync(assetPath).isDirectory()
|
|
465
492
|
) {
|
|
466
|
-
|
|
493
|
+
const mimeType = getMimeType(assetPath)
|
|
494
|
+
res.setHeader('Content-Type', mimeType)
|
|
467
495
|
res.setHeader('Cache-Control', getCacheControl(assetPath))
|
|
468
|
-
createReadStream(assetPath)
|
|
496
|
+
const stream = createReadStream(assetPath)
|
|
497
|
+
if (GZIP_TYPES.has(mimeType) && acceptsGzip(req)) {
|
|
498
|
+
res.setHeader('Content-Encoding', 'gzip')
|
|
499
|
+
stream.pipe(createGzip()).pipe(res)
|
|
500
|
+
} else {
|
|
501
|
+
stream.pipe(res)
|
|
502
|
+
}
|
|
469
503
|
return
|
|
470
504
|
}
|
|
471
505
|
}
|