@nextsparkjs/theme-blog 0.1.0-beta.19 → 0.1.0-beta.191

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 (72) hide show
  1. package/api/authors/[username]/route.ts +5 -2
  2. package/api/authors/docs.md +135 -0
  3. package/api/authors/presets.ts +45 -0
  4. package/api/authors/route.ts +4 -1
  5. package/api/posts/public/docs.md +124 -0
  6. package/api/posts/public/presets.ts +65 -0
  7. package/api/posts/public/route.ts +4 -1
  8. package/components/ExportPostsButton.tsx +2 -1
  9. package/components/ImportPostsDialog.tsx +2 -1
  10. package/components/editor/FeaturedImageUpload.tsx +2 -1
  11. package/components/editor/WysiwygEditor.tsx +56 -7
  12. package/components/public/AuthorBio.tsx +3 -2
  13. package/components/public/AuthorCard.tsx +3 -2
  14. package/components/public/BlogFooter.tsx +2 -1
  15. package/components/public/PostCard.tsx +8 -7
  16. package/components/public/RelatedPosts.tsx +2 -1
  17. package/config/app.config.ts +8 -15
  18. package/config/billing.config.ts +4 -7
  19. package/config/dashboard.config.ts +13 -0
  20. package/config/permissions.config.ts +11 -0
  21. package/entities/categories/api/docs.md +119 -0
  22. package/entities/categories/api/presets.ts +67 -0
  23. package/entities/posts/api/docs.md +174 -0
  24. package/entities/posts/api/presets.ts +137 -0
  25. package/lib/sanitize-post-html.ts +52 -0
  26. package/lib/selectors.ts +2 -3
  27. package/migrations/090_billing_sample_data.sql +99 -0
  28. package/migrations/999_theme_sample_data.sql +13 -13
  29. package/nextsparkjs-theme-blog-0.1.0-beta.137.tgz +0 -0
  30. package/package.json +13 -5
  31. package/styles/globals.css +58 -0
  32. package/templates/(public)/author/[username]/page.tsx +6 -5
  33. package/templates/(public)/authors/page.tsx +3 -2
  34. package/templates/(public)/page.tsx +2 -1
  35. package/templates/(public)/posts/[slug]/page.tsx +7 -5
  36. package/templates/dashboard/(main)/page.tsx +2 -1
  37. package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +4 -3
  38. package/templates/dashboard/(main)/posts/create/page.tsx +2 -1
  39. package/templates/dashboard/(main)/posts/page.tsx +7 -6
  40. package/tests/cypress/e2e/README.md +170 -0
  41. package/tests/cypress/e2e/categories/categories-crud.cy.ts +322 -0
  42. package/tests/cypress/e2e/categories/categories-crud.md +73 -0
  43. package/tests/cypress/e2e/posts/posts-crud.cy.ts +460 -0
  44. package/tests/cypress/e2e/posts/posts-crud.md +115 -0
  45. package/tests/cypress/e2e/posts/posts-editor.cy.ts +290 -0
  46. package/tests/cypress/e2e/posts/posts-editor.md +139 -0
  47. package/tests/cypress/e2e/posts/posts-status-workflow.cy.ts +302 -0
  48. package/tests/cypress/e2e/posts/posts-status-workflow.md +83 -0
  49. package/tests/cypress/fixtures/blocks.json +9 -0
  50. package/tests/cypress/fixtures/entities.json +51 -0
  51. package/tests/cypress/src/FeaturedImageUpload.js +131 -0
  52. package/tests/cypress/src/PostEditor.js +386 -0
  53. package/tests/cypress/src/PostsList.js +350 -0
  54. package/tests/cypress/src/WysiwygEditor.js +373 -0
  55. package/tests/cypress/src/components/EntityForm.ts +378 -0
  56. package/tests/cypress/src/components/EntityList.ts +378 -0
  57. package/tests/cypress/src/components/PostEditorPOM.ts +447 -0
  58. package/tests/cypress/src/components/PostsPOM.ts +362 -0
  59. package/tests/cypress/src/components/index.ts +18 -0
  60. package/tests/cypress/src/index.js +33 -0
  61. package/tests/cypress/src/selectors.ts +49 -0
  62. package/tests/cypress/src/session-helpers.ts +151 -0
  63. package/tests/cypress/support/e2e.ts +90 -0
  64. package/tests/cypress.config.ts +154 -0
  65. package/tests/jest/__mocks__/jose.js +22 -0
  66. package/tests/jest/__mocks__/next-server.js +56 -0
  67. package/tests/jest/components/WysiwygEditor.test.tsx +117 -0
  68. package/tests/jest/jest.config.cjs +148 -0
  69. package/tests/jest/lib/sanitize-post-html.test.ts +47 -0
  70. package/tests/jest/setup.ts +170 -0
  71. package/tests/jest/tsconfig.jest.json +6 -0
  72. package/tests/tsconfig.json +15 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Cypress Configuration for blog Theme
