@jasonshimmy/vite-plugin-cer-app 0.4.6 → 0.6.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 (93) hide show
  1. package/.github/copilot-instructions.md +4 -2
  2. package/CHANGELOG.md +8 -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/cli/create/templates/spa/package.json.tpl +2 -2
  13. package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
  14. package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
  15. package/dist/plugin/build-ssg.d.ts.map +1 -1
  16. package/dist/plugin/build-ssg.js +4 -2
  17. package/dist/plugin/build-ssg.js.map +1 -1
  18. package/dist/plugin/dev-server.d.ts +3 -0
  19. package/dist/plugin/dev-server.d.ts.map +1 -1
  20. package/dist/plugin/dev-server.js.map +1 -1
  21. package/dist/plugin/dts-generator.d.ts.map +1 -1
  22. package/dist/plugin/dts-generator.js +8 -1
  23. package/dist/plugin/dts-generator.js.map +1 -1
  24. package/dist/plugin/index.d.ts.map +1 -1
  25. package/dist/plugin/index.js +9 -1
  26. package/dist/plugin/index.js.map +1 -1
  27. package/dist/plugin/transforms/auto-import.js +2 -2
  28. package/dist/plugin/transforms/auto-import.js.map +1 -1
  29. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  30. package/dist/plugin/virtual/routes.js +95 -8
  31. package/dist/plugin/virtual/routes.js.map +1 -1
  32. package/dist/runtime/app-template.d.ts +1 -1
  33. package/dist/runtime/app-template.d.ts.map +1 -1
  34. package/dist/runtime/app-template.js +16 -4
  35. package/dist/runtime/app-template.js.map +1 -1
  36. package/dist/runtime/composables/index.d.ts +1 -0
  37. package/dist/runtime/composables/index.d.ts.map +1 -1
  38. package/dist/runtime/composables/index.js +1 -0
  39. package/dist/runtime/composables/index.js.map +1 -1
  40. package/dist/runtime/composables/use-runtime-config.d.ts +32 -0
  41. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -0
  42. package/dist/runtime/composables/use-runtime-config.js +41 -0
  43. package/dist/runtime/composables/use-runtime-config.js.map +1 -0
  44. package/dist/runtime/entry-server-template.d.ts +2 -2
  45. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  46. package/dist/runtime/entry-server-template.js +50 -21
  47. package/dist/runtime/entry-server-template.js.map +1 -1
  48. package/dist/types/config.d.ts +24 -0
  49. package/dist/types/config.d.ts.map +1 -1
  50. package/dist/types/config.js.map +1 -1
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/types/page.d.ts +17 -0
  54. package/dist/types/page.d.ts.map +1 -1
  55. package/docs/composables.md +36 -0
  56. package/docs/configuration.md +52 -0
  57. package/docs/layouts.md +82 -0
  58. package/docs/rendering-modes.md +55 -14
  59. package/docs/routing.md +66 -0
  60. package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +100 -0
  61. package/e2e/kitchen-sink/app/layouts/admin.ts +13 -0
  62. package/e2e/kitchen-sink/app/pages/admin/_layout.ts +1 -0
  63. package/e2e/kitchen-sink/app/pages/admin/dashboard.ts +11 -0
  64. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +1 -0
  65. package/e2e/kitchen-sink/app/pages/isr-test.ts +17 -0
  66. package/e2e/kitchen-sink/cer.config.ts +5 -0
  67. package/package.json +3 -3
  68. package/src/__tests__/cli/preview-isr.test.ts +246 -0
  69. package/src/__tests__/plugin/build-ssg-render.test.ts +46 -0
  70. package/src/__tests__/plugin/dts-generator.test.ts +20 -0
  71. package/src/__tests__/plugin/entry-server-template.test.ts +23 -5
  72. package/src/__tests__/plugin/resolve-config.test.ts +15 -0
  73. package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
  74. package/src/__tests__/plugin/virtual/routes.test.ts +195 -0
  75. package/src/__tests__/runtime/use-runtime-config.test.ts +59 -0
  76. package/src/cli/commands/preview-isr.ts +139 -0
  77. package/src/cli/commands/preview.ts +71 -2
  78. package/src/cli/create/templates/spa/package.json.tpl +2 -2
  79. package/src/cli/create/templates/ssg/package.json.tpl +2 -2
  80. package/src/cli/create/templates/ssr/package.json.tpl +2 -2
  81. package/src/plugin/build-ssg.ts +4 -2
  82. package/src/plugin/dev-server.ts +1 -0
  83. package/src/plugin/dts-generator.ts +8 -1
  84. package/src/plugin/index.ts +11 -1
  85. package/src/plugin/transforms/auto-import.ts +2 -2
  86. package/src/plugin/virtual/routes.ts +106 -9
  87. package/src/runtime/app-template.ts +16 -4
  88. package/src/runtime/composables/index.ts +1 -0
  89. package/src/runtime/composables/use-runtime-config.ts +40 -0
  90. package/src/runtime/entry-server-template.ts +50 -21
  91. package/src/types/config.ts +26 -0
  92. package/src/types/index.ts +1 -1
  93. package/src/types/page.ts +17 -0
