@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
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 `
|
|
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>
|
|
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
|
-
|
|
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
|
@@ -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('
|
|
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 =
|
|
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 =
|
|
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 =
|
|
88
|
-
|
|
89
|
-
|
|
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 =
|
|
99
|
-
|
|
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('
|
|
106
|
-
const out =
|
|
107
|
-
expect(out).toContain('
|
|
108
|
-
|
|
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('
|
|
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&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
|
})
|