@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
@@ -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
@@ -4,23 +4,18 @@
4
4
  * Always written to `.cer/app.ts` on every dev/build so consumers
5
5
  * automatically receive the latest bootstrap code on plugin update.
6
6
  * This file is gitignored and should never be edited directly.
7
- *
8
- * @param customColors - Project-specific color families to register in the JIT
9
- * CSS engine at runtime. Serialized as a JSON literal in the generated source.
10
7
  */
11
- export function generateAppEntryTemplate(
12
- customColors?: Record<string, Record<string, string>>,
13
- ): string {
14
- const enableJITCSSCall =
15
- customColors && Object.keys(customColors).length > 0
16
- ? `enableJITCSS({ customColors: ${JSON.stringify(customColors)} })`
17
- : `enableJITCSS()`
18
-
8
+ export function generateAppEntryTemplate(): string {
19
9
  return `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app — do not edit.
20
10
  // Regenerated automatically on every dev server start and build.
21
11
 
22
12
  import '@jasonshimmy/custom-elements-runtime/css'
23
13
  import 'virtual:cer-jit-css'
14
+ // virtual:cer-jit-init must run before virtual:cer-layouts and virtual:cer-plugins
15
+ // so JIT CSS is enabled before those modules upgrade custom elements via
16
+ // customElements.define(). Static imports execute depth-first in module order,
17
+ // so placing this import here guarantees enableJITCSS() fires first.
18
+ import 'virtual:cer-jit-init'
24
19
  import 'virtual:cer-content-components'
25
20
  import routes from 'virtual:cer-routes'
26
21
  import layouts from 'virtual:cer-layouts'
@@ -38,7 +33,6 @@ import {
38
33
  registerBuiltinComponents,
39
34
  } from '@jasonshimmy/custom-elements-runtime'
40
35
  import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
41
- import { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'
42
36
  import { initRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
43
37
 
44
38
  const _cerProcess = (globalThis).process
@@ -57,7 +51,6 @@ const _cerRuntimeDev =
57
51
 
58
52
  registerBuiltinComponents()
59
53
  setDevMode(_cerRuntimeDev)
60
- ${enableJITCSSCall}
61
54
  initRuntimeConfig(runtimeConfig)
62
55
 
63
56
  const router = initRouter({ routes })
@@ -217,9 +210,13 @@ component('cer-layout-view', () => {
217
210
  // store.subscribe() synchronously pushes the current route once on
218
211
  // subscription. That initial callback is not a real navigation and must not
219
212
  // tear down the SSR slot before _doHydrate has pre-loaded the page.
220
- // Subsequent callbacks are real navigations (including the initial _replace
221
- // in _doHydrate), so if they arrive during the hydration gap we drop the
222
- // slot immediately and let the live render take over.
213
+ // Subsequent callbacks may be real navigations, but initRouter() also fires
214
+ // a queueMicrotask(() => navigate(...)) to run guards on the entry URL.
215
+ // That startup microtask resolves during the await route.load() gap
216
+ // before _currentPageTag is set — so we must not drop the slot until the
217
+ // page module has actually been loaded (_currentPageTag !== null).
218
+ // The explicit _cerHydrating.value = false in _doHydrate() fires after
219
+ // _loadPageForPath completes and is the authoritative hydration trigger.
223
220
  unsub = router.subscribe((s) => {
224
221
  const _isInitialSubscribePush = !_sawInitialRouteState
225
222
  _sawInitialRouteState = true
@@ -227,7 +224,7 @@ component('cer-layout-view', () => {
227
224
  current.value = s
228
225
  return
229
226
  }
230
- if (_cerHydrating.value) _cerHydrating.value = false
227
+ if (_cerHydrating.value && _currentPageTag !== null) _cerHydrating.value = false
231
228
  current.value = s
232
229
  })
233
230
  })
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  createComposable,
3
+ getCurrentComponentContext,
3
4
  ref,
4
5
  useOnConnected,
6
+ useOnDisconnected,
5
7
  watch,
6
8
  } from '@jasonshimmy/custom-elements-runtime'
7
9
  import type { ReactiveState } from '@jasonshimmy/custom-elements-runtime'
@@ -19,11 +21,14 @@ let _indexPromise: Promise<unknown> | null = null
19
21
  * Returns the same Promise on repeated calls — the index is built at most once
20
22
  * per session regardless of how many search components are mounted.
21
23
  *
24
+ * If the fetch fails the singleton is cleared so the next search attempt
25
+ * retries automatically (no page reload required after a transient error).
26
+ *
22
27
  * @internal Exported for unit testing only.
23
28
  */
24
- export async function loadIndex(): Promise<unknown> {
29
+ export function loadIndex(): Promise<unknown> {
25
30
  if (_indexPromise) return _indexPromise
26
- _indexPromise = (async () => {
31
+ const attempt = (async () => {
27
32
  const [{ default: MiniSearch }, raw] = await Promise.all([
28
33
  import('minisearch'),
29
34
  fetch(contentSearchIndexUrl()).then((r) => {
@@ -37,7 +42,13 @@ export async function loadIndex(): Promise<unknown> {
37
42
  idField: '_path',
38
43
  })
39
44
  })()
40
- return _indexPromise
45
+ _indexPromise = attempt
46
+ // Clear the singleton on failure so the next call retries the fetch.
47
+ // The === guard ensures a newer concurrent attempt is not accidentally cleared.
48
+ attempt.catch(() => {
49
+ if (_indexPromise === attempt) _indexPromise = null
50
+ })
51
+ return attempt
41
52
  }
42
53
 
43
54
  /** Resets the module-level singleton. Used in tests only. @internal */
@@ -45,6 +56,33 @@ export function resetIndexSingleton(): void {
45
56
  _indexPromise = null
46
57
  }
47
58
 
59
+ // ─── Per-component debounce state ────────────────────────────────────────────
60
+
61
+ // Stores the debounce timer handle and stale-seq counter directly on the
62
+ // component context object (non-enumerable, same pattern the runtime uses for
63
+ // _hookCallbacks). This makes the values stable across re-renders — the context
64
+ // object is fixed for the lifetime of the element instance — without leaking
65
+ // into the reactive proxy or triggering spurious updates.
66
+
67
+ interface DebounceState {
68
+ seq: number
69
+ timer: ReturnType<typeof setTimeout> | null
70
+ }
71
+
72
+ const _STATE_KEY = '_cerSearchDebounce'
73
+
74
+ function getDebounceState(ctx: Record<string, unknown>): DebounceState {
75
+ if (!Object.prototype.hasOwnProperty.call(ctx, _STATE_KEY)) {
76
+ Object.defineProperty(ctx, _STATE_KEY, {
77
+ value: { seq: 0, timer: null } as DebounceState,
78
+ writable: false, // object ref is fixed; its properties are still mutable
79
+ enumerable: false, // invisible to the reactive proxy set-trap
80
+ configurable: false,
81
+ })
82
+ }
83
+ return (ctx as Record<string, DebounceState>)[_STATE_KEY]
84
+ }
85
+
48
86
  // ─── Composable ───────────────────────────────────────────────────────────────
49
87
 
50
88
  export interface UseContentSearchReturn {
@@ -59,24 +97,45 @@ const _factory = createComposable((): UseContentSearchReturn => {
59
97
  const results = ref<ContentSearchResult[]>([])
60
98
  const loading = ref(false)
61
99
 
62
- // Pre-warm index on mount
100
+ // Debounce state lives on the component context, not in local render-body variables.
101
+ // Local variables are re-created on every re-render; the context object is stable
102
+ // for the lifetime of the element. Storing state here lets the render-body
103
+ // watch() (see below) pick up the same timer and sequence counter across re-renders
104
+ // without the watcher accumulation that occurs when watch() is placed inside
105
+ // useOnConnected() (which runs once per mount but is not registered for cleanup
106
+ // by the reactive system, leaking watchers on every disconnect + reconnect cycle).
107
+ const state = getDebounceState(getCurrentComponentContext()! as Record<string, unknown>)
108
+
109
+ // Pre-warm the index on first mount so the first real search is faster.
63
110
  useOnConnected(() => {
64
111
  loadIndex().catch(() => {/* silently ignore pre-warm errors */})
65
112
  })
66
113
 
67
- // Monotonic counter to discard stale async results
68
- let _seq = 0
69
- let _debounceTimer: ReturnType<typeof setTimeout> | null = null
114
+ // Cancel any in-flight debounce on unmount so stale async work doesn't land
115
+ // after the component is gone.
116
+ useOnDisconnected(() => {
117
+ if (state.timer !== null) {
118
+ clearTimeout(state.timer)
119
+ state.timer = null
120
+ }
121
+ state.seq++ // discard any in-flight async search
122
+ loading.value = false
123
+ })
70
124
 
125
+ // watch() is in the render body so the reactive system registers it under the
126
+ // current component and tears it down automatically on re-render and disconnect.
127
+ // The mutable state (seq / timer) lives on the context (above) and persists
128
+ // across re-renders — new watcher instances see the same timer and counter,
129
+ // which is what makes debounce cancellation correct even after a re-render.
71
130
  watch(query, (q: string) => {
72
- if (_debounceTimer !== null) {
73
- clearTimeout(_debounceTimer)
74
- _debounceTimer = null
131
+ if (state.timer !== null) {
132
+ clearTimeout(state.timer)
133
+ state.timer = null
75
134
  }
76
135
 
77
136
  if (!q) {
78
137
  // Increment seq so any in-flight async search is discarded when it resolves
79
- _seq++
138
+ state.seq++
80
139
  loading.value = false
81
140
  results.value = []
82
141
  return
@@ -85,21 +144,21 @@ const _factory = createComposable((): UseContentSearchReturn => {
85
144
  // Signal loading immediately so the UI can respond before the debounce fires
86
145
  loading.value = true
87
146
 
88
- _debounceTimer = setTimeout(async () => {
89
- _debounceTimer = null
90
- const seq = ++_seq
147
+ state.timer = setTimeout(async () => {
148
+ state.timer = null
149
+ const seq = ++state.seq
91
150
 
92
151
  try {
93
152
  const index = await loadIndex() as { search(q: string, opts?: { prefix?: boolean }): ContentSearchResult[] }
94
- if (seq !== _seq) return // stale — a newer query is in flight
153
+ if (seq !== state.seq) return // stale — a newer query is in flight
95
154
  results.value = index.search(q, { prefix: true }) as ContentSearchResult[]
96
155
  } catch {
97
- if (seq !== _seq) return
156
+ if (seq !== state.seq) return
98
157
  results.value = []
99
158
  } finally {
100
159
  // Only clear loading for the most recent search; a newer in-flight search
101
160
  // keeps loading=true until it settles.
102
- if (seq === _seq) loading.value = false
161
+ if (seq === state.seq) loading.value = false
103
162
  }
104
163
  }, 200)
105
164
  })
@@ -152,7 +152,14 @@ export function useHead(input: HeadInput): void {
152
152
  const rel = link.rel
153
153
  const href = link.href
154
154
  if (rel && href) {
155
- let el = document.querySelector(`link[rel="${rel}"][href="${href}"]`)
155
+ // Canonical and icon links are singletons — find by rel alone so that
156
+ // updating the URL overwrites the existing tag rather than adding a
157
+ // duplicate. All other link types are matched by rel+href (e.g.
158
+ // stylesheets, where multiple hrefs are valid).
159
+ const isSingleton = rel === 'canonical' || rel === 'icon' || rel === 'shortcut icon'
160
+ let el = isSingleton
161
+ ? document.querySelector(`link[rel="${rel}"]`)
162
+ : document.querySelector(`link[rel="${rel}"][href="${href}"]`)
156
163
  if (!el) {
157
164
  el = document.createElement('link')
158
165
  document.head.appendChild(el)
@@ -176,12 +183,27 @@ export function useHead(input: HeadInput): void {
176
183
  }
177
184
  document.head.appendChild(el)
178
185
  } else if (innerHTML && !src) {
179
- const el = document.createElement('script')
180
- el.textContent = innerHTML
181
- for (const [k, v] of Object.entries(rest)) {
182
- el.setAttribute(k, v)
186
+ const type = rest.type ?? ''
187
+ if (type === 'application/ld+json') {
188
+ // JSON-LD is a singleton update existing content rather than
189
+ // appending a duplicate on re-render or SPA navigation.
190
+ let el = document.querySelector('script[type="application/ld+json"]')
191
+ if (!el) {
192
+ el = document.createElement('script')
193
+ document.head.appendChild(el)
194
+ for (const [k, v] of Object.entries(rest)) {
195
+ el.setAttribute(k, v)
196
+ }
197
+ }
198
+ ;(el as HTMLScriptElement).textContent = innerHTML
199
+ } else {
200
+ const el = document.createElement('script')
201
+ el.textContent = innerHTML
202
+ for (const [k, v] of Object.entries(rest)) {
203
+ el.setAttribute(k, v)
204
+ }
205
+ document.head.appendChild(el)
183
206
  }
184
- document.head.appendChild(el)
185
207
  }
186
208
  }
187
209
  }
@@ -241,6 +241,14 @@ export interface RuntimeConfig {
241
241
  export interface CerAppConfig {
242
242
  mode?: 'spa' | 'ssr' | 'ssg'
243
243
  srcDir?: string // defaults to 'app'
244
+ /**
245
+ * The canonical origin of the site (e.g. `'https://example.com'`).
246
+ * No trailing slash. When set, the SSG build automatically:
247
+ * - Injects `<link rel="canonical" href="${siteUrl}${path}">` into every page.
248
+ * - Writes a `robots.txt` to `dist/` (skipped when a `public/robots.txt` already exists).
249
+ * Also available at runtime via `useRuntimeConfig().public.siteUrl`.
250
+ */
251
+ siteUrl?: string
244
252
  /** File-based content layer configuration. Reads from `content/` at the project root by default. */
245
253
  content?: import('./content.js').CerContentConfig
246
254
  /**