@jasonshimmy/vite-plugin-cer-app 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.github/copilot-instructions.md +4 -2
  2. package/CHANGELOG.md +4 -0
  3. package/IMPLEMENTATION_PLAN.md +52 -10
  4. package/commits.txt +1 -1
  5. package/dist/cli/commands/preview-isr.d.ts +51 -0
  6. package/dist/cli/commands/preview-isr.d.ts.map +1 -0
  7. package/dist/cli/commands/preview-isr.js +104 -0
  8. package/dist/cli/commands/preview-isr.js.map +1 -0
  9. package/dist/cli/commands/preview.d.ts.map +1 -1
  10. package/dist/cli/commands/preview.js +65 -1
  11. package/dist/cli/commands/preview.js.map +1 -1
  12. package/dist/plugin/dev-server.d.ts +3 -0
  13. package/dist/plugin/dev-server.d.ts.map +1 -1
  14. package/dist/plugin/dev-server.js.map +1 -1
  15. package/dist/plugin/dts-generator.d.ts.map +1 -1
  16. package/dist/plugin/dts-generator.js +8 -1
  17. package/dist/plugin/dts-generator.js.map +1 -1
  18. package/dist/plugin/index.d.ts.map +1 -1
  19. package/dist/plugin/index.js +9 -1
  20. package/dist/plugin/index.js.map +1 -1
  21. package/dist/plugin/transforms/auto-import.js +2 -2
  22. package/dist/plugin/transforms/auto-import.js.map +1 -1
  23. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  24. package/dist/plugin/virtual/routes.js +95 -8
  25. package/dist/plugin/virtual/routes.js.map +1 -1
  26. package/dist/runtime/app-template.d.ts +1 -1
  27. package/dist/runtime/app-template.d.ts.map +1 -1
  28. package/dist/runtime/app-template.js +16 -4
  29. package/dist/runtime/app-template.js.map +1 -1
  30. package/dist/runtime/composables/index.d.ts +1 -0
  31. package/dist/runtime/composables/index.d.ts.map +1 -1
  32. package/dist/runtime/composables/index.js +1 -0
  33. package/dist/runtime/composables/index.js.map +1 -1
  34. package/dist/runtime/composables/use-runtime-config.d.ts +32 -0
  35. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -0
  36. package/dist/runtime/composables/use-runtime-config.js +41 -0
  37. package/dist/runtime/composables/use-runtime-config.js.map +1 -0
  38. package/dist/runtime/entry-server-template.d.ts +1 -1
  39. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  40. package/dist/runtime/entry-server-template.js +14 -6
  41. package/dist/runtime/entry-server-template.js.map +1 -1
  42. package/dist/types/config.d.ts +24 -0
  43. package/dist/types/config.d.ts.map +1 -1
  44. package/dist/types/config.js.map +1 -1
  45. package/dist/types/index.d.ts +1 -1
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/types/page.d.ts +17 -0
  48. package/dist/types/page.d.ts.map +1 -1
  49. package/docs/composables.md +36 -0
  50. package/docs/configuration.md +52 -0
  51. package/docs/layouts.md +82 -0
  52. package/docs/rendering-modes.md +52 -11
  53. package/docs/routing.md +66 -0
  54. package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +100 -0
  55. package/e2e/kitchen-sink/app/layouts/admin.ts +13 -0
  56. package/e2e/kitchen-sink/app/pages/admin/_layout.ts +1 -0
  57. package/e2e/kitchen-sink/app/pages/admin/dashboard.ts +11 -0
  58. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +1 -0
  59. package/e2e/kitchen-sink/app/pages/isr-test.ts +17 -0
  60. package/e2e/kitchen-sink/cer.config.ts +5 -0
  61. package/package.json +1 -1
  62. package/src/__tests__/cli/preview-isr.test.ts +246 -0
  63. package/src/__tests__/plugin/dts-generator.test.ts +20 -0
  64. package/src/__tests__/plugin/resolve-config.test.ts +15 -0
  65. package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
  66. package/src/__tests__/plugin/virtual/routes.test.ts +195 -0
  67. package/src/__tests__/runtime/use-runtime-config.test.ts +59 -0
  68. package/src/cli/commands/preview-isr.ts +139 -0
  69. package/src/cli/commands/preview.ts +71 -2
  70. package/src/plugin/dev-server.ts +1 -0
  71. package/src/plugin/dts-generator.ts +8 -1
  72. package/src/plugin/index.ts +11 -1
  73. package/src/plugin/transforms/auto-import.ts +2 -2
  74. package/src/plugin/virtual/routes.ts +106 -9
  75. package/src/runtime/app-template.ts +16 -4
  76. package/src/runtime/composables/index.ts +1 -0
  77. package/src/runtime/composables/use-runtime-config.ts +40 -0
  78. package/src/runtime/entry-server-template.ts +14 -6
  79. package/src/types/config.ts +26 -0
  80. package/src/types/index.ts +1 -1
  81. package/src/types/page.ts +17 -0
