@jasonshimmy/vite-plugin-cer-app 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/commits.txt +1 -1
  3. package/cypress.config.ts +16 -0
  4. package/dist/cli/create/index.js +1 -1
  5. package/dist/cli/create/index.js.map +1 -1
  6. package/dist/plugin/build-ssg.d.ts +7 -0
  7. package/dist/plugin/build-ssg.d.ts.map +1 -1
  8. package/dist/plugin/build-ssg.js +2 -1
  9. package/dist/plugin/build-ssg.js.map +1 -1
  10. package/dist/plugin/build-ssr.d.ts.map +1 -1
  11. package/dist/plugin/build-ssr.js +26 -6
  12. package/dist/plugin/build-ssr.js.map +1 -1
  13. package/dist/runtime/composables/index.d.ts +1 -1
  14. package/dist/runtime/composables/index.d.ts.map +1 -1
  15. package/dist/runtime/composables/index.js +1 -1
  16. package/dist/runtime/composables/index.js.map +1 -1
  17. package/dist/runtime/composables/use-head.d.ts.map +1 -1
  18. package/dist/runtime/composables/use-head.js +12 -8
  19. package/dist/runtime/composables/use-head.js.map +1 -1
  20. package/dist/runtime/entry-server-template.d.ts +1 -1
  21. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  22. package/dist/runtime/entry-server-template.js +14 -4
  23. package/dist/runtime/entry-server-template.js.map +1 -1
  24. package/docs/cli.md +2 -0
  25. package/docs/components.md +57 -0
  26. package/docs/composables.md +9 -2
  27. package/docs/data-loading.md +45 -1
  28. package/docs/getting-started.md +71 -6
  29. package/docs/head-management.md +6 -0
  30. package/docs/plugins.md +25 -0
  31. package/docs/routing.md +48 -6
  32. package/e2e/cypress/e2e/api.cy.ts +81 -0
  33. package/e2e/cypress/e2e/data.cy.ts +111 -0
  34. package/e2e/cypress/e2e/fouc.cy.ts +65 -0
  35. package/e2e/cypress/e2e/head.cy.ts +89 -0
  36. package/e2e/cypress/e2e/interactive.cy.ts +122 -0
  37. package/e2e/cypress/e2e/routes.cy.ts +128 -0
  38. package/e2e/cypress/support/commands.ts +60 -0
  39. package/e2e/cypress/support/e2e.ts +10 -0
  40. package/{src/runtime/app-template.ts → e2e/kitchen-sink/app/app.ts} +43 -49
  41. package/e2e/kitchen-sink/app/components/ks-badge.ts +8 -0
  42. package/e2e/kitchen-sink/app/composables/useKsCounter.ts +9 -0
  43. package/e2e/kitchen-sink/app/error.ts +13 -0
  44. package/e2e/kitchen-sink/app/layouts/default.ts +21 -0
  45. package/e2e/kitchen-sink/app/layouts/minimal.ts +7 -0
  46. package/e2e/kitchen-sink/app/loading.ts +9 -0
  47. package/e2e/kitchen-sink/app/middleware/auth.ts +13 -0
  48. package/e2e/kitchen-sink/app/pages/(auth)/login.ts +13 -0
  49. package/e2e/kitchen-sink/app/pages/(auth)/protected.ts +20 -0
  50. package/e2e/kitchen-sink/app/pages/404.ts +9 -0
  51. package/e2e/kitchen-sink/app/pages/about.ts +17 -0
  52. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +54 -0
  53. package/e2e/kitchen-sink/app/pages/blog/index.ts +46 -0
  54. package/e2e/kitchen-sink/app/pages/counter.ts +17 -0
  55. package/e2e/kitchen-sink/app/pages/head.ts +20 -0
  56. package/e2e/kitchen-sink/app/pages/index.ts +27 -0
  57. package/e2e/kitchen-sink/app/pages/items/[id].ts +20 -0
  58. package/e2e/kitchen-sink/app/plugins/01.setup.ts +7 -0
  59. package/e2e/kitchen-sink/cer-auto-imports.d.ts +50 -0
  60. package/e2e/kitchen-sink/cer-env.d.ts +36 -0
  61. package/e2e/kitchen-sink/cer-tsconfig.json +30 -0
  62. package/e2e/kitchen-sink/cer.config.ts +6 -0
  63. package/e2e/kitchen-sink/index.html +12 -0
  64. package/e2e/kitchen-sink/server/api/health.ts +3 -0
  65. package/e2e/kitchen-sink/server/api/posts/[slug].ts +11 -0
  66. package/e2e/kitchen-sink/server/api/posts/index.ts +5 -0
  67. package/e2e/kitchen-sink/server/data/posts.ts +21 -0
  68. package/e2e/scripts/clean.mjs +8 -0
  69. package/package.json +19 -2
  70. package/src/__tests__/plugin/build-ssg-render.test.ts +110 -0
  71. package/src/__tests__/plugin/build-ssg.test.ts +47 -1
  72. package/src/__tests__/plugin/build-ssr.test.ts +93 -1
  73. package/src/__tests__/plugin/dev-server.test.ts +493 -0
  74. package/src/__tests__/plugin/scanner.test.ts +15 -1
  75. package/src/__tests__/plugin/transforms/auto-import.test.ts +63 -0
  76. package/src/cli/create/index.ts +1 -1
  77. package/src/cli/create/templates/spa/app/app.ts.tpl +23 -3
  78. package/src/cli/create/templates/ssg/app/app.ts.tpl +27 -3
  79. package/src/cli/create/templates/ssg/app/pages/index.ts.tpl +0 -9
  80. package/src/cli/create/templates/ssr/app/app.ts.tpl +27 -3
  81. package/src/plugin/build-ssg.ts +2 -1
  82. package/src/plugin/build-ssr.ts +26 -6
  83. package/src/runtime/composables/index.ts +1 -1
  84. package/src/runtime/composables/use-head.ts +12 -8
  85. package/src/runtime/entry-server-template.ts +14 -4
  86. package/vitest.config.ts +5 -1
  87. package/VITE_PLUGIN_FRAMEWORK_PLAN.md +0 -594
  88. package/dist/runtime/app-template.d.ts +0 -10
  89. package/dist/runtime/app-template.d.ts.map +0 -1
  90. package/dist/runtime/app-template.js +0 -149
  91. package/dist/runtime/app-template.js.map +0 -1
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Interactivity tests — counter, navigation, middleware redirect.
3
+ * All tests run in every build mode (SPA, SSR, SSG).
4
+ */
5
+
6
+ describe('Counter interactivity', () => {
7
+ beforeEach(() => {
8
+ cy.visit('/counter')
9
+ // Wait for the component to hydrate and show count 0
10
+ cy.get('page-counter').should('exist')
11
+ cy.get('[data-cy=count]').should('contain', '0')
12
+ })
13
+
14
+ // Scope to cer-layout-view's shadow DOM to target only the reactive
15
+ // (JS-hydrated) page-counter, not the static pre-rendered DSD copy.
16
+ it('increments count on + click', () => {
17
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
18
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=count]').should('contain', '1')
19
+ })
20
+
21
+ it('decrements count on − click', () => {
22
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
23
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
24
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=count]').should('contain', '2')
25
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=decrement]').click({ force: true })
26
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=count]').should('contain', '1')
27
+ })
28
+
29
+ it('resets count on Reset click', () => {
30
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
31
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
32
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=count]').should('contain', '2')
33
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=reset]').click({ force: true })
34
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=count]').should('contain', '0')
35
+ })
36
+
37
+ it('multiple increments accumulate correctly', () => {
38
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
39
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
40
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
41
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=count]').should('contain', '3')
42
+ })
43
+ })
44
+
45
+ describe('Client-side navigation', () => {
46
+ it('navigates from home to about via nav link', () => {
47
+ cy.visit('/')
48
+ cy.get('[data-cy=nav-about]').first().click({ force: true })
49
+ cy.url().should('include', '/about')
50
+ cy.get('[data-cy=about-heading]').should('contain', 'About')
51
+ })
52
+
53
+ it('navigates from home to counter via nav link', () => {
54
+ cy.visit('/')
55
+ cy.get('[data-cy=nav-counter]').first().click({ force: true })
56
+ cy.url().should('include', '/counter')
57
+ cy.get('[data-cy=counter-heading]').should('contain', 'Counter')
58
+ })
59
+
60
+ it('navigates from home to blog via nav link', () => {
61
+ cy.visit('/')
62
+ cy.get('[data-cy=nav-blog]').first().click({ force: true })
63
+ cy.url().should('include', '/blog')
64
+ cy.get('[data-cy=blog-heading]').should('contain', 'Blog')
65
+ })
66
+
67
+ it('navigates from blog to a post via link click', () => {
68
+ cy.visit('/blog')
69
+ cy.get('[data-cy=blog-item]').first().find('a').first().click({ force: true })
70
+ cy.url().should('include', '/blog/')
71
+ cy.get('[data-cy=post-title]').should('exist')
72
+ })
73
+
74
+ it('navigates back from about to home', () => {
75
+ cy.visit('/about')
76
+ cy.get('[data-cy=about-back]').first().click({ force: true })
77
+ cy.url().should('eq', Cypress.config('baseUrl') + '/')
78
+ cy.get('[data-cy=home-heading]').should('contain', 'Kitchen Sink')
79
+ })
80
+
81
+ it('counter state resets when navigating away and back', () => {
82
+ cy.visit('/counter')
83
+ cy.get('cer-layout-view').shadow().find('page-counter').should('exist')
84
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=increment]').click({ force: true })
85
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=count]').should('contain', '1')
86
+ // Navigate away
87
+ cy.get('[data-cy=nav-home]').first().click({ force: true })
88
+ cy.url().should('eq', Cypress.config('baseUrl') + '/')
89
+ cy.get('[data-cy=home-heading]').should('contain', 'Kitchen Sink')
90
+ // Navigate back — component re-mounts, state resets
91
+ cy.get('[data-cy=nav-counter]').first().click({ force: true })
92
+ cy.url().should('include', '/counter')
93
+ cy.get('cer-layout-view').shadow().find('page-counter').should('exist')
94
+ cy.get('cer-layout-view').shadow().find('page-counter').shadow().find('[data-cy=count]').should('contain', '0')
95
+ })
96
+ })
97
+
98
+ describe('Auth middleware', () => {
99
+ beforeEach(() => {
100
+ cy.clearLocalStorage()
101
+ })
102
+
103
+ it('redirects to /login when not authenticated', () => {
104
+ cy.visit('/protected')
105
+ cy.url().should('include', '/login')
106
+ cy.get('cer-layout-view').shadow().find('page-login', { timeout: 8000 }).should('exist')
107
+ cy.get('cer-layout-view').shadow().find('[data-cy=login-heading]').should('contain', 'Login')
108
+ })
109
+
110
+ it('shows protected page when token is set', () => {
111
+ cy.window().then((win) => win.localStorage.setItem('ks-token', '1'))
112
+ cy.visit('/protected')
113
+ cy.url().should('include', '/protected')
114
+ cy.get('cer-layout-view').shadow().find('[data-cy=protected-heading]').should('contain', 'Protected Page')
115
+ })
116
+
117
+ it('shows plugin greeting on protected page', () => {
118
+ cy.window().then((win) => win.localStorage.setItem('ks-token', '1'))
119
+ cy.visit('/protected')
120
+ cy.get('cer-layout-view').shadow().find('[data-cy=plugin-greeting]').should('contain', 'Hello from ks-setup plugin!')
121
+ })
122
+ })
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Routes test — verifies every route renders correct content in all build modes.
3
+ */
4
+
5
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
6
+
7
+ describe('Route rendering', () => {
8
+ context('Home page (/)', () => {
9
+ it('renders the home heading', () => {
10
+ cy.visit('/')
11
+ cy.get('[data-cy=home-heading]').should('contain', 'Kitchen Sink')
12
+ })
13
+
14
+ it('renders the ks-badge component', () => {
15
+ cy.visit('/')
16
+ cy.get('[data-cy=ks-badge]').should('exist')
17
+ })
18
+
19
+ it('renders the default layout with nav', () => {
20
+ cy.visit('/')
21
+ cy.get('[data-cy=site-nav]').should('exist')
22
+ cy.get('[data-cy=site-header]').should('exist')
23
+ cy.get('[data-cy=site-footer]').should('exist')
24
+ })
25
+
26
+ it('renders all nav links', () => {
27
+ cy.visit('/')
28
+ cy.get('[data-cy=nav-home]').should('exist')
29
+ cy.get('[data-cy=nav-about]').should('exist')
30
+ cy.get('[data-cy=nav-counter]').should('exist')
31
+ cy.get('[data-cy=nav-blog]').should('exist')
32
+ })
33
+ })
34
+
35
+ context('About page (/about) — minimal layout + useHead', () => {
36
+ it('renders the about heading', () => {
37
+ cy.visit('/about')
38
+ cy.get('[data-cy=about-heading]').should('contain', 'About')
39
+ })
40
+
41
+ it('uses the minimal layout (no site-nav)', () => {
42
+ cy.visit('/about')
43
+ cy.get('[data-cy=minimal-layout]').should('exist')
44
+ cy.get('[data-cy=site-nav]').should('not.exist')
45
+ })
46
+ })
47
+
48
+ context('Counter page (/counter)', () => {
49
+ it('renders the counter heading', () => {
50
+ cy.visit('/counter')
51
+ cy.get('[data-cy=counter-heading]').should('contain', 'Counter')
52
+ })
53
+
54
+ it('renders the counter widget with initial count of 0', () => {
55
+ cy.visit('/counter')
56
+ cy.get('[data-cy=count]').should('contain', '0')
57
+ })
58
+
59
+ it('renders increment, decrement, and reset buttons', () => {
60
+ cy.visit('/counter')
61
+ cy.get('[data-cy=increment]').should('exist')
62
+ cy.get('[data-cy=decrement]').should('exist')
63
+ cy.get('[data-cy=reset]').should('exist')
64
+ })
65
+ })
66
+
67
+ context('Head page (/head) — useHead', () => {
68
+ it('renders the head-test heading', () => {
69
+ cy.visit('/head')
70
+ cy.get('[data-cy=head-heading]').should('contain', 'Head Test')
71
+ })
72
+ })
73
+
74
+ context('Blog list page (/blog)', () => {
75
+ it('renders the blog heading', () => {
76
+ cy.visit('/blog')
77
+ cy.get('[data-cy=blog-heading]').should('contain', 'Blog')
78
+ })
79
+ })
80
+
81
+ context('Blog detail page (/blog/first-post) — dynamic route', () => {
82
+ it('renders the post title', () => {
83
+ cy.visit('/blog/first-post')
84
+ cy.get('[data-cy=post-title]').should('contain', 'First Post')
85
+ })
86
+
87
+ it('renders the post slug', () => {
88
+ cy.visit('/blog/first-post')
89
+ cy.get('[data-cy=post-slug]').should('contain', 'first-post')
90
+ })
91
+ })
92
+
93
+ context('Item page (/items/1) — route params via useProps', () => {
94
+ it('renders the item heading', () => {
95
+ cy.visit('/items/1')
96
+ cy.get('[data-cy=item-heading]').should('contain', 'Item Detail')
97
+ })
98
+
99
+ it('shows the correct item ID from route params', () => {
100
+ cy.visit('/items/1')
101
+ cy.get('[data-cy=item-id]').should('contain', '1')
102
+ })
103
+
104
+ it('shows a different ID for /items/2', () => {
105
+ cy.visit('/items/2')
106
+ cy.get('[data-cy=item-id]').should('contain', '2')
107
+ })
108
+ })
109
+
110
+ context('Login page (/login) — route group, minimal layout', () => {
111
+ it('renders the login heading', () => {
112
+ cy.visit('/login')
113
+ cy.get('[data-cy=login-heading]').should('contain', 'Login')
114
+ })
115
+
116
+ it('uses the minimal layout', () => {
117
+ cy.visit('/login')
118
+ cy.get('[data-cy=minimal-layout]').should('exist')
119
+ })
120
+ })
121
+
122
+ context('404 page — catch-all route', () => {
123
+ it('renders the 404 heading for an unknown path', () => {
124
+ cy.visit('/this-page-does-not-exist-at-all', { failOnStatusCode: false })
125
+ cy.get('[data-cy=not-found-heading]').should('contain', '404')
126
+ })
127
+ })
128
+ })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Custom Cypress commands for the kitchen sink e2e suite.
3
+ */
4
+
5
+ /**
6
+ * Assert that the raw HTML for a route has proper DSD structure:
7
+ * - Contains <template shadowrootmode> elements
8
+ * - Each template has embedded <style> (not stripped to <head>)
9
+ * - No loading indicator in the pre-rendered HTML
10
+ * - Content is present (not empty cer-layout-view)
11
+ */
12
+ Cypress.Commands.add('assertNoDSD_FOUC', (path: string) => {
13
+ cy.request(path).then((response) => {
14
+ const html: string = response.body
15
+
16
+ // Must have DSD templates
17
+ expect(html, `${path}: should contain DSD templates`).to.include('<template shadowrootmode')
18
+
19
+ // Each shadow template must have its own <style> block (not hoisted to <head>)
20
+ const templateMatches = [...html.matchAll(/<template shadowrootmode[^>]*>([\s\S]*?)<\/template>/g)]
21
+ expect(templateMatches.length, `${path}: should have at least 1 shadow template`).to.be.greaterThan(0)
22
+ templateMatches.forEach(([, content], i) => {
23
+ expect(content, `${path}: template[${i}] should contain <style>`).to.include('<style')
24
+ })
25
+
26
+ // The <head> must NOT contain raw unnamed <style> blocks (only id'd global ones are OK)
27
+ const headMatch = html.match(/<head>([\s\S]*?)<\/head>/)
28
+ if (headMatch) {
29
+ const headContent = headMatch[1]
30
+ // Allow <style id=...> (global JIT/SSR styles), reject bare <style> without id
31
+ const bareStyles = headContent.match(/<style(?!\s+id)[^>]*>/g) ?? []
32
+ expect(bareStyles.length, `${path}: <head> must not contain un-named <style> blocks`).to.equal(0)
33
+ }
34
+
35
+ // Loading indicator must NOT appear in the initial server-rendered HTML
36
+ expect(html, `${path}: loading indicator must not be in initial HTML`).not.to.include('data-cy="loading-indicator"')
37
+
38
+ // cer-layout-view must not be empty (pre-rendered content present)
39
+ const layoutViewMatch = html.match(/<cer-layout-view>([\s\S]*?)<\/cer-layout-view>/)
40
+ if (layoutViewMatch) {
41
+ expect(layoutViewMatch[1].trim(), `${path}: cer-layout-view must have pre-rendered content`).not.to.be.empty
42
+ }
43
+ })
44
+ })
45
+
46
+ /**
47
+ * Wait for a shadow DOM element to appear (handles lazy rendering).
48
+ */
49
+ Cypress.Commands.add('getShadow', (selector: string) => {
50
+ return cy.get(selector, { includeShadowDom: true })
51
+ })
52
+
53
+ declare global {
54
+ namespace Cypress {
55
+ interface Chainable {
56
+ assertNoDSD_FOUC(path: string): Chainable<void>
57
+ getShadow(selector: string): Chainable<JQuery<HTMLElement>>
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,10 @@
1
+ import './commands'
2
+
3
+ // Fail tests on uncaught exceptions only if they're from the app (not network errors)
4
+ Cypress.on('uncaught:exception', (err) => {
5
+ // Ignore fetch errors (expected in SSG mode where API routes don't exist)
6
+ if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) {
7
+ return false
8
+ }
9
+ return true
10
+ })
@@ -1,12 +1,4 @@
1
- /**
2
- * Template string for `app/app.ts`.
3
- *
4
- * This file is the main application bootstrap entry point.
5
- * It registers all auto-discovered components, initialises the router,
6
- * runs plugins, and registers the framework-level <cer-layout-view> component
7
- * that handles layout selection, loading indicators, and error pages.
8
- */
9
- export const APP_TEMPLATE = `import '@jasonshimmy/custom-elements-runtime/css'
1
+ import '@jasonshimmy/custom-elements-runtime/css'
10
2
  import 'virtual:cer-components'
11
3
  import routes from 'virtual:cer-routes'
12
4
  import layouts from 'virtual:cer-layouts'
@@ -16,6 +8,7 @@ import { hasError, errorTag } from 'virtual:cer-error'
16
8
  import {
17
9
  component,
18
10
  ref,
11
+ provide,
19
12
  useOnConnected,
20
13
  useOnDisconnected,
21
14
  registerBuiltinComponents,
@@ -25,28 +18,20 @@ import { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'
25
18
  import { createDOMJITCSS } from '@jasonshimmy/custom-elements-runtime/dom-jit-css'
26
19
 
27
20
  registerBuiltinComponents()
28
-
29
- // Enable JIT CSS globally for all Shadow DOM components.
30
21
  enableJITCSS()
31
22
 
32
- // initRouter registers router-view/router-link, creates the router, and sets it as active.
33
23
  const router = initRouter({ routes })
34
24
 
35
25
  // ─── Navigation state ────────────────────────────────────────────────────────
36
26
 
37
- // isNavigating becomes true while a lazy route chunk is loading.
38
27
  const isNavigating = ref(false)
39
-
40
- // currentError holds the last uncaught navigation or render error.
41
28
  const currentError = ref(null)
42
29
 
43
- // Expose resetError globally so page-error components can call it.
44
30
  ;(globalThis as any).resetError = () => {
45
31
  currentError.value = null
46
32
  router.replace(router.getCurrent().path)
47
33
  }
48
34
 
49
- // Wrap push/replace to track navigation pending state.
50
35
  const _push = router.push.bind(router)
51
36
  const _replace = router.replace.bind(router)
52
37
 
@@ -74,75 +59,84 @@ router.replace = async (path) => {
74
59
  }
75
60
  }
76
61
 
62
+ // ─── Plugins ─────────────────────────────────────────────────────────────────
63
+
64
+ // Collect plugin-provided values so cer-layout-view can forward them into
65
+ // the component context tree via the real provide() hook (which inject() walks).
66
+ // Declared BEFORE component('cer-layout-view') to avoid a temporal dead zone
67
+ // ReferenceError: customElements.define() upgrades existing DOM elements
68
+ // synchronously, calling the render function immediately.
69
+ const _pluginProvides = new Map<string, unknown>()
70
+ // Expose plugin provides globally so page components can read them synchronously
71
+ // regardless of render order (inject/provide has timing issues in SSG mode).
72
+ ;(globalThis as any).__cerPluginProvides = _pluginProvides
73
+
77
74
  // ─── <cer-layout-view> ───────────────────────────────────────────────────────
78
- //
79
- // Wraps <router-view> in the layout appropriate for the current route.
80
- // Falls back to rendering <router-view> directly when no matching layout
81
- // exists. Also renders loading / error pages when those states are active.
82
- //
83
- // Layout stays mounted across navigations that share the same layout — the
84
- // vdom diff preserves the outer element when its tag name doesn't change.
85
75
 
86
76
  component('cer-layout-view', () => {
77
+ // Forward plugin-provided values into the component context so inject() in
78
+ // any descendant component can resolve them by walking up the DOM tree.
79
+ for (const [key, value] of _pluginProvides) {
80
+ provide(key, value)
81
+ }
82
+
87
83
  const current = ref(router.getCurrent())
88
84
  let unsub: (() => void) | undefined
89
85
 
90
86
  useOnConnected(() => {
91
- unsub = router.subscribe((s: typeof current.value) => {
92
- current.value = s
93
- })
94
- })
95
-
96
- useOnDisconnected(() => {
97
- unsub?.()
98
- unsub = undefined
87
+ unsub = router.subscribe((s: typeof current.value) => { current.value = s })
99
88
  })
89
+ useOnDisconnected(() => { unsub?.(); unsub = undefined })
100
90
 
101
- // Error state — show page-error if available, otherwise plain text.
102
91
  if (currentError.value !== null) {
103
92
  if (hasError && errorTag) {
104
93
  return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
105
94
  }
106
- return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: [String(currentError.value)] }
95
+ return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
107
96
  }
108
97
 
109
- // Loading state — show page-loading while a route chunk is fetching.
110
98
  if (isNavigating.value && hasLoading && loadingTag) {
111
99
  return { tag: loadingTag, props: {}, children: [] }
112
100
  }
113
101
 
114
- // Normal state — wrap router-view in the active layout (if any).
115
102
  const matched = router.matchRoute(current.value.path)
116
103
  const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'
117
104
  const layoutTag = (layouts as Record<string, string>)[layoutName]
118
105
  const routerView = { tag: 'router-view', props: {}, children: [] }
119
106
 
120
- if (layoutTag) {
121
- return { tag: layoutTag, props: {}, children: [routerView] }
122
- }
107
+ if (layoutTag) return { tag: layoutTag, props: {}, children: [routerView] }
123
108
  return routerView
124
109
  })
125
110
 
126
- // ─── Plugins ─────────────────────────────────────────────────────────────────
127
-
128
111
  for (const plugin of plugins) {
129
112
  if (plugin && typeof plugin.setup === 'function') {
130
- await plugin.setup({ router, provide: (key, value) => { (globalThis as any)[key] = value }, config: {} })
113
+ await plugin.setup({ router, provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) }, config: {} })
114
+ }
115
+ }
116
+
117
+ // ─── Pre-load initial route ───────────────────────────────────────────────────
118
+ // Download the current page's route chunk AFTER plugins run so that
119
+ // cer-layout-view's first render (which calls provide()) completes before
120
+ // page components are defined and their renders are scheduled. This ensures
121
+ // inject() in child components can find values stored by provide().
122
+
123
+ if (typeof window !== 'undefined') {
124
+ const _initMatch = router.matchRoute(window.location.pathname)
125
+ if (_initMatch?.route?.load) {
126
+ try { await _initMatch.route.load() } catch { /* non-fatal */ }
131
127
  }
132
128
  }
133
129
 
134
130
  // ─── Initial navigation ──────────────────────────────────────────────────────
135
131
 
136
132
  if (typeof window !== 'undefined') {
137
- await router.replace(window.location.pathname + window.location.search + window.location.hash)
138
- // Clear SSR loader data after the initial navigation so that subsequent
139
- // client-side navigations don't accidentally reuse it. We wait until after
140
- // router.replace() so both the pre-rendered element (upgraded during
141
- // component registration) and the new element from router.replace() can
142
- // both call usePageData() and receive the hydration data.
133
+ // Use the original (unwrapped) replace so isNavigating stays false during
134
+ // the initial paint the loading component must not flash over pre-rendered content.
135
+ await _replace(window.location.pathname + window.location.search + window.location.hash)
136
+ // Clear SSR loader data after initial navigation so subsequent client-side
137
+ // navigations don't accidentally reuse stale server data.
143
138
  delete (globalThis as any).__CER_DATA__
144
139
  createDOMJITCSS().mount()
145
140
  }
146
141
 
147
142
  export { router }
148
- `
@@ -0,0 +1,8 @@
1
+ component('ks-badge', () => {
2
+ const slots = useSlots()
3
+ return html`
4
+ <span data-cy="ks-badge" style="display:inline-block;background:#e0f2fe;color:#0369a1;padding:2px 8px;border-radius:4px;font-size:0.85em;font-weight:600">
5
+ ${slots.default ?? 'badge'}
6
+ </span>
7
+ `
8
+ })
@@ -0,0 +1,9 @@
1
+ import { ref } from '@jasonshimmy/custom-elements-runtime'
2
+
3
+ export function useKsCounter(initial = 0) {
4
+ const count = ref(initial)
5
+ const increment = () => { count.value++ }
6
+ const decrement = () => { count.value-- }
7
+ const reset = () => { count.value = 0 }
8
+ return { count, increment, decrement, reset }
9
+ }
@@ -0,0 +1,13 @@
1
+ component('page-error', () => {
2
+ const props = useProps<{ error: string }>({ error: 'An unexpected error occurred.' })
3
+
4
+ return html`
5
+ <div data-cy="error-boundary" style="padding:2rem;font-family:sans-serif">
6
+ <h2 data-cy="error-heading" style="color:#c00;margin-top:0">Something went wrong</h2>
7
+ <pre data-cy="error-message" style="background:#fff0f0;border:1px solid #fcc;padding:1rem;border-radius:4px">${props.error}</pre>
8
+ <button data-cy="error-retry" @click="${() => (globalThis as any).resetError?.()}">
9
+ Try again
10
+ </button>
11
+ </div>
12
+ `
13
+ })
@@ -0,0 +1,21 @@
1
+ component('layout-default', () => {
2
+ return html`
3
+ <div>
4
+ <header data-cy="site-header">
5
+ <nav data-cy="site-nav">
6
+ <a data-cy="nav-home" href="/">Home</a>
7
+ <a data-cy="nav-about" href="/about">About</a>
8
+ <a data-cy="nav-counter" href="/counter">Counter</a>
9
+ <a data-cy="nav-blog" href="/blog">Blog</a>
10
+ <a data-cy="nav-protected" href="/protected">Protected</a>
11
+ </nav>
12
+ </header>
13
+ <main data-cy="site-main">
14
+ <slot></slot>
15
+ </main>
16
+ <footer data-cy="site-footer">
17
+ <p>Kitchen Sink — testing every framework capability</p>
18
+ </footer>
19
+ </div>
20
+ `
21
+ })
@@ -0,0 +1,7 @@
1
+ component('layout-minimal', () => {
2
+ return html`
3
+ <div data-cy="minimal-layout">
4
+ <slot></slot>
5
+ </div>
6
+ `
7
+ })
@@ -0,0 +1,9 @@
1
+ component('page-loading', () => {
2
+ return html`
3
+ <div data-cy="loading-indicator" style="display:flex;align-items:center;gap:8px;padding:1rem;font-family:sans-serif;color:#888">
4
+ <span style="display:inline-block;width:14px;height:14px;border:2px solid #ccc;border-top-color:#555;border-radius:50%;animation:spin 0.7s linear infinite"></span>
5
+ Loading…
6
+ <style>@keyframes spin { to { transform: rotate(360deg) } }</style>
7
+ </div>
8
+ `
9
+ })
@@ -0,0 +1,13 @@
1
+ // Route middleware — redirects to /login if not authenticated.
2
+ // Set localStorage.setItem('ks-token', '1') to simulate login.
3
+ export default (to: any, _from: any, next: (path?: string) => void) => {
4
+ const isLoggedIn = typeof localStorage !== 'undefined'
5
+ ? !!localStorage.getItem('ks-token')
6
+ : false
7
+
8
+ if (!isLoggedIn) {
9
+ next('/login')
10
+ } else {
11
+ next()
12
+ }
13
+ }
@@ -0,0 +1,13 @@
1
+ component('page-login', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="login-heading">Login</h1>
5
+ <p data-cy="login-description">This page uses the <strong>minimal</strong> layout.</p>
6
+ <button data-cy="login-btn" @click="${() => { localStorage.setItem('ks-token', '1'); location.href = '/protected' }}">
7
+ Simulate Login
8
+ </button>
9
+ </div>
10
+ `
11
+ })
12
+
13
+ export const meta = { layout: 'minimal' }
@@ -0,0 +1,20 @@
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'
7
+
8
+ return html`
9
+ <div>
10
+ <h1 data-cy="protected-heading">Protected Page</h1>
11
+ <p data-cy="protected-note">You are authenticated! This page requires the <code>auth</code> middleware.</p>
12
+ <p data-cy="plugin-greeting">Plugin says: <strong>${greeting}</strong></p>
13
+ <button data-cy="logout-btn" @click="${() => { localStorage.removeItem('ks-token'); location.href = '/protected' }}">
14
+ Log out and reload
15
+ </button>
16
+ </div>
17
+ `
18
+ })
19
+
20
+ export const meta = { middleware: ['auth'] }
@@ -0,0 +1,9 @@
1
+ component('page-404', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="not-found-heading">404 — Not Found</h1>
5
+ <p data-cy="not-found-description">The page you are looking for does not exist.</p>
6
+ <p><a href="/" data-cy="not-found-home">← Back home</a></p>
7
+ </div>
8
+ `
9
+ })
@@ -0,0 +1,17 @@
1
+ component('page-about', () => {
2
+ useHead({
3
+ title: 'About — Kitchen Sink',
4
+ meta: [{ name: 'description', content: 'About the kitchen sink test app.' }],
5
+ })
6
+
7
+ return html`
8
+ <div>
9
+ <h1 data-cy="about-heading">About</h1>
10
+ <p data-cy="about-description">This page uses the <strong>minimal</strong> layout.</p>
11
+ <p data-cy="about-layout-note">It also calls <code>useHead()</code> to set title and meta tags.</p>
12
+ <p><a href="/" data-cy="about-back">← Back home</a></p>
13
+ </div>
14
+ `
15
+ })
16
+
17
+ export const meta = { layout: 'minimal' }