@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.
- package/CHANGELOG.md +8 -0
- package/commits.txt +1 -1
- package/dist/cli/create/index.js +1 -1
- package/dist/cli/create/templates/spa/index.html.tpl +1 -1
- package/dist/cli/create/templates/ssg/index.html.tpl +1 -1
- package/dist/cli/create/templates/ssr/index.html.tpl +1 -1
- package/dist/plugin/build-ssr.d.ts.map +1 -1
- package/dist/plugin/build-ssr.js +18 -0
- package/dist/plugin/build-ssr.js.map +1 -1
- package/dist/plugin/dts-generator.js +1 -1
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/generated-dir.d.ts +1 -1
- package/dist/plugin/generated-dir.js +2 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +21 -0
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.js +2 -2
- package/dist/plugin/transforms/auto-import.js.map +1 -1
- package/dist/runtime/app-template.d.ts +1 -1
- package/dist/runtime/app-template.d.ts.map +1 -1
- package/dist/runtime/app-template.js +10 -10
- package/dist/runtime/composables/index.d.ts +1 -0
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +1 -0
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-inject.d.ts +29 -0
- package/dist/runtime/composables/use-inject.d.ts.map +1 -0
- package/dist/runtime/composables/use-inject.js +48 -0
- package/dist/runtime/composables/use-inject.js.map +1 -0
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +20 -0
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/docs/composables.md +37 -0
- package/docs/plugins.md +23 -15
- package/docs/rendering-modes.md +1 -1
- package/docs/testing.md +3 -3
- package/e2e/cypress/e2e/interactive.cy.ts +15 -0
- package/e2e/kitchen-sink/app/pages/(auth)/protected.ts +1 -5
- package/e2e/kitchen-sink/cer-auto-imports.d.ts +1 -0
- package/package.json +1 -1
- package/src/__tests__/plugin/build-ssr.test.ts +10 -0
- package/src/__tests__/plugin/cer-app-plugin.test.ts +55 -2
- package/src/__tests__/plugin/dts-generator.test.ts +5 -0
- package/src/__tests__/plugin/entry-server-template.test.ts +24 -0
- package/src/__tests__/plugin/generated-dir.test.ts +2 -2
- package/src/__tests__/plugin/transforms/auto-import.test.ts +7 -0
- package/src/__tests__/runtime/use-inject-client.test.ts +67 -0
- package/src/__tests__/runtime/use-inject.test.ts +66 -0
- package/src/cli/create/index.ts +1 -1
- package/src/cli/create/templates/spa/index.html.tpl +1 -1
- package/src/cli/create/templates/ssg/index.html.tpl +1 -1
- package/src/cli/create/templates/ssr/index.html.tpl +1 -1
- package/src/plugin/build-ssr.ts +18 -0
- package/src/plugin/dts-generator.ts +1 -1
- package/src/plugin/generated-dir.ts +2 -2
- package/src/plugin/index.ts +22 -0
- package/src/plugin/transforms/auto-import.ts +2 -2
- package/src/runtime/app-template.ts +10 -10
- package/src/runtime/composables/index.ts +1 -0
- package/src/runtime/composables/use-inject.ts +49 -0
- 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 — decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (<, >, & \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 — decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (<, >, & \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,
|
|
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 (<, >, & …) 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
|
|
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"}
|
package/docs/composables.md
CHANGED
|
@@ -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:
|
|
34
|
+
provide(key: PropertyKey, value: unknown): void
|
|
35
35
|
router: Router
|
|
36
|
-
config:
|
|
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 =
|
|
79
|
-
const count = computed(() => store
|
|
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
|
-
##
|
|
123
|
+
## Reading provided values with `useInject`
|
|
124
124
|
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
package/docs/rendering-modes.md
CHANGED
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 =
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
+
it('always references /@cer/app.ts', () => {
|
|
65
65
|
const html = generateDefaultHtml()
|
|
66
|
-
expect(html).toContain('
|
|
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
|
+
})
|
package/src/cli/create/index.ts
CHANGED
|
@@ -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="
|
|
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
|
}
|