@@ -14,6 +14,24 @@ export interface AutoImportsConfig {
14
14
  directives?: boolean;
15
15
  runtime?: boolean;
16
16
  }
17
+ export interface RuntimePublicConfig {
18
+ [key: string]: unknown;
19
+ }
20
+ export interface RuntimeConfig {
21
+ /**
22
+ * Public runtime config — available on both server and client via
23
+ * `useRuntimeConfig().public`. Values are serialized into the virtual module
24
+ * at build time, so only use static/env-var values here.
25
+ *
26
+ * @example
27
+ * runtimeConfig: {
28
+ * public: {
29
+ * apiBase: process.env.VITE_API_BASE ?? 'https://api.example.com',
30
+ * }
31
+ * }
32
+ */
33
+ public?: RuntimePublicConfig;
34
+ }
17
35
  export interface CerAppConfig {
18
36
  mode?: 'spa' | 'ssr' | 'ssg';
19
37
  srcDir?: string;
@@ -22,6 +40,12 @@ export interface CerAppConfig {
22
40
  jitCss?: JitCssConfig;
23
41
  autoImports?: AutoImportsConfig;
24
42
  port?: number;
43
+ /**
44
+ * Runtime configuration accessible via `useRuntimeConfig()`.
45
+ * Only `public` values are exposed to the client; keep secrets
46
+ * out of `public`.
47
+ */
48
+ runtimeConfig?: RuntimeConfig;
25
49
  }
26
50
  export declare function defineConfig(config: CerAppConfig): CerAppConfig;
27
51
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6CAA6C,CAAA;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAA;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,MAAM,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,CAAC,CAAA;IACxD,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAE/D"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6CAA6C,CAAA;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,mBAAmB,CAAA;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAA;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,MAAM,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,CAAC,CAAA;IACxD,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAA;CAC9B;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAE/D"}
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AA8BA,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAwDA,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC"}
@@ -1,4 +1,4 @@
1
- export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig } from './config.js';
1
+ export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig, RuntimeConfig, RuntimePublicConfig } from './config.js';
2
2
  export { defineConfig } from './config.js';
3
3
  export type { HydrateStrategy, SsgPathsContext, PageSsgConfig, PageMeta, PageLoaderContext, PageLoader } from './page.js';
4
4
  export type { ApiRequest, ApiResponse, ApiHandler, ApiContext } from './api.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAC3F,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,aAAa,EAAE,QAAQ,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACzH,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC/E,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACxD,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAC/H,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,aAAa,EAAE,QAAQ,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACzH,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC/E,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACxD,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA"}
@@ -5,12 +5,29 @@ export interface SsgPathsContext {
5
5
  }
