@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
package/docs/content.md CHANGED
@@ -473,18 +473,19 @@ Important behavior notes:
473
473
 
474
474
  **Auto-imported** in pages, layouts, and components.
475
475
 
476
- Returns reactive `query` and `results` refs. The MiniSearch index is loaded once on the client (lazily, the first time the composable is used). Search is debounce-free results update synchronously on each keypress, but the initial index fetch is async.
476
+ Returns reactive `query`, `results`, and `loading` refs. The MiniSearch index is loaded lazily the first time the component mounts (pre-warmed via `useOnConnected`) and cached for the lifetime of the session. Input is debounced (200 ms) so the index is not queried on every keystroke.
477
477
 
478
478
  ```ts
479
- const { query, results } = useContentSearch()
479
+ const { query, results, loading } = useContentSearch()
480
480
  ```
481
481
 
482
482
  ### Return value
483
483
 
484
484
  ```ts
485
485
  interface UseContentSearchReturn {
486
- query: Ref<string> // bind to an <input> value
486
+ query: Ref<string> // bind with :model or @input
487
487
  results: Ref<ContentSearchResult[]> // reactive search results
488
+ loading: Ref<boolean> // true from first keystroke until results arrive
488
489
  }
489
490
  ```
490
491
 
@@ -492,10 +493,11 @@ interface UseContentSearchReturn {
492
493
 
493
494
  ```ts
494
495
  component('page-search', () => {
495
- const { query, results } = useContentSearch()
496
+ const { query, results, loading } = useContentSearch()
496
497
 
497
498
  return html`
498
499
  <input type="search" :model="${query}" placeholder="Search…" />
500
+ ${loading.value ? html`<p>Searching…</p>` : ''}
499
501
  <ul>
500
502
  ${each(results.value, r => html`
501
503
  <li><a :href="${r._path}">${r.title}</a></li>
@@ -505,7 +507,7 @@ component('page-search', () => {
505
507
  })
506
508
  ```
507
509
 
508
- Search activates when `query.value.length >= 2`. Empty or single-character queries return an empty array.
510
+ `loading` becomes `true` as soon as the user types anything and returns to `false` once results arrive or the query is cleared. An empty query clears results immediately without waiting for the debounce. Search is always client-side in SSR mode the component renders with empty results and hydrates on mount.
509
511
 
510
512
  ### Searched fields
511
513
 
@@ -303,6 +303,63 @@ describe('Content search — useContentSearch()', () => {
303
303
  setSearchQuery('')
304
304
  cy.get('[data-cy=content-search-result]').should('not.exist')
305
305
  })
306
+
307
+ it('shows no-results message for an unmatched query', () => {
308
+ // Use a query with no underscores — MiniSearch tokenizes on \p{P} which
309
+ // includes underscores, splitting e.g. "foo_bar" into two tokens that can
310
+ // accidentally match real content via OR semantics.
311
+ cy.visit('/content-search')
312
+ cy.wait('@searchIndex')
313
+ setSearchQuery('zzznomatch')
314
+ // Wait for the empty state — implicitly waits for loading to clear first
315
+ cy.get('[data-cy=content-search-empty]', { timeout: 8000 }).should('exist')
316
+ cy.get('[data-cy=content-search-result]').should('not.exist')
317
+ cy.get('[data-cy=content-search-loading]').should('not.exist')
318
+ })
319
+
320
+ it('sequential search: search → clear → search again returns correct results', () => {
321
+ cy.visit('/content-search')
322
+ cy.wait('@searchIndex')
323
+
324
+ // First search
325
+ setSearchQuery('Hello')
326
+ cy.get('[data-cy=content-search-result]', { timeout: 8000 }).should('contain', 'Hello World')
327
+
328
+ // Clear — results gone
329
+ setSearchQuery('')
330
+ cy.get('[data-cy=content-search-result]').should('not.exist')
331
+ cy.get('[data-cy=content-search-empty]').should('not.exist')
332
+
333
+ // Second search with a different term
334
+ setSearchQuery('Getting')
335
+ cy.get('[data-cy=content-search-result]', { timeout: 8000 }).should('contain', 'Getting Started')
336
+ // First search result must not bleed through
337
+ cy.get('[data-cy=content-search-result]').each(($el) => {
338
+ expect($el.text()).not.to.include('Hello World')
339
+ })
340
+ })
341
+
342
+ it('rapid typing triggers only one search request (debounce)', () => {
343
+ cy.visit('/content-search')
344
+ cy.wait('@searchIndex')
345
+
346
+ // Type characters in quick succession — each triggers a new watch callback
347
+ // but only the last one should produce a network request after 200 ms.
348
+ cy.intercept('GET', '/_content/search-index.json').as('secondIndex')
349
+ setSearchQuery('G')
350
+ setSearchQuery('Ge')
351
+ setSearchQuery('Get')
352
+ setSearchQuery('Gett')
353
+ setSearchQuery('Getti')
354
+ setSearchQuery('Getting')
355
+
356
+ // Results arrive for the final query
357
+ cy.get('[data-cy=content-search-result]', { timeout: 8000 }).should('contain', 'Getting Started')
358
+
359
+ // The search index is cached after the first pre-warm fetch (singleton), so
360
+ // no additional network request should occur for these follow-on searches.
361
+ cy.get('@secondIndex.all').should('have.length', 0)
362
+ })
306
363
  })
307
364
 
308
365
  // ─── /content-fallback ────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.23.0",
3
+ "version": "0.23.2",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { resolve } from 'pathe'
4
4
  import { generateAppEntryTemplate } from '../../runtime/app-template.js'
5
+ import { generateJitInitModule } from '../../plugin/index.js'
5
6
 
6
7
  const src = readFileSync(
7
8
  resolve(import.meta.dirname, '../../runtime/app-template.ts'),
@@ -44,15 +45,27 @@ describe('app-template (source content)', () => {
44
45
  expect(src).toContain('custom-elements-runtime/router')
45
46
  })
46
47
 
47
- it('imports enableJITCSS from the jit-css subpath', () => {
48
- expect(src).toContain('enableJITCSS')
49
- expect(src).toContain('custom-elements-runtime/jit-css')
50
- })
51
-
52
48
  it('imports virtual:cer-jit-css', () => {
53
49
  expect(src).toContain('virtual:cer-jit-css')
54
50
  })
55
51
 
52
+ it('imports virtual:cer-jit-init before virtual:cer-layouts so JIT CSS is enabled before elements upgrade', () => {
53
+ expect(src).toContain('virtual:cer-jit-init')
54
+ const initIdx = src.indexOf('virtual:cer-jit-init')
55
+ const layoutsIdx = src.indexOf('virtual:cer-layouts')
56
+ const pluginsIdx = src.indexOf('virtual:cer-plugins')
57
+ expect(initIdx).toBeLessThan(layoutsIdx)
58
+ expect(initIdx).toBeLessThan(pluginsIdx)
59
+ })
60
+
61
+ it('does not import enableJITCSS in the module body (delegated to virtual:cer-jit-init)', () => {
62
+ // The enableJITCSS import must not appear in the template literal — it belongs
63
+ // in virtual:cer-jit-init which runs during the static import phase, before
64
+ // virtual:cer-layouts and virtual:cer-plugins upgrade custom elements.
65
+ const templateStart = src.indexOf('return `')
66
+ expect(src.slice(templateStart)).not.toContain("import { enableJITCSS }")
67
+ })
68
+
56
69
  it('imports virtual:cer-content-components for markdown-backed custom elements', () => {
57
70
  expect(src).toContain('virtual:cer-content-components')
58
71
  })
@@ -63,20 +76,50 @@ describe('app-template (source content)', () => {
63
76
  })
64
77
 
65
78
  describe('generateAppEntryTemplate', () => {
66
- it('calls enableJITCSS() with no arguments when customColors is omitted', () => {
79
+ it('includes virtual:cer-jit-init import', () => {
80
+ const out = generateAppEntryTemplate()
81
+ expect(out).toContain("import 'virtual:cer-jit-init'")
82
+ })
83
+
84
+ it('places virtual:cer-jit-init before virtual:cer-layouts and virtual:cer-plugins', () => {
85
+ const out = generateAppEntryTemplate()
86
+ const initIdx = out.indexOf('virtual:cer-jit-init')
87
+ const layoutsIdx = out.indexOf('virtual:cer-layouts')
88
+ const pluginsIdx = out.indexOf('virtual:cer-plugins')
89
+ expect(initIdx).toBeGreaterThanOrEqual(0)
90
+ expect(initIdx).toBeLessThan(layoutsIdx)
91
+ expect(initIdx).toBeLessThan(pluginsIdx)
92
+ })
93
+
94
+ it('does not import enableJITCSS in the generated output (delegated to virtual:cer-jit-init)', () => {
95
+ const out = generateAppEntryTemplate()
96
+ expect(out).not.toContain("import { enableJITCSS }")
97
+ })
98
+
99
+ it('still includes all standard template content', () => {
67
100
  const out = generateAppEntryTemplate()
101
+ expect(out).toContain('virtual:cer-jit-css')
102
+ expect(out).toContain('virtual:cer-routes')
103
+ expect(out).toContain('export { router }')
104
+ })
105
+ })
106
+
107
+ describe('generateJitInitModule', () => {
108
+ it('calls enableJITCSS() with no arguments when no options are set', () => {
109
+ const out = generateJitInitModule({ content: [], extendedColors: false, customColors: undefined })
68
110
  expect(out).toContain('enableJITCSS()')
69
111
  expect(out).not.toContain('customColors')
112
+ expect(out).not.toContain('extendedColors')
70
113
  })
71
114
 
72
115
  it('calls enableJITCSS() with no arguments when customColors is an empty object', () => {
73
- const out = generateAppEntryTemplate({})
116
+ const out = generateJitInitModule({ content: [], extendedColors: false, customColors: {} })
74
117
  expect(out).toContain('enableJITCSS()')
75
118
  expect(out).not.toContain('customColors')
76
119
  })
77
120
 
78
121
  it('serializes a single color family into the enableJITCSS call', () => {
79
- const out = generateAppEntryTemplate({ brand: { '500': '#7c3aed' } })
122
+ const out = generateJitInitModule({ content: [], extendedColors: false, customColors: { brand: { '500': '#7c3aed' } } })
80
123
  expect(out).toContain('enableJITCSS({ customColors:')
81
124
  expect(out).toContain('"brand"')
82
125
  expect(out).toContain('"500"')
@@ -84,9 +127,10 @@ describe('generateAppEntryTemplate', () => {
84
127
  })
85
128
 
86
129
  it('serializes multiple color families correctly', () => {
87
- const out = generateAppEntryTemplate({
88
- brand: { '100': '#ede9fe', '900': '#4c1d95' },
89
- accent: { DEFAULT: '#f59e0b' },
130
+ const out = generateJitInitModule({
131
+ content: [],
132
+ extendedColors: false,
133
+ customColors: { brand: { '100': '#ede9fe', '900': '#4c1d95' }, accent: { DEFAULT: '#f59e0b' } },
90
134
  })
91
135
  expect(out).toContain('"brand"')
92
136
  expect(out).toContain('"accent"')
@@ -95,18 +139,28 @@ describe('generateAppEntryTemplate', () => {
95
139
  })
96
140
 
97
141
  it('serializes CSS variable references as color values', () => {
98
- const out = generateAppEntryTemplate({
99
- surface: { DEFAULT: 'var(--md-sys-color-surface)' },
142
+ const out = generateJitInitModule({
143
+ content: [],
144
+ extendedColors: false,
145
+ customColors: { surface: { DEFAULT: 'var(--md-sys-color-surface)' } },
100
146
  })
101
147
  expect(out).toContain('"surface"')
102
148
  expect(out).toContain('var(--md-sys-color-surface)')
103
149
  })
104
150
 
105
- it('still includes all standard template content when customColors is provided', () => {
106
- const out = generateAppEntryTemplate({ brand: { '500': '#ff0000' } })
107
- expect(out).toContain('virtual:cer-jit-css')
108
- expect(out).toContain('virtual:cer-routes')
151
+ it('includes extendedColors: true when set', () => {
152
+ const out = generateJitInitModule({ content: [], extendedColors: true, customColors: undefined })
153
+ expect(out).toContain('extendedColors: true')
154
+ })
155
+
156
+ it('includes extendedColors array when set', () => {
157
+ const out = generateJitInitModule({ content: [], extendedColors: ['slate', 'blue'], customColors: undefined })
158
+ expect(out).toContain('extendedColors: ["slate","blue"]')
159
+ })
160
+
161
+ it('imports enableJITCSS from the jit-css subpath', () => {
162
+ const out = generateJitInitModule({ content: [], extendedColors: false, customColors: undefined })
109
163
  expect(out).toContain('enableJITCSS')
110
- expect(out).toContain('export { router }')
164
+ expect(out).toContain('custom-elements-runtime/jit-css')
111
165
  })
112
166
  })
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ injectFaviconLink,
4
+ injectCanonicalLink,
5
+ addNoopenerToExternalLinks,
6
+ generateRobotsTxt,
7
+ } from '../../plugin/html-post-process.js'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // injectFaviconLink
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe('injectFaviconLink', () => {
14
+ it('injects link before </head> when none exists', () => {
15
+ const html = '<html><head><title>Test</title></head><body></body></html>'
16
+ const result = injectFaviconLink(html, '/favicon.ico')
17
+ expect(result).toContain('<link rel="icon" href="/favicon.ico">')
18
+ expect(result.indexOf('<link rel="icon"')).toBeLessThan(result.indexOf('</head>'))
19
+ })
20
+
21
+ it('does not inject when rel="icon" already present', () => {
22
+ const html = '<html><head><link rel="icon" href="/custom.ico"></head></html>'
23
+ expect(injectFaviconLink(html, '/favicon.ico')).toBe(html)
24
+ })
25
+
26
+ it('does not inject when rel="shortcut icon" already present', () => {
27
+ const html = '<html><head><link rel="shortcut icon" href="/old.ico"></head></html>'
28
+ expect(injectFaviconLink(html, '/favicon.ico')).toBe(html)
29
+ })
30
+
31
+ it('prepends tag when no </head> is found', () => {
32
+ const html = '<html><body></body></html>'
33
+ const result = injectFaviconLink(html, '/favicon.svg')
34
+ expect(result).toContain('<link rel="icon" href="/favicon.svg">')
35
+ })
36
+
37
+ it('supports svg favicon href', () => {
38
+ const html = '<html><head></head></html>'
39
+ expect(injectFaviconLink(html, '/favicon.svg')).toContain('href="/favicon.svg"')
40
+ })
41
+ })
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // injectCanonicalLink
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('injectCanonicalLink', () => {
48
+ it('injects canonical before </head>', () => {
49
+ const html = '<html><head><title>T</title></head><body></body></html>'
50
+ const result = injectCanonicalLink(html, 'https://example.com/about')
51
+ expect(result).toContain('<link rel="canonical" href="https://example.com/about">')
52
+ expect(result.indexOf('<link rel="canonical"')).toBeLessThan(result.indexOf('</head>'))
53
+ })
54
+
55
+ it('does not inject when canonical already present', () => {
56
+ const html = '<head><link rel="canonical" href="https://example.com/"></head>'
57
+ expect(injectCanonicalLink(html, 'https://example.com/')).toBe(html)
58
+ })
59
+
60
+ it('escapes & in the URL', () => {
61
+ const html = '<html><head></head></html>'
62
+ const result = injectCanonicalLink(html, 'https://example.com/?a=1&b=2')
63
+ expect(result).toContain('href="https://example.com/?a=1&amp;b=2"')
64
+ })
65
+
66
+ it('prepends tag when no </head> is found', () => {
67
+ const html = '<html><body></body></html>'
68
+ const result = injectCanonicalLink(html, 'https://example.com/')
69
+ expect(result).toContain('<link rel="canonical"')
70
+ })
71
+ })
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // addNoopenerToExternalLinks
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe('addNoopenerToExternalLinks', () => {
78
+ it('adds rel to target="_blank" link with no rel', () => {
79
+ const html = '<a href="https://example.com" target="_blank">Link</a>'
80
+ const result = addNoopenerToExternalLinks(html)
81
+ expect(result).toContain('rel="noopener noreferrer"')
82
+ })
83
+
84
+ it('does not modify links without target="_blank"', () => {
85
+ const html = '<a href="https://example.com">Link</a>'
86
+ expect(addNoopenerToExternalLinks(html)).toBe(html)
87
+ })
88
+
89
+ it('does not duplicate when both values already present', () => {
90
+ const html = '<a href="https://x.com" target="_blank" rel="noopener noreferrer">X</a>'
91
+ const result = addNoopenerToExternalLinks(html)
92
+ expect(result).toBe(html)
93
+ })
94
+
95
+ it('appends missing noopener to existing rel', () => {
96
+ const html = '<a href="https://x.com" target="_blank" rel="noreferrer">X</a>'
97
+ const result = addNoopenerToExternalLinks(html)
98
+ expect(result).toContain('noopener')
99
+ expect(result).toContain('noreferrer')
100
+ })
101
+
102
+ it('appends missing noreferrer to existing rel', () => {
103
+ const html = '<a href="https://x.com" target="_blank" rel="noopener">X</a>'
104
+ const result = addNoopenerToExternalLinks(html)
105
+ expect(result).toContain('noreferrer')
106
+ })
107
+
108
+ it('handles target="_blank" after href in attributes', () => {
109
+ const html = '<a href="https://x.com" class="btn" target="_blank">X</a>'
110
+ const result = addNoopenerToExternalLinks(html)
111
+ expect(result).toContain('rel="noopener noreferrer"')
112
+ })
113
+
114
+ it('handles multiple links in the same document', () => {
115
+ const html = [
116
+ '<a href="https://a.com" target="_blank">A</a>',
117
+ '<a href="/internal">Internal</a>',
118
+ '<a href="https://b.com" target="_blank">B</a>',
119
+ ].join('')
120
+ const result = addNoopenerToExternalLinks(html)
121
+ expect(result.match(/rel="noopener noreferrer"/g)?.length).toBe(2)
122
+ })
123
+ })
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // generateRobotsTxt
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe('generateRobotsTxt', () => {
130
+ it('generates allow-all rules without siteUrl', () => {
131
+ const txt = generateRobotsTxt(null)
132
+ expect(txt).toContain('User-agent: *')
133
+ expect(txt).toContain('Allow: /')
134
+ expect(txt).not.toContain('Sitemap:')
135
+ })
136
+
137
+ it('includes Sitemap directive when siteUrl is provided', () => {
138
+ const txt = generateRobotsTxt('https://example.com')
139
+ expect(txt).toContain('Sitemap: https://example.com/sitemap.xml')
140
+ })
141
+
142
+ it('ends with a newline', () => {
143
+ expect(generateRobotsTxt(null).endsWith('\n')).toBe(true)
144
+ expect(generateRobotsTxt('https://example.com').endsWith('\n')).toBe(true)
145
+ })
146
+ })
@@ -197,4 +197,37 @@ describe('resolveConfig', () => {
197
197
  expect(cfg.runtimeConfig.public).toEqual({ apiBase: '/api' })
198
198
  expect(cfg.runtimeConfig.private).toEqual({ token: '' })
199
199
  })
200
+
201
+ it('defaults siteUrl to null when not set', () => {
202
+ const cfg = resolveConfig({}, ROOT)
203
+ expect(cfg.siteUrl).toBeNull()
204
+ })
205
+
206
+ it('stores siteUrl with trailing slash stripped', () => {
207
+ const cfg = resolveConfig({ siteUrl: 'https://example.com/' }, ROOT)
208
+ expect(cfg.siteUrl).toBe('https://example.com')
209
+ })
210
+
211
+ it('stores siteUrl unchanged when no trailing slash', () => {
212
+ const cfg = resolveConfig({ siteUrl: 'https://example.com' }, ROOT)
213
+ expect(cfg.siteUrl).toBe('https://example.com')
214
+ })
215
+
216
+ it('exposes siteUrl in runtimeConfig.public', () => {
217
+ const cfg = resolveConfig({ siteUrl: 'https://example.com' }, ROOT)
218
+ expect(cfg.runtimeConfig.public['siteUrl']).toBe('https://example.com')
219
+ })
220
+
221
+ it('does not add siteUrl to runtimeConfig.public when not set', () => {
222
+ const cfg = resolveConfig({}, ROOT)
223
+ expect(Object.prototype.hasOwnProperty.call(cfg.runtimeConfig.public, 'siteUrl')).toBe(false)
224
+ })
225
+
226
+ it('user-supplied runtimeConfig.public.siteUrl overrides the siteUrl shorthand', () => {
227
+ const cfg = resolveConfig({
228
+ siteUrl: 'https://example.com',
229
+ runtimeConfig: { public: { siteUrl: 'https://override.com' } },
230
+ }, ROOT)
231
+ expect(cfg.runtimeConfig.public['siteUrl']).toBe('https://override.com')
232
+ })
200
233
  })
@@ -91,6 +91,16 @@ describe('APP_ENTRY_TEMPLATE — meta.hydrate', () => {
91
91
  expect(APP_ENTRY_TEMPLATE).toContain('return')
92
92
  })
93
93
 
94
+ it('keeps the SSR slot during the initRouter startup microtask navigation (before page chunk loads)', () => {
95
+ // initRouter() queues queueMicrotask(() => navigate(...)) to run guards on
96
+ // the entry URL. That microtask fires during await route.load() — before
97
+ // _currentPageTag is set. Without this guard, _cerHydrating would be set to
98
+ // false prematurely, dropping the SSR slot and showing an empty router-view.
99
+ expect(APP_ENTRY_TEMPLATE).toContain(
100
+ 'if (_cerHydrating.value && _currentPageTag !== null) _cerHydrating.value = false',
101
+ )
102
+ })
103
+
94
104
  it('exposes router globally as __cerRouter', () => {
95
105
  expect(APP_ENTRY_TEMPLATE).toContain('__cerRouter')
96
106
  })