@nextsparkjs/theme-blog 0.1.0-beta.188 → 0.1.0-beta.189

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.
@@ -6,12 +6,15 @@
6
6
  * A rich text editor built with native contentEditable.
7
7
  * Provides formatting, markdown shortcuts, and a clean interface.
8
8
  *
9
- * No external dependencies - uses document.execCommand for formatting.
9
+ * Formatting uses document.execCommand. Stored HTML, and markup pasted or
10
+ * dropped in, is sanitised before it reaches the DOM, in the editable area and
11
+ * in the preview.
10
12
  */
11
13
 
12
14
  import { useRef, useCallback, useEffect, useState } from 'react'
13
15
  import { cn } from '@nextsparkjs/core/lib/utils'
14
16
  import { Button } from '@nextsparkjs/core/components/ui/button'
17
+ import { postHtmlHasUnsafeMarkup, sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
15
18
  import {
16
19
  Bold,
17
20
  Italic,
@@ -78,6 +81,22 @@ const TOOLBAR_GROUPS: ToolbarButton[][] = [
78
81
  ],
79
82
  ]
80
83
 
84
+ /** The caret position under a point, so a drop lands where the pointer is. */
85
+ function rangeAtPoint(x: number, y: number): Range | null {
86
+ const doc = document as Document & {
87
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
88
+ caretRangeFromPoint?: (x: number, y: number) => Range | null
89
+ }
90
+ const position = doc.caretPositionFromPoint?.(x, y)
91
+ if (position) {
92
+ const range = document.createRange()
93
+ range.setStart(position.offsetNode, position.offset)
94
+ range.collapse(true)
95
+ return range
96
+ }
97
+ return doc.caretRangeFromPoint?.(x, y) ?? null
98
+ }
99
+
81
100
  export function WysiwygEditor({
82
101
  value,
83
102
  onChange,
@@ -91,15 +110,18 @@ export function WysiwygEditor({
91
110
  const [isFocused, setIsFocused] = useState(false)
92
111
  const isComposing = useRef(false)
93
112
 
94
- // Initialize content
113
+ // Fill the editable area. A value from outside (a loaded post) always goes in
114
+ // sanitised, and so does the value when leaving the preview mounts a new,
115
+ // empty area. One the area produced itself (typing, a paste, a command) is
116
+ // replaced only when sanitising would change it: rewriting it for a mere
117
+ // re-serialisation would move the caret to the start.
95
118
  useEffect(() => {
96
119
  if (editorRef.current && !isComposing.current) {
97
- const currentContent = editorRef.current.innerHTML
98
- if (currentContent !== value) {
99
- editorRef.current.innerHTML = value || ''
120
+ if (editorRef.current.innerHTML !== value || postHtmlHasUnsafeMarkup(value)) {
121
+ editorRef.current.innerHTML = sanitizePostHtml(value)
100
122
  }
101
123
  }
102
- }, [value])
124
+ }, [value, isPreview])
103
125
 
104
126
  // Auto focus
105
127
  useEffect(() => {
@@ -115,6 +137,31 @@ export function WysiwygEditor({
115
137
  }
116
138
  }, [onChange])
117
139
 
140
+ // Markup pasted or dropped in that sanitising would change goes in sanitised,
141
+ // in place of the browser's own insertion. Replacing it once it is in the
142
+ // editable area is too late: an attribute such as onerror fires as soon as
143
+ // the element is in the document. Anything else keeps the browser's paste
144
+ // and drop, which is also how text moves within the editor.
145
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
146
+ const html = e.clipboardData.getData('text/html')
147
+ if (!postHtmlHasUnsafeMarkup(html)) return
148
+ e.preventDefault()
149
+ document.execCommand('insertHTML', false, sanitizePostHtml(html))
150
+ }, [])
151
+
152
+ const handleDrop = useCallback((e: React.DragEvent) => {
153
+ const html = e.dataTransfer.getData('text/html')
154
+ if (!postHtmlHasUnsafeMarkup(html)) return
155
+ e.preventDefault()
156
+ const range = rangeAtPoint(e.clientX, e.clientY)
157
+ if (range) {
158
+ const selection = window.getSelection()
159
+ selection?.removeAllRanges()
160
+ selection?.addRange(range)
161
+ }
162
+ document.execCommand('insertHTML', false, sanitizePostHtml(html))
163
+ }, [])
164
+
118
165
  const execCommand = useCallback((command: string, value?: string) => {
119
166
  if (command === 'createLink') {
120
167
  const url = prompt('Enter URL:')
@@ -275,7 +322,7 @@ export function WysiwygEditor({
275
322
  <div
276
323
  className="flex-1 min-h-0 prose prose-sm max-w-none p-4 overflow-auto"
277
324
  style={{ minHeight }}
278
- dangerouslySetInnerHTML={{ __html: value || '<p class="text-muted-foreground">Nothing to preview...</p>' }}
325
+ dangerouslySetInnerHTML={{ __html: value ? sanitizePostHtml(value) : '<p class="text-muted-foreground">Nothing to preview...</p>' }}
279
326
  data-cy="wysiwyg-preview"
280
327
  />
281
328
  ) : (
@@ -302,6 +349,8 @@ export function WysiwygEditor({
302
349
  )}
303
350
  style={{ minHeight }}
304
351
  onInput={handleInput}
352
+ onPaste={handlePaste}
353
+ onDrop={handleDrop}
305
354
  onKeyDown={handleKeyDown}
306
355
  onFocus={() => setIsFocused(true)}
307
356
  onBlur={() => setIsFocused(false)}
@@ -0,0 +1,52 @@
1
+ import DOMPurify from 'dompurify'
2
+
3
+ /**
4
+ * A post body is HTML written by anyone who can create a post, a team member
5
+ * and not only an owner, so every place that turns it back into DOM sanitises
6
+ * it first: the public page, the editor and the editor's preview.
7
+ *
8
+ * DOMPurify needs a DOM. Where there is none (a server render pass) this
9
+ * returns '' rather than the raw string, failing closed.
10
+ */
11
+ export function sanitizePostHtml(html: string | null | undefined): string {
12
+ if (!html) return ''
13
+ if (!DOMPurify.isSupported) return ''
14
+ return DOMPurify.sanitize(html)
15
+ }
16
+
17
+ /**
18
+ * Whether sanitising changes the markup beyond what parsing it already does.
19
+ *
20
+ * Parsing alone rewrites markup that is not unsafe: the browser's editing
21
+ * commands leave a list inside a <p>, and the parser moves it out. So the
22
+ * original and the sanitised result are both parsed and compared as trees; any
23
+ * difference is something the sanitiser took out or altered. It does not read
24
+ * DOMPurify.removed, which DOMPurify documents as unfit for security decisions
25
+ * and which misses changes such as an attribute it rewrites.
26
+ *
27
+ * Both are parsed as a fragment, the way the editable area receives a paste. A
28
+ * document parse moves a leading <script> or <base> into a <head> that a
29
+ * comparison of bodies never sees. A template's content is inert, so nothing in
30
+ * the unsanitised markup runs or loads while it is inspected. Where there is no
31
+ * DOM the answer is true, sending callers down the sanitising path.
32
+ */
33
+ export function postHtmlHasUnsafeMarkup(html: string | null | undefined): boolean {
34
+ if (!html) return false
35
+ if (!DOMPurify.isSupported || typeof document === 'undefined') return true
36
+ return !sameMarkup(parseFragment(html), parseFragment(DOMPurify.sanitize(html)))
37
+ }
38
+
39
+ function parseFragment(markup: string): DocumentFragment {
40
+ const template = document.createElement('template')
41
+ template.innerHTML = markup
42
+ return template.content
43
+ }
44
+
45
+ /** Equal trees, including what each <template> holds: isEqualNode does not look inside one. */
46
+ function sameMarkup(a: DocumentFragment, b: DocumentFragment): boolean {
47
+ if (!a.isEqualNode(b)) return false
48
+ const templatesA = Array.from(a.querySelectorAll('template'))
49
+ const templatesB = Array.from(b.querySelectorAll('template'))
50
+ return templatesA.length === templatesB.length
51
+ && templatesA.every((template, i) => sameMarkup(template.content, templatesB[i].content))
52
+ }
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "@nextsparkjs/theme-blog",
3
- "version": "0.1.0-beta.188",
3
+ "version": "0.1.0-beta.189",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./config/theme.config.ts",
7
7
  "requiredPlugins": [],
8
- "dependencies": {},
8
+ "dependencies": {
9
+ "@tailwindcss/container-queries": "^0.1.1",
10
+ "dompurify": "^3.2.7",
11
+ "next-themes": "^0.4.6"
12
+ },
9
13
  "peerDependencies": {
10
- "@nextsparkjs/core": "*",
11
- "@nextsparkjs/testing": "*",
14
+ "@nextsparkjs/core": ">=0.1.0-0",
15
+ "@nextsparkjs/testing": ">=0.1.0-0",
12
16
  "lucide-react": "^0.539.0",
13
17
  "next": "^15.0.0",
14
18
  "next-intl": "^4.0.0",
@@ -19,5 +23,8 @@
19
23
  "nextspark": {
20
24
  "type": "theme",
21
25
  "name": "blog"
26
+ },
27
+ "devDependencies": {
28
+ "@types/dompurify": "^3.2.0"
22
29
  }
23
30
  }
@@ -12,6 +12,7 @@ import Link from 'next/link'
12
12
  import Image from 'next/image'
13
13
  import { ArrowLeft, Calendar, Clock, Loader2, Share2, Bookmark } from 'lucide-react'
14
14
  import { use, useEffect, useState } from 'react'
15
+ import { sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
15
16
  import { ReadingProgress } from '@/themes/blog/components/public/ReadingProgress'
16
17
  import { AuthorBio } from '@/themes/blog/components/public/AuthorBio'
17
18
  import { RelatedPosts } from '@/themes/blog/components/public/RelatedPosts'
@@ -294,7 +295,7 @@ export default function PostPage({ params }: PageProps) {
294
295
 
295
296
  {/* Article Content */}
296
297
  <div className="article-content">
297
- <div dangerouslySetInnerHTML={{ __html: renderContent(post.content) }} />
298
+ <div dangerouslySetInnerHTML={{ __html: sanitizePostHtml(renderContent(post.content)) }} />
298
299
  </div>
299
300
 
300
301
  {/* Divider */}
@@ -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
+ })
@@ -48,6 +48,8 @@ const moduleNameMapper = isNpmMode
48
48
  // Monorepo mode: resolve from packages/core/src (rootDir is apps/dev)
49
49
  '^@nextsparkjs/core/(.*)$': '<rootDir>/../../packages/core/src/$1',
50
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',
51
53
  '^@/contents/(.*)$': '<rootDir>/contents/$1',
52
54
  '^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
53
55
  '^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
@@ -97,10 +99,25 @@ module.exports = {
97
99
  // Setup files
98
100
  setupFilesAfterEnv,
99
101
 
100
- // Transform configuration
102
+ // In npm mode, use project's tsconfig with jsx override
103
+ // In monorepo, use the local tsconfig.jest.json
101
104
  transform: {
102
105
  '^.+\\.(ts|tsx)$': ['ts-jest', {
103
- tsconfig: path.join(projectRoot, 'tsconfig.json'),
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'),
104
121
  }],
105
122
  },
106
123
 
@@ -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
+ })
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../../../../apps/dev/tsconfig.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx"
5
+ }
6
+ }