@@ -7,9 +7,9 @@
7
7
  *
8
8
  * Key features:
9
9
  * - AsyncLocalStorage for race-condition-free concurrent renders (SSG concurrency > 1)
10
- * - Declarative Shadow DOM via renderToStringWithJITCSSDSD (always on)
10
+ * - Declarative Shadow DOM via renderToStreamWithJITCSSDSD (always on, streamed)
11
11
  * - useHead() support via beginHeadCollection / endHeadCollection
12
12
  * - DSD polyfill injected at end of <body> after client-template merge
13
13
  */
14
- export declare const ENTRY_SERVER_TEMPLATE = "// Server-side entry \u2014 AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport apiRoutes from 'virtual:cer-server-api'\nimport { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { registerEntityMap, renderToStringWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'\nimport entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { beginHeadCollection, endHeadCollection, serializeHeadTags } from '@jasonshimmy/vite-plugin-cer-app/composables'\n\nregisterBuiltinComponents()\n\n// Pre-load the full HTML entity map so named entities like &mdash; decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (&lt;, &gt;, &amp; \u2026) and re-escapes everything else.\nregisterEntityMap(entitiesJson)\n\n// Run plugins once at server startup so their provide() values are available\n// to useInject() during every SSR/SSG render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference.\nconst _pluginProvides = new Map()\n;(globalThis).__cerPluginProvides = _pluginProvides\nconst _pluginsReady = (async () => {\n const _bootstrapRouter = initRouter({ routes })\n for (const plugin of plugins) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({\n router: _bootstrapRouter,\n provide: (key, value) => _pluginProvides.set(key, value),\n config: {},\n })\n }\n }\n})()\n\n// Async-local storage for request-scoped SSR loader data.\n// Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with\n// concurrency > 1) never see each other's data \u2014 each request's async chain\n// carries its own store value, so usePageData() is always race-condition-free.\nconst _cerDataStore = new AsyncLocalStorage()\n// Expose the store so the usePageData() composable can read it server-side.\n;(globalThis).__CER_DATA_STORE__ = _cerDataStore\n\n// Load the Vite-built client index.html (dist/client/index.html) so every SSR\n// response includes the client-side scripts needed for hydration and routing.\n// The server bundle lives at dist/server/server.js, so ../client resolves correctly.\nconst _clientTemplatePath = join(dirname(fileURLToPath(import.meta.url)), '../client/index.html')\nconst _clientTemplate = existsSync(_clientTemplatePath)\n ? readFileSync(_clientTemplatePath, 'utf-8')\n : null\n\n// Merge the SSR rendered body with the Vite client shell so the final page\n// contains both pre-rendered DSD content and the client bundle scripts.\nfunction _mergeWithClientTemplate(ssrHtml, clientTemplate) {\n const headTag = '<head>', headCloseTag = '</head>'\n const bodyTag = '<body>', bodyCloseTag = '</body>'\n const headStart = ssrHtml.indexOf(headTag)\n const headEnd = ssrHtml.indexOf(headCloseTag)\n const bodyStart = ssrHtml.indexOf(bodyTag)\n const bodyEnd = ssrHtml.lastIndexOf(bodyCloseTag)\n const ssrHead = headStart >= 0 && headEnd > headStart\n ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''\n const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart\n ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml\n // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)\n // from the SSR body into the document <head>. Plain <style> blocks without\n // an id attribute belong to shadow DOM templates and must stay in place \u2014\n // hoisting them to <head> breaks shadow DOM style encapsulation (document\n // styles do not pierce shadow roots), which is the root cause of FOUC.\n const headParts = ssrHead ? [ssrHead] : []\n let ssrBodyContent = ssrBody\n let pos = 0\n while (pos < ssrBodyContent.length) {\n const styleOpen = ssrBodyContent.indexOf('<style id=', pos)\n if (styleOpen < 0) break\n const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)\n if (styleClose < 0) break\n headParts.push(ssrBodyContent.slice(styleOpen, styleClose + 8))\n ssrBodyContent = ssrBodyContent.slice(0, styleOpen) + ssrBodyContent.slice(styleClose + 8)\n pos = styleOpen\n }\n ssrBodyContent = ssrBodyContent.trim()\n // Inject the pre-rendered layout+page as light DOM of the app mount element\n // so it is visible before JS boots, then the client router takes over.\n let merged = clientTemplate\n if (merged.includes('<cer-layout-view></cer-layout-view>')) {\n merged = merged.replace('<cer-layout-view></cer-layout-view>',\n '<cer-layout-view>' + ssrBodyContent + '</cer-layout-view>')\n } else if (merged.includes('<div id=\"app\"></div>')) {\n merged = merged.replace('<div id=\"app\"></div>',\n '<div id=\"app\">' + ssrBodyContent + '</div>')\n }\n const headAdditions = headParts.filter(Boolean).join('\\n')\n if (headAdditions) {\n // If SSR provides a <title>, replace the client template's <title> so the\n // SSR title wins (client template title is the fallback default).\n if (headAdditions.includes('<title>')) {\n merged = merged.replace(/<title>[^<]*<\\/title>/, '')\n }\n merged = merged.replace('</head>', headAdditions + '\\n</head>')\n }\n return merged\n}\n\n// Per-request async setup: initialize a fresh router, resolve the matched\n// route and layout, pre-load the page module, and call the data loader.\n// Loader data is scoped to the current AsyncLocalStorage context via enterWith()\n// so concurrent renders never share state.\nconst _prepareRequest = async (req) => {\n await _pluginsReady\n const router = initRouter({ routes, initialUrl: req.url ?? '/' })\n const current = router.getCurrent()\n const { route, params } = router.matchRoute(current.path)\n const layoutName = route?.meta?.layout ?? 'default'\n const layoutTag = layouts[layoutName]\n\n // Pre-load the page module so we can embed the component tag directly.\n // This avoids the async router-view (which injects content via script tags\n // and breaks Declarative Shadow DOM on initial parse).\n let pageVnode = { tag: 'div', props: {}, children: [] }\n let head\n if (route?.load) {\n try {\n const mod = await route.load()\n const pageTag = mod.default\n if (pageTag) {\n pageVnode = { tag: pageTag, props: { attrs: { ...params } }, children: [] }\n }\n if (typeof mod.loader === 'function') {\n const query = current.query ?? {}\n const data = await mod.loader({ params, query, req })\n if (data !== undefined && data !== null) {\n // enterWith() scopes the value to the current async context so\n // concurrent renders (SSG concurrency > 1) never share data.\n _cerDataStore.enterWith(data)\n head = `<script>window.__CER_DATA__ = ${JSON.stringify(data)}</script>`\n }\n }\n } catch {\n // Non-fatal: loader errors fall back to an empty page; client will refetch.\n }\n }\n\n const vnode = layoutTag\n ? { tag: layoutTag, props: {}, children: [pageVnode] }\n : pageVnode\n\n return { vnode, router, head }\n}\n\nexport const handler = async (req, res) => {\n await _cerDataStore.run(null, async () => {\n const { vnode, router, head } = await _prepareRequest(req)\n\n // Begin collecting useHead() calls made during the synchronous render pass.\n beginHeadCollection()\n\n // dsdPolyfill: false \u2014 we inject the polyfill manually after merging so it\n // lands at the end of <body>, not inside <cer-layout-view> light DOM where\n // scripts may not execute.\n const { htmlWithStyles } = renderToStringWithJITCSSDSD(vnode, {\n dsdPolyfill: false,\n router,\n })\n\n // Collect and serialize any useHead() calls from the rendered components.\n const headTags = serializeHeadTags(endHeadCollection())\n\n // Merge loader data script + useHead() tags into the document head.\n const headContent = [head, headTags].filter(Boolean).join('\\n')\n\n // Wrap the rendered body in a full HTML document and inject the head additions\n // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.\n const ssrHtml = `<!DOCTYPE html><html><head>${headContent}</head><body>${htmlWithStyles}</body></html>`\n\n let finalHtml = _clientTemplate\n ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)\n : ssrHtml\n\n // Inject DSD polyfill at end of <body>, outside <cer-layout-view>, so the\n // browser runs it after parsing the declarative shadow roots.\n finalHtml = finalHtml.includes('</body>')\n ? finalHtml.replace('</body>', DSD_POLYFILL_SCRIPT + '</body>')\n : finalHtml + DSD_POLYFILL_SCRIPT\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end(finalHtml)\n })\n}\n\nexport { apiRoutes, plugins, layouts, routes }\nexport default handler\n";
14
+ export declare const ENTRY_SERVER_TEMPLATE = "// Server-side entry \u2014 AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport apiRoutes from 'virtual:cer-server-api'\nimport { runtimeConfig } from 'virtual:cer-app-config'\nimport { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'\nimport entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'\n\nregisterBuiltinComponents()\ninitRuntimeConfig(runtimeConfig)\n\n// Pre-load the full HTML entity map so named entities like &mdash; decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (&lt;, &gt;, &amp; \u2026) and re-escapes everything else.\nregisterEntityMap(entitiesJson)\n\n// Run plugins once at server startup so their provide() values are available\n// to useInject() during every SSR/SSG render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference.\nconst _pluginProvides = new Map()\n;(globalThis).__cerPluginProvides = _pluginProvides\nconst _pluginsReady = (async () => {\n const _bootstrapRouter = initRouter({ routes })\n for (const plugin of plugins) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({\n router: _bootstrapRouter,\n provide: (key, value) => _pluginProvides.set(key, value),\n config: {},\n })\n }\n }\n})()\n\n// Async-local storage for request-scoped SSR loader data.\n// Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with\n// concurrency > 1) never see each other's data \u2014 each request's async chain\n// carries its own store value, so usePageData() is always race-condition-free.\nconst _cerDataStore = new AsyncLocalStorage()\n// Expose the store so the usePageData() composable can read it server-side.\n;(globalThis).__CER_DATA_STORE__ = _cerDataStore\n\n// Load the Vite-built client index.html (dist/client/index.html) so every SSR\n// response includes the client-side scripts needed for hydration and routing.\n// The server bundle lives at dist/server/server.js, so ../client resolves correctly.\nconst _clientTemplatePath = join(dirname(fileURLToPath(import.meta.url)), '../client/index.html')\nconst _clientTemplate = existsSync(_clientTemplatePath)\n ? readFileSync(_clientTemplatePath, 'utf-8')\n : null\n\n// Merge the SSR rendered body with the Vite client shell so the final page\n// contains both pre-rendered DSD content and the client bundle scripts.\nfunction _mergeWithClientTemplate(ssrHtml, clientTemplate) {\n const headTag = '<head>', headCloseTag = '</head>'\n const bodyTag = '<body>', bodyCloseTag = '</body>'\n const headStart = ssrHtml.indexOf(headTag)\n const headEnd = ssrHtml.indexOf(headCloseTag)\n const bodyStart = ssrHtml.indexOf(bodyTag)\n const bodyEnd = ssrHtml.lastIndexOf(bodyCloseTag)\n const ssrHead = headStart >= 0 && headEnd > headStart\n ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''\n const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart\n ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml\n // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)\n // from the SSR body into the document <head>. Plain <style> blocks without\n // an id attribute belong to shadow DOM templates and must stay in place \u2014\n // hoisting them to <head> breaks shadow DOM style encapsulation (document\n // styles do not pierce shadow roots), which is the root cause of FOUC.\n const headParts = ssrHead ? [ssrHead] : []\n let ssrBodyContent = ssrBody\n let pos = 0\n while (pos < ssrBodyContent.length) {\n const styleOpen = ssrBodyContent.indexOf('<style id=', pos)\n if (styleOpen < 0) break\n const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)\n if (styleClose < 0) break\n headParts.push(ssrBodyContent.slice(styleOpen, styleClose + 8))\n ssrBodyContent = ssrBodyContent.slice(0, styleOpen) + ssrBodyContent.slice(styleClose + 8)\n pos = styleOpen\n }\n ssrBodyContent = ssrBodyContent.trim()\n // Inject the pre-rendered layout+page as light DOM of the app mount element\n // so it is visible before JS boots, then the client router takes over.\n let merged = clientTemplate\n if (merged.includes('<cer-layout-view></cer-layout-view>')) {\n merged = merged.replace('<cer-layout-view></cer-layout-view>',\n '<cer-layout-view>' + ssrBodyContent + '</cer-layout-view>')\n } else if (merged.includes('<div id=\"app\"></div>')) {\n merged = merged.replace('<div id=\"app\"></div>',\n '<div id=\"app\">' + ssrBodyContent + '</div>')\n }\n const headAdditions = headParts.filter(Boolean).join('\\n')\n if (headAdditions) {\n // If SSR provides a <title>, replace the client template's <title> so the\n // SSR title wins (client template title is the fallback default).\n if (headAdditions.includes('<title>')) {\n merged = merged.replace(/<title>[^<]*<\\/title>/, '')\n }\n merged = merged.replace('</head>', headAdditions + '\\n</head>')\n }\n return merged\n}\n\n// Per-request async setup: initialize a fresh router, resolve the matched\n// route and layout, pre-load the page module, and call the data loader.\n// Loader data is scoped to the current AsyncLocalStorage context via enterWith()\n// so concurrent renders never share state.\nconst _prepareRequest = async (req) => {\n await _pluginsReady\n const router = initRouter({ routes, initialUrl: req.url ?? '/' })\n const current = router.getCurrent()\n const { route, params } = router.matchRoute(current.path)\n\n // Pre-load the page module so we can embed the component tag directly.\n // This avoids the async router-view (which injects content via script tags\n // and breaks Declarative Shadow DOM on initial parse).\n let pageVnode = { tag: 'div', props: {}, children: [] }\n let head\n if (route?.load) {\n try {\n const mod = await route.load()\n const pageTag = mod.default\n if (pageTag) {\n pageVnode = { tag: pageTag, props: { attrs: { ...params } }, children: [] }\n }\n if (typeof mod.loader === 'function') {\n const query = current.query ?? {}\n const data = await mod.loader({ params, query, req })\n if (data !== undefined && data !== null) {\n // enterWith() scopes the value to the current async context so\n // concurrent renders (SSG concurrency > 1) never share data.\n _cerDataStore.enterWith(data)\n head = `<script>window.__CER_DATA__ = ${JSON.stringify(data)}</script>`\n }\n }\n } catch {\n // Non-fatal: loader errors fall back to an empty page; client will refetch.\n }\n }\n\n // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.\n const chain = route?.meta?.layoutChain\n ? route.meta.layoutChain\n : [route?.meta?.layout ?? 'default']\n\n // Wrap pageVnode in the layout chain from innermost to outermost.\n let vnode = pageVnode\n for (let i = chain.length - 1; i >= 0; i--) {\n const tag = layouts[chain[i]]\n if (tag) vnode = { tag, props: {}, children: [vnode] }\n }\n\n return { vnode, router, head }\n}\n\nexport const handler = async (req, res) => {\n await _cerDataStore.run(null, async () => {\n const { vnode, router, head } = await _prepareRequest(req)\n\n // Begin collecting useHead() calls made during the synchronous render pass.\n // IMPORTANT: the stream's start() function runs synchronously on construction,\n // so ALL useHead() calls happen before the stream object is returned. We must\n // call endHeadCollection() immediately \u2014 before any await \u2014 to avoid a race\n // window where a concurrent request (e.g. SSG concurrency > 1) resets the\n // shared globalThis collector while this handler is suspended at an await.\n beginHeadCollection()\n\n // dsdPolyfill: false \u2014 we inject the polyfill manually after merging so it\n // lands at the end of <body>, not inside <cer-layout-view> light DOM where\n // scripts may not execute.\n // The first chunk from the stream is the full synchronous render. Subsequent\n // chunks are async component swap scripts streamed as they resolve.\n const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })\n\n // Collect head tags synchronously \u2014 all useHead() calls have already fired\n // inside the stream constructor's start() before it returned.\n const headTags = serializeHeadTags(endHeadCollection())\n\n const reader = stream.getReader()\n\n // Read the first (synchronous) chunk.\n const { value: firstChunk = '' } = await reader.read()\n\n // Merge loader data script + useHead() tags into the document head.\n const headContent = [head, headTags].filter(Boolean).join('\\n')\n\n // Wrap the rendered body in a full HTML document and inject the head additions\n // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.\n const ssrHtml = `<!DOCTYPE html><html><head>${headContent}</head><body>${firstChunk}</body></html>`\n\n const merged = _clientTemplate\n ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)\n : ssrHtml\n\n // Split at </body> so async swap scripts and the DSD polyfill can be streamed\n // in before the document is closed.\n const bodyCloseIdx = merged.lastIndexOf('</body>')\n const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged\n const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.setHeader('Transfer-Encoding', 'chunked')\n res.write(beforeBodyClose)\n\n // Stream async component swap scripts through as-is.\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n res.write(value)\n }\n\n // Inject DSD polyfill immediately before </body>, then close the document.\n res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)\n })\n}\n\nexport { apiRoutes, plugins, layouts, routes }\nexport default handler\n";
15
15
  //# sourceMappingURL=entry-server-template.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,giSAsMjC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,6hVAmOjC,CAAA"}
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Key features:
9
9
  * - AsyncLocalStorage for race-condition-free concurrent renders (SSG concurrency > 1)
