@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
package/docs/data-loading.md
CHANGED
|
@@ -67,7 +67,7 @@ interface PageLoaderContext<P extends Record<string, string>> {
|
|
|
67
67
|
|
|
68
68
|
1. Browser receives the full HTML — content is immediately visible via Declarative Shadow DOM before any JS runs
|
|
69
69
|
2. Client JS boots; `usePageData()` reads `window.__CER_DATA__` and returns the hydrated values
|
|
70
|
-
3.
|
|
70
|
+
3. After the initial `router.replace()` in `app/app.ts` completes, `window.__CER_DATA__` is deleted so subsequent client-side navigations trigger a fresh fetch instead of reusing stale server data
|
|
71
71
|
4. Components that received SSR data skip their `useOnConnected` fetch — no duplicate request
|
|
72
72
|
|
|
73
73
|
---
|
|
@@ -164,3 +164,47 @@ export const loader: PageLoader<
|
|
|
164
164
|
return { user: await fetchUser(params.id), posts: await fetchPosts(params.id) }
|
|
165
165
|
}
|
|
166
166
|
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Multi-mode data loading (SPA fallback)
|
|
171
|
+
|
|
172
|
+
When building a page that needs to work in **all three modes** — SSR/SSG (with a `loader`) and SPA (no server, no loader) — use the following pattern:
|
|
173
|
+
|
|
174
|
+
1. In SSR/SSG, the server runs `loader` and injects the data via `window.__CER_DATA__`. `usePageData()` returns it immediately; `useOnConnected` sees `ssrData` and skips the client fetch.
|
|
175
|
+
2. In SPA mode there is no server, so `ssrData` is `null`. The client tries the API first, then falls back to a direct module import.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// app/pages/blog/index.ts
|
|
179
|
+
component('page-blog-index', () => {
|
|
180
|
+
const ssrData = usePageData<{ posts: Post[] }>()
|
|
181
|
+
const posts = ref<Post[]>(ssrData?.posts ?? [])
|
|
182
|
+
|
|
183
|
+
useOnConnected(async () => {
|
|
184
|
+
if (ssrData) return // SSR/SSG: already hydrated, skip the fetch
|
|
185
|
+
|
|
186
|
+
// SPA: try the API server first
|
|
187
|
+
try {
|
|
188
|
+
const r = await fetch('/api/posts')
|
|
189
|
+
if (r.ok) {
|
|
190
|
+
posts.value = await r.json()
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
} catch { /* no API server in static preview */ }
|
|
194
|
+
|
|
195
|
+
// SPA static fallback: import data directly from source
|
|
196
|
+
const { posts: staticPosts } = await import('../data/posts')
|
|
197
|
+
posts.value = staticPosts
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
return html`<ul>${posts.value.map(p => html`<li>${p.title}</li>`)}</ul>`
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// loader runs in SSR and SSG only
|
|
204
|
+
export const loader = async () => {
|
|
205
|
+
const posts = await fetch('/api/posts').then(r => r.json())
|
|
206
|
+
return { posts }
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The key rule: **always check `ssrData` before fetching on the client**. This prevents a redundant network request when the data was already serialized by the server.
|
package/docs/getting-started.md
CHANGED
|
@@ -135,40 +135,105 @@ component('layout-default', () => {
|
|
|
135
135
|
|
|
136
136
|
### 7. Create `app/app.ts` (auto-generated if absent)
|
|
137
137
|
|
|
138
|
-
The framework
|
|
138
|
+
The framework generates this file when you scaffold a new project. It bootstraps the router, registers all auto-discovered components, runs plugins, and mounts the app:
|
|
139
139
|
|
|
140
140
|
```ts
|
|
141
141
|
// app/app.ts
|
|
142
142
|
import '@jasonshimmy/custom-elements-runtime/css'
|
|
143
143
|
import 'virtual:cer-components'
|
|
144
144
|
import routes from 'virtual:cer-routes'
|
|
145
|
+
import layouts from 'virtual:cer-layouts'
|
|
145
146
|
import plugins from 'virtual:cer-plugins'
|
|
146
|
-
import {
|
|
147
|
+
import { hasLoading, loadingTag } from 'virtual:cer-loading'
|
|
148
|
+
import { hasError, errorTag } from 'virtual:cer-error'
|
|
149
|
+
import {
|
|
150
|
+
component, ref, provide,
|
|
151
|
+
useOnConnected, useOnDisconnected,
|
|
152
|
+
registerBuiltinComponents,
|
|
153
|
+
} from '@jasonshimmy/custom-elements-runtime'
|
|
147
154
|
import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
|
|
148
155
|
import { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'
|
|
149
156
|
import { createDOMJITCSS } from '@jasonshimmy/custom-elements-runtime/dom-jit-css'
|
|
150
157
|
|
|
151
158
|
registerBuiltinComponents()
|
|
152
|
-
|
|
153
|
-
// Enable JIT CSS globally for all Shadow DOM components.
|
|
154
159
|
enableJITCSS()
|
|
155
160
|
|
|
156
161
|
const router = initRouter({ routes })
|
|
157
162
|
|
|
163
|
+
const isNavigating = ref(false)
|
|
164
|
+
const currentError = ref(null)
|
|
165
|
+
;(globalThis as any).resetError = () => {
|
|
166
|
+
currentError.value = null
|
|
167
|
+
router.replace(router.getCurrent().path)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const _push = router.push.bind(router)
|
|
171
|
+
const _replace = router.replace.bind(router)
|
|
172
|
+
router.push = async (path) => {
|
|
173
|
+
isNavigating.value = true; currentError.value = null
|
|
174
|
+
try { await _push(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
|
|
175
|
+
}
|
|
176
|
+
router.replace = async (path) => {
|
|
177
|
+
isNavigating.value = true; currentError.value = null
|
|
178
|
+
try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// _pluginProvides is populated by plugin setup and forwarded into the component
|
|
182
|
+
// context tree via provide() inside cer-layout-view so inject() works in all modes.
|
|
183
|
+
// Also exposed on globalThis for the SSG timing edge case — see docs/plugins.md.
|
|
184
|
+
const _pluginProvides = new Map<string, unknown>()
|
|
185
|
+
;(globalThis as any).__cerPluginProvides = _pluginProvides
|
|
186
|
+
|
|
187
|
+
component('cer-layout-view', () => {
|
|
188
|
+
for (const [key, value] of _pluginProvides) { provide(key, value) }
|
|
189
|
+
|
|
190
|
+
const current = ref(router.getCurrent())
|
|
191
|
+
let unsub: (() => void) | undefined
|
|
192
|
+
useOnConnected(() => { unsub = router.subscribe((s) => { current.value = s }) })
|
|
193
|
+
useOnDisconnected(() => { unsub?.(); unsub = undefined })
|
|
194
|
+
|
|
195
|
+
if (currentError.value !== null) {
|
|
196
|
+
if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
|
|
197
|
+
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
|
|
198
|
+
}
|
|
199
|
+
if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
|
|
200
|
+
|
|
201
|
+
const matched = router.matchRoute(current.value.path)
|
|
202
|
+
const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'
|
|
203
|
+
const layoutTag = (layouts as Record<string, string>)[layoutName]
|
|
204
|
+
const routerView = { tag: 'router-view', props: {}, children: [] }
|
|
205
|
+
return layoutTag ? { tag: layoutTag, props: {}, children: [routerView] } : routerView
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Plugins run AFTER cer-layout-view is defined so provide() calls from plugins
|
|
209
|
+
// are forwarded into the component tree on the very first render.
|
|
158
210
|
for (const plugin of plugins ?? []) {
|
|
159
211
|
if (plugin && typeof plugin.setup === 'function') {
|
|
160
|
-
await plugin.setup({ router, provide: (key, value) => {
|
|
212
|
+
await plugin.setup({ router, provide: (key, value) => { _pluginProvides.set(key, value) }, config: {} })
|
|
161
213
|
}
|
|
162
214
|
}
|
|
163
215
|
|
|
216
|
+
// Pre-load the current page's route chunk AFTER plugins run.
|
|
217
|
+
// This ensures cer-layout-view's first render (and its provide() calls) completes
|
|
218
|
+
// before page component modules are imported and their renders are scheduled.
|
|
164
219
|
if (typeof window !== 'undefined') {
|
|
165
|
-
|
|
220
|
+
const _initMatch = router.matchRoute(window.location.pathname)
|
|
221
|
+
if (_initMatch?.route?.load) {
|
|
222
|
+
try { await _initMatch.route.load() } catch { /* non-fatal */ }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (typeof window !== 'undefined') {
|
|
227
|
+
await _replace(window.location.pathname + window.location.search + window.location.hash)
|
|
228
|
+
delete (globalThis as any).__CER_DATA__
|
|
166
229
|
createDOMJITCSS().mount()
|
|
167
230
|
}
|
|
168
231
|
|
|
169
232
|
export { router }
|
|
170
233
|
```
|
|
171
234
|
|
|
235
|
+
> **Note:** Do not move the plugin loop before `component('cer-layout-view', …)`. The layout component must be defined first so that when plugins call `app.provide()`, the values are available to the component tree from the very first render. See [Plugins](plugins.md) for details.
|
|
236
|
+
|
|
172
237
|
---
|
|
173
238
|
|
|
174
239
|
## Running the dev server
|
package/docs/head-management.md
CHANGED
|
@@ -9,15 +9,21 @@ The `useHead()` composable manages `<title>`, `<meta>`, `<link>`, `<script>`, an
|
|
|
9
9
|
|
|
10
10
|
## Import
|
|
11
11
|
|
|
12
|
+
`useHead` is **not** auto-imported. You must explicitly import it in every file that uses it:
|
|
13
|
+
|
|
12
14
|
```ts
|
|
13
15
|
import { useHead } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
14
16
|
```
|
|
15
17
|
|
|
18
|
+
Unlike `component`, `html`, `ref`, and other runtime APIs (which are injected automatically when `autoImports.runtime` is enabled), `useHead` comes from the framework package and requires an explicit import statement.
|
|
19
|
+
|
|
16
20
|
---
|
|
17
21
|
|
|
18
22
|
## Basic usage
|
|
19
23
|
|
|
20
24
|
```ts
|
|
25
|
+
import { useHead } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
26
|
+
|
|
21
27
|
component('page-about', () => {
|
|
22
28
|
useHead({
|
|
23
29
|
title: 'About Us',
|
package/docs/plugins.md
CHANGED
|
@@ -120,6 +120,31 @@ export default {
|
|
|
120
120
|
|
|
121
121
|
---
|
|
122
122
|
|
|
123
|
+
## SSG and `inject()`
|
|
124
|
+
|
|
125
|
+
In SSG mode there is a timing subtlety: the router loads a page chunk and renders it before `<cer-layout-view>` has called `provide()`. This means `inject()` may return `undefined` on the first render in SSG — even though the plugin ran and called `app.provide()` correctly.
|
|
126
|
+
|
|
127
|
+
To write pages that work correctly in **all three modes** (SPA, SSR, SSG), use `globalThis.__cerPluginProvides` as a synchronous fallback when `inject()` returns `undefined`:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// app/pages/dashboard.ts
|
|
131
|
+
component('page-dashboard', () => {
|
|
132
|
+
// inject() resolves correctly in SPA and SSR modes.
|
|
133
|
+
// In SSG the page chunk may render before cer-layout-view calls provide(),
|
|
134
|
+
// so fall back to the global map that app/app.ts populates before any render.
|
|
135
|
+
const pluginProvides = (globalThis as any).__cerPluginProvides as Map<string, unknown> | undefined
|
|
136
|
+
const store = inject<Store>('store') ?? pluginProvides?.get('store') as Store | undefined
|
|
137
|
+
|
|
138
|
+
if (!store) return html`<p>Loading…</p>`
|
|
139
|
+
|
|
140
|
+
return html`<p>Count: ${store.state.count}</p>`
|
|
141
|
+
})
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The `__cerPluginProvides` map is written by the bootstrapped `app/app.ts` before any route is rendered, so it is always available as a synchronous fallback regardless of render order.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
123
148
|
## Virtual module
|
|
124
149
|
|
|
125
150
|
The sorted plugin list is available via `virtual:cer-plugins`:
|
package/docs/routing.md
CHANGED
|
@@ -13,15 +13,17 @@ Routes are automatically derived from files in the `app/pages/` directory. No ma
|
|
|
13
13
|
| `app/pages/blog/index.ts` | `/blog` | `page-blog-index` |
|
|
14
14
|
| `app/pages/blog/[slug].ts` | `/blog/:slug` | `page-blog-slug` |
|
|
15
15
|
| `app/pages/users/[id]/edit.ts` | `/users/:id/edit` | `page-users-id-edit` |
|
|
16
|
-
| `app/pages/
|
|
16
|
+
| `app/pages/404.ts` | `/:all*` (catch-all) | `page-404` |
|
|
17
|
+
| `app/pages/[...all].ts` | `/:all*` (catch-all) | `page-all` |
|
|
17
18
|
| `app/pages/(auth)/login.ts` | `/login` | `page-login` |
|
|
18
19
|
|
|
19
20
|
### Rules
|
|
20
21
|
|
|
21
22
|
1. **`index.ts`** — The `index` segment is stripped: `blog/index.ts` → `/blog`
|
|
22
23
|
2. **`[param]`** — Dynamic segment: `[slug].ts` → `:slug`
|
|
23
|
-
3. **`[...rest]`** — Catch-all segment: `[...all].ts` →
|
|
24
|
-
4. **`
|
|
24
|
+
3. **`[...rest]`** — Catch-all segment: `[...all].ts` → `/:all*`
|
|
25
|
+
4. **`404.ts`** — Special shorthand: treated as a catch-all (`/:all*`) — the conventional 404 page
|
|
26
|
+
5. **`(group)/`** — Route group: directory name stripped from path, not from tag name
|
|
25
27
|
|
|
26
28
|
---
|
|
27
29
|
|
|
@@ -57,9 +59,27 @@ The `:slug` param is populated by the router and passed as a prop to the compone
|
|
|
57
59
|
|
|
58
60
|
---
|
|
59
61
|
|
|
60
|
-
## Catch-all routes
|
|
62
|
+
## Catch-all routes and 404 pages
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
There are two equivalent ways to define a catch-all / 404 page:
|
|
65
|
+
|
|
66
|
+
**Option A — `404.ts` (recommended shorthand)**
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// app/pages/404.ts
|
|
70
|
+
component('page-404', () => {
|
|
71
|
+
return html`
|
|
72
|
+
<div>
|
|
73
|
+
<h1>404 — Page not found</h1>
|
|
74
|
+
<p><a href="/">← Back home</a></p>
|
|
75
|
+
</div>
|
|
76
|
+
`
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The framework special-cases `404.ts` and automatically registers it as the catch-all route (`/:all*`). This is the conventional name and is the approach used by the kitchen-sink example.
|
|
81
|
+
|
|
82
|
+
**Option B — `[...name].ts` explicit catch-all**
|
|
63
83
|
|
|
64
84
|
```ts
|
|
65
85
|
// app/pages/[...all].ts
|
|
@@ -68,7 +88,7 @@ component('page-all', () => {
|
|
|
68
88
|
})
|
|
69
89
|
```
|
|
70
90
|
|
|
71
|
-
|
|
91
|
+
Both produce the same route (`/:all*`) and either form works. Use `404.ts` for clarity.
|
|
72
92
|
|
|
73
93
|
---
|
|
74
94
|
|
|
@@ -166,6 +186,28 @@ Within each tier, routes are sorted alphabetically.
|
|
|
166
186
|
|
|
167
187
|
---
|
|
168
188
|
|
|
189
|
+
## Navigation with `<router-link>`
|
|
190
|
+
|
|
191
|
+
`initRouter()` registers a `<router-link>` built-in custom element that renders a client-side navigation link. Use it anywhere in your pages or layouts instead of a plain `<a>` tag when you want the router to handle the navigation (no full page reload):
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// app/layouts/default.ts
|
|
195
|
+
component('layout-default', () => {
|
|
196
|
+
return html`
|
|
197
|
+
<nav>
|
|
198
|
+
<router-link to="/">Home</router-link>
|
|
199
|
+
<router-link to="/about">About</router-link>
|
|
200
|
+
<router-link to="/blog">Blog</router-link>
|
|
201
|
+
</nav>
|
|
202
|
+
<main><slot></slot></main>
|
|
203
|
+
`
|
|
204
|
+
})
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`<router-link to="/path">` calls `router.push(path)` on click, which triggers the wrapped navigation handler (loading state, error capture, etc.). Use a plain `<a href="/path">` when you need a standard browser navigation or an external link.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
169
211
|
## Virtual module
|
|
170
212
|
|
|
171
213
|
The route list is exposed as the virtual module `virtual:cer-routes`, which you can import directly in `app/app.ts`:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server API route tests.
|
|
3
|
+
*
|
|
4
|
+
* Only run in SSR mode — SSG and SPA don't have live API endpoints.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
|
|
8
|
+
|
|
9
|
+
if (mode !== 'ssr') {
|
|
10
|
+
describe('Server API routes', () => {
|
|
11
|
+
it('skipped — only tested in SSR mode', () => {
|
|
12
|
+
cy.log(`API tests skipped in ${mode} mode`)
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
} else {
|
|
16
|
+
describe('Server API routes (SSR mode)', () => {
|
|
17
|
+
context('GET /api/health', () => {
|
|
18
|
+
it('returns 200 with status ok', () => {
|
|
19
|
+
cy.request('/api/health').then((response) => {
|
|
20
|
+
expect(response.status).to.eq(200)
|
|
21
|
+
expect(response.body.status).to.eq('ok')
|
|
22
|
+
expect(response.body.service).to.eq('kitchen-sink')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
context('GET /api/posts', () => {
|
|
28
|
+
it('returns 200 with array of posts', () => {
|
|
29
|
+
cy.request('/api/posts').then((response) => {
|
|
30
|
+
expect(response.status).to.eq(200)
|
|
31
|
+
expect(response.body).to.be.an('array')
|
|
32
|
+
expect(response.body.length).to.be.greaterThan(0)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('first post has slug, title, excerpt, body', () => {
|
|
37
|
+
cy.request('/api/posts').then((response) => {
|
|
38
|
+
const post = response.body[0]
|
|
39
|
+
expect(post).to.have.property('slug')
|
|
40
|
+
expect(post).to.have.property('title')
|
|
41
|
+
expect(post).to.have.property('excerpt')
|
|
42
|
+
expect(post).to.have.property('body')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('contains first-post and second-post', () => {
|
|
47
|
+
cy.request('/api/posts').then((response) => {
|
|
48
|
+
const slugs = response.body.map((p: any) => p.slug)
|
|
49
|
+
expect(slugs).to.include('first-post')
|
|
50
|
+
expect(slugs).to.include('second-post')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
context('GET /api/posts/:slug', () => {
|
|
56
|
+
it('returns the correct post for first-post', () => {
|
|
57
|
+
cy.request('/api/posts/first-post').then((response) => {
|
|
58
|
+
expect(response.status).to.eq(200)
|
|
59
|
+
expect(response.body.slug).to.eq('first-post')
|
|
60
|
+
expect(response.body.title).to.eq('First Post')
|
|
61
|
+
expect(response.body.body).to.include('First post body content')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns the correct post for second-post', () => {
|
|
66
|
+
cy.request('/api/posts/second-post').then((response) => {
|
|
67
|
+
expect(response.status).to.eq(200)
|
|
68
|
+
expect(response.body.slug).to.eq('second-post')
|
|
69
|
+
expect(response.body.title).to.eq('Second Post')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns 404 for unknown slug', () => {
|
|
74
|
+
cy.request({ url: '/api/posts/not-a-post', failOnStatusCode: false }).then((response) => {
|
|
75
|
+
expect(response.status).to.eq(404)
|
|
76
|
+
expect(response.body).to.have.property('error')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data loading tests — page loaders, usePageData, dynamic params.
|
|
3
|
+
*
|
|
4
|
+
* Blog data is loaded via a page `loader` in SSR/SSG mode and falls back to
|
|
5
|
+
* client-side fetch in SPA mode. Item IDs come from route params (all modes).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
|
|
9
|
+
|
|
10
|
+
describe('Blog list — data loader', () => {
|
|
11
|
+
// In SSR/SSG the loader runs server-side; data is embedded in HTML.
|
|
12
|
+
// In SPA the page fetches from /api/posts on mount.
|
|
13
|
+
|
|
14
|
+
if (mode !== 'spa') {
|
|
15
|
+
it('renders pre-loaded posts in initial HTML (SSR/SSG)', () => {
|
|
16
|
+
cy.request('/blog').then((response) => {
|
|
17
|
+
expect(response.body).to.include('First Post')
|
|
18
|
+
expect(response.body).to.include('Second Post')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it('blog list renders at least 2 items after hydration', () => {
|
|
24
|
+
cy.visit('/blog')
|
|
25
|
+
cy.get('[data-cy=blog-list]').should('exist')
|
|
26
|
+
// Items appear after hydration (SSR) or fetch (SPA)
|
|
27
|
+
cy.get('[data-cy=blog-item]', { timeout: 8000 }).should('have.length.at.least', 2)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('First Post link is present', () => {
|
|
31
|
+
cy.visit('/blog')
|
|
32
|
+
cy.get('[data-cy=blog-item]').should('have.length.at.least', 1)
|
|
33
|
+
cy.get('[data-cy="blog-link-first-post"]').should('exist')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('Second Post link is present', () => {
|
|
37
|
+
cy.visit('/blog')
|
|
38
|
+
cy.get('[data-cy="blog-link-second-post"]').should('exist')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('Blog detail — dynamic route with loader', () => {
|
|
43
|
+
it('renders "First Post" title for /blog/first-post', () => {
|
|
44
|
+
cy.visit('/blog/first-post')
|
|
45
|
+
cy.get('[data-cy=post-title]').should('contain', 'First Post')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders "Second Post" title for /blog/second-post', () => {
|
|
49
|
+
cy.visit('/blog/second-post')
|
|
50
|
+
cy.get('[data-cy=post-title]').should('contain', 'Second Post')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('shows the correct slug in the post', () => {
|
|
54
|
+
cy.visit('/blog/first-post')
|
|
55
|
+
cy.get('[data-cy=post-slug]').should('contain', 'first-post')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (mode !== 'spa') {
|
|
59
|
+
it('body content is pre-rendered in initial HTML (SSR/SSG)', () => {
|
|
60
|
+
cy.request('/blog/first-post').then((response) => {
|
|
61
|
+
expect(response.body).to.include('First post body content')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('second post body is pre-rendered (SSR/SSG)', () => {
|
|
66
|
+
cy.request('/blog/second-post').then((response) => {
|
|
67
|
+
expect(response.body).to.include('Second post body content')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it('renders the body content after hydration', () => {
|
|
73
|
+
cy.visit('/blog/first-post')
|
|
74
|
+
cy.get('[data-cy=post-body]', { timeout: 8000 }).should('contain', 'First post body content')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('back link navigates to /blog', () => {
|
|
78
|
+
cy.visit('/blog/first-post')
|
|
79
|
+
cy.get('[data-cy=post-back]').first().click({ force: true })
|
|
80
|
+
cy.url().should('include', '/blog')
|
|
81
|
+
cy.get('[data-cy=blog-heading]').should('contain', 'Blog')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('Item detail — route params via useProps', () => {
|
|
86
|
+
it('shows item ID 1 for /items/1', () => {
|
|
87
|
+
cy.visit('/items/1')
|
|
88
|
+
cy.get('[data-cy=item-id]').should('contain', '1')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('shows item ID 2 for /items/2', () => {
|
|
92
|
+
cy.visit('/items/2')
|
|
93
|
+
cy.get('[data-cy=item-id]').should('contain', '2')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('shows correct ID for client-side nav to /items/2 from /items/1', () => {
|
|
97
|
+
cy.visit('/items/1')
|
|
98
|
+
cy.get('[data-cy=item-id]').should('contain', '1')
|
|
99
|
+
// Navigate via browser history pushState via the router
|
|
100
|
+
cy.window().then((win) => {
|
|
101
|
+
// Trigger client-side navigation
|
|
102
|
+
const router = (win as any).__cer_router__ ?? (win as any).router
|
|
103
|
+
if (router?.push) {
|
|
104
|
+
router.push('/items/2')
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
// Fallback: visit directly
|
|
108
|
+
cy.visit('/items/2')
|
|
109
|
+
cy.get('[data-cy=item-id]').should('contain', '2')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FOUC (Flash of Unstyled Content) tests.
|
|
3
|
+
*
|
|
4
|
+
* For SSR and SSG modes, validates that the server-rendered HTML:
|
|
5
|
+
* 1. Contains Declarative Shadow DOM templates
|
|
6
|
+
* 2. Each shadow template has its own embedded <style> block
|
|
7
|
+
* 3. Shadow DOM styles are NOT hoisted to <head> (which would break encapsulation)
|
|
8
|
+
* 4. The loading indicator is NOT present in the initial HTML
|
|
9
|
+
* 5. cer-layout-view has pre-rendered content (not empty)
|
|
10
|
+
*
|
|
11
|
+
* FOUC occurs when styles are missing from shadow roots on first parse.
|
|
12
|
+
* These checks verify the DSD structure prevents FOUC before JS hydrates.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
|
|
16
|
+
|
|
17
|
+
// Routes to check for FOUC — all pre-renderable routes
|
|
18
|
+
const ROUTES_TO_CHECK = [
|
|
19
|
+
'/',
|
|
20
|
+
'/about',
|
|
21
|
+
'/counter',
|
|
22
|
+
'/head',
|
|
23
|
+
'/blog',
|
|
24
|
+
'/blog/first-post',
|
|
25
|
+
'/blog/second-post',
|
|
26
|
+
'/items/1',
|
|
27
|
+
'/items/2',
|
|
28
|
+
'/login',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
describe('FOUC prevention', () => {
|
|
32
|
+
before(() => {
|
|
33
|
+
if (mode === 'spa') {
|
|
34
|
+
cy.log('Skipping FOUC tests in SPA mode (no server-side rendering)')
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
if (mode === 'spa') return
|
|
39
|
+
|
|
40
|
+
ROUTES_TO_CHECK.forEach((path) => {
|
|
41
|
+
it(`${path}: shadow templates have embedded styles (no FOUC)`, () => {
|
|
42
|
+
cy.assertNoDSD_FOUC(path)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('DSD polyfill is placed after </cer-layout-view>, not inside it', () => {
|
|
47
|
+
cy.request('/').then((response) => {
|
|
48
|
+
const html: string = response.body
|
|
49
|
+
const clvEnd = html.lastIndexOf('</cer-layout-view>')
|
|
50
|
+
const polyfillIdx = html.indexOf('attachShadow')
|
|
51
|
+
if (polyfillIdx >= 0) {
|
|
52
|
+
expect(polyfillIdx, 'DSD polyfill must come AFTER </cer-layout-view>').to.be.greaterThan(clvEnd)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('home page: loading indicator is not visible on first paint', () => {
|
|
58
|
+
// The loading component (page-loading) should never appear in initial HTML.
|
|
59
|
+
// If it does, it means isNavigating was true during SSR — a FOUC bug.
|
|
60
|
+
cy.request('/').then((response) => {
|
|
61
|
+
expect(response.body).not.to.include('page-loading')
|
|
62
|
+
expect(response.body).not.to.include('data-cy="loading-indicator"')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useHead() tests — verifies document title and meta tags are set correctly.
|
|
3
|
+
*
|
|
4
|
+
* In SSR/SSG: tags should be in the initial HTML (server-side injection).
|
|
5
|
+
* In all modes: tags should be set after client-side hydration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
|
|
9
|
+
|
|
10
|
+
describe('useHead() — document title', () => {
|
|
11
|
+
it('sets title to "Home — Kitchen Sink" on home page', () => {
|
|
12
|
+
cy.visit('/')
|
|
13
|
+
cy.title().should('eq', 'Home — Kitchen Sink')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('sets title to "About — Kitchen Sink" on about page', () => {
|
|
17
|
+
cy.visit('/about')
|
|
18
|
+
cy.title().should('eq', 'About — Kitchen Sink')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('sets title to "Head Test — Kitchen Sink" on head page', () => {
|
|
22
|
+
cy.visit('/head')
|
|
23
|
+
cy.title().should('eq', 'Head Test — Kitchen Sink')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('sets title to "Blog — Kitchen Sink" on blog list page', () => {
|
|
27
|
+
cy.visit('/blog')
|
|
28
|
+
cy.title().should('eq', 'Blog — Kitchen Sink')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('updates title when navigating between pages', () => {
|
|
32
|
+
cy.visit('/')
|
|
33
|
+
cy.title().should('eq', 'Home — Kitchen Sink')
|
|
34
|
+
cy.get('[data-cy=nav-about]').first().click({ force: true })
|
|
35
|
+
cy.title().should('eq', 'About — Kitchen Sink')
|
|
36
|
+
cy.get('[data-cy=about-back]').first().click({ force: true })
|
|
37
|
+
cy.title().should('eq', 'Home — Kitchen Sink')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('useHead() — meta tags', () => {
|
|
42
|
+
it('sets meta description on home page', () => {
|
|
43
|
+
cy.visit('/')
|
|
44
|
+
cy.get('meta[name="description"]').should('have.attr', 'content', 'Kitchen sink test app.')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('sets meta description on about page', () => {
|
|
48
|
+
cy.visit('/about')
|
|
49
|
+
cy.get('meta[name="description"]').should('have.attr', 'content', 'About the kitchen sink test app.')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('sets meta description on head test page', () => {
|
|
53
|
+
cy.visit('/head')
|
|
54
|
+
cy.get('meta[name="description"]').should('have.attr', 'content', 'A test page for useHead().')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('sets og:title meta on head test page', () => {
|
|
58
|
+
cy.visit('/head')
|
|
59
|
+
cy.get('meta[property="og:title"]').should('have.attr', 'content', 'Head Test')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
if (mode !== 'spa') {
|
|
64
|
+
describe('useHead() — server-side injection (SSR/SSG)', () => {
|
|
65
|
+
it('home page title is in the initial HTML', () => {
|
|
66
|
+
cy.request('/').then((response) => {
|
|
67
|
+
expect(response.body).to.include('<title>Home — Kitchen Sink</title>')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('about page title is in the initial HTML', () => {
|
|
72
|
+
cy.request('/about').then((response) => {
|
|
73
|
+
expect(response.body).to.include('<title>About — Kitchen Sink</title>')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('head test page title is in the initial HTML', () => {
|
|
78
|
+
cy.request('/head').then((response) => {
|
|
79
|
+
expect(response.body).to.include('<title>Head Test — Kitchen Sink</title>')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('meta description is in the initial HTML for home', () => {
|
|
84
|
+
cy.request('/').then((response) => {
|
|
85
|
+
expect(response.body).to.include('Kitchen sink test app.')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
}
|