@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +4 -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-head.d.ts.map +1 -1
  28. package/dist/runtime/composables/use-head.js +30 -6
  29. package/dist/runtime/composables/use-head.js.map +1 -1
  30. package/dist/types/config.d.ts +8 -0
  31. package/dist/types/config.d.ts.map +1 -1
  32. package/dist/types/config.js.map +1 -1
  33. package/docs/configuration.md +29 -0
  34. package/package.json +1 -1
  35. package/src/__tests__/plugin/app-template.test.ts +72 -18
  36. package/src/__tests__/plugin/html-post-process.test.ts +146 -0
  37. package/src/__tests__/plugin/resolve-config.test.ts +33 -0
  38. package/src/__tests__/runtime/app-template.test.ts +10 -0
  39. package/src/__tests__/runtime/use-head.test.ts +45 -0
  40. package/src/cli/commands/preview.ts +38 -4
  41. package/src/plugin/build-ssg.ts +72 -5
  42. package/src/plugin/dev-server.ts +2 -0
  43. package/src/plugin/generated-dir.ts +1 -1
  44. package/src/plugin/html-post-process.ts +96 -0
  45. package/src/plugin/index.ts +33 -2
  46. package/src/runtime/app-template.ts +14 -17
  47. package/src/runtime/composables/use-head.ts +28 -6
  48. 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&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
  })
@@ -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
  }
@@ -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. Write HTML files to dist/
264
- * 5. Write ssg-manifest.json
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
- // Step 3+4: Render and write paths with bounded concurrency.
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 html = await renderPath(path, serverBundlePath)
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 5: Write SSG manifest
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,
@@ -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(config.jitCss.customColors), 'utf-8')
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, '&amp;').replace(/"/g, '&quot;')
96
+ }
@@ -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: userConfig.runtimeConfig?.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(config.jitCss.customColors)
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