@jasonshimmy/vite-plugin-cer-app 0.23.1 → 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 +4 -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-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/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-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-head.ts +28 -6
- package/src/types/config.ts +8 -0
|
@@ -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
|
})
|
|
@@ -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
|
}
|
package/src/plugin/build-ssg.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { writeFile, mkdir, readFile } from 'node:fs/promises'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { join } from 'pathe'
|
|
4
|
+
import {
|
|
5
|
+
injectFaviconLink,
|
|
6
|
+
injectCanonicalLink,
|
|
7
|
+
addNoopenerToExternalLinks,
|
|
8
|
+
generateRobotsTxt,
|
|
9
|
+
} from './html-post-process.js'
|
|
4
10
|
import { createServer, type UserConfig } from 'vite'
|
|
5
11
|
import type { ResolvedCerConfig } from './dev-server.js'
|
|
6
12
|
import type { ContentItem } from '../types/content.js'
|
|
@@ -255,13 +261,59 @@ export async function writeRenderedPath(
|
|
|
255
261
|
await writeFile(outputPath, html, 'utf-8')
|
|
256
262
|
}
|
|
257
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Detects which favicon href to use from the project's public directory.
|
|
266
|
+
* Prefers .svg > .ico > .png. Returns null when none is found.
|
|
267
|
+
*/
|
|
268
|
+
function detectFaviconHref(root: string): string | null {
|
|
269
|
+
const candidates = ['/favicon.svg', '/favicon.ico', '/favicon.png']
|
|
270
|
+
for (const href of candidates) {
|
|
271
|
+
if (existsSync(join(root, 'public', href.slice(1)))) return href
|
|
272
|
+
}
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Applies all HTML post-processing transforms to a rendered page.
|
|
278
|
+
*
|
|
279
|
+
* - Injects `<link rel="icon">` when a favicon exists in public/ and none
|
|
280
|
+
* is already declared in the HTML.
|
|
281
|
+
* - Injects `<link rel="canonical">` when `config.siteUrl` is set and none
|
|
282
|
+
* is already declared.
|
|
283
|
+
* - Adds `rel="noopener noreferrer"` to every `<a target="_blank">` link
|
|
284
|
+
* that is missing it.
|
|
285
|
+
*/
|
|
286
|
+
function postProcessHtml(
|
|
287
|
+
html: string,
|
|
288
|
+
path: string,
|
|
289
|
+
config: ResolvedCerConfig,
|
|
290
|
+
faviconHref: string | null,
|
|
291
|
+
): string {
|
|
292
|
+
let out = html
|
|
293
|
+
|
|
294
|
+
if (faviconHref) {
|
|
295
|
+
out = injectFaviconLink(out, faviconHref)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (config.siteUrl) {
|
|
299
|
+
const canonicalUrl = config.siteUrl + (path === '/' ? '' : path)
|
|
300
|
+
out = injectCanonicalLink(out, canonicalUrl)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
out = addNoopenerToExternalLinks(out)
|
|
304
|
+
|
|
305
|
+
return out
|
|
306
|
+
}
|
|
307
|
+
|
|
258
308
|
/**
|
|
259
309
|
* Full SSG build pipeline:
|
|
260
310
|
* 1. Run the SSR dual-build (client + server bundles)
|
|
261
311
|
* 2. Enumerate all paths to generate
|
|
262
312
|
* 3. Render each path using the server bundle
|
|
263
|
-
* 4.
|
|
264
|
-
* 5. Write
|
|
313
|
+
* 4. Post-process HTML (favicon, canonical, noopener)
|
|
314
|
+
* 5. Write HTML files to dist/
|
|
315
|
+
* 6. Write robots.txt (when not already present in public/)
|
|
316
|
+
* 7. Write ssg-manifest.json
|
|
265
317
|
*/
|
|
266
318
|
export async function buildSSG(
|
|
267
319
|
config: ResolvedCerConfig,
|
|
@@ -281,7 +333,10 @@ export async function buildSSG(
|
|
|
281
333
|
const paths = await collectSsgPaths(config, viteUserConfig)
|
|
282
334
|
console.log(`[cer-app] Found ${paths.length} path(s) to generate:`, paths)
|
|
283
335
|
|
|
284
|
-
//
|
|
336
|
+
// Detect favicon once for use in every page's post-processing pass.
|
|
337
|
+
const faviconHref = detectFaviconHref(config.root)
|
|
338
|
+
|
|
339
|
+
// Step 3+4+5: Render, post-process, and write paths with bounded concurrency.
|
|
285
340
|
// The server bundle uses per-request router instances (initRouter returns the
|
|
286
341
|
// router; the factory passes it to createStreamingSSRHandler as { vnode, router })
|
|
287
342
|
// so concurrent renders are safe — each request carries its own router with its
|
|
@@ -299,7 +354,8 @@ export async function buildSSG(
|
|
|
299
354
|
const results = await Promise.allSettled(
|
|
300
355
|
chunk.map(async (path) => {
|
|
301
356
|
console.log(`[cer-app] Generating: ${path}`)
|
|
302
|
-
const
|
|
357
|
+
const raw = await renderPath(path, serverBundlePath)
|
|
358
|
+
const html = postProcessHtml(raw, path, config, faviconHref)
|
|
303
359
|
await writeRenderedPath(path, html, distDir)
|
|
304
360
|
return path
|
|
305
361
|
}),
|
|
@@ -316,7 +372,18 @@ export async function buildSSG(
|
|
|
316
372
|
}
|
|
317
373
|
}
|
|
318
374
|
|
|
319
|
-
// Step
|
|
375
|
+
// Step 6: Write robots.txt unless the project supplies its own via public/robots.txt,
|
|
376
|
+
// which Vite copies to dist/ as-is. Always overwrite any previously generated file so
|
|
377
|
+
// that siteUrl changes (e.g. adding the Sitemap directive) take effect on rebuild.
|
|
378
|
+
const publicRobots = join(config.root, 'public', 'robots.txt')
|
|
379
|
+
if (!existsSync(publicRobots)) {
|
|
380
|
+
const distRobots = join(distDir, 'robots.txt')
|
|
381
|
+
await mkdir(distDir, { recursive: true })
|
|
382
|
+
await writeFile(distRobots, generateRobotsTxt(config.siteUrl), 'utf-8')
|
|
383
|
+
console.log('[cer-app] Generated robots.txt')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Step 7: Write SSG manifest
|
|
320
387
|
const manifest: SsgManifest = {
|
|
321
388
|
generatedAt: new Date().toISOString(),
|
|
322
389
|
paths: generatedPaths,
|
package/src/plugin/dev-server.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface ResolvedCerConfig {
|
|
|
9
9
|
mode: 'spa' | 'ssr' | 'ssg'
|
|
10
10
|
srcDir: string
|
|
11
11
|
root: string
|
|
12
|
+
/** Canonical site origin (no trailing slash). Null when not configured. */
|
|
13
|
+
siteUrl: string | null
|
|
12
14
|
contentDir: string
|
|
13
15
|
pagesDir: string
|
|
14
16
|
layoutsDir: string
|
|
@@ -107,7 +107,7 @@ export function writeGeneratedDir(config: ResolvedCerConfig): void {
|
|
|
107
107
|
// Always write the generated app.ts — this is the framework entry point and
|
|
108
108
|
// is never user-owned. Regenerating it on every dev/build ensures consumers
|
|
109
109
|
// automatically get the latest bootstrap code on plugin update (Nuxt-style).
|
|
110
|
-
writeFileSync(join(dir, 'app.ts'), generateAppEntryTemplate(
|
|
110
|
+
writeFileSync(join(dir, 'app.ts'), generateAppEntryTemplate(), 'utf-8')
|
|
111
111
|
|
|
112
112
|
// Always write the SSR entry — used by the dev server's ssrLoadModule call.
|
|
113
113
|
// The production build injects this as a virtual module, but the dev server
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML post-processing transforms applied to every SSG-rendered page.
|
|
3
|
+
*
|
|
4
|
+
* Each function is a pure string → string transform. They are composed in
|
|
5
|
+
* build-ssg.ts after the server bundle renders each path.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Favicon injection
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Injects `<link rel="icon" href="${faviconHref}">` before `</head>` when no
|
|
14
|
+
* `<link rel="icon">` or `<link rel="shortcut icon">` is already present.
|
|
15
|
+
*/
|
|
16
|
+
export function injectFaviconLink(html: string, faviconHref: string): string {
|
|
17
|
+
if (/<link[^>]+rel=["'](?:shortcut )?icon["']/i.test(html)) return html
|
|
18
|
+
const tag = `<link rel="icon" href="${faviconHref}">`
|
|
19
|
+
return insertBeforeHead(html, tag)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Canonical link injection
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Injects `<link rel="canonical" href="${url}">` before `</head>` when no
|
|
28
|
+
* `<link rel="canonical">` is already present.
|
|
29
|
+
*/
|
|
30
|
+
export function injectCanonicalLink(html: string, url: string): string {
|
|
31
|
+
if (/<link[^>]+rel=["']canonical["']/i.test(html)) return html
|
|
32
|
+
const tag = `<link rel="canonical" href="${escapeAttr(url)}">`
|
|
33
|
+
return insertBeforeHead(html, tag)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// noopener / noreferrer on external target="_blank" links
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Adds `rel="noopener noreferrer"` to every `<a target="_blank">` element
|
|
42
|
+
* that does not already have a `rel` attribute containing both values.
|
|
43
|
+
*
|
|
44
|
+
* Only touches anchor tags — not `<form>` or other elements with `target`.
|
|
45
|
+
*/
|
|
46
|
+
export function addNoopenerToExternalLinks(html: string): string {
|
|
47
|
+
return html.replace(
|
|
48
|
+
/(<a\b[^>]*\btarget\s*=\s*["']_blank["'][^>]*)(>)/gi,
|
|
49
|
+
(match, attrs: string, close: string) => {
|
|
50
|
+
const relMatch = attrs.match(/\brel\s*=\s*["']([^"']*)["']/i)
|
|
51
|
+
if (relMatch) {
|
|
52
|
+
const existing = relMatch[1]
|
|
53
|
+
const hasNoopener = /\bnoopener\b/i.test(existing)
|
|
54
|
+
const hasNoreferrer = /\bnoreferrer\b/i.test(existing)
|
|
55
|
+
if (hasNoopener && hasNoreferrer) return match
|
|
56
|
+
const parts = existing.trim().split(/\s+/).filter(Boolean)
|
|
57
|
+
if (!hasNoopener) parts.push('noopener')
|
|
58
|
+
if (!hasNoreferrer) parts.push('noreferrer')
|
|
59
|
+
return attrs.replace(relMatch[0], `rel="${parts.join(' ')}"`) + close
|
|
60
|
+
}
|
|
61
|
+
return `${attrs} rel="noopener noreferrer"${close}`
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// robots.txt generation
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generates the content of a `robots.txt` file.
|
|
72
|
+
* When `siteUrl` is provided a `Sitemap:` directive is included.
|
|
73
|
+
*/
|
|
74
|
+
export function generateRobotsTxt(siteUrl: string | null): string {
|
|
75
|
+
const lines = ['User-agent: *', 'Allow: /']
|
|
76
|
+
if (siteUrl) {
|
|
77
|
+
lines.push('', `Sitemap: ${siteUrl}/sitemap.xml`)
|
|
78
|
+
}
|
|
79
|
+
return lines.join('\n') + '\n'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Helpers
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function insertBeforeHead(html: string, tag: string): string {
|
|
87
|
+
const idx = html.indexOf('</head>')
|
|
88
|
+
if (idx !== -1) {
|
|
89
|
+
return html.slice(0, idx) + tag + '\n' + html.slice(idx)
|
|
90
|
+
}
|
|
91
|
+
return tag + '\n' + html
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeAttr(value: string): string {
|
|
95
|
+
return value.replace(/&/g, '&').replace(/"/g, '"')
|
|
96
|
+
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ const VIRTUAL_IDS = {
|
|
|
36
36
|
error: 'virtual:cer-error',
|
|
37
37
|
contentComponents: 'virtual:cer-content-components',
|
|
38
38
|
i18n: 'virtual:cer-i18n',
|
|
39
|
+
jitInit: 'virtual:cer-jit-init',
|
|
39
40
|
} as const
|
|
40
41
|
|
|
41
42
|
// Resolved virtual module IDs (prefixed with \0)
|
|
@@ -62,6 +63,7 @@ export function resolveConfig(userConfig: CerAppConfig, root: string = process.c
|
|
|
62
63
|
mode,
|
|
63
64
|
srcDir,
|
|
64
65
|
root,
|
|
66
|
+
siteUrl: userConfig.siteUrl ? userConfig.siteUrl.replace(/\/$/, '') : null,
|
|
65
67
|
contentDir: resolve(root, userConfig.content?.dir ?? 'content'),
|
|
66
68
|
pagesDir: join(srcDir, 'pages'),
|
|
67
69
|
layoutsDir: join(srcDir, 'layouts'),
|
|
@@ -97,7 +99,10 @@ export function resolveConfig(userConfig: CerAppConfig, root: string = process.c
|
|
|
97
99
|
runtime: userConfig.autoImports?.runtime ?? true,
|
|
98
100
|
},
|
|
99
101
|
runtimeConfig: {
|
|
100
|
-
public:
|
|
102
|
+
public: {
|
|
103
|
+
...(userConfig.siteUrl ? { siteUrl: userConfig.siteUrl.replace(/\/$/, '') } : {}),
|
|
104
|
+
...userConfig.runtimeConfig?.public,
|
|
105
|
+
},
|
|
101
106
|
private: userConfig.runtimeConfig?.private ?? {},
|
|
102
107
|
},
|
|
103
108
|
auth: userConfig.auth ?? null,
|
|
@@ -144,6 +149,8 @@ async function generateVirtualModule(
|
|
|
144
149
|
return generateContentComponentsCode(config.componentsDir, config.contentDir)
|
|
145
150
|
case RESOLVED_IDS.i18n:
|
|
146
151
|
return generateI18nModule(config.i18n)
|
|
152
|
+
case RESOLVED_IDS.jitInit:
|
|
153
|
+
return generateJitInitModule(config.jitCss)
|
|
147
154
|
default:
|
|
148
155
|
return null
|
|
149
156
|
}
|
|
@@ -167,6 +174,30 @@ function generateI18nModule(
|
|
|
167
174
|
)
|
|
168
175
|
}
|
|
169
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Generates the virtual:cer-jit-init module that calls enableJITCSS() with the
|
|
179
|
+
* project's jitCss options. Imported as the first side-effect import in app.ts
|
|
180
|
+
* so JIT CSS is active before virtual:cer-layouts and virtual:cer-plugins run
|
|
181
|
+
* their static imports — which upgrade custom elements synchronously via
|
|
182
|
+
* customElements.define(). Without this, applyStyle() runs with _jitCSSEnabled=false
|
|
183
|
+
* and produces unstyled renders that replace the DSD pre-rendered content.
|
|
184
|
+
*/
|
|
185
|
+
export function generateJitInitModule(jitCss: ResolvedCerConfig['jitCss']): string {
|
|
186
|
+
const args: string[] = []
|
|
187
|
+
if (jitCss.extendedColors) {
|
|
188
|
+
args.push(`extendedColors: ${JSON.stringify(jitCss.extendedColors)}`)
|
|
189
|
+
}
|
|
190
|
+
if (jitCss.customColors && Object.keys(jitCss.customColors).length > 0) {
|
|
191
|
+
args.push(`customColors: ${JSON.stringify(jitCss.customColors)}`)
|
|
192
|
+
}
|
|
193
|
+
const callArgs = args.length > 0 ? `{ ${args.join(', ')} }` : ''
|
|
194
|
+
return (
|
|
195
|
+
`// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\n` +
|
|
196
|
+
`import { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'\n` +
|
|
197
|
+
`enableJITCSS(${callArgs})\n`
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
170
201
|
/**
|
|
171
202
|
* Generates a virtual module that exports the resolved app config.
|
|
172
203
|
* When `ssr` is true, also exports `_runtimePrivateDefaults` (server-only).
|
|
@@ -313,7 +344,7 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
|
|
|
313
344
|
},
|
|
314
345
|
|
|
315
346
|
async load(id: string, options?: { ssr?: boolean }) {
|
|
316
|
-
if (id === RESOLVED_APP_ENTRY) return generateAppEntryTemplate(
|
|
347
|
+
if (id === RESOLVED_APP_ENTRY) return generateAppEntryTemplate()
|
|
317
348
|
|
|
318
349
|
const allResolved = Object.values(RESOLVED_IDS) as string[]
|
|
319
350
|
if (!allResolved.includes(id)) return null
|