@jasonshimmy/vite-plugin-cer-app 0.3.0 → 0.4.1

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 (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/commits.txt +1 -1
  3. package/dist/cli/create/index.js +1 -1
  4. package/dist/cli/create/templates/spa/index.html.tpl +1 -1
  5. package/dist/cli/create/templates/ssg/index.html.tpl +1 -1
  6. package/dist/cli/create/templates/ssr/index.html.tpl +1 -1
  7. package/dist/plugin/build-ssr.d.ts.map +1 -1
  8. package/dist/plugin/build-ssr.js +18 -0
  9. package/dist/plugin/build-ssr.js.map +1 -1
  10. package/dist/plugin/dts-generator.js +1 -1
  11. package/dist/plugin/dts-generator.js.map +1 -1
  12. package/dist/plugin/generated-dir.d.ts +1 -1
  13. package/dist/plugin/generated-dir.js +2 -2
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +21 -0
  16. package/dist/plugin/index.js.map +1 -1
  17. package/dist/plugin/transforms/auto-import.js +2 -2
  18. package/dist/plugin/transforms/auto-import.js.map +1 -1
  19. package/dist/runtime/app-template.d.ts +1 -1
  20. package/dist/runtime/app-template.d.ts.map +1 -1
  21. package/dist/runtime/app-template.js +10 -10
  22. package/dist/runtime/composables/index.d.ts +1 -0
  23. package/dist/runtime/composables/index.d.ts.map +1 -1
  24. package/dist/runtime/composables/index.js +1 -0
  25. package/dist/runtime/composables/index.js.map +1 -1
  26. package/dist/runtime/composables/use-inject.d.ts +29 -0
  27. package/dist/runtime/composables/use-inject.d.ts.map +1 -0
  28. package/dist/runtime/composables/use-inject.js +48 -0
  29. package/dist/runtime/composables/use-inject.js.map +1 -0
  30. package/dist/runtime/entry-server-template.d.ts +1 -1
  31. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  32. package/dist/runtime/entry-server-template.js +20 -0
  33. package/dist/runtime/entry-server-template.js.map +1 -1
  34. package/docs/composables.md +37 -0
  35. package/docs/plugins.md +23 -15
  36. package/docs/rendering-modes.md +1 -1
  37. package/docs/testing.md +3 -3
  38. package/e2e/cypress/e2e/interactive.cy.ts +15 -0
  39. package/e2e/kitchen-sink/app/pages/(auth)/protected.ts +1 -5
  40. package/e2e/kitchen-sink/cer-auto-imports.d.ts +1 -0
  41. package/package.json +1 -1
  42. package/src/__tests__/plugin/build-ssr.test.ts +10 -0
  43. package/src/__tests__/plugin/cer-app-plugin.test.ts +55 -2
  44. package/src/__tests__/plugin/dts-generator.test.ts +5 -0
  45. package/src/__tests__/plugin/entry-server-template.test.ts +24 -0
  46. package/src/__tests__/plugin/generated-dir.test.ts +2 -2
  47. package/src/__tests__/plugin/transforms/auto-import.test.ts +7 -0
  48. package/src/__tests__/runtime/use-inject-client.test.ts +67 -0
  49. package/src/__tests__/runtime/use-inject.test.ts +66 -0
  50. package/src/cli/create/index.ts +1 -1
  51. package/src/cli/create/templates/spa/index.html.tpl +1 -1
  52. package/src/cli/create/templates/ssg/index.html.tpl +1 -1
  53. package/src/cli/create/templates/ssr/index.html.tpl +1 -1
  54. package/src/plugin/build-ssr.ts +18 -0
  55. package/src/plugin/dts-generator.ts +1 -1
  56. package/src/plugin/generated-dir.ts +2 -2
  57. package/src/plugin/index.ts +22 -0
  58. package/src/plugin/transforms/auto-import.ts +2 -2
  59. package/src/runtime/app-template.ts +10 -10
  60. package/src/runtime/composables/index.ts +1 -0
  61. package/src/runtime/composables/use-inject.ts +49 -0
  62. package/src/runtime/entry-server-template.ts +20 -0
@@ -5,5 +5,5 @@
5
5
  * wires up the routing, and exports a handler compatible with
6
6
  * Express/Fastify/Node http.
7
7
  */
8
- 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, 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 { createSSRHandler } from '@jasonshimmy/custom-elements-runtime/ssr-middleware'\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// 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 handler's full HTML document with the Vite client shell so the\n// final page 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/**\n * Per-request VNode factory \u2014 initializes a fresh router at the request URL,\n * resolves the active layout from the matched route's meta, pre-loads the\n * matched page component (bypassing the async router-view so DSD renders\n * synchronously), calls the route's data loader (if any), and injects the\n * serialized result into the document head as window.__CER_DATA__ for\n * client-side hydration.\n *\n * createStreamingSSRHandler threads the router through each component's SSR\n * context so concurrent renders never share state.\n */\nconst vnodeFactory = async (req) => {\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 // Make data available to usePageData() during the SSR render pass.\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\n// Capture the raw SSR handler and wrap it to merge the response with the\n// Vite client template before sending \u2014 this injects the JS/CSS asset bundles\n// so the browser can hydrate and enable client-side routing.\nconst _rawHandler = createSSRHandler(vnodeFactory, {\n render: { dsd: true, dsdPolyfill: false },\n})\n\n/**\n * The main request handler.\n * Compatible with Express, Fastify, and Node's raw http.createServer.\n *\n * Each request is run inside a fresh _cerDataStore.run() context so that\n * concurrent renders (e.g. SSG with concurrency > 1) get isolated stores.\n * vnodeFactory calls _cerDataStore.enterWith(loaderData) from within this\n * context, making the data visible to usePageData() during SSR rendering\n * without any global-state races.\n */\nexport const handler = async (req, res) => {\n if (!_clientTemplate) {\n // No client template \u2014 run handler normally, then inject DSD polyfill.\n let _html = ''\n await _cerDataStore.run(null, async () => {\n await _rawHandler(req, { setHeader: () => {}, end: (body) => { _html = body } })\n })\n // Inject DSD polyfill at end of <body>, outside any custom element light DOM.\n const _final = _html.includes('</body>')\n ? _html.replace('</body>', DSD_POLYFILL_SCRIPT + '</body>')\n : _html + DSD_POLYFILL_SCRIPT\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n return res.end(_final)\n }\n let _capturedHtml = ''\n // Wrap _rawHandler in an isolated async-local-storage context so that\n // vnodeFactory's enterWith() call is scoped to this request only.\n await _cerDataStore.run(null, async () => {\n // Omit write() to force the non-streaming collect-then-end code path.\n await _rawHandler(req, { setHeader: () => {}, end: (body) => { _capturedHtml = body } })\n })\n let _merged = _mergeWithClientTemplate(_capturedHtml, _clientTemplate)\n // Inject DSD polyfill at end of <body>, outside <cer-layout-view> light DOM.\n _merged = _merged.includes('</body>')\n ? _merged.replace('</body>', DSD_POLYFILL_SCRIPT + '</body>')\n : _merged + DSD_POLYFILL_SCRIPT\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end(_merged)\n}\n\nexport { apiRoutes, plugins, layouts, routes }\nexport default handler\n";
8
+ 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, 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 { createSSRHandler } from '@jasonshimmy/custom-elements-runtime/ssr-middleware'\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 render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference (same pattern as\n// __CER_HEAD_COLLECTOR__ and __CER_DATA_STORE__).\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 handler's full HTML document with the Vite client shell so the\n// final page 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/**\n * Per-request VNode factory \u2014 initializes a fresh router at the request URL,\n * resolves the active layout from the matched route's meta, pre-loads the\n * matched page component (bypassing the async router-view so DSD renders\n * synchronously), calls the route's data loader (if any), and injects the\n * serialized result into the document head as window.__CER_DATA__ for\n * client-side hydration.\n *\n * createStreamingSSRHandler threads the router through each component's SSR\n * context so concurrent renders never share state.\n */\nconst vnodeFactory = 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 // Make data available to usePageData() during the SSR render pass.\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\n// Capture the raw SSR handler and wrap it to merge the response with the\n// Vite client template before sending \u2014 this injects the JS/CSS asset bundles\n// so the browser can hydrate and enable client-side routing.\nconst _rawHandler = createSSRHandler(vnodeFactory, {\n render: { dsd: true, dsdPolyfill: false },\n})\n\n/**\n * The main request handler.\n * Compatible with Express, Fastify, and Node's raw http.createServer.\n *\n * Each request is run inside a fresh _cerDataStore.run() context so that\n * concurrent renders (e.g. SSG with concurrency > 1) get isolated stores.\n * vnodeFactory calls _cerDataStore.enterWith(loaderData) from within this\n * context, making the data visible to usePageData() during SSR rendering\n * without any global-state races.\n */\nexport const handler = async (req, res) => {\n if (!_clientTemplate) {\n // No client template \u2014 run handler normally, then inject DSD polyfill.\n let _html = ''\n await _cerDataStore.run(null, async () => {\n await _rawHandler(req, { setHeader: () => {}, end: (body) => { _html = body } })\n })\n // Inject DSD polyfill at end of <body>, outside any custom element light DOM.\n const _final = _html.includes('</body>')\n ? _html.replace('</body>', DSD_POLYFILL_SCRIPT + '</body>')\n : _html + DSD_POLYFILL_SCRIPT\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n return res.end(_final)\n }\n let _capturedHtml = ''\n // Wrap _rawHandler in an isolated async-local-storage context so that\n // vnodeFactory's enterWith() call is scoped to this request only.\n await _cerDataStore.run(null, async () => {\n // Omit write() to force the non-streaming collect-then-end code path.\n await _rawHandler(req, { setHeader: () => {}, end: (body) => { _capturedHtml = body } })\n })\n let _merged = _mergeWithClientTemplate(_capturedHtml, _clientTemplate)\n // Inject DSD polyfill at end of <body>, outside <cer-layout-view> light DOM.\n _merged = _merged.includes('</body>')\n ? _merged.replace('</body>', DSD_POLYFILL_SCRIPT + '</body>')\n : _merged + DSD_POLYFILL_SCRIPT\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end(_merged)\n}\n\nexport { apiRoutes, plugins, layouts, routes }\nexport default handler\n";
9
9
  //# 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;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,2qSAkMjC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,m6TAsNjC,CAAA"}
@@ -28,6 +28,25 @@ registerBuiltinComponents()
28
28
  // minimal set (&lt;, &gt;, &amp; …) and re-escapes everything else.
29
29
  registerEntityMap(entitiesJson)
30
30
 
31
+ // Run plugins once at server startup so their provide() values are available
32
+ // to useInject() during every SSR render pass. Stored on globalThis so all
33
+ // dynamically-imported page chunks share the same reference (same pattern as
34
+ // __CER_HEAD_COLLECTOR__ and __CER_DATA_STORE__).
35
+ const _pluginProvides = new Map()
36
+ ;(globalThis).__cerPluginProvides = _pluginProvides
37
+ const _pluginsReady = (async () => {
38
+ const _bootstrapRouter = initRouter({ routes })
39
+ for (const plugin of plugins) {
40
+ if (plugin && typeof plugin.setup === 'function') {
41
+ await plugin.setup({
42
+ router: _bootstrapRouter,
43
+ provide: (key, value) => _pluginProvides.set(key, value),
44
+ config: {},
45
+ })
46
+ }
47
+ }
48
+ })()
49
+
31
50
  // Async-local storage for request-scoped SSR loader data.
32
51
  // Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with
33
52
  // concurrency > 1) never see each other's data — each request's async chain
@@ -109,6 +128,7 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
109
128
  * context so concurrent renders never share state.
110
129
  */
111
130
  const vnodeFactory = async (req) => {
131
+ await _pluginsReady
112
132
  const router = initRouter({ routes, initialUrl: req.url ?? '/' })
113
133
  const current = router.getCurrent()
114
134
  const { route, params } = router.matchRoute(current.path)
@@ -1 +1 @@
1
- {"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkMpC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsNpC,CAAA"}
@@ -104,3 +104,40 @@ export function useSession() { return session }
104
104
  ```
105
105
 
106
106
  Use `useOnConnected` or lazy initialization inside the function body for side effects.
107
+
108
+ ---
109
+
110
+ ## Built-in framework composables
111
+
112
+ These composables are provided by the framework and auto-imported alongside the runtime. They do **not** live in `app/composables/` — they are injected from `@jasonshimmy/vite-plugin-cer-app/composables`.
113
+
114
+ ### `useHead(input)`
115
+
116
+ Sets document head tags (`<title>`, `<meta>`, `<link>`, etc.). Works in SPA, SSR, and SSG modes. See [head-management.md](./head-management.md).
117
+
118
+ ### `usePageData<T>()`
119
+
120
+ Returns the serialized loader data for the current page, hydrated from `window.__CER_DATA__` on the client or from the per-request `AsyncLocalStorage` context during SSR/SSG. See [data-loading.md](./data-loading.md).
121
+
122
+ ### `useInject<T>(key, defaultValue?)`
123
+
124
+ Reads a value provided by a plugin via `app.provide(key, value)`. Works consistently in all rendering modes:
125
+
126
+ - **SPA / client** — resolves via `inject()` from the component context tree.
127
+ - **SSR / SSG** — reads from `globalThis.__cerPluginProvides`, populated by the server entry before the first render.
128
+
129
+ ```ts
130
+ // app/pages/dashboard.ts
131
+ component('page-dashboard', () => {
132
+ const store = useInject<Store>('store')
133
+ return html`<p>Count: ${store?.state.count ?? 0}</p>`
134
+ })
135
+ ```
136
+
137
+ If you need it outside auto-imported directories, import explicitly:
138
+
139
+ ```ts
140
+ import { useInject } from '@jasonshimmy/vite-plugin-cer-app/composables'
141
+ ```
142
+
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.
package/docs/plugins.md CHANGED
@@ -31,9 +31,9 @@ interface AppPlugin {
31
31
  }
32
32
 
33
33
  interface AppContext {
34
- provide(key: string, value: unknown): void
34
+ provide(key: PropertyKey, value: unknown): void
35
35
  router: Router
36
- config: ResolvedCerConfig
36
+ config: CerAppConfig
37
37
  }
38
38
  ```
39
39
 
@@ -75,8 +75,8 @@ export default {
75
75
  ```ts
76
76
  // app/pages/index.ts
77
77
  component('page-index', () => {
78
- const store = inject('store')
79
- const count = computed(() => store.state.count)
78
+ const store = useInject<Store>('store')
79
+ const count = computed(() => store?.state.count ?? 0)
80
80
 
81
81
  return html`<p>Count: ${count}</p>`
82
82
  })
@@ -120,20 +120,14 @@ export default {
120
120
 
121
121
  ---
122
122
 
123
- ## SSG and `inject()`
123
+ ## Reading provided values with `useInject`
124
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 SSGeven 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`:
125
+ To read values provided by a plugin, use `useInject` instead of the raw `inject()` from the runtime. `useInject` works correctly in **all three modes**SPA, SSR, and SSG:
128
126
 
129
127
  ```ts
130
128
  // app/pages/dashboard.ts
131
129
  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
130
+ const store = useInject<Store>('store')
137
131
 
138
132
  if (!store) return html`<p>Loading…</p>`
139
133
 
@@ -141,7 +135,21 @@ component('page-dashboard', () => {
141
135
  })
142
136
  ```
143
137
 
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.
138
+ `useInject` is auto-imported in `app/pages/`, `app/layouts/`, and `app/components/` no explicit import needed.
139
+
140
+ **Why not raw `inject()`?** In SSR and SSG modes the server renders the component tree directly, without `<cer-layout-view>` establishing the Vue-style provide context. `useInject` bridges this gap:
141
+
142
+ | Mode | How the value is resolved |
143
+ |------|--------------------------|
144
+ | SPA / client | `inject()` walks the component tree (provided by `<cer-layout-view>`) |
145
+ | SSR (dev & prod) | reads from `globalThis.__cerPluginProvides` (set by the server entry at startup) |
146
+ | SSG | same as SSR |
147
+
148
+ If you need `useInject` outside of auto-imported directories, import it explicitly:
149
+
150
+ ```ts
151
+ import { useInject } from '@jasonshimmy/vite-plugin-cer-app/composables'
152
+ ```
145
153
 
146
154
  ---
147
155
 
@@ -154,7 +162,7 @@ import plugins from 'virtual:cer-plugins'
154
162
  // plugins is an array of AppPlugin objects in load order
155
163
  ```
156
164
 
157
- In `app/app.ts`, plugins are executed sequentially before the router initializes:
165
+ In `.cer/app.ts` (the auto-generated framework entry), plugins are executed sequentially before the router initializes:
158
166
 
159
167
  ```ts
160
168
  for (const plugin of plugins) {
@@ -83,7 +83,7 @@ dist/
83
83
 
84
84
  ```ts
85
85
  export const handler: (req, res) => void // main request handler
86
- export { apiRoutes, middleware, plugins, layouts, routes }
86
+ export { apiRoutes, plugins, layouts }
87
87
  export default handler
88
88
  ```
89
89
 
package/docs/testing.md CHANGED
@@ -223,12 +223,12 @@ Use in a page:
223
223
  ```ts
224
224
  // app/pages/index.ts
225
225
  component('page-index', () => {
226
- const greeting = inject('greeting')
226
+ const greeting = useInject<string>('greeting')
227
227
  return html`<p>${greeting}</p>`
228
228
  })
229
229
  ```
230
230
 
231
- **Expected:** Page shows the injected string.
231
+ **Expected:** Page shows the injected string in all three modes (SPA, SSR, SSG). `useInject` is auto-imported — no explicit import needed.
232
232
 
233
233
  ---
234
234
 
@@ -428,7 +428,7 @@ cat dist/items/1/index.html # should contain "Item 1"
428
428
 
429
429
  ## 14. Run the automated test suite
430
430
 
431
- The framework ships with 211 unit and integration tests:
431
+ The framework ships with unit and integration tests. Run them with:
432
432
 
433
433
  ```sh
434
434
  cd /path/to/@jasonshimmy/vite-plugin-cer-app
@@ -3,6 +3,8 @@
3
3
  * All tests run in every build mode (SPA, SSR, SSG).
4
4
  */
5
5
 
6
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
7
+
6
8
  describe('Counter interactivity', () => {
7
9
  beforeEach(() => {
8
10
  cy.visit('/counter')
@@ -120,3 +122,16 @@ describe('Auth middleware', () => {
120
122
  cy.get('cer-layout-view').shadow().find('[data-cy=plugin-greeting]').should('contain', 'Hello from ks-setup plugin!')
121
123
  })
122
124
  })
125
+
126
+ if (mode !== 'spa') {
127
+ describe('Plugin provide/inject — server-side rendering', () => {
128
+ it('plugin greeting is present in the initial server HTML', () => {
129
+ // Auth middleware is client-side only, so the server renders /protected
130
+ // unconditionally. This verifies useInject() reads from __cerPluginProvides
131
+ // during the SSR render pass (before any client JS runs).
132
+ cy.request('/protected').then((resp) => {
133
+ expect(resp.body).to.include('Hello from ks-setup plugin!')
134
+ })
135
+ })
136
+ })
137
+ }
@@ -1,9 +1,5 @@
1
1
  component('page-protected', () => {
2
- // inject() works for SSR and client-side navigations; fall back to the
3
- // globalThis store for SSG where router-view loads the chunk before
4
- // cer-layout-view has had a chance to call provide().
5
- const appProvides = (globalThis as any).__cerPluginProvides as Map<string, unknown> | undefined
6
- const greeting = inject<string>('ks-greeting') ?? appProvides?.get('ks-greeting') as string | undefined ?? 'No greeting'
2
+ const greeting = useInject<string>('ks-greeting', 'No greeting')
7
3
 
8
4
  return html`
9
5
  <div>
@@ -40,6 +40,7 @@ declare global {
40
40
 
41
41
  const useHead: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useHead']
42
42
  const usePageData: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['usePageData']
43
+ const useInject: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useInject']
43
44
 
44
45
  const useKsCounter: typeof import('./app/composables/useKsCounter')['useKsCounter']
45
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -133,6 +133,16 @@ describe('build-ssr generateServerEntryCode (template content)', () => {
133
133
  it('sets Content-Type header on response', () => {
134
134
  expect(src).toContain('text/html; charset=utf-8')
135
135
  })
136
+
137
+ it('template initializes plugins and sets globalThis.__cerPluginProvides', () => {
138
+ expect(src).toContain('__cerPluginProvides')
139
+ expect(src).toContain('_pluginProvides')
140
+ expect(src).toContain('_pluginsReady')
141
+ })
142
+
143
+ it('template awaits _pluginsReady before handling each request', () => {
144
+ expect(src).toContain('await _pluginsReady')
145
+ })
136
146
  })
137
147
 
138
148
  describe('buildSSR', () => {
@@ -37,6 +37,7 @@ vi.mock('../../plugin/virtual/error.js', () => ({ generateErrorCode: vi.fn().moc
37
37
  vi.mock('../../plugin/transforms/auto-import.js', () => ({ autoImportTransform: vi.fn().mockReturnValue(null) }))
38
38
 
39
39
  import { cerApp } from '../../plugin/index.js'
40
+ import { APP_ENTRY_TEMPLATE } from '../../runtime/app-template.js'
40
41
 
41
42
 
42
43
  type TestPlugin = {
@@ -46,6 +47,7 @@ type TestPlugin = {
46
47
  resolveId: (id: string) => string | undefined
47
48
  load: (id: string) => Promise<string | null>
48
49
  transform: (code: string, id: string) => unknown
50
+ transformIndexHtml: (html: string) => string
49
51
  buildStart: () => Promise<void>
50
52
  configureServer: (server: unknown) => Promise<void>
51
53
  }
@@ -142,6 +144,18 @@ describe('cerApp plugin — resolveId hook', () => {
142
144
  expect(plugin.resolveId('virtual:cer-error')).toBe('\0virtual:cer-error')
143
145
  })
144
146
 
147
+ it('resolves /@cer/app.ts to \\0cer-app-entry (virtual module)', () => {
148
+ const plugin = getCerPlugin()
149
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
150
+ expect(plugin.resolveId('/@cer/app.ts')).toBe('\0cer-app-entry')
151
+ })
152
+
153
+ it('does not resolve /.cer/app.ts (old URL, rewritten by transformIndexHtml)', () => {
154
+ const plugin = getCerPlugin()
155
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
156
+ expect(plugin.resolveId('/.cer/app.ts')).toBeUndefined()
157
+ })
158
+
145
159
  it('returns undefined for unknown ids', () => {
146
160
  const plugin = getCerPlugin()
147
161
  plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
@@ -150,6 +164,14 @@ describe('cerApp plugin — resolveId hook', () => {
150
164
  })
151
165
 
152
166
  describe('cerApp plugin — load hook', () => {
167
+ it('loads \\0cer-app-entry with APP_ENTRY_TEMPLATE content', async () => {
168
+ const plugin = getCerPlugin()
169
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
170
+ plugin.configResolved(FAKE_RESOLVED)
171
+ const result = await plugin.load('\0cer-app-entry')
172
+ expect(result).toBe(APP_ENTRY_TEMPLATE)
173
+ })
174
+
153
175
  it('returns null for unknown resolved ids', async () => {
154
176
  const plugin = getCerPlugin()
155
177
  plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
@@ -271,6 +293,35 @@ describe('cerApp plugin — transform hook', () => {
271
293
  })
272
294
  })
273
295
 
296
+ describe('cerApp plugin — transformIndexHtml hook', () => {
297
+ it('rewrites /.cer/app.ts src to /@cer/app.ts', () => {
298
+ const plugin = getCerPlugin()
299
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
300
+ const input = '<script type="module" src="/.cer/app.ts"></script>'
301
+ const output = plugin.transformIndexHtml(input)
302
+ expect(output).toContain('src="/@cer/app.ts"')
303
+ expect(output).not.toContain('src="/.cer/app.ts"')
304
+ })
305
+
306
+ it('leaves html unchanged when /.cer/app.ts is not present', () => {
307
+ const plugin = getCerPlugin()
308
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
309
+ const input = '<script type="module" src="/@cer/app.ts"></script>'
310
+ expect(plugin.transformIndexHtml(input)).toBe(input)
311
+ })
312
+ })
313
+
314
+ describe('cerApp plugin — configResolved hook', () => {
315
+ it('calls writeGeneratedDir during configResolved', async () => {
316
+ const { writeGeneratedDir } = await import('../../plugin/generated-dir.js')
317
+ vi.mocked(writeGeneratedDir).mockClear()
318
+ const plugin = getCerPlugin()
319
+ plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
320
+ plugin.configResolved(FAKE_RESOLVED)
321
+ expect(writeGeneratedDir).toHaveBeenCalledTimes(1)
322
+ })
323
+ })
324
+
274
325
  describe('cerApp plugin — buildStart hook', () => {
275
326
  it('calls writeGeneratedDir on build start', async () => {
276
327
  const { writeGeneratedDir } = await import('../../plugin/generated-dir.js')
@@ -279,7 +330,8 @@ describe('cerApp plugin — buildStart hook', () => {
279
330
  plugin.config({ root: '/project' }, { command: 'build', mode: 'production' })
280
331
  plugin.configResolved(FAKE_RESOLVED)
281
332
  await plugin.buildStart()
282
- expect(writeGeneratedDir).toHaveBeenCalledTimes(1)
333
+ // writeGeneratedDir is called once in configResolved and once in buildStart
334
+ expect(writeGeneratedDir).toHaveBeenCalledTimes(2)
283
335
  })
284
336
 
285
337
  it('calls scanComposableExports on build start', async () => {
@@ -327,7 +379,8 @@ describe('cerApp plugin — configureServer hook', () => {
327
379
  middlewares: { use: vi.fn() },
328
380
  }
329
381
  await plugin.configureServer(mockServer)
330
- expect(writeGeneratedDir).toHaveBeenCalledTimes(1)
382
+ // configResolved calls it once, configureServer calls it again
383
+ expect(writeGeneratedDir).toHaveBeenCalledTimes(2)
331
384
  })
332
385
 
333
386
  it('calls scanComposableExports on server configure', async () => {
@@ -163,6 +163,11 @@ describe('generateAutoImportDts', () => {
163
163
  expect(dts).toContain("const usePageData: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['usePageData']")
164
164
  })
165
165
 
166
+ it('declares useInject as a framework global', async () => {
167
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
168
+ expect(dts).toContain("const useInject: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useInject']")
169
+ })
170
+
166
171
  it('declares when directive as a global', async () => {
167
172
  const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
168
173
  expect(dts).toContain("const when: typeof import('@jasonshimmy/custom-elements-runtime/directives')['when']")
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'pathe'
4
+
5
+ const src = readFileSync(
6
+ resolve(import.meta.dirname, '../../runtime/entry-server-template.ts'),
7
+ 'utf-8',
8
+ )
9
+
10
+ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
11
+ it('template imports plugins from virtual:cer-plugins', () => {
12
+ expect(src).toContain('virtual:cer-plugins')
13
+ })
14
+
15
+ it('template initializes plugins and sets globalThis.__cerPluginProvides', () => {
16
+ expect(src).toContain('__cerPluginProvides')
17
+ expect(src).toContain('_pluginProvides')
18
+ expect(src).toContain('_pluginsReady')
19
+ })
20
+
21
+ it('template awaits _pluginsReady before handling each request', () => {
22
+ expect(src).toContain('await _pluginsReady')
23
+ })
24
+ })
@@ -61,9 +61,9 @@ describe('resolveHtmlEntry', () => {
61
61
  })
62
62
 
63
63
  describe('generateDefaultHtml', () => {
64
- it('always references /.cer/app.ts', () => {
64
+ it('always references /@cer/app.ts', () => {
65
65
  const html = generateDefaultHtml()
66
- expect(html).toContain('/.cer/app.ts')
66
+ expect(html).toContain('/@cer/app.ts')
67
67
  expect(html).not.toContain('/app/app.ts')
68
68
  })
69
69
 
@@ -221,6 +221,13 @@ describe('autoImportTransform — framework composable injection', () => {
221
221
  expect(count).toBe(1)
222
222
  })
223
223
 
224
+ it('injects useInject import when useInject is used', () => {
225
+ const code = "component('page-about', () => { const svc = useInject('my-service'); return html`<div></div>` })"
226
+ const result = autoImportTransform(code, '/project/app/pages/about.ts', opts)!
227
+ expect(result).toContain(`from ${FRAMEWORK_PKG}`)
228
+ expect(result).toContain('useInject')
229
+ })
230
+
224
231
  it('injects usePageData for root-level convention files (loading.ts, error.ts)', () => {
225
232
  const code = "component('page-loading', () => { const d = usePageData(); return html`<div></div>` })"
226
233
  const result = autoImportTransform(code, '/project/app/loading.ts', opts)!
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ *
4
+ * Tests for useInject — client-side path.
5
+ *
6
+ * Runs in happy-dom so `document` is defined, putting useInject() on the
7
+ * client branch. inject() from the runtime is mocked since it requires a
8
+ * live component context that doesn't exist in unit tests.
9
+ */
10
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
11
+
12
+ vi.mock('@jasonshimmy/custom-elements-runtime', () => ({
13
+ inject: vi.fn(),
14
+ }))
15
+
16
+ import { inject } from '@jasonshimmy/custom-elements-runtime'
17
+ import { useInject } from '../../runtime/composables/use-inject.js'
18
+
19
+ const _g = globalThis as Record<string, unknown>
20
+
21
+ describe('useInject — client-side', () => {
22
+ beforeEach(() => {
23
+ delete _g['__cerPluginProvides']
24
+ vi.mocked(inject).mockReturnValue(undefined)
25
+ })
26
+
27
+ afterEach(() => {
28
+ delete _g['__cerPluginProvides']
29
+ })
30
+
31
+ it('returns the value from inject() when the component context has it', () => {
32
+ vi.mocked(inject).mockReturnValue('injected-value')
33
+ expect(useInject('my-key')).toBe('injected-value')
34
+ })
35
+
36
+ it('falls back to __cerPluginProvides when inject() returns undefined', () => {
37
+ _g['__cerPluginProvides'] = new Map([['my-key', 'plugin-provided']])
38
+ expect(useInject('my-key')).toBe('plugin-provided')
39
+ })
40
+
41
+ it('inject() result takes priority over __cerPluginProvides', () => {
42
+ vi.mocked(inject).mockReturnValue('inject-wins')
43
+ _g['__cerPluginProvides'] = new Map([['my-key', 'global-value']])
44
+ expect(useInject('my-key')).toBe('inject-wins')
45
+ })
46
+
47
+ it('returns defaultValue when inject() and provides both miss', () => {
48
+ expect(useInject('missing', 'default')).toBe('default')
49
+ })
50
+
51
+ it('returns undefined when inject() and provides both miss with no defaultValue', () => {
52
+ expect(useInject('missing')).toBeUndefined()
53
+ })
54
+
55
+ it('returns defaultValue when inject() returns undefined and key is absent from provides', () => {
56
+ _g['__cerPluginProvides'] = new Map()
57
+ expect(useInject('missing', 42)).toBe(42)
58
+ })
59
+
60
+ it('is generic and preserves the typed shape from inject()', () => {
61
+ interface Service { call(): string }
62
+ const svc: Service = { call: () => 'ok' }
63
+ vi.mocked(inject).mockReturnValue(svc)
64
+ const result = useInject<Service>('svc')
65
+ expect(result?.call()).toBe('ok')
66
+ })
67
+ })
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tests for useInject — server-side (SSR/SSG) path.
3
+ *
4
+ * This file runs in the default 'node' environment where `document` is
5
+ * undefined, so useInject() always takes the SSR branch: reading from
6
+ * globalThis.__cerPluginProvides.
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
9
+ import { useInject } from '../../runtime/composables/use-inject.js'
10
+
11
+ const _g = globalThis as Record<string, unknown>
12
+
13
+ describe('useInject — server-side (SSR/SSG)', () => {
14
+ beforeEach(() => {
15
+ delete _g['__cerPluginProvides']
16
+ })
17
+
18
+ afterEach(() => {
19
+ delete _g['__cerPluginProvides']
20
+ })
21
+
22
+ it('returns value from __cerPluginProvides when key is present', () => {
23
+ _g['__cerPluginProvides'] = new Map([['my-service', { greet: () => 'hello' }]])
24
+ const result = useInject<{ greet(): string }>('my-service')
25
+ expect(typeof result?.greet).toBe('function')
26
+ expect(result?.greet()).toBe('hello')
27
+ })
28
+
29
+ it('returns defaultValue when key is absent from the provides map', () => {
30
+ _g['__cerPluginProvides'] = new Map()
31
+ expect(useInject('missing', 'fallback')).toBe('fallback')
32
+ })
33
+
34
+ it('returns undefined when key is absent and no defaultValue is given', () => {
35
+ _g['__cerPluginProvides'] = new Map()
36
+ expect(useInject('missing')).toBeUndefined()
37
+ })
38
+
39
+ it('returns undefined when __cerPluginProvides is not set at all', () => {
40
+ expect(useInject('my-service')).toBeUndefined()
41
+ })
42
+
43
+ it('returns defaultValue when __cerPluginProvides is not set and defaultValue is given', () => {
44
+ expect(useInject('my-service', 'default')).toBe('default')
45
+ })
46
+
47
+ it('is generic and preserves the typed shape', () => {
48
+ interface Store { count: number }
49
+ const store: Store = { count: 42 }
50
+ _g['__cerPluginProvides'] = new Map([['store', store]])
51
+ const result = useInject<Store>('store')
52
+ expect(result?.count).toBe(42)
53
+ })
54
+
55
+ it('is safe to call multiple times — does not consume the value', () => {
56
+ _g['__cerPluginProvides'] = new Map([['key', 'value']])
57
+ expect(useInject('key')).toBe('value')
58
+ expect(useInject('key')).toBe('value')
59
+ })
60
+
61
+ it('supports multiple keys independently', () => {
62
+ _g['__cerPluginProvides'] = new Map([['a', 1], ['b', 2]])
63
+ expect(useInject('a')).toBe(1)
64
+ expect(useInject('b')).toBe(2)
65
+ })
66
+ })
@@ -243,7 +243,7 @@ async function generateInlineTemplate(
243
243
  // index.html
244
244
  await writeFile(
245
245
  join(targetDir, 'index.html'),
246
- `<!DOCTYPE html>\n<html lang="en">\n <head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>${projectName}</title>\n </head>\n <body>\n <cer-layout-view></cer-layout-view>\n <script type="module" src="/.cer/app.ts"></script>\n </body>\n</html>\n`,
246
+ `<!DOCTYPE html>\n<html lang="en">\n <head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>${projectName}</title>\n </head>\n <body>\n <cer-layout-view></cer-layout-view>\n <script type="module" src="/@cer/app.ts"></script>\n </body>\n</html>\n`,
247
247
  'utf-8',
248
248
  )
249
249
  }