10
- * - Declarative Shadow DOM via renderToStringWithJITCSSDSD (always on)
10
+ * - Declarative Shadow DOM via renderToStreamWithJITCSSDSD (always on, streamed)
11
11
  * - useHead() support via beginHeadCollection / endHeadCollection
12
12
  * - DSD polyfill injected at end of <body> after client-template merge
13
13
  */
@@ -21,13 +21,15 @@ import routes from 'virtual:cer-routes'
21
21
  import layouts from 'virtual:cer-layouts'
22
22
  import plugins from 'virtual:cer-plugins'
23
23
  import apiRoutes from 'virtual:cer-server-api'
24
+ import { runtimeConfig } from 'virtual:cer-app-config'
24
25
  import { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'
25
- import { registerEntityMap, renderToStringWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
26
+ import { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
26
27
  import entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'
27
28
  import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
28
- import { beginHeadCollection, endHeadCollection, serializeHeadTags } from '@jasonshimmy/vite-plugin-cer-app/composables'
29
+ import { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
29
30
 
30
31
  registerBuiltinComponents()
32
+ initRuntimeConfig(runtimeConfig)
31
33
 
32
34
  // Pre-load the full HTML entity map so named entities like &mdash; decode
33
35
  // correctly during SSR. Without this the bundled runtime falls back to a
@@ -130,8 +132,6 @@ const _prepareRequest = async (req) => {
130
132
  const router = initRouter({ routes, initialUrl: req.url ?? '/' })
131
133
  const current = router.getCurrent()
132
134
  const { route, params } = router.matchRoute(current.path)
133
- const layoutName = route?.meta?.layout ?? 'default'
134
- const layoutTag = layouts[layoutName]
135
135
 
136
136
  // Pre-load the page module so we can embed the component tag directly.
137
137
  // This avoids the async router-view (which injects content via script tags
@@ -160,9 +160,17 @@ const _prepareRequest = async (req) => {
160
160
  }
161
161
  }
162
162
 
163
- const vnode = layoutTag
164
- ? { tag: layoutTag, props: {}, children: [pageVnode] }
165
- : pageVnode
163
+ // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.
164
+ const chain = route?.meta?.layoutChain
165
+ ? route.meta.layoutChain
166
+ : [route?.meta?.layout ?? 'default']
167
+
168
+ // Wrap pageVnode in the layout chain from innermost to outermost.
169
+ let vnode = pageVnode
170
+ for (let i = chain.length - 1; i >= 0; i--) {
171
+ const tag = layouts[chain[i]]
172
+ if (tag) vnode = { tag, props: {}, children: [vnode] }
173
+ }
166
174
 
167
175
  return { vnode, router, head }
168
176
  }
@@ -172,38 +180,59 @@ export const handler = async (req, res) => {
172
180
  const { vnode, router, head } = await _prepareRequest(req)
173
181
 
174
182
  // Begin collecting useHead() calls made during the synchronous render pass.
183
+ // IMPORTANT: the stream's start() function runs synchronously on construction,
184
+ // so ALL useHead() calls happen before the stream object is returned. We must
185
+ // call endHeadCollection() immediately — before any await — to avoid a race
186
+ // window where a concurrent request (e.g. SSG concurrency > 1) resets the
187
+ // shared globalThis collector while this handler is suspended at an await.
175
188
  beginHeadCollection()
176
189
 
177
190
  // dsdPolyfill: false — we inject the polyfill manually after merging so it
178
191
  // lands at the end of <body>, not inside <cer-layout-view> light DOM where
179
192
  // scripts may not execute.
180
- const { htmlWithStyles } = renderToStringWithJITCSSDSD(vnode, {
181
- dsdPolyfill: false,
182
- router,
183
- })
193
+ // The first chunk from the stream is the full synchronous render. Subsequent
194
+ // chunks are async component swap scripts streamed as they resolve.
195
+ const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })
184
196
 
185
- // Collect and serialize any useHead() calls from the rendered components.
197
+ // Collect head tags synchronously — all useHead() calls have already fired
198
+ // inside the stream constructor's start() before it returned.
186
199
  const headTags = serializeHeadTags(endHeadCollection())
187
200
 
201
+ const reader = stream.getReader()
202
+
203
+ // Read the first (synchronous) chunk.
204
+ const { value: firstChunk = '' } = await reader.read()
205
+
188
206
  // Merge loader data script + useHead() tags into the document head.
189
207
  const headContent = [head, headTags].filter(Boolean).join('\\n')
190
208
 
191
209
  // Wrap the rendered body in a full HTML document and inject the head additions
192
210
  // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
193
- const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${htmlWithStyles}</body></html>\`
211
+ const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
194
212
 
195
- let finalHtml = _clientTemplate
213
+ const merged = _clientTemplate
196
214
  ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
197
215
  : ssrHtml
198
216
 
199
- // Inject DSD polyfill at end of <body>, outside <cer-layout-view>, so the
200
- // browser runs it after parsing the declarative shadow roots.
201
- finalHtml = finalHtml.includes('</body>')
202
- ? finalHtml.replace('</body>', DSD_POLYFILL_SCRIPT + '</body>')
203
- : finalHtml + DSD_POLYFILL_SCRIPT
217
+ // Split at </body> so async swap scripts and the DSD polyfill can be streamed
218
+ // in before the document is closed.
219
+ const bodyCloseIdx = merged.lastIndexOf('</body>')
220
+ const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged
221
+ const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''
204
222
 
205
223
  res.setHeader('Content-Type', 'text/html; charset=utf-8')
206
- res.end(finalHtml)
224
+ res.setHeader('Transfer-Encoding', 'chunked')
225
+ res.write(beforeBodyClose)
226
+
227
+ // Stream async component swap scripts through as-is.
228
+ while (true) {
229
+ const { value, done } = await reader.read()
230
+ if (done) break
231
+ res.write(value)
232
+ }
233
+
234
+ // Inject DSD polyfill immediately before </body>, then close the document.
235
+ res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)
207
236
  })
208
237
  }
209
238
 
@@ -1 +1 @@
1
- {"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsMpC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmOpC,CAAA"}
@@ -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
+ ```
@@ -63,9 +63,9 @@ The server renders HTML for each request. Uses Declarative Shadow DOM (DSD) to e
63
63
  3. API route handlers run if the URL matches `/api/`
64
64
  4. For HTML requests, the router matches the URL to a page
65
65
  5. The page's `loader` is called (if present)
66
- 6. The component tree is rendered to HTML with Declarative Shadow DOM via `renderToStringWithJITCSSDSD`
67
- 7. `useHead()` calls are collected and injected before `</head>`
68
- 8. The rendered HTML is merged with the Vite client bundle shell and sent as a full response
66
+ 6. The component tree is rendered to HTML with Declarative Shadow DOM via `renderToStreamWithJITCSSDSD`; the synchronous first chunk is flushed immediately, then async component swap scripts follow as they resolve
67
+ 7. `useHead()` calls are collected from the synchronous render and injected before `</head>`
68
+ 8. The rendered HTML is merged with the Vite client bundle shell and streamed as a chunked response
69
69
 
70
70
  ### Build output
71
71
 
@@ -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