@jasonshimmy/vite-plugin-cer-app 0.23.0 → 0.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/commits.txt +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +37 -4
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/plugin/build-ssg.d.ts +4 -2
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +55 -5
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/dev-server.d.ts +2 -0
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js.map +1 -1
- package/dist/plugin/generated-dir.js +1 -1
- package/dist/plugin/generated-dir.js.map +1 -1
- package/dist/plugin/html-post-process.d.ts +29 -0
- package/dist/plugin/html-post-process.d.ts.map +1 -0
- package/dist/plugin/html-post-process.js +88 -0
- package/dist/plugin/html-post-process.js.map +1 -0
- package/dist/plugin/index.d.ts +9 -0
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +30 -2
- package/dist/plugin/index.js.map +1 -1
- package/dist/runtime/app-template.d.ts +1 -4
- package/dist/runtime/app-template.d.ts.map +1 -1
- package/dist/runtime/app-template.js +14 -13
- package/dist/runtime/app-template.js.map +1 -1
- package/dist/runtime/composables/use-content-search.d.ts +3 -0
- package/dist/runtime/composables/use-content-search.d.ts.map +1 -1
- package/dist/runtime/composables/use-content-search.js +60 -18
- package/dist/runtime/composables/use-content-search.js.map +1 -1
- package/dist/runtime/composables/use-head.d.ts.map +1 -1
- package/dist/runtime/composables/use-head.js +30 -6
- package/dist/runtime/composables/use-head.js.map +1 -1
- package/dist/types/config.d.ts +8 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/docs/configuration.md +29 -0
- package/docs/content.md +7 -5
- package/e2e/cypress/e2e/content.cy.ts +57 -0
- package/package.json +1 -1
- package/src/__tests__/plugin/app-template.test.ts +72 -18
- package/src/__tests__/plugin/html-post-process.test.ts +146 -0
- package/src/__tests__/plugin/resolve-config.test.ts +33 -0
- package/src/__tests__/runtime/app-template.test.ts +10 -0
- package/src/__tests__/runtime/use-content-search-composable.test.ts +405 -0
- package/src/__tests__/runtime/use-content-search.test.ts +28 -6
- package/src/__tests__/runtime/use-head.test.ts +45 -0
- package/src/cli/commands/preview.ts +38 -4
- package/src/plugin/build-ssg.ts +72 -5
- package/src/plugin/dev-server.ts +2 -0
- package/src/plugin/generated-dir.ts +1 -1
- package/src/plugin/html-post-process.ts +96 -0
- package/src/plugin/index.ts +33 -2
- package/src/runtime/app-template.ts +14 -17
- package/src/runtime/composables/use-content-search.ts +76 -17
- package/src/runtime/composables/use-head.ts +28 -6
- package/src/types/config.ts +8 -0
package/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
|
|
@@ -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
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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
|
|
29
|
+
export function loadIndex(): Promise<unknown> {
|
|
25
30
|
if (_indexPromise) return _indexPromise
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
73
|
-
clearTimeout(
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
const 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 !==
|
|
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 !==
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
/**
|