@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/commits.txt +1 -1
  3. package/dist/cli/commands/preview.d.ts.map +1 -1
  4. package/dist/cli/commands/preview.js +37 -4
  5. package/dist/cli/commands/preview.js.map +1 -1
  6. package/dist/plugin/build-ssg.d.ts +4 -2
  7. package/dist/plugin/build-ssg.d.ts.map +1 -1
  8. package/dist/plugin/build-ssg.js +55 -5
  9. package/dist/plugin/build-ssg.js.map +1 -1
  10. package/dist/plugin/dev-server.d.ts +2 -0
  11. package/dist/plugin/dev-server.d.ts.map +1 -1
  12. package/dist/plugin/dev-server.js.map +1 -1
  13. package/dist/plugin/generated-dir.js +1 -1
  14. package/dist/plugin/generated-dir.js.map +1 -1
  15. package/dist/plugin/html-post-process.d.ts +29 -0
  16. package/dist/plugin/html-post-process.d.ts.map +1 -0
  17. package/dist/plugin/html-post-process.js +88 -0
  18. package/dist/plugin/html-post-process.js.map +1 -0
  19. package/dist/plugin/index.d.ts +9 -0
  20. package/dist/plugin/index.d.ts.map +1 -1
  21. package/dist/plugin/index.js +30 -2
  22. package/dist/plugin/index.js.map +1 -1
  23. package/dist/runtime/app-template.d.ts +1 -4
  24. package/dist/runtime/app-template.d.ts.map +1 -1
  25. package/dist/runtime/app-template.js +14 -13
  26. package/dist/runtime/app-template.js.map +1 -1
  27. package/dist/runtime/composables/use-content-search.d.ts +3 -0
  28. package/dist/runtime/composables/use-content-search.d.ts.map +1 -1
  29. package/dist/runtime/composables/use-content-search.js +60 -18
  30. package/dist/runtime/composables/use-content-search.js.map +1 -1
  31. package/dist/runtime/composables/use-head.d.ts.map +1 -1
  32. package/dist/runtime/composables/use-head.js +30 -6
  33. package/dist/runtime/composables/use-head.js.map +1 -1
  34. package/dist/types/config.d.ts +8 -0
  35. package/dist/types/config.d.ts.map +1 -1
  36. package/dist/types/config.js.map +1 -1
  37. package/docs/configuration.md +29 -0
  38. package/docs/content.md +7 -5
  39. package/e2e/cypress/e2e/content.cy.ts +57 -0
  40. package/package.json +1 -1
  41. package/src/__tests__/plugin/app-template.test.ts +72 -18
  42. package/src/__tests__/plugin/html-post-process.test.ts +146 -0
  43. package/src/__tests__/plugin/resolve-config.test.ts +33 -0
  44. package/src/__tests__/runtime/app-template.test.ts +10 -0
  45. package/src/__tests__/runtime/use-content-search-composable.test.ts +405 -0
  46. package/src/__tests__/runtime/use-content-search.test.ts +28 -6
  47. package/src/__tests__/runtime/use-head.test.ts +45 -0
  48. package/src/cli/commands/preview.ts +38 -4
  49. package/src/plugin/build-ssg.ts +72 -5
  50. package/src/plugin/dev-server.ts +2 -0
  51. package/src/plugin/generated-dir.ts +1 -1
  52. package/src/plugin/html-post-process.ts +96 -0
  53. package/src/plugin/index.ts +33 -2
  54. package/src/runtime/app-template.ts +14 -17
  55. package/src/runtime/composables/use-content-search.ts +76 -17
  56. package/src/runtime/composables/use-head.ts +28 -6
  57. 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
- * Note: The full useContentSearch() composable (debounce, stale-seq guard,
11
- * 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
- * Specifically: input is debounced (300 ms) and an empty query immediately
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
- res.setHeader('Content-Type', getMimeType(filePath))
183
+ const mimeType = getMimeType(filePath)
184
+ res.setHeader('Content-Type', mimeType)
165
185
  res.setHeader('Cache-Control', getCacheControl(filePath))
166
186
  setSecurityHeaders(res)
167
- createReadStream(filePath).pipe(res)
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
- res.setHeader('Content-Type', getMimeType(assetPath))
493
+ const mimeType = getMimeType(assetPath)
494
+ res.setHeader('Content-Type', mimeType)
467
495
  res.setHeader('Cache-Control', getCacheControl(assetPath))
468
- createReadStream(assetPath).pipe(res)
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
  }