@jasonshimmy/vite-plugin-cer-app 0.1.2 → 0.1.4
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/cypress.config.ts +16 -0
- package/dist/cli/create/index.js +1 -1
- package/dist/cli/create/index.js.map +1 -1
- package/dist/plugin/build-ssg.d.ts +7 -0
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +2 -1
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/build-ssr.d.ts.map +1 -1
- package/dist/plugin/build-ssr.js +26 -6
- package/dist/plugin/build-ssr.js.map +1 -1
- package/dist/runtime/composables/index.d.ts +1 -1
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +1 -1
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-head.d.ts.map +1 -1
- package/dist/runtime/composables/use-head.js +12 -8
- package/dist/runtime/composables/use-head.js.map +1 -1
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +14 -4
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/docs/cli.md +2 -0
- package/docs/components.md +57 -0
- package/docs/composables.md +9 -2
- package/docs/data-loading.md +45 -1
- package/docs/getting-started.md +71 -6
- package/docs/head-management.md +6 -0
- package/docs/plugins.md +25 -0
- package/docs/routing.md +48 -6
- package/e2e/cypress/e2e/api.cy.ts +81 -0
- package/e2e/cypress/e2e/data.cy.ts +111 -0
- package/e2e/cypress/e2e/fouc.cy.ts +65 -0
- package/e2e/cypress/e2e/head.cy.ts +89 -0
- package/e2e/cypress/e2e/interactive.cy.ts +122 -0
- package/e2e/cypress/e2e/routes.cy.ts +128 -0
- package/e2e/cypress/support/commands.ts +60 -0
- package/e2e/cypress/support/e2e.ts +10 -0
- package/{src/runtime/app-template.ts → e2e/kitchen-sink/app/app.ts} +43 -49
- package/e2e/kitchen-sink/app/components/ks-badge.ts +8 -0
- package/e2e/kitchen-sink/app/composables/useKsCounter.ts +9 -0
- package/e2e/kitchen-sink/app/error.ts +13 -0
- package/e2e/kitchen-sink/app/layouts/default.ts +21 -0
- package/e2e/kitchen-sink/app/layouts/minimal.ts +7 -0
- package/e2e/kitchen-sink/app/loading.ts +9 -0
- package/e2e/kitchen-sink/app/middleware/auth.ts +13 -0
- package/e2e/kitchen-sink/app/pages/(auth)/login.ts +13 -0
- package/e2e/kitchen-sink/app/pages/(auth)/protected.ts +20 -0
- package/e2e/kitchen-sink/app/pages/404.ts +9 -0
- package/e2e/kitchen-sink/app/pages/about.ts +17 -0
- package/e2e/kitchen-sink/app/pages/blog/[slug].ts +54 -0
- package/e2e/kitchen-sink/app/pages/blog/index.ts +46 -0
- package/e2e/kitchen-sink/app/pages/counter.ts +17 -0
- package/e2e/kitchen-sink/app/pages/head.ts +20 -0
- package/e2e/kitchen-sink/app/pages/index.ts +27 -0
- package/e2e/kitchen-sink/app/pages/items/[id].ts +20 -0
- package/e2e/kitchen-sink/app/plugins/01.setup.ts +7 -0
- package/e2e/kitchen-sink/cer-auto-imports.d.ts +50 -0
- package/e2e/kitchen-sink/cer-env.d.ts +36 -0
- package/e2e/kitchen-sink/cer-tsconfig.json +30 -0
- package/e2e/kitchen-sink/cer.config.ts +6 -0
- package/e2e/kitchen-sink/index.html +12 -0
- package/e2e/kitchen-sink/server/api/health.ts +3 -0
- package/e2e/kitchen-sink/server/api/posts/[slug].ts +11 -0
- package/e2e/kitchen-sink/server/api/posts/index.ts +5 -0
- package/e2e/kitchen-sink/server/data/posts.ts +21 -0
- package/e2e/scripts/clean.mjs +8 -0
- package/package.json +19 -2
- package/src/__tests__/plugin/build-ssg-render.test.ts +110 -0
- package/src/__tests__/plugin/build-ssg.test.ts +47 -1
- package/src/__tests__/plugin/build-ssr.test.ts +93 -1
- package/src/__tests__/plugin/dev-server.test.ts +493 -0
- package/src/__tests__/plugin/scanner.test.ts +15 -1
- package/src/__tests__/plugin/transforms/auto-import.test.ts +63 -0
- package/src/cli/create/index.ts +1 -1
- package/src/cli/create/templates/spa/app/app.ts.tpl +23 -3
- package/src/cli/create/templates/ssg/app/app.ts.tpl +27 -3
- package/src/cli/create/templates/ssg/app/pages/index.ts.tpl +0 -9
- package/src/cli/create/templates/ssr/app/app.ts.tpl +27 -3
- package/src/plugin/build-ssg.ts +2 -1
- package/src/plugin/build-ssr.ts +26 -6
- package/src/runtime/composables/index.ts +1 -1
- package/src/runtime/composables/use-head.ts +12 -8
- package/src/runtime/entry-server-template.ts +14 -4
- package/vitest.config.ts +5 -1
- package/VITE_PLUGIN_FRAMEWORK_PLAN.md +0 -594
- package/dist/runtime/app-template.d.ts +0 -10
- package/dist/runtime/app-template.d.ts.map +0 -1
- package/dist/runtime/app-template.js +0 -149
- package/dist/runtime/app-template.js.map +0 -1
|
@@ -227,3 +227,66 @@ describe('autoImportTransform — framework composable injection', () => {
|
|
|
227
227
|
expect(result).toContain('usePageData')
|
|
228
228
|
})
|
|
229
229
|
})
|
|
230
|
+
|
|
231
|
+
// ─── Composable import injection ─────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
describe('autoImportTransform — composable import injection', () => {
|
|
234
|
+
const COMPOSABLES_PKG = `'virtual:cer-composables'`
|
|
235
|
+
|
|
236
|
+
it('injects composable import when a registered composable is used', () => {
|
|
237
|
+
const composableExports = new Map([['useTheme', '/project/app/composables/useTheme.ts']])
|
|
238
|
+
const code = "component('page-x', () => { const t = useTheme(); return html`<div></div>` })"
|
|
239
|
+
const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })!
|
|
240
|
+
expect(result).toContain(`from ${COMPOSABLES_PKG}`)
|
|
241
|
+
expect(result).toContain('useTheme')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('does not inject composable import when composable is not used in file', () => {
|
|
245
|
+
const composableExports = new Map([['useTheme', '/project/app/composables/useTheme.ts']])
|
|
246
|
+
const code = "component('page-x', () => html`<h1>Hello</h1>`)"
|
|
247
|
+
const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })
|
|
248
|
+
expect(result === null || !result!.includes('virtual:cer-composables')).toBe(true)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('does not inject when already imported from virtual:cer-composables (single quotes)', () => {
|
|
252
|
+
const composableExports = new Map([['useTheme', '/project/app/composables/useTheme.ts']])
|
|
253
|
+
const code = `import { useTheme } from 'virtual:cer-composables'\ncomponent('x', () => { useTheme(); return html\`\` })`
|
|
254
|
+
const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })
|
|
255
|
+
const count = (result ?? code).split(`from ${COMPOSABLES_PKG}`).length - 1
|
|
256
|
+
expect(count).toBe(1)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('does not inject when already imported from virtual:cer-composables (double quotes)', () => {
|
|
260
|
+
const composableExports = new Map([['useTheme', '/project/app/composables/useTheme.ts']])
|
|
261
|
+
const code = `import { useTheme } from "virtual:cer-composables"\ncomponent('x', () => { useTheme(); return html\`\` })`
|
|
262
|
+
const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })
|
|
263
|
+
const count = (result ?? code).split('virtual:cer-composables').length - 1
|
|
264
|
+
expect(count).toBe(1)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('injects all used composables in a single import statement', () => {
|
|
268
|
+
const composableExports = new Map([
|
|
269
|
+
['useTheme', '/project/app/composables/useTheme.ts'],
|
|
270
|
+
['useAuth', '/project/app/composables/useAuth.ts'],
|
|
271
|
+
])
|
|
272
|
+
const code = "component('page-x', () => { useTheme(); useAuth(); return html`<div></div>` })"
|
|
273
|
+
const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })!
|
|
274
|
+
expect(result).toContain('useTheme')
|
|
275
|
+
expect(result).toContain('useAuth')
|
|
276
|
+
const count = result.split(`from ${COMPOSABLES_PKG}`).length - 1
|
|
277
|
+
expect(count).toBe(1)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('returns null when composableExports is empty and no other identifiers used', () => {
|
|
281
|
+
const composableExports = new Map<string, string>()
|
|
282
|
+
const code = 'const x = 1'
|
|
283
|
+
const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir, composableExports })
|
|
284
|
+
expect(result).toBeNull()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('returns null when composableExports is undefined and no other identifiers used', () => {
|
|
288
|
+
const code = 'const x = 1'
|
|
289
|
+
const result = autoImportTransform(code, '/project/app/pages/x.ts', { srcDir })
|
|
290
|
+
expect(result).toBeNull()
|
|
291
|
+
})
|
|
292
|
+
})
|
package/src/cli/create/index.ts
CHANGED
|
@@ -218,7 +218,7 @@ async function generateInlineTemplate(
|
|
|
218
218
|
// app/app.ts
|
|
219
219
|
await writeFile(
|
|
220
220
|
join(targetDir, 'app/app.ts'),
|
|
221
|
-
`import '@jasonshimmy/custom-elements-runtime/css'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport { hasLoading, loadingTag } from 'virtual:cer-loading'\nimport { hasError, errorTag } from 'virtual:cer-error'\nimport { component, ref, useOnConnected, useOnDisconnected, registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'\nimport { createDOMJITCSS } from '@jasonshimmy/custom-elements-runtime/dom-jit-css'\n\nregisterBuiltinComponents()\nenableJITCSS()\n\nconst router = initRouter({ routes })\n\nconst isNavigating = ref(false)\nconst currentError = ref(null)\n;(globalThis as any).resetError = () => { currentError.value = null; router.replace(router.getCurrent().path) }\nconst _push = router.push.bind(router)\nconst _replace = router.replace.bind(router)\nrouter.push = async (path) => { isNavigating.value = true; currentError.value = null; try { await _push(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\nrouter.replace = async (path) => { isNavigating.value = true; currentError.value = null; try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\n\ncomponent('cer-layout-view', () => {\n const current = ref(router.getCurrent())\n let unsub: (() => void) | undefined\n useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })\n useOnDisconnected(() => { unsub?.(); unsub = undefined })\n if (currentError.value !== null) {\n if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }\n return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }\n }\n if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }\n const matched = router.matchRoute(current.value.path)\n const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'\n const layoutTag = (layouts as Record<string, string>)[layoutName]\n const routerView = { tag: 'router-view', props: {}, children: [] }\n return layoutTag ? { tag: layoutTag, props: {}, children: [routerView] } : routerView\n})\n\nfor (const plugin of plugins ?? []) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({ router, provide: (key, value) => { (
|
|
221
|
+
`import '@jasonshimmy/custom-elements-runtime/css'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport { hasLoading, loadingTag } from 'virtual:cer-loading'\nimport { hasError, errorTag } from 'virtual:cer-error'\nimport { component, ref, provide, useOnConnected, useOnDisconnected, registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'\nimport { createDOMJITCSS } from '@jasonshimmy/custom-elements-runtime/dom-jit-css'\n\nregisterBuiltinComponents()\nenableJITCSS()\n\nconst router = initRouter({ routes })\n\nconst isNavigating = ref(false)\nconst currentError = ref(null)\n;(globalThis as any).resetError = () => { currentError.value = null; router.replace(router.getCurrent().path) }\nconst _push = router.push.bind(router)\nconst _replace = router.replace.bind(router)\nrouter.push = async (path) => { isNavigating.value = true; currentError.value = null; try { await _push(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\nrouter.replace = async (path) => { isNavigating.value = true; currentError.value = null; try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false } }\n\nconst _pluginProvides = new Map()\n;(globalThis as any).__cerPluginProvides = _pluginProvides\n\ncomponent('cer-layout-view', () => {\n for (const [key, value] of _pluginProvides) { provide(key, value) }\n const current = ref(router.getCurrent())\n let unsub: (() => void) | undefined\n useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })\n useOnDisconnected(() => { unsub?.(); unsub = undefined })\n if (currentError.value !== null) {\n if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }\n return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }\n }\n if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }\n const matched = router.matchRoute(current.value.path)\n const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'\n const layoutTag = (layouts as Record<string, string>)[layoutName]\n const routerView = { tag: 'router-view', props: {}, children: [] }\n return layoutTag ? { tag: layoutTag, props: {}, children: [routerView] } : routerView\n})\n\nfor (const plugin of plugins ?? []) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })\n }\n}\n\nif (typeof window !== 'undefined') {\n const _initMatch = router.matchRoute(window.location.pathname)\n if (_initMatch?.route?.load) {\n try { await _initMatch.route.load() } catch { /* non-fatal */ }\n }\n}\n\nif (typeof window !== 'undefined') {\n await _replace(window.location.pathname + window.location.search + window.location.hash)\n delete (globalThis as any).__CER_DATA__\n createDOMJITCSS().mount()\n}\n\nexport { router }\n`,
|
|
222
222
|
'utf-8',
|
|
223
223
|
)
|
|
224
224
|
|
|
@@ -8,6 +8,7 @@ import { hasError, errorTag } from 'virtual:cer-error'
|
|
|
8
8
|
import {
|
|
9
9
|
component,
|
|
10
10
|
ref,
|
|
11
|
+
provide,
|
|
11
12
|
useOnConnected,
|
|
12
13
|
useOnDisconnected,
|
|
13
14
|
registerBuiltinComponents,
|
|
@@ -41,7 +42,14 @@ router.replace = async (path) => {
|
|
|
41
42
|
try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
const _pluginProvides = new Map<string, unknown>()
|
|
46
|
+
;(globalThis as any).__cerPluginProvides = _pluginProvides
|
|
47
|
+
|
|
44
48
|
component('cer-layout-view', () => {
|
|
49
|
+
for (const [key, value] of _pluginProvides) {
|
|
50
|
+
provide(key, value)
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
const current = ref(router.getCurrent())
|
|
46
54
|
let unsub: (() => void) | undefined
|
|
47
55
|
useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })
|
|
@@ -49,7 +57,7 @@ component('cer-layout-view', () => {
|
|
|
49
57
|
|
|
50
58
|
if (currentError.value !== null) {
|
|
51
59
|
if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
|
|
52
|
-
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children:
|
|
60
|
+
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
|
|
53
61
|
}
|
|
54
62
|
if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
|
|
55
63
|
|
|
@@ -62,12 +70,24 @@ component('cer-layout-view', () => {
|
|
|
62
70
|
|
|
63
71
|
for (const plugin of plugins) {
|
|
64
72
|
if (plugin && typeof plugin.setup === 'function') {
|
|
65
|
-
await plugin.setup({ router, provide: (key, value) => { (
|
|
73
|
+
await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pre-load the current page's route chunk AFTER plugins run so that
|
|
78
|
+
// cer-layout-view's first render (which calls provide()) completes before
|
|
79
|
+
// page components are defined. This ensures inject() in child components
|
|
80
|
+
// can find values stored by provide().
|
|
81
|
+
if (typeof window !== 'undefined') {
|
|
82
|
+
const _initMatch = router.matchRoute(window.location.pathname)
|
|
83
|
+
if (_initMatch?.route?.load) {
|
|
84
|
+
try { await _initMatch.route.load() } catch { /* non-fatal */ }
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
if (typeof window !== 'undefined') {
|
|
70
|
-
|
|
89
|
+
// Use the original (unwrapped) replace so isNavigating stays false on first paint.
|
|
90
|
+
await _replace(window.location.pathname + window.location.search + window.location.hash)
|
|
71
91
|
createDOMJITCSS().mount()
|
|
72
92
|
}
|
|
73
93
|
|
|
@@ -8,6 +8,7 @@ import { hasError, errorTag } from 'virtual:cer-error'
|
|
|
8
8
|
import {
|
|
9
9
|
component,
|
|
10
10
|
ref,
|
|
11
|
+
provide,
|
|
11
12
|
useOnConnected,
|
|
12
13
|
useOnDisconnected,
|
|
13
14
|
registerBuiltinComponents,
|
|
@@ -41,7 +42,14 @@ router.replace = async (path) => {
|
|
|
41
42
|
try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
const _pluginProvides = new Map<string, unknown>()
|
|
46
|
+
;(globalThis as any).__cerPluginProvides = _pluginProvides
|
|
47
|
+
|
|
44
48
|
component('cer-layout-view', () => {
|
|
49
|
+
for (const [key, value] of _pluginProvides) {
|
|
50
|
+
provide(key, value)
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
const current = ref(router.getCurrent())
|
|
46
54
|
let unsub: (() => void) | undefined
|
|
47
55
|
useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })
|
|
@@ -49,7 +57,7 @@ component('cer-layout-view', () => {
|
|
|
49
57
|
|
|
50
58
|
if (currentError.value !== null) {
|
|
51
59
|
if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
|
|
52
|
-
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children:
|
|
60
|
+
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
|
|
53
61
|
}
|
|
54
62
|
if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
|
|
55
63
|
|
|
@@ -62,12 +70,28 @@ component('cer-layout-view', () => {
|
|
|
62
70
|
|
|
63
71
|
for (const plugin of plugins) {
|
|
64
72
|
if (plugin && typeof plugin.setup === 'function') {
|
|
65
|
-
await plugin.setup({ router, provide: (key, value) => { (
|
|
73
|
+
await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pre-load the current page's route chunk AFTER plugins run so that
|
|
78
|
+
// cer-layout-view's first render (which calls provide()) completes before
|
|
79
|
+
// page components are defined. This ensures inject() in child components
|
|
80
|
+
// can find values stored by provide().
|
|
81
|
+
if (typeof window !== 'undefined') {
|
|
82
|
+
const _initMatch = router.matchRoute(window.location.pathname)
|
|
83
|
+
if (_initMatch?.route?.load) {
|
|
84
|
+
try { await _initMatch.route.load() } catch { /* non-fatal */ }
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
if (typeof window !== 'undefined') {
|
|
70
|
-
|
|
89
|
+
// Use the original (unwrapped) replace so isNavigating stays false on first
|
|
90
|
+
// paint — the loading component must not flash over pre-rendered SSG content.
|
|
91
|
+
await _replace(window.location.pathname + window.location.search + window.location.hash)
|
|
92
|
+
// Clear SSR hydration data after initial navigation so subsequent navigations
|
|
93
|
+
// don't accidentally reuse it.
|
|
94
|
+
delete (globalThis as any).__CER_DATA__
|
|
71
95
|
createDOMJITCSS().mount()
|
|
72
96
|
}
|
|
73
97
|
|
|
@@ -8,6 +8,7 @@ import { hasError, errorTag } from 'virtual:cer-error'
|
|
|
8
8
|
import {
|
|
9
9
|
component,
|
|
10
10
|
ref,
|
|
11
|
+
provide,
|
|
11
12
|
useOnConnected,
|
|
12
13
|
useOnDisconnected,
|
|
13
14
|
registerBuiltinComponents,
|
|
@@ -41,7 +42,14 @@ router.replace = async (path) => {
|
|
|
41
42
|
try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
const _pluginProvides = new Map<string, unknown>()
|
|
46
|
+
;(globalThis as any).__cerPluginProvides = _pluginProvides
|
|
47
|
+
|
|
44
48
|
component('cer-layout-view', () => {
|
|
49
|
+
for (const [key, value] of _pluginProvides) {
|
|
50
|
+
provide(key, value)
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
const current = ref(router.getCurrent())
|
|
46
54
|
let unsub: (() => void) | undefined
|
|
47
55
|
useOnConnected(() => { unsub = router.subscribe((s: typeof current.value) => { current.value = s }) })
|
|
@@ -49,7 +57,7 @@ component('cer-layout-view', () => {
|
|
|
49
57
|
|
|
50
58
|
if (currentError.value !== null) {
|
|
51
59
|
if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
|
|
52
|
-
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children:
|
|
60
|
+
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
|
|
53
61
|
}
|
|
54
62
|
if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
|
|
55
63
|
|
|
@@ -62,12 +70,28 @@ component('cer-layout-view', () => {
|
|
|
62
70
|
|
|
63
71
|
for (const plugin of plugins) {
|
|
64
72
|
if (plugin && typeof plugin.setup === 'function') {
|
|
65
|
-
await plugin.setup({ router, provide: (key, value) => { (
|
|
73
|
+
await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pre-load the current page's route chunk AFTER plugins run so that
|
|
78
|
+
// cer-layout-view's first render (which calls provide()) completes before
|
|
79
|
+
// page components are defined. This ensures inject() in child components
|
|
80
|
+
// can find values stored by provide().
|
|
81
|
+
if (typeof window !== 'undefined') {
|
|
82
|
+
const _initMatch = router.matchRoute(window.location.pathname)
|
|
83
|
+
if (_initMatch?.route?.load) {
|
|
84
|
+
try { await _initMatch.route.load() } catch { /* non-fatal */ }
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
if (typeof window !== 'undefined') {
|
|
70
|
-
|
|
89
|
+
// Use the original (unwrapped) replace so isNavigating stays false on first
|
|
90
|
+
// paint — the loading component must not flash over pre-rendered SSR content.
|
|
91
|
+
await _replace(window.location.pathname + window.location.search + window.location.hash)
|
|
92
|
+
// Clear SSR hydration data after initial navigation so subsequent navigations
|
|
93
|
+
// don't accidentally reuse it.
|
|
94
|
+
delete (globalThis as any).__CER_DATA__
|
|
71
95
|
createDOMJITCSS().mount()
|
|
72
96
|
}
|
|
73
97
|
|
package/src/plugin/build-ssg.ts
CHANGED
|
@@ -146,8 +146,9 @@ async function renderPath(
|
|
|
146
146
|
* Writes the rendered HTML to the output directory.
|
|
147
147
|
* path '/' -> dist/index.html
|
|
148
148
|
* path '/about' -> dist/about/index.html
|
|
149
|
+
* @internal exported for unit testing
|
|
149
150
|
*/
|
|
150
|
-
async function writeRenderedPath(
|
|
151
|
+
export async function writeRenderedPath(
|
|
151
152
|
path: string,
|
|
152
153
|
html: string,
|
|
153
154
|
distDir: string,
|
package/src/plugin/build-ssr.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'
|
|
|
40
40
|
import { registerEntityMap, renderToStringWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
|
|
41
41
|
import entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'
|
|
42
42
|
import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
|
|
43
|
+
import { beginHeadCollection, endHeadCollection, serializeHeadTags } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
43
44
|
|
|
44
45
|
registerBuiltinComponents()
|
|
45
46
|
|
|
@@ -69,13 +70,16 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
|
|
|
69
70
|
? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''
|
|
70
71
|
const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart
|
|
71
72
|
? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml
|
|
72
|
-
// Hoist <style
|
|
73
|
-
//
|
|
73
|
+
// Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)
|
|
74
|
+
// from the SSR body into the document <head>. Plain <style> blocks without
|
|
75
|
+
// an id attribute belong to shadow DOM templates and must stay in place —
|
|
76
|
+
// hoisting them to <head> breaks shadow DOM style encapsulation (document
|
|
77
|
+
// styles do not pierce shadow roots), which is the root cause of FOUC.
|
|
74
78
|
const headParts = ssrHead ? [ssrHead] : []
|
|
75
79
|
let ssrBodyContent = ssrBody
|
|
76
80
|
let pos = 0
|
|
77
81
|
while (pos < ssrBodyContent.length) {
|
|
78
|
-
const styleOpen = ssrBodyContent.indexOf('<style', pos)
|
|
82
|
+
const styleOpen = ssrBodyContent.indexOf('<style id=', pos)
|
|
79
83
|
if (styleOpen < 0) break
|
|
80
84
|
const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)
|
|
81
85
|
if (styleClose < 0) break
|
|
@@ -95,7 +99,14 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
|
|
|
95
99
|
'<div id="app">' + ssrBodyContent + '</div>')
|
|
96
100
|
}
|
|
97
101
|
const headAdditions = headParts.filter(Boolean).join('\\n')
|
|
98
|
-
if (headAdditions)
|
|
102
|
+
if (headAdditions) {
|
|
103
|
+
// If SSR provides a <title>, replace the client template's <title> so the
|
|
104
|
+
// SSR title wins (client template title is the fallback default).
|
|
105
|
+
if (headAdditions.includes('<title>')) {
|
|
106
|
+
merged = merged.replace(/<title>[^<]*<\\/title>/, '')
|
|
107
|
+
}
|
|
108
|
+
merged = merged.replace('</head>', headAdditions + '\\n</head>')
|
|
109
|
+
}
|
|
99
110
|
return merged
|
|
100
111
|
}
|
|
101
112
|
|
|
@@ -157,6 +168,9 @@ export const handler = async (req, res) => {
|
|
|
157
168
|
;(globalThis).__CER_DATA__ = loaderData
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
// Begin collecting useHead() calls made during the synchronous render pass.
|
|
172
|
+
beginHeadCollection()
|
|
173
|
+
|
|
160
174
|
// dsdPolyfill: false — we inject the polyfill manually after merging so it
|
|
161
175
|
// lands at the end of <body>, not inside <cer-layout-view> light DOM where
|
|
162
176
|
// scripts may not execute.
|
|
@@ -165,13 +179,19 @@ export const handler = async (req, res) => {
|
|
|
165
179
|
router,
|
|
166
180
|
})
|
|
167
181
|
|
|
182
|
+
// Collect and serialize any useHead() calls from the rendered components.
|
|
183
|
+
const headTags = serializeHeadTags(endHeadCollection())
|
|
184
|
+
|
|
168
185
|
// Clear immediately after the synchronous render so the value never leaks
|
|
169
186
|
// to the next request on this same server process.
|
|
170
187
|
delete (globalThis).__CER_DATA__
|
|
171
188
|
|
|
189
|
+
// Merge loader data script + useHead() tags into the document head.
|
|
190
|
+
const headContent = [head, headTags].filter(Boolean).join('\\n')
|
|
191
|
+
|
|
172
192
|
// Wrap the rendered body in a full HTML document and inject the head additions
|
|
173
193
|
// (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
|
|
174
|
-
const ssrHtml = \`<!DOCTYPE html><html><head>\${
|
|
194
|
+
const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${htmlWithStyles}</body></html>\`
|
|
175
195
|
|
|
176
196
|
let finalHtml = _clientTemplate
|
|
177
197
|
? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
|
|
@@ -259,7 +279,7 @@ export async function buildSSR(
|
|
|
259
279
|
},
|
|
260
280
|
},
|
|
261
281
|
ssr: {
|
|
262
|
-
noExternal: ['@jasonshimmy/custom-elements-runtime'],
|
|
282
|
+
noExternal: ['@jasonshimmy/custom-elements-runtime', '@jasonshimmy/vite-plugin-cer-app'],
|
|
263
283
|
},
|
|
264
284
|
})
|
|
265
285
|
|
|
@@ -6,15 +6,18 @@ export interface HeadInput {
|
|
|
6
6
|
style?: Array<Record<string, string>>
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
// SSR:
|
|
10
|
-
|
|
9
|
+
// SSR: store collector on globalThis so all module instances (server entry +
|
|
10
|
+
// dynamically-imported page chunks) share the same reference. A module-level
|
|
11
|
+
// variable would create separate instances per chunk, breaking the singleton.
|
|
12
|
+
const _g = globalThis as Record<string, unknown>
|
|
13
|
+
const _KEY = '__CER_HEAD_COLLECTOR__'
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Begin collecting head tags for an SSR render pass.
|
|
14
17
|
* Call this before invoking the render function.
|
|
15
18
|
*/
|
|
16
19
|
export function beginHeadCollection(): void {
|
|
17
|
-
|
|
20
|
+
_g[_KEY] = [] as HeadInput[]
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -22,8 +25,8 @@ export function beginHeadCollection(): void {
|
|
|
22
25
|
* Resets the collector to null.
|
|
23
26
|
*/
|
|
24
27
|
export function endHeadCollection(): HeadInput[] {
|
|
25
|
-
const collected =
|
|
26
|
-
|
|
28
|
+
const collected = (_g[_KEY] as HeadInput[]) ?? []
|
|
29
|
+
_g[_KEY] = null
|
|
27
30
|
return collected
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -111,9 +114,10 @@ export function serializeHeadTags(heads: HeadInput[]): string {
|
|
|
111
114
|
* - On the client: imperatively updates document.title and meta/link tags
|
|
112
115
|
*/
|
|
113
116
|
export function useHead(input: HeadInput): void {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
const collector = _g[_KEY] as HeadInput[] | null | undefined
|
|
118
|
+
if (collector != null) {
|
|
119
|
+
// SSR mode: push to the shared globalThis collector
|
|
120
|
+
collector.push(input)
|
|
117
121
|
} else if (typeof document !== 'undefined') {
|
|
118
122
|
// Client-side
|
|
119
123
|
if (input.title !== undefined) {
|
|
@@ -57,13 +57,16 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
|
|
|
57
57
|
? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''
|
|
58
58
|
const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart
|
|
59
59
|
? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml
|
|
60
|
-
// Hoist <style
|
|
61
|
-
//
|
|
60
|
+
// Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)
|
|
61
|
+
// from the SSR body into the document <head>. Plain <style> blocks without
|
|
62
|
+
// an id attribute belong to shadow DOM templates and must stay in place —
|
|
63
|
+
// hoisting them to <head> breaks shadow DOM style encapsulation (document
|
|
64
|
+
// styles do not pierce shadow roots), which is the root cause of FOUC.
|
|
62
65
|
const headParts = ssrHead ? [ssrHead] : []
|
|
63
66
|
let ssrBodyContent = ssrBody
|
|
64
67
|
let pos = 0
|
|
65
68
|
while (pos < ssrBodyContent.length) {
|
|
66
|
-
const styleOpen = ssrBodyContent.indexOf('<style', pos)
|
|
69
|
+
const styleOpen = ssrBodyContent.indexOf('<style id=', pos)
|
|
67
70
|
if (styleOpen < 0) break
|
|
68
71
|
const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)
|
|
69
72
|
if (styleClose < 0) break
|
|
@@ -83,7 +86,14 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
|
|
|
83
86
|
'<div id="app">' + ssrBodyContent + '</div>')
|
|
84
87
|
}
|
|
85
88
|
const headAdditions = headParts.filter(Boolean).join('\\n')
|
|
86
|
-
if (headAdditions)
|
|
89
|
+
if (headAdditions) {
|
|
90
|
+
// If SSR provides a <title>, replace the client template's <title> so the
|
|
91
|
+
// SSR title wins (client template title is the fallback default).
|
|
92
|
+
if (headAdditions.includes('<title>')) {
|
|
93
|
+
merged = merged.replace(/<title>[^<]*<\\/title>/, '')
|
|
94
|
+
}
|
|
95
|
+
merged = merged.replace('</head>', headAdditions + '\\n</head>')
|
|
96
|
+
}
|
|
87
97
|
return merged
|
|
88
98
|
}
|
|
89
99
|
|
package/vitest.config.ts
CHANGED
|
@@ -19,10 +19,14 @@ export default defineConfig({
|
|
|
19
19
|
include: ['src/**/*.ts'],
|
|
20
20
|
exclude: [
|
|
21
21
|
'src/cli/**',
|
|
22
|
-
'src/runtime/app-template.ts',
|
|
23
22
|
'src/runtime/entry-client-template.ts',
|
|
24
23
|
'src/runtime/entry-server-template.ts',
|
|
25
24
|
'src/__tests__/**',
|
|
25
|
+
// Pure type-only files — no executable statements, V8 cannot instrument them
|
|
26
|
+
'src/types/**',
|
|
27
|
+
// Barrel re-export files — only `export { } from ...` lines, no logic
|
|
28
|
+
'src/index.ts',
|
|
29
|
+
'src/runtime/composables/index.ts',
|
|
26
30
|
],
|
|
27
31
|
},
|
|
28
32
|
},
|