3
+ *
4
+ * This config works in both monorepo and npm mode.
5
+ * Run with: NEXT_PUBLIC_ACTIVE_THEME=default pnpm cy:open
6
+ */
7
+
8
+ import { defineConfig } from 'cypress'
9
+ import { fileURLToPath } from 'url'
10
+ import path from 'path'
11
+ import fs from 'fs'
12
+
13
+ // ESM-compatible __dirname
14
+ const __filename = fileURLToPath(import.meta.url)
15
+ const __dirname = path.dirname(__filename)
16
+
17
+ // Paths relative to this config file
18
+ const themeRoot = path.resolve(__dirname, '..')
19
+ const projectRoot = path.resolve(__dirname, '../../../..')
20
+ const narrationsOutputDir = path.join(__dirname, 'cypress/videos/narrations')
21
+
22
+ // Load environment variables
23
+ import dotenv from 'dotenv'
24
+ dotenv.config({ path: path.join(projectRoot, '.env') })
25
+
26
+ // Server port (from .env or default 3000)
27
+ const port = process.env.PORT || 3000
28
+
29
+ export default defineConfig({
30
+ e2e: {
31
+ // Base URL for the application
32
+ baseUrl: `http://localhost:${port}`,
33
+
34
+ // Spec patterns: theme tests only
35
+ specPattern: [
36
+ path.join(__dirname, 'cypress/e2e/**/*.cy.{js,ts}'),
37
+ ],
38
+
39
+ // Support file (always theme-local)
40
+ supportFile: path.join(__dirname, 'cypress/support/e2e.ts'),
41
+
42
+ // Fixtures folder (theme-specific)
43
+ fixturesFolder: path.join(__dirname, 'cypress/fixtures'),
44
+
45
+ // Output folders (theme-specific)
46
+ downloadsFolder: path.join(__dirname, 'cypress/downloads'),
47
+ screenshotsFolder: path.join(__dirname, 'cypress/screenshots'),
48
+ videosFolder: path.join(__dirname, 'cypress/videos'),
49
+
50
+ // Viewport settings
51
+ viewportWidth: 1280,
52
+ viewportHeight: 720,
53
+
54
+ // Video and screenshot settings
55
+ video: true,
56
+ screenshotOnRunFailure: true,
57
+
58
+ // Timeouts
59
+ defaultCommandTimeout: 10000,
60
+ requestTimeout: 10000,
61
+ responseTimeout: 10000,
62
+ pageLoadTimeout: 30000,
63
+
64
+ // Browser settings
65
+ chromeWebSecurity: false,
66
+
67
+ // Test isolation
68
+ testIsolation: true,
69
+
70
+ // Retry settings
71
+ retries: {
72
+ runMode: 1,
73
+ openMode: 0,
74
+ },
75
+
76
+ // Environment variables
77
+ env: {
78
+ // Theme info
79
+ ACTIVE_THEME: 'blog',
80
+ THEME_PATH: themeRoot,
81
+
82
+ // Test user credentials
83
+ TEST_USER_EMAIL: 'user@example.com',
84
+ TEST_USER_PASSWORD: 'Testing1234',
85
+
86
+ // Feature flags
87
+ ENABLE_ALLURE: true,
88
+
89
+ // Allure reporting
90
+ allureResultsPath: path.join(__dirname, 'cypress/allure-results'),
91
+
92
+ // API settings
93
+ API_URL: `http://localhost:${port}/api`,
94
+ API_BASE_URL: `http://localhost:${port}`,
95
+
96
+ // @cypress/grep - filter specs by tags
97
+ grepFilterSpecs: true,
98
+ grepOmitFiltered: true,
99
+ },
100
+
101
+ async setupNodeEvents(on, config) {
102
+ // Allure plugin setup (allure-cypress)
103
+ const { allureCypress } = await import('allure-cypress/reporter')
104
+ allureCypress(on, config, {
105
+ resultsDir: path.join(__dirname, 'cypress/allure-results'),
106
+ })
107
+
108
+ // @cypress/grep plugin for test filtering by tags
109
+ // v5.x uses named export { plugin } from '@cypress/grep/plugin'
110
+ const { plugin: grepPlugin } = await import('@cypress/grep/plugin')
111
+ grepPlugin(config)
112
+
113
+ // Documentation video tasks
114
+ on('task', {
115
+ /**
116
+ * Save narrations to JSON file for post-processing
117
+ */
118
+ saveNarrations({ specName, narrations }: { specName: string; narrations: unknown[] }) {
119
+ // Ensure output directory exists
120
+ if (!fs.existsSync(narrationsOutputDir)) {
121
+ fs.mkdirSync(narrationsOutputDir, { recursive: true })
122
+ }
123
+
124
+ const filename = `${specName}-narrations.json`
125
+ const filepath = path.join(narrationsOutputDir, filename)
126
+
127
+ fs.writeFileSync(filepath, JSON.stringify(narrations, null, 2))
128
+ console.log(`📝 Narrations saved to: ${filepath}`)
129
+
130
+ return null
131
+ },
132
+
133
+ /**
134
+ * Add narration entry (called per narration)
135
+ */
136
+ addNarration(narration: unknown) {
137
+ // This could be used for real-time streaming to a narration service
138
+ console.log('🎙️ Narration:', narration)
139
+ return null
140
+ },
141
+ })
142
+
143
+ return config
144
+ },
145
+ },
146
+
147
+ // Component testing (future use)
148
+ component: {
149
+ devServer: {
150
+ framework: 'next',
151
+ bundler: 'webpack',
152
+ },
153
+ },
154
+ })
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Mock for jose package
3
+ * Resolves ES module import issues in Jest tests
4
+ */
5
+
6
+ module.exports = {
7
+ jwtVerify: jest.fn(),
8
+ SignJWT: jest.fn().mockImplementation(() => ({
9
+ setProtectedHeader: jest.fn().mockReturnThis(),
10
+ setIssuedAt: jest.fn().mockReturnThis(),
11
+ setExpirationTime: jest.fn().mockReturnThis(),
12
+ sign: jest.fn().mockResolvedValue('mock-jwt-token')
13
+ })),
14
+ importJWK: jest.fn(),
15
+ generateSecret: jest.fn(),
16
+ createRemoteJWKSet: jest.fn(),
17
+ errors: {
18
+ JWTExpired: class JWTExpired extends Error {},
19
+ JWTInvalid: class JWTInvalid extends Error {},
20
+ JWTClaimValidationFailed: class JWTClaimValidationFailed extends Error {}
21
+ }
22
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Mock for next/server
3
+ * Provides NextRequest and NextResponse mocks for API testing
4
+ */
5
+
6
+ class MockNextRequest {
7
+ constructor(url, options = {}) {
8
+ this.url = url
9
+ this.method = options.method || 'GET'
10
+ this.headers = new Map(Object.entries(options.headers || {}))
11
+ this._body = options.body
12
+ }
13
+
14
+ async json() {
15
+ if (!this._body) return {}
16
+ try {
17
+ return typeof this._body === 'string' ? JSON.parse(this._body) : this._body
18
+ } catch {
19
+ throw new SyntaxError('Invalid JSON')
20
+ }
21
+ }
22
+
23
+ async text() {
24
+ return this._body || ''
25
+ }
26
+ }
27
+
28
+ class MockNextResponse {
29
+ constructor(body, options = {}) {
30
+ this.body = body
31
+ this.status = options.status || 200
32
+ this.statusText = options.statusText || 'OK'
33
+ this.headers = new Map(Object.entries(options.headers || {}))
34
+ }
35
+
36
+ async json() {
37
+ if (!this.body) return {}
38
+ try {
39
+ return typeof this.body === 'string' ? JSON.parse(this.body) : this.body
40
+ } catch {
41
+ throw new SyntaxError('Invalid JSON')
42
+ }
43
+ }
44
+
45
+ static json(data, options = {}) {
46
+ return new MockNextResponse(data, {
47
+ ...options,
48
+ headers: { 'Content-Type': 'application/json', ...options.headers }
49
+ })
50
+ }
51
+ }
52
+
53
+ module.exports = {
54
+ NextRequest: MockNextRequest,
55
+ NextResponse: MockNextResponse
56
+ }
@@ -0,0 +1,117 @@
1
+ import { render, fireEvent } from '@testing-library/react'
2
+ import { WysiwygEditor } from '@/themes/blog/components/editor/WysiwygEditor'
3
+
4
+ // What a team member can store as a post body through the API.
5
+ const STORED = [
6
+ '<p>kept <strong>bold</strong></p>',
7
+ '<img src="x" onerror="globalThis.pwned = 1">',
8
+ '<script>globalThis.pwned = 1</script>',
9
+ '<a href="javascript:globalThis.pwned = 1">link</a>',
10
+ ].join('')
11
+
12
+ function expectInert(root: Element) {
13
+ expect(root.querySelector('script')).toBeNull()
14
+ expect(root.querySelector('[onerror]')).toBeNull()
15
+ expect(root.querySelector('a[href^="javascript:"]')).toBeNull()
16
+ expect(root.querySelector('strong')?.textContent).toBe('bold')
17
+ }
18
+
19
+ describe('WysiwygEditor with stored HTML', () => {
20
+ it('renders formatting but no script in the editable area', () => {
21
+ const { container } = render(<WysiwygEditor value={STORED} onChange={jest.fn()} />)
22
+ expectInert(container.querySelector('[contenteditable]')!)
23
+ })
24
+
25
+ it('drops script that reaches the editable area while editing', () => {
26
+ const onChange = jest.fn()
27
+ const { container, rerender } = render(<WysiwygEditor value="" onChange={onChange} />)
28
+ const editable = container.querySelector('[contenteditable]')!
29
+ editable.innerHTML = STORED
30
+ fireEvent.input(editable)
31
+ const emitted = onChange.mock.calls[onChange.mock.calls.length - 1][0]
32
+ rerender(<WysiwygEditor value={emitted} onChange={onChange} />)
33
+ expectInert(editable)
34
+ })
35
+
36
+ it('drops a handler the sanitiser only reports as a rewritten attribute', () => {
37
+ const onChange = jest.fn()
38
+ const { container, rerender } = render(<WysiwygEditor value="" onChange={onChange} />)
39
+ const editable = container.querySelector('[contenteditable]')!
40
+ editable.innerHTML = '<p><strong>bold</strong></p><img title=" x " onerror="globalThis.pwned = 1" src="bad">'
41
+ fireEvent.input(editable)
42
+ const emitted = onChange.mock.calls[onChange.mock.calls.length - 1][0]
43
+ rerender(<WysiwygEditor value={emitted} onChange={onChange} />)
44
+ expectInert(editable)
45
+ })
46
+
47
+ it('renders formatting but no script in the preview', () => {
48
+ const { container } = render(<WysiwygEditor value={STORED} onChange={jest.fn()} />)
49
+ fireEvent.click(container.querySelector('[data-cy="wysiwyg-preview-toggle"]')!)
50
+ expectInert(container.querySelector('[data-cy="wysiwyg-preview"]')!)
51
+ })
52
+ })
53
+
54
+ describe('WysiwygEditor with markup pasted or dropped in', () => {
55
+ const UNSAFE = '<p><strong>bold</strong></p><img title=" x " onerror="globalThis.pwned = 1" src="bad">'
56
+ const transfer = (html: string) => ({ getData: (type: string) => (type === 'text/html' ? html : '') })
57
+ let execCommand: jest.Mock
58
+
59
+ beforeEach(() => {
60
+ execCommand = jest.fn()
61
+ Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true })
62
+ })
63
+
64
+ afterEach(() => {
65
+ delete (document as unknown as { execCommand?: unknown }).execCommand
66
+ })
67
+
68
+ function expectSanitisedInsertion() {
69
+ expect(execCommand).toHaveBeenCalledTimes(1)
70
+ const [command, , inserted] = execCommand.mock.calls[0]
71
+ expect(command).toBe('insertHTML')
72
+ expect(inserted).toContain('<strong>bold</strong>')
73
+ expect(inserted).not.toContain('onerror')
74
+ }
75
+
76
+ it('inserts a paste with a handler sanitised, instead of the browser paste', () => {
77
+ const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
78
+ const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, { clipboardData: transfer(UNSAFE) })
79
+ expect(proceeded).toBe(false)
80
+ expectSanitisedInsertion()
81
+ })
82
+
83
+ it('inserts a drop with a handler sanitised, instead of the browser drop', () => {
84
+ const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
85
+ const proceeded = fireEvent.drop(container.querySelector('[contenteditable]')!, { dataTransfer: transfer(UNSAFE) })
86
+ expect(proceeded).toBe(false)
87
+ expectSanitisedInsertion()
88
+ })
89
+
90
+ it('inserts a paste whose script a document parse would move out of the body, sanitised', () => {
91
+ const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
92
+ const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, {
93
+ clipboardData: transfer('<script>globalThis.pwned = 1</script><p><strong>bold</strong></p>'),
94
+ })
95
+ expect(proceeded).toBe(false)
96
+ expectSanitisedInsertion()
97
+ expect(execCommand.mock.calls[0][2]).not.toContain('<script')
98
+ })
99
+
100
+ it('leaves a paste with nothing to take out to the browser', () => {
101
+ const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
102
+ const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, { clipboardData: transfer('<p>plain <em>text</em></p>') })
103
+ expect(proceeded).toBe(true)
104
+ expect(execCommand).not.toHaveBeenCalled()
105
+ })
106
+ })
107
+
108
+ describe('WysiwygEditor preview toggle', () => {
109
+ it('keeps the content in the editable area after leaving the preview', () => {
110
+ const { container } = render(<WysiwygEditor value="<p>kept <strong>bold</strong></p>" onChange={jest.fn()} />)
111
+ const toggle = container.querySelector('[data-cy="wysiwyg-preview-toggle"]')!
112
+ fireEvent.click(toggle)
113
+ expect(container.querySelector('[contenteditable]')).toBeNull()
114
+ fireEvent.click(toggle)
115
+ expect(container.querySelector('[contenteditable]')!.innerHTML).toBe('<p>kept <strong>bold</strong></p>')
116
+ })
117
+ })
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Jest Configuration for Blog Theme
3
+ *
4
+ * This config works in both monorepo and npm mode.
5
+ * Run with: pnpm test:theme
6
+ */
7
+
8
+ const path = require('path')
9
+ const fs = require('fs')
10
+
11
+ // Paths relative to this config file
12
+ const themeTestsRoot = __dirname
13
+ const themeRoot = path.resolve(__dirname, '../..')
14
+
15
+ // In monorepo: themes/blog/tests/jest -> apps/dev (via symlink in contents/)
16
+ // In npm mode: contents/themes/blog/tests/jest -> project root (5 levels up)
17
+ const monorepoRepoRoot = path.resolve(__dirname, '../../../..')
18
+ const npmModeRoot = path.resolve(__dirname, '../../../../..')
19
+
20
+ // Detect if running in npm mode (no packages/core folder) vs monorepo
21
+ const isNpmMode = !fs.existsSync(path.join(monorepoRepoRoot, 'packages/core'))
22
+
23
+ // In monorepo, use apps/dev as rootDir since contents/ symlinks to themes/
24
+ const monorepoAppRoot = path.join(monorepoRepoRoot, 'apps/dev')
25
+ const projectRoot = isNpmMode ? npmModeRoot : monorepoAppRoot
26
+
27
+ // Module name mapper based on mode
28
+ const moduleNameMapper = isNpmMode
29
+ ? {
30
+ // NPM mode: explicitly resolve @nextsparkjs/core subpaths to dist directory
31
+ // Jest doesn't respect package.json exports, so we map directly to dist files
32
+ '^@nextsparkjs/core/lib/(.*)$': '<rootDir>/node_modules/@nextsparkjs/core/dist/lib/$1',
33
+ '^@nextsparkjs/core/hooks/(.*)$': '<rootDir>/node_modules/@nextsparkjs/core/dist/hooks/$1',
34
+ '^@nextsparkjs/core/components/(.*)$': '<rootDir>/node_modules/@nextsparkjs/core/dist/components/$1',
35
+ '^@nextsparkjs/core/(.*)$': '<rootDir>/node_modules/@nextsparkjs/core/dist/$1',
36
+ '^@nextsparkjs/core$': '<rootDir>/node_modules/@nextsparkjs/core/dist/index.js',
37
+ '^@/contents/(.*)$': '<rootDir>/contents/$1',
38
+ '^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
39
+ '^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
40
+ '^@/themes/(.*)$': '<rootDir>/contents/themes/$1',
41
+ '^@/(.*)$': '<rootDir>/$1',
42
+ // Mocks from theme-local folder
43
+ 'next/server': path.join(themeTestsRoot, '__mocks__/next-server.js'),
44
+ '^jose$': path.join(themeTestsRoot, '__mocks__/jose.js'),
45
+ '^jose/(.*)$': path.join(themeTestsRoot, '__mocks__/jose.js'),
46
+ }
47
+ : {
48
+ // Monorepo mode: resolve from packages/core/src (rootDir is apps/dev)
49
+ '^@nextsparkjs/core/(.*)$': '<rootDir>/../../packages/core/src/$1',
50
+ '^@nextsparkjs/core$': '<rootDir>/../../packages/core/src',
51
+ // core's components import the UI package, resolved from source as core's own jest config does
52
+ '^@nextsparkjs/ui$': '<rootDir>/../../packages/ui/src/index.ts',
53
+ '^@/contents/(.*)$': '<rootDir>/contents/$1',
54
+ '^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
55
+ '^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
56
+ '^@/themes/(.*)$': '<rootDir>/contents/themes/$1',
57
+ '^@/(.*)$': '<rootDir>/$1',
58
+ // Mocks from core
59
+ 'next/server': '<rootDir>/../../packages/core/tests/jest/__mocks__/next-server.js',
60
+ '^jose$': '<rootDir>/../../packages/core/tests/jest/__mocks__/jose.js',
61
+ '^jose/(.*)$': '<rootDir>/../../packages/core/tests/jest/__mocks__/jose.js',
62
+ }
63
+
64
+ // Setup files based on mode
65
+ const setupFilesAfterEnv = isNpmMode
66
+ ? [
67
+ // NPM mode: use theme's local setup only (it includes everything needed)
68
+ path.join(themeTestsRoot, 'setup.ts'),
69
+ ]
70
+ : [
71
+ // Monorepo mode: use local core setup (rootDir is apps/dev)
72
+ '<rootDir>/../../packages/core/tests/jest/setup.ts',
73
+ ]
74
+
75
+ /** @type {import('jest').Config} */
76
+ module.exports = {
77
+ displayName: 'theme-blog',
78
+ rootDir: projectRoot,
79
+
80
+ // Use roots to explicitly set test location (handles symlinks better)
81
+ roots: [themeTestsRoot],
82
+
83
+ // Test file patterns
84
+ testMatch: [
85
+ '**/*.{test,spec}.{js,ts,tsx}',
86
+ ],
87
+ testPathIgnorePatterns: [
88
+ '<rootDir>/node_modules/',
89
+ '<rootDir>/.next/',
90
+ ],
91
+
92
+ // Preset and environment
93
+ preset: 'ts-jest',
94
+ testEnvironment: 'jsdom',
95
+
96
+ // Module resolution
97
+ moduleNameMapper,
98
+
99
+ // Setup files
100
+ setupFilesAfterEnv,
101
+
102
+ // In npm mode, use project's tsconfig with jsx override
103
+ // In monorepo, use the local tsconfig.jest.json
104
+ transform: {
105
+ '^.+\\.(ts|tsx)$': ['ts-jest', {
106
+ tsconfig: isNpmMode
107
+ ? {
108
+ jsx: 'react-jsx',
109
+ esModuleInterop: true,
110
+ allowSyntheticDefaultImports: true,
111
+ module: 'ESNext',
112
+ moduleResolution: 'bundler',
113
+ strict: true,
114
+ baseUrl: projectRoot,
115
+ paths: {
116
+ '@/*': ['./*'],
117
+ '@/contents/*': ['./contents/*'],
118
+ },
119
+ }
120
+ : path.join(themeTestsRoot, 'tsconfig.jest.json'),
121
+ }],
122
+ },
123
+
124
+ // Transform ignore patterns - allow TypeScript from core's jest-setup
125
+ transformIgnorePatterns: [
126
+ 'node_modules/(?!(uncrypto|better-auth|@noble|.*jose.*|remark.*|unified.*|@nextsparkjs/core/tests|.*\\.mjs$))',
127
+ 'node_modules/\\.pnpm/(?!(.*uncrypto.*|.*better-auth.*|.*@noble.*|.*jose.*|.*remark.*|.*unified.*|@nextsparkjs.*core.*tests|.*\\.mjs$))',
128
+ ],
129
+
130
+ // File extensions
131
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
132
+
133
+ // Test timeout
134
+ testTimeout: 10000,
135
+
136
+ // Verbose output
137
+ verbose: true,
138
+
139
+ // Force exit after tests complete
140
+ forceExit: true,
141
+
142
+ // Disable watchman for symlink support
143
+ watchman: false,
144
+
145
+ // Coverage output directory
146
+ coverageDirectory: path.join(themeTestsRoot, 'coverage'),
147
+ coverageReporters: ['text', 'lcov', 'html'],
148
+ }
@@ -0,0 +1,47 @@
1
+ import { postHtmlHasUnsafeMarkup, sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
2
+
3
+ describe('sanitizePostHtml', () => {
4
+ it('keeps formatting and drops what scripts', () => {
5
+ const out = sanitizePostHtml('<p><strong>bold</strong></p><img src="x" onerror="globalThis.pwned = 1"><script>globalThis.pwned = 1</script>')
6
+ expect(out).toContain('<strong>bold</strong>')
7
+ expect(out).not.toMatch(/onerror|<script/)
8
+ })
9
+
10
+ it('returns an empty string for no input', () => {
11
+ expect(sanitizePostHtml('')).toBe('')
12
+ expect(sanitizePostHtml(null)).toBe('')
13
+ expect(sanitizePostHtml(undefined)).toBe('')
14
+ })
15
+ })
16
+
17
+ describe('postHtmlHasUnsafeMarkup', () => {
18
+ it('is true when sanitising removes or alters anything', () => {
19
+ for (const unsafe of [
20
+ '<img src="x" onerror="globalThis.pwned = 1">',
21
+ '<p>x</p><script>globalThis.pwned = 1</script>',
22
+ '<a href="javascript:globalThis.pwned = 1">x</a>',
23
+ '<img title=" x " onerror="globalThis.pwned = 1" src="bad">',
24
+ '<p onclick="globalThis.pwned = 1">x</p>',
25
+ '<a title=" y " href=" javascript:globalThis.pwned = 1">x</a>',
26
+ '<script>globalThis.pwned = 1</script>',
27
+ '<head><base href="https://attacker.test/"></head><p>safe</p>',
28
+ '<template><img src="x" onerror="globalThis.pwned = 1"></template>',
29
+ '<template><template><img src="x" onerror="globalThis.pwned = 1"></template></template>',
30
+ ]) {
31
+ expect(postHtmlHasUnsafeMarkup(unsafe)).toBe(true)
32
+ }
33
+ })
34
+
35
+ it('is false for markup the parser only re-nests or re-serialises', () => {
36
+ for (const safe of [
37
+ '<p><ul><li>item</li></ul></p>',
38
+ '<p>plain <em>text</em></p>',
39
+ ' <p>leading whitespace</p>',
40
+ '<p>a&nbsp;b<br></p>',
41
+ '<ol><li>one</li><li><b>two</b></li></ol>',
42
+ '<p style="text-align: center">centred</p>',
43
+ ]) {
44
+ expect(postHtmlHasUnsafeMarkup(safe)).toBe(false)
45
+ }
46
+ })
47
+ })