@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.
- package/CHANGELOG.md +8 -0
- package/commits.txt +1 -1
- package/cypress.config.ts +16 -0
- package/dist/cli/create/index.js +1 -1
- package/dist/cli/create/index.js.map +1 -1
- package/dist/plugin/build-ssg.d.ts +7 -0
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +2 -1
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/build-ssr.d.ts.map +1 -1
- package/dist/plugin/build-ssr.js +26 -6
- package/dist/plugin/build-ssr.js.map +1 -1
- package/dist/runtime/composables/index.d.ts +1 -1
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +1 -1
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-head.d.ts.map +1 -1
- package/dist/runtime/composables/use-head.js +12 -8
- package/dist/runtime/composables/use-head.js.map +1 -1
- 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 +14 -4
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/docs/cli.md +2 -0
- package/docs/components.md +57 -0
- package/docs/composables.md +9 -2
- package/docs/data-loading.md +45 -1
- package/docs/getting-started.md +71 -6
- package/docs/head-management.md +6 -0
- package/docs/plugins.md +25 -0
- package/docs/routing.md +48 -6
- package/e2e/cypress/e2e/api.cy.ts +81 -0
- package/e2e/cypress/e2e/data.cy.ts +111 -0
- package/e2e/cypress/e2e/fouc.cy.ts +65 -0
- package/e2e/cypress/e2e/head.cy.ts +89 -0
- package/e2e/cypress/e2e/interactive.cy.ts +122 -0
- package/e2e/cypress/e2e/routes.cy.ts +128 -0
- package/e2e/cypress/support/commands.ts +60 -0
- package/e2e/cypress/support/e2e.ts +10 -0
- package/{src/runtime/app-template.ts → e2e/kitchen-sink/app/app.ts} +43 -49
- package/e2e/kitchen-sink/app/components/ks-badge.ts +8 -0
- package/e2e/kitchen-sink/app/composables/useKsCounter.ts +9 -0
- package/e2e/kitchen-sink/app/error.ts +13 -0
- package/e2e/kitchen-sink/app/layouts/default.ts +21 -0
- package/e2e/kitchen-sink/app/layouts/minimal.ts +7 -0
- package/e2e/kitchen-sink/app/loading.ts +9 -0
- package/e2e/kitchen-sink/app/middleware/auth.ts +13 -0
- package/e2e/kitchen-sink/app/pages/(auth)/login.ts +13 -0
- package/e2e/kitchen-sink/app/pages/(auth)/protected.ts +20 -0
- package/e2e/kitchen-sink/app/pages/404.ts +9 -0
- package/e2e/kitchen-sink/app/pages/about.ts +17 -0
- package/e2e/kitchen-sink/app/pages/blog/[slug].ts +54 -0
- package/e2e/kitchen-sink/app/pages/blog/index.ts +46 -0
- package/e2e/kitchen-sink/app/pages/counter.ts +17 -0
- package/e2e/kitchen-sink/app/pages/head.ts +20 -0
- package/e2e/kitchen-sink/app/pages/index.ts +27 -0
- package/e2e/kitchen-sink/app/pages/items/[id].ts +20 -0
- package/e2e/kitchen-sink/app/plugins/01.setup.ts +7 -0
- package/e2e/kitchen-sink/cer-auto-imports.d.ts +50 -0
- package/e2e/kitchen-sink/cer-env.d.ts +36 -0
- package/e2e/kitchen-sink/cer-tsconfig.json +30 -0
- package/e2e/kitchen-sink/cer.config.ts +6 -0
- package/e2e/kitchen-sink/index.html +12 -0
- package/e2e/kitchen-sink/server/api/health.ts +3 -0
- package/e2e/kitchen-sink/server/api/posts/[slug].ts +11 -0
- package/e2e/kitchen-sink/server/api/posts/index.ts +5 -0
- package/e2e/kitchen-sink/server/data/posts.ts +21 -0
- package/e2e/scripts/clean.mjs +8 -0
- package/package.json +19 -2
- package/src/__tests__/plugin/build-ssg-render.test.ts +110 -0
- package/src/__tests__/plugin/build-ssg.test.ts +47 -1
- package/src/__tests__/plugin/build-ssr.test.ts +93 -1
- package/src/__tests__/plugin/dev-server.test.ts +493 -0
- package/src/__tests__/plugin/scanner.test.ts +15 -1
- package/src/__tests__/plugin/transforms/auto-import.test.ts +63 -0
- package/src/cli/create/index.ts +1 -1
- package/src/cli/create/templates/spa/app/app.ts.tpl +23 -3
- package/src/cli/create/templates/ssg/app/app.ts.tpl +27 -3
- package/src/cli/create/templates/ssg/app/pages/index.ts.tpl +0 -9
- package/src/cli/create/templates/ssr/app/app.ts.tpl +27 -3
- package/src/plugin/build-ssg.ts +2 -1
- package/src/plugin/build-ssr.ts +26 -6
- package/src/runtime/composables/index.ts +1 -1
- package/src/runtime/composables/use-head.ts +12 -8
- package/src/runtime/entry-server-template.ts +14 -4
- package/vitest.config.ts +5 -1
- package/VITE_PLUGIN_FRAMEWORK_PLAN.md +0 -594
- package/dist/runtime/app-template.d.ts +0 -10
- package/dist/runtime/app-template.d.ts.map +0 -1
- package/dist/runtime/app-template.js +0 -149
- 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:
|
|
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) => { (
|
|
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
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
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,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' }
|