6
6
  export interface PageSsgConfig {
7
7
  paths?: () => Promise<SsgPathsContext[]> | SsgPathsContext[];
8
+ /**
9
+ * Seconds before a cached SSR response is stale and should be re-rendered.
10
+ * Enables Incremental Static Regeneration (ISR) in the preview server and
11
+ * any production adapter that reads `meta.ssg.revalidate`.
12
+ *
13
+ * @example export const meta = { ssg: { revalidate: 60 } }
14
+ */
15
+ revalidate?: number;
8
16
  }
9
17
  export interface PageMeta {
10
18
  layout?: string;
11
19
  middleware?: string[];
12
20
  hydrate?: HydrateStrategy;
13
21
  ssg?: PageSsgConfig;
22
+ /**
23
+ * CSS transition name applied to the page during route changes.
24
+ * Set to `true` to use the default 'page' transition name.
25
+ * The framework adds/removes `[data-transition="<name>"]` on the root element
26
+ * so you can target it with CSS animations.
27
+ *
28
+ * @example export const meta = { transition: 'fade' }
29
+ */
30
+ transition?: string | boolean;
14
31
  }
15
32
  export interface PageLoaderContext<P extends Record<string, string> = Record<string, string>> {
16
33
  params: P;
@@ -1 +1 @@
1
- {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../src/types/page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAEhD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AAElE,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,GAAG,eAAe,EAAE,CAAA;CAC7D;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,GAAG,CAAC,EAAE,aAAa,CAAA;CACpB;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAC1F,MAAM,EAAE,CAAC,CAAA;IACT,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,GAAG,EAAE,eAAe,CAAA;CACrB;AAED,MAAM,MAAM,UAAU,CACpB,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACzD,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IACzB,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA"}
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../src/types/page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAEhD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AAElE,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,GAAG,eAAe,EAAE,CAAA;IAC5D;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,GAAG,CAAC,EAAE,aAAa,CAAA;IACnB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAC9B;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAC1F,MAAM,EAAE,CAAC,CAAA;IACT,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,GAAG,EAAE,eAAe,CAAA;CACrB;AAED,MAAM,MAAM,UAAU,CACpB,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACzD,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IACzB,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA"}
@@ -141,3 +141,39 @@ import { useInject } from '@jasonshimmy/vite-plugin-cer-app/composables'
141
141
  ```
142
142
 
143
143
  > **Note:** Prefer `useInject` over the raw `inject()` primitive whenever reading plugin-provided values. Raw `inject()` works in SPA mode but returns `undefined` in SSR and SSG because the server renders components without `<cer-layout-view>`'s provide context.
144
+
145
+ ### `useRuntimeConfig()`
146
+
147
+ Returns the `public` runtime configuration object set in `cer.config.ts` under `runtimeConfig.public`. Available in all rendering modes (SPA, SSR, SSG) and on both server and client.
148
+
149
+ ```ts
150
+ // cer.config.ts
151
+ export default defineConfig({
152
+ runtimeConfig: {
153
+ public: {
154
+ apiBase: process.env.VITE_API_BASE ?? '/api',
155
+ featureFlags: { darkMode: true },
156
+ },
157
+ },
158
+ })
159
+ ```
160
+
161
+ ```ts
162
+ // app/pages/index.ts — auto-imported, no import statement needed
163
+ component('page-index', () => {
164
+ const { public: cfg } = useRuntimeConfig()
165
+ // cfg.apiBase → '/api'
166
+
167
+ return html`<p>API base: ${cfg.apiBase}</p>`
168
+ })
169
+ ```
170
+
171
+ The config is initialized at app boot (both client and server) by calling `initRuntimeConfig(runtimeConfig)` with the value from `virtual:cer-app-config`. You only need `useRuntimeConfig()` to read it.
172
+
173
+ **Only use `runtimeConfig.public` for values safe to expose to the browser.** Secrets, tokens, and private keys must stay in server-only code (loaders, API handlers, server middleware).
174
+
175
+ If you need it outside auto-imported directories:
176
+
177
+ ```ts
178
+ import { useRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
179
+ ```
@@ -198,10 +198,60 @@ When `directives: true`, the following are injected if used and not already impo
198
198
  import { when, each, match, anchorBlock } from '@jasonshimmy/custom-elements-runtime/directives'
199
199
  ```
200
200
 
201
+ The following framework composables are **always** auto-imported when used, regardless of the `runtime` flag — they come from the plugin package:
202
+
203
+ ```ts
204
+ import { useHead, usePageData, useInject, useRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
205
+ ```
206
+
201
207
  Set any flag to `false` to opt out and manage imports manually.
202
208
 
203
209
  ---
204
210
 
211
+ ## `runtimeConfig` options
212
+
213
+ Expose typed, centralized public configuration to both server and client code via `useRuntimeConfig()`.
214
+
215
+ ```ts
216
+ export default defineConfig({
217
+ runtimeConfig: {
218
+ public: {
219
+ apiBase: process.env.VITE_API_BASE ?? 'https://api.example.com',
220
+ appVersion: '1.0.0',
221
+ },
222
+ },
223
+ })
224
+ ```
225
+
226
+ ### `runtimeConfig.public`
227
+
228
+ **Type:** `Record<string, unknown>`
229
+ **Default:** `{}`
230
+
231
+ Values placed here are serialized into `virtual:cer-app-config` at build time and accessible on both server and client via `useRuntimeConfig().public`.
232
+
233
+ > **Security:** Only put values here that are safe to expose to the browser. Do not put secrets, tokens, or private keys in `public`. Those should be read directly from `process.env` inside server-only code (loaders, server middleware, API handlers).
234
+
235
+ > **Serialization:** Values must be JSON-serializable (strings, numbers, booleans, plain objects, arrays). Functions, class instances, `undefined`, and circular references are not supported and will be lost or throw during the build.
236
+
237
+ ```ts
238
+ // Any page, layout, component, or composable
239
+ component('page-index', () => {
240
+ const config = useRuntimeConfig()
241
+ // config.public.apiBase → 'https://api.example.com'
242
+
243
+ return html`<p>API: ${config.public.apiBase}</p>`
244
+ })
245
+ ```
246
+
247
+ **TypeScript:** Import `RuntimePublicConfig` to type your public config if needed:
248
+
249
+ ```ts
250
+ import type { RuntimePublicConfig } from '@jasonshimmy/vite-plugin-cer-app/types'
251
+ ```
252
+
253
+ ---
254
+
205
255
  ## Passing config to the Vite plugin directly
206
256
 
207
257
  When using `vite.config.ts` instead of (or alongside) `cer.config.ts`:
@@ -234,5 +284,7 @@ import type {
234
284
  SsgConfig,
235
285
  JitCssConfig,
236
286
  AutoImportsConfig,
287
+ RuntimeConfig,
288
+ RuntimePublicConfig,
237
289
  } from '@jasonshimmy/vite-plugin-cer-app/types'
238
290
  ```
package/docs/layouts.md CHANGED
@@ -110,3 +110,85 @@ import layouts from 'virtual:cer-layouts'
110
110
  ## Layout switching and DOM preservation
111
111
 
112
112
  When navigating between pages with different layouts, the framework uses `<cer-keep-alive>` to preserve the layout DOM and avoid unnecessary teardown/remount cycles. This means transitions between pages sharing the same layout are smooth with no layout flash.
113
+
114
+ ---
115
+
116
+ ## 🪆 Nested layouts
117
+
118
+ Place a `_layout.ts` file inside any page subdirectory to add an inner layout that wraps pages in that subtree. The outer layout (from `meta.layout` or the default) wraps the inner layout, which wraps `<router-view>`.
119
+
120
+ ### File convention
121
+
122
+ ```
123
+ app/
124
+ layouts/
125
+ default.ts # outer layout — full shell (header, footer)
126
+ minimal.ts # outer layout — bare minimum
127
+ sidebar.ts # inner layout — adds a sidebar panel
128
+ pages/
129
+ index.ts # uses 'default' layout only
130
+ admin/
131
+ _layout.ts # ← inner layout override for all /admin/* pages
132
+ index.ts # layout chain: ['default', 'sidebar']
133
+ users.ts # layout chain: ['default', 'sidebar']
134
+ settings.ts # layout chain: ['default', 'sidebar']
135
+ ```
136
+
137
+ ### `_layout.ts` syntax
138
+
139
+ Export the inner layout name as a default string:
140
+
141
+ ```ts
142
+ // app/pages/admin/_layout.ts
143
+ export default 'sidebar'
144
+ ```
145
+
146
+ The value must match a filename (without extension) in `app/layouts/`.
147
+
148
+ ### Rendered structure
149
+
150
+ For a page at `app/pages/admin/users.ts` with the above setup:
151
+
152
+ ```html
153
+ <layout-default>
154
+ <layout-sidebar>
155
+ <router-view></router-view>
156
+ </layout-sidebar>
157
+ </layout-default>
158
+ ```
159
+
160
+ Each layout receives the inner content via `<slot>`.
161
+
162
+ ### Overriding the outer layout
163
+
164
+ If a page in a nested subtree needs a different outer layout, declare it in `meta.layout` as usual:
165
+
166
+ ```ts
167
+ // app/pages/admin/login.ts
168
+ export const meta = {
169
+ layout: 'minimal', // overrides outer; chain = ['minimal', 'sidebar']
170
+ }
171
+ ```
172
+
173
+ ### Multiple nesting levels
174
+
175
+ Nesting is resolved recursively. Each ancestor directory that contains a `_layout.ts` contributes one level to the chain:
176
+
177
+ ```
178
+ app/pages/
179
+ admin/
180
+ _layout.ts → 'sidebar'
181
+ settings/
182
+ _layout.ts → 'settings-tabs'
183
+ profile.ts # chain: ['default', 'sidebar', 'settings-tabs']
184
+ ```
185
+
186
+ ### `meta.layoutChain` in routes
187
+
188
+ The framework emits the resolved chain as `meta.layoutChain` on the route object at build time. You can read it at runtime:
189
+
190
+ ```ts
191
+ import routes from 'virtual:cer-routes'
192
+ const adminUsers = routes.find(r => r.path === '/admin/users')
193
+ // adminUsers.meta.layoutChain → ['default', 'sidebar']
194
+ ```
@@ -223,19 +223,60 @@ Any CDN or static host. Upload the entire `dist/` directory (excluding `dist/ser
223
223
 
224
224
  ---
225
225
 
226
+ ## ISR — Incremental Static Regeneration
227
+
228
+ ISR is a per-route cache layer in the SSR preview server. Pages with `meta.ssg.revalidate` set are rendered once, cached, and re-rendered in the background when the TTL expires (stale-while-revalidate).
229
+
230
+ ### How it works
231
+
232
+ 1. **First request (HIT after fresh render):** Cache miss — render via SSR, store in memory cache with TTL, then serve from the newly-populated cache. `X-Cache: HIT` is set.
233
+ 2. **Within TTL (HIT):** Serve directly from cache. `X-Cache: HIT` header is set.
234
+ 3. **After TTL expires (STALE):** Serve the stale cached HTML immediately with `X-Cache: STALE`. Kick off a background re-render. When the re-render completes, update the cache.
235
+ 4. **While revalidating:** Continue serving stale HTML to new requests.
236
+
237
+ ### Configuration
238
+
239
+ Add `revalidate` (seconds) to `meta.ssg` in any page:
240
+
241
+ ```ts
242
+ // app/pages/blog/[slug].ts
243
+ export const meta = {
244
+ ssg: {
245
+ revalidate: 60, // cache for 60 s; re-render in background after expiry
246
+ paths: async () => {
247
+ const posts = await fetchPosts()
248
+ return posts.map(p => ({ params: { slug: p.slug } }))
249
+ },
250
+ },
251
+ }
252
+ ```
253
+
254
+ ### Use cases
255
+
256
+ | TTL | Use case |
257
+ |---|---|
258
+ | `revalidate: 0` | TTL expires immediately — first request is HIT; every subsequent request is STALE with a background re-render |
259
+ | `revalidate: 60` | News articles, dashboards |
260
+ | `revalidate: 3600` | Product pages, documentation |
261
+ | `revalidate: 86400` | Marketing pages, rarely-changing content |
262
+
263
+ ### Availability
264
+
265
+ ISR is currently active in the built-in **preview server** (`cer-app preview`). When integrating the server bundle into Express / Hono / Fastify in production, implement the same stale-while-revalidate pattern using `route.meta?.ssg?.revalidate` from the exported `routes` array.
266
+
267
+ ---
268
+
226
269
  ## Comparing modes
227
270
 
228
- | Feature | SPA | SSR | SSG |
229
- |---|---|---|---|
230
- | Initial HTML | Empty shell | Full HTML | Full HTML |
231
- | SEO | Poor | Excellent | Excellent |
232
- | TTFB | Fast | Depends on server | Very fast (CDN) |
233
- | Server required | No | Yes | No |
234
- | Data freshness | Real-time | Real-time | Build-time |
235
- | Dynamic routes | Yes | Yes | Requires `ssg.paths` |
236
- | API routes | Separate deploy | Same process | Separate deploy |
237
- | `useHead()` SSR injection | No | Yes | Yes |
238
- | Streaming | No | No | No |
271
+ | Feature | SPA | SSR | SSG | ISR |
272
+ |---|---|---|---|---|
273
+ | Initial HTML | Empty shell | Full HTML | Full HTML | Full HTML |
274
+ | SEO | Poor | Excellent | Excellent | Excellent |
275
+ | TTFB | Fast | Depends on server | Very fast (CDN) | Very fast after first render |
276
+ | Server required | No | Yes | No | Yes |
277
+ | Data freshness | Real-time | Real-time | Build-time | Configurable TTL |
278
+ | Dynamic routes | Yes | Yes | Requires `ssg.paths` | Yes |
279
+ | API routes | Separate deploy | Same process | Separate deploy | Same process |
239
280
 
240
281
  ---
241
282
 
package/docs/routing.md CHANGED
@@ -172,6 +172,72 @@ export const meta = {
172
172
  }
173
173
  ```
174
174
 
175
+ ### `meta.ssg.revalidate`
176
+
177
+ **Type:** `number` (seconds)
178
+
179
+ Enables Incremental Static Regeneration (ISR) for the route. When set, the preview server caches the rendered HTML and serves it until the TTL expires. After expiry, stale HTML is served immediately while a fresh render runs in the background (stale-while-revalidate).
180
+
181
+ ```ts
182
+ // app/pages/blog/[slug].ts
183
+ export const meta = {
184
+ ssg: {
185
+ revalidate: 60, // re-render at most once per minute
186
+ paths: async () => { /* ... */ },
187
+ },
188
+ }
189
+ ```
190
+
191
+ See [Rendering Modes — ISR](rendering-modes.md#isr--incremental-static-regeneration) for full details.
192
+
193
+ ### `meta.transition`
194
+
195
+ **Type:** `string | boolean`
196
+
197
+ Attaches transition metadata to the route. The value is extracted at build time and emitted as `meta.transition` on the route object — the framework does **not** apply any CSS or DOM changes automatically.
198
+
199
+ ```ts
200
+ // app/pages/about.ts
201
+ export const meta = {
202
+ transition: 'fade', // stored as route.meta.transition at runtime
203
+ }
204
+ ```
205
+
206
+ Read the value in your navigation handler or layout to implement transitions yourself:
207
+
208
+ ```ts
209
+ import routes from 'virtual:cer-routes'
210
+ const about = routes.find(r => r.path === '/about')
211
+ // about.meta.transition → 'fade'
212
+ ```
213
+
214
+ Example — apply a CSS class during navigation using a plugin:
215
+
216
+ ```ts
217
+ // app/plugins/transitions.ts
218
+ export default {
219
+ setup({ router }) {
220
+ router.beforeEach((to) => {
221
+ const name = to.meta?.transition
222
+ if (name) document.documentElement.setAttribute('data-transition', String(name))
223
+ else document.documentElement.removeAttribute('data-transition')
224
+ })
225
+ },
226
+ }
227
+ ```
228
+
229
+ ```css
230
+ [data-transition="fade"] cer-layout-view {
231
+ animation: fadeIn 0.2s ease;
232
+ }
233
+ @keyframes fadeIn {
234
+ from { opacity: 0 }
235
+ to { opacity: 1 }
236
+ }
237
+ ```
238
+
239
+ Set to `false` to explicitly mark a page as having no transition (useful when a catch-all or default would otherwise apply one).
240
+
175
241
  ---
176
242
 
177
243
  ## Route sorting
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Tests for ISR (Incremental Static Regeneration), nested layouts, and
3
+ * runtimeConfig — features added alongside Nuxt/Next parity improvements.
4
+ *
5
+ * ISR and runtimeConfig tests only run in SSR mode (preview server).
6
+ * Nested-layout tests run in all modes.
7
+ */
8
+
9
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
10
+
11
+ // ─── ISR (preview server only) ────────────────────────────────────────────────
12
+
13
+ if (mode === 'ssr') {
14
+ describe('ISR — stale-while-revalidate cache', () => {
15
+ it('request to a route with revalidate returns X-Cache: HIT', () => {
16
+ // On first render (cache miss) the server renders, caches, then serves
17
+ // from cache — so all requests within the TTL return HIT.
18
+ cy.request('/blog/first-post').then((response) => {
19
+ expect(response.headers['x-cache']).to.equal('HIT')
20
+ })
21
+ })
22
+
23
+ it('subsequent requests within TTL also return X-Cache: HIT', () => {
24
+ cy.request('/blog/first-post')
25
+ cy.request('/blog/first-post').then((response) => {
26
+ expect(response.headers['x-cache']).to.equal('HIT')
27
+ })
28
+ })
29
+
30
+ it('X-Cache header is absent on non-revalidate routes', () => {
31
+ cy.request('/about').then((response) => {
32
+ expect(response.headers).not.to.have.property('x-cache')
33
+ })
34
+ })
35
+
36
+ // /isr-test uses revalidate: 0 — the TTL is always expired after the first
37
+ // render, so the second request is always served stale-while-revalidate.
38
+ it('first request to a revalidate:0 route returns X-Cache: HIT', () => {
39
+ cy.request('/isr-test').then((response) => {
40
+ expect(response.headers['x-cache']).to.equal('HIT')
41
+ })
42
+ })
43
+
44
+ it('second request to a revalidate:0 route returns X-Cache: STALE', () => {
45
+ cy.request('/isr-test') // prime the cache
46
+ cy.request('/isr-test').then((response) => {
47
+ expect(response.headers['x-cache']).to.equal('STALE')
48
+ })
49
+ })
50
+ })
51
+ }
52
+
53
+ // ─── Nested layouts ────────────────────────────────────────────────────────────
54
+
55
+ describe('Nested layouts — admin section', () => {
56
+ it('renders the admin dashboard page', () => {
57
+ cy.visit('/admin/dashboard')
58
+ cy.get('[data-cy=admin-dashboard-heading]').should('contain', 'Admin Dashboard')
59
+ })
60
+
61
+ it('renders the outer default layout (site-header, site-nav)', () => {
62
+ cy.visit('/admin/dashboard')
63
+ cy.get('[data-cy=site-header]').should('exist')
64
+ cy.get('[data-cy=site-nav]').should('exist')
65
+ })
66
+
67
+ it('renders the inner admin layout (admin-sidebar, admin-content)', () => {
68
+ cy.visit('/admin/dashboard')
69
+ cy.get('[data-cy=admin-layout]').should('exist')
70
+ cy.get('[data-cy=admin-sidebar]').should('exist')
71
+ cy.get('[data-cy=admin-content]').should('exist')
72
+ })
73
+
74
+ it('layout-admin is a DOM child of layout-default (layout chain order)', () => {
75
+ // Verifies the chain ['default', 'admin'] is rendered outermost-first.
76
+ // layout-admin must be in the light DOM of layout-default, not the other
77
+ // way around. A CSS descendant selector proves this without needing to
78
+ // pierce slot boundaries.
79
+ cy.visit('/admin/dashboard')
80
+ cy.get('layout-default layout-admin').should('exist')
81
+ })
82
+
83
+ if (mode !== 'spa') {
84
+ it('admin layout is pre-rendered in initial HTML (SSR/SSG)', () => {
85
+ cy.request('/admin/dashboard').then((response) => {
86
+ expect(response.body).to.include('admin-layout')
87
+ expect(response.body).to.include('Admin Dashboard')
88
+ })
89
+ })
90
+ }
91
+ })
92
+
93
+ // ─── runtimeConfig ─────────────────────────────────────────────────────────────
94
+
95
+ describe('runtimeConfig.public', () => {
96
+ it('renders the appName from runtimeConfig on the admin dashboard', () => {
97
+ cy.visit('/admin/dashboard')
98
+ cy.get('[data-cy=admin-app-name]').should('contain', 'Kitchen Sink')
99
+ })
100
+ })
@@ -0,0 +1,13 @@
1
+ component('layout-admin', () => {
2
+ return html`
3
+ <div data-cy="admin-layout">
4
+ <aside data-cy="admin-sidebar">
5
+ <p>Admin Panel</p>
6
+ <a href="/admin/dashboard" data-cy="admin-nav-dashboard">Dashboard</a>
7
+ </aside>
8
+ <section data-cy="admin-content">
9
+ <slot></slot>
10
+ </section>
11
+ </div>
12
+ `
13
+ })
@@ -0,0 +1 @@
1
+ export default 'admin'
@@ -0,0 +1,11 @@
1
+ component('page-admin-dashboard', () => {
2
+ const config = useRuntimeConfig()
3
+ const appName = config.public?.appName ?? 'Kitchen Sink'
4
+
5
+ return html`
6
+ <div>
7
+ <h1 data-cy="admin-dashboard-heading">Admin Dashboard</h1>
8
+ <p data-cy="admin-app-name">App: ${appName}</p>
9
+ </div>
10
+ `
11
+ })
@@ -46,6 +46,7 @@ export const loader = async ({ params }: { params: { slug: string } }) => {
46
46
 
47
47
  export const meta = {
48
48
  ssg: {
49
+ revalidate: 60,
49
50
  paths: async () => [
50
51
  { params: { slug: 'first-post' } },
51
52
  { params: { slug: 'second-post' } },
@@ -0,0 +1,17 @@
1
+ component('page-isr-test', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="isr-test-heading">ISR Test</h1>
5
+ <p data-cy="isr-test-description">
6
+ This page uses <code>revalidate: 0</code> so every post-first request
7
+ is immediately stale.
8
+ </p>
9
+ </div>
10
+ `
11
+ })
12
+
13
+ export const meta = {
14
+ ssg: {
15
+ revalidate: 0,
16
+ },
17
+ }
@@ -2,4 +2,9 @@
2
2
  export default {
3
3
  ssg: { routes: 'auto', concurrency: 2 },
4
4
  autoImports: { runtime: true, components: true, composables: true },
5
+ runtimeConfig: {
6
+ public: {
7
+ appName: 'Kitchen Sink',
8
+ },
9
+ },
5
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [