Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "markdown-editor-integrator",
|
||||||
|
"description": "This skill should be used when installing and configuring markdown editor functionality using @uiw/react-md-editor. Applies when adding rich text editing, markdown support, WYSIWYG editors, content editing with preview, or text formatting features. Trigger terms include markdown editor, rich text editor, text editor, add markdown, install markdown editor, markdown component, WYSIWYG, content editor, text formatting, editor preview.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Hope Overture",
|
||||||
|
"email": "support@worldbuilding-app-skills.dev"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# markdown-editor-integrator
|
||||||
|
|
||||||
|
This skill should be used when installing and configuring markdown editor functionality using @uiw/react-md-editor. Applies when adding rich text editing, markdown support, WYSIWYG editors, content editing with preview, or text formatting features. Trigger terms include markdown editor, rich text editor, text editor, add markdown, install markdown editor, markdown component, WYSIWYG, content editor, text formatting, editor preview.
|
||||||
61
plugin.lock.json
Normal file
61
plugin.lock.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:hopeoverture/worldbuilding-app-skills:plugins/markdown-editor-integrator",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "f96d521eee98f1e28aaa4d8500db4c0a7dce930c",
|
||||||
|
"treeHash": "8d21d930bcec85893a4dc9b9c0f634e0c6d800a2bd38fe292d11e7062163afc3",
|
||||||
|
"generatedAt": "2025-11-28T10:17:34.760894Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "markdown-editor-integrator",
|
||||||
|
"description": "This skill should be used when installing and configuring markdown editor functionality using @uiw/react-md-editor. Applies when adding rich text editing, markdown support, WYSIWYG editors, content editing with preview, or text formatting features. Trigger terms include markdown editor, rich text editor, text editor, add markdown, install markdown editor, markdown component, WYSIWYG, content editor, text formatting, editor preview.",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "29b330a75dc8e48d0a159956f0c8b6ae929a6ccba1640740e284a77fba81f3c0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "651fb76b513703395f014dfbc1b3bddda4643b545b047ddc23358af0cdf4b476"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/markdown-editor-integrator/SKILL.md",
|
||||||
|
"sha256": "ecff7d669db1abcd8362feb4ec21443a3615acf0f82131bc2597975e78b3550a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/markdown-editor-integrator/references/theme-integration.md",
|
||||||
|
"sha256": "932d1cd24c298b6c307b192793da6ab4215052a38dd971e911b170fe29bd88de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/markdown-editor-integrator/references/sanitization.md",
|
||||||
|
"sha256": "a32301c08715a3865cae2dc633092f58775391f01b6b8fa31d0d0f03082edbe7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/markdown-editor-integrator/assets/MarkdownEditor.tsx",
|
||||||
|
"sha256": "0cf7dac8d1b6e1c26a2b8df96e6525698d92db3fab1c225cb4427845d7b3d4da"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/markdown-editor-integrator/assets/MarkdownPreview.tsx",
|
||||||
|
"sha256": "4e46cb5edca463cdfd6da0a4bf1bb9898554db63d3dc131739186463b399d49c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "8d21d930bcec85893a4dc9b9c0f634e0c6d800a2bd38fe292d11e7062163afc3"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
1002
skills/markdown-editor-integrator/SKILL.md
Normal file
1002
skills/markdown-editor-integrator/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
160
skills/markdown-editor-integrator/assets/MarkdownEditor.tsx
Normal file
160
skills/markdown-editor-integrator/assets/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { ComponentProps } from 'react'
|
||||||
|
import rehypeSanitize from 'rehype-sanitize'
|
||||||
|
import '@uiw/react-md-editor/markdown-editor.css'
|
||||||
|
import '@uiw/react-markdown-preview/markdown.css'
|
||||||
|
|
||||||
|
const MDEditor = dynamic(
|
||||||
|
() => import('@uiw/react-md-editor'),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
type MDEditorProps = ComponentProps<typeof MDEditor>
|
||||||
|
|
||||||
|
interface MarkdownEditorProps extends Omit<MDEditorProps, 'data-color-mode'> {
|
||||||
|
value: string
|
||||||
|
onChange?: (value?: string) => void
|
||||||
|
height?: number | string
|
||||||
|
hideToolbar?: boolean
|
||||||
|
enablePreview?: boolean
|
||||||
|
preview?: 'edit' | 'live' | 'preview'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown Editor component with theme integration and sanitization
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Automatic theme switching (light/dark)
|
||||||
|
* - XSS protection with rehype-sanitize
|
||||||
|
* - Controlled component for forms
|
||||||
|
* - Customizable toolbar and preview
|
||||||
|
* - Next.js SSR compatible
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const [content, setContent] = useState('')
|
||||||
|
*
|
||||||
|
* <MarkdownEditor
|
||||||
|
* value={content}
|
||||||
|
* onChange={setContent}
|
||||||
|
* height={400}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function MarkdownEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
height = 400,
|
||||||
|
hideToolbar = false,
|
||||||
|
enablePreview = true,
|
||||||
|
preview = 'live',
|
||||||
|
...props
|
||||||
|
}: MarkdownEditorProps) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-color-mode={theme === 'dark' ? 'dark' : 'light'}
|
||||||
|
className="markdown-editor-wrapper"
|
||||||
|
>
|
||||||
|
<MDEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
height={height}
|
||||||
|
hideToolbar={hideToolbar}
|
||||||
|
enablePreview={enablePreview}
|
||||||
|
preview={preview}
|
||||||
|
previewOptions={{
|
||||||
|
rehypePlugins: [[rehypeSanitize]],
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS for theme integration (add to globals.css)
|
||||||
|
const editorStyles = `
|
||||||
|
/* Markdown Editor Theme Integration */
|
||||||
|
|
||||||
|
.markdown-editor-wrapper {
|
||||||
|
@apply rounded-md border border-input;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor {
|
||||||
|
@apply bg-background text-foreground shadow-none;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-toolbar {
|
||||||
|
@apply border-b border-border bg-muted/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-toolbar button,
|
||||||
|
.w-md-editor-toolbar li button {
|
||||||
|
@apply text-foreground hover:bg-accent hover:text-accent-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-toolbar li.active button {
|
||||||
|
@apply bg-accent text-accent-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-content {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-preview {
|
||||||
|
@apply prose prose-sm dark:prose-invert max-w-none p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-text-pre,
|
||||||
|
.w-md-editor-text-input {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-text-pre > code,
|
||||||
|
.w-md-editor-text-input > code {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks in preview */
|
||||||
|
.wmde-markdown {
|
||||||
|
@apply bg-transparent text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown pre {
|
||||||
|
@apply bg-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown code {
|
||||||
|
@apply text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown blockquote {
|
||||||
|
@apply border-l-primary/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown a {
|
||||||
|
@apply text-primary hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables in preview */
|
||||||
|
.wmde-markdown table {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown th,
|
||||||
|
.wmde-markdown td {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown th {
|
||||||
|
@apply bg-muted;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Export styles constant for documentation
|
||||||
|
export { editorStyles }
|
||||||
151
skills/markdown-editor-integrator/assets/MarkdownPreview.tsx
Normal file
151
skills/markdown-editor-integrator/assets/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { ComponentProps } from 'react'
|
||||||
|
import rehypeSanitize from 'rehype-sanitize'
|
||||||
|
import '@uiw/react-markdown-preview/markdown.css'
|
||||||
|
|
||||||
|
const MDPreview = dynamic(
|
||||||
|
() => import('@uiw/react-markdown-preview'),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
type MDPreviewProps = ComponentProps<typeof MDPreview>
|
||||||
|
|
||||||
|
interface MarkdownPreviewProps extends Omit<MDPreviewProps, 'source' | 'data-color-mode'> {
|
||||||
|
content: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown Preview component for displaying formatted markdown
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Automatic theme switching
|
||||||
|
* - XSS protection with rehype-sanitize
|
||||||
|
* - GitHub-flavored markdown support
|
||||||
|
* - Syntax highlighting for code blocks
|
||||||
|
* - Next.js SSR compatible
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <MarkdownPreview
|
||||||
|
* content={character.biography}
|
||||||
|
* className="max-w-3xl"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function MarkdownPreview({
|
||||||
|
content,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: MarkdownPreviewProps) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-color-mode={theme === 'dark' ? 'dark' : 'light'}
|
||||||
|
className={`markdown-preview-wrapper ${className}`}
|
||||||
|
>
|
||||||
|
<MDPreview
|
||||||
|
source={content}
|
||||||
|
rehypePlugins={[[rehypeSanitize]]}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS for preview theme integration (add to globals.css)
|
||||||
|
const previewStyles = `
|
||||||
|
/* Markdown Preview Theme Integration */
|
||||||
|
|
||||||
|
.markdown-preview-wrapper {
|
||||||
|
@apply rounded-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-wrapper .wmde-markdown {
|
||||||
|
@apply bg-transparent text-foreground;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown h1,
|
||||||
|
.markdown-preview-wrapper .wmde-markdown h2,
|
||||||
|
.markdown-preview-wrapper .wmde-markdown h3,
|
||||||
|
.markdown-preview-wrapper .wmde-markdown h4,
|
||||||
|
.markdown-preview-wrapper .wmde-markdown h5,
|
||||||
|
.markdown-preview-wrapper .wmde-markdown h6 {
|
||||||
|
@apply text-foreground font-semibold;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-wrapper .wmde-markdown p {
|
||||||
|
@apply text-foreground leading-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown a {
|
||||||
|
@apply text-primary hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown ul,
|
||||||
|
.markdown-preview-wrapper .wmde-markdown ol {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquotes */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown blockquote {
|
||||||
|
@apply border-l-4 border-primary/50 bg-muted/50 text-muted-foreground italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown code {
|
||||||
|
@apply bg-muted text-primary px-1.5 py-0.5 rounded text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-wrapper .wmde-markdown pre {
|
||||||
|
@apply bg-muted rounded-md p-4 overflow-x-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-wrapper .wmde-markdown pre code {
|
||||||
|
@apply bg-transparent p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown table {
|
||||||
|
@apply border-collapse w-full border border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-wrapper .wmde-markdown th {
|
||||||
|
@apply bg-muted font-semibold text-left p-2 border border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-wrapper .wmde-markdown td {
|
||||||
|
@apply p-2 border border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-wrapper .wmde-markdown tr:nth-child(even) {
|
||||||
|
@apply bg-muted/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal Rule */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown hr {
|
||||||
|
@apply border-border my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Images */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown img {
|
||||||
|
@apply rounded-md max-w-full h-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Lists */
|
||||||
|
.markdown-preview-wrapper .wmde-markdown input[type="checkbox"] {
|
||||||
|
@apply mr-2;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Export styles constant for documentation
|
||||||
|
export { previewStyles }
|
||||||
573
skills/markdown-editor-integrator/references/sanitization.md
Normal file
573
skills/markdown-editor-integrator/references/sanitization.md
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
# Markdown Sanitization Security Guide
|
||||||
|
|
||||||
|
## Why Sanitization is Critical
|
||||||
|
|
||||||
|
Markdown editors can be exploited for XSS attacks through:
|
||||||
|
- Malicious JavaScript in HTML tags
|
||||||
|
- Script injection via event handlers
|
||||||
|
- Data exfiltration through image sources
|
||||||
|
- Link-based phishing attacks
|
||||||
|
- Iframe injection
|
||||||
|
|
||||||
|
## Client-Side Sanitization
|
||||||
|
|
||||||
|
### Using rehype-sanitize
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install rehype-sanitize
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MDEditor from '@uiw/react-md-editor'
|
||||||
|
import rehypeSanitize from 'rehype-sanitize'
|
||||||
|
|
||||||
|
<MDEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
previewOptions={{
|
||||||
|
rehypePlugins: [[rehypeSanitize]],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Sanitization Schema
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||||
|
import { deepmerge } from 'deepmerge-ts'
|
||||||
|
|
||||||
|
const customSchema = deepmerge(defaultSchema, {
|
||||||
|
attributes: {
|
||||||
|
'*': ['className'], // Allow className on all elements
|
||||||
|
a: ['href', 'title', 'target', 'rel'],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height'],
|
||||||
|
code: ['className'], // For syntax highlighting
|
||||||
|
},
|
||||||
|
tagNames: [
|
||||||
|
// Standard markdown
|
||||||
|
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'strong', 'em', 'u', 's', 'del', 'ins',
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
'blockquote', 'code', 'pre',
|
||||||
|
'a', 'img',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||||
|
'hr', 'div', 'span',
|
||||||
|
// Additional if needed
|
||||||
|
'kbd', 'mark', 'abbr',
|
||||||
|
],
|
||||||
|
protocols: {
|
||||||
|
href: ['http', 'https', 'mailto'],
|
||||||
|
src: ['http', 'https'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
<MDEditor
|
||||||
|
previewOptions={{
|
||||||
|
rehypePlugins: [[rehypeSanitize, customSchema]],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strict Sanitization (No HTML)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import rehypeSanitize from 'rehype-sanitize'
|
||||||
|
|
||||||
|
const strictSchema = {
|
||||||
|
tagNames: [
|
||||||
|
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'strong', 'em', 'code', 'pre',
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
'blockquote',
|
||||||
|
'a',
|
||||||
|
'hr',
|
||||||
|
],
|
||||||
|
attributes: {
|
||||||
|
a: ['href'],
|
||||||
|
code: ['className'],
|
||||||
|
},
|
||||||
|
protocols: {
|
||||||
|
href: ['https'], // Only HTTPS links
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
<MDEditor
|
||||||
|
previewOptions={{
|
||||||
|
rehypePlugins: [[rehypeSanitize, strictSchema]],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side Sanitization
|
||||||
|
|
||||||
|
### Using DOMPurify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install isomorphic-dompurify
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/sanitize-markdown.ts
|
||||||
|
import DOMPurify from 'isomorphic-dompurify'
|
||||||
|
|
||||||
|
export function sanitizeMarkdown(markdown: string): string {
|
||||||
|
return DOMPurify.sanitize(markdown, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'p', 'br', 'strong', 'em', 'u', 's',
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
'blockquote', 'code', 'pre',
|
||||||
|
'a', 'img',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||||
|
'hr', 'div', 'span',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href', 'src', 'alt', 'title',
|
||||||
|
'class', 'id',
|
||||||
|
'width', 'height',
|
||||||
|
],
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
|
||||||
|
KEEP_CONTENT: true,
|
||||||
|
RETURN_DOM: false,
|
||||||
|
RETURN_DOM_FRAGMENT: false,
|
||||||
|
RETURN_DOM_IMPORT: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Action with Sanitization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { sanitizeMarkdown } from '@/lib/sanitize-markdown'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function saveCharacterBio(
|
||||||
|
characterId: string,
|
||||||
|
biography: string
|
||||||
|
) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return { success: false, message: 'Unauthorized' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize before saving
|
||||||
|
const sanitizedBio = sanitizeMarkdown(biography)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { biography: sanitizedBio },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save biography:', error)
|
||||||
|
return { success: false, message: 'Failed to save' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Route with Sanitization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/entities/[id]/description/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { sanitizeMarkdown } from '@/lib/sanitize-markdown'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession()
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { description } = await request.json()
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!description || typeof description !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid description' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize
|
||||||
|
const sanitized = sanitizeMarkdown(description)
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
await db.entity.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: { description: sanitized },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Sanitization Patterns
|
||||||
|
|
||||||
|
### Markdown-to-HTML with Sanitization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { remark } from 'remark'
|
||||||
|
import remarkHtml from 'remark-html'
|
||||||
|
import DOMPurify from 'isomorphic-dompurify'
|
||||||
|
|
||||||
|
export async function markdownToSafeHtml(markdown: string): Promise<string> {
|
||||||
|
// Convert markdown to HTML
|
||||||
|
const result = await remark()
|
||||||
|
.use(remarkHtml, { sanitize: false }) // Don't double-sanitize
|
||||||
|
.process(markdown)
|
||||||
|
|
||||||
|
const html = result.toString()
|
||||||
|
|
||||||
|
// Sanitize HTML
|
||||||
|
const sanitized = DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'p', 'br', 'strong', 'em', 'code', 'pre',
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
'blockquote', 'a', 'img',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||||
|
'hr',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in server action
|
||||||
|
export async function saveDescription(id: string, markdown: string) {
|
||||||
|
const html = await markdownToSafeHtml(markdown)
|
||||||
|
|
||||||
|
await db.entity.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
descriptionMarkdown: markdown, // Store original
|
||||||
|
descriptionHtml: html, // Store sanitized HTML
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Link Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import DOMPurify from 'isomorphic-dompurify'
|
||||||
|
|
||||||
|
export function sanitizeWithLinkValidation(markdown: string): string {
|
||||||
|
return DOMPurify.sanitize(markdown, {
|
||||||
|
ALLOWED_TAGS: ['a', 'p', 'h1', 'h2', 'h3', 'strong', 'em', 'code'],
|
||||||
|
ALLOWED_ATTR: ['href'],
|
||||||
|
ALLOWED_URI_REGEXP: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/,
|
||||||
|
// Block certain domains
|
||||||
|
FORBID_ATTR: [],
|
||||||
|
HOOKS: {
|
||||||
|
afterSanitizeAttributes: (node) => {
|
||||||
|
if (node.tagName === 'A') {
|
||||||
|
const href = node.getAttribute('href')
|
||||||
|
if (href) {
|
||||||
|
// Block suspicious links
|
||||||
|
const suspiciousDomains = ['malicious.com', 'phishing.com']
|
||||||
|
const url = new URL(href)
|
||||||
|
if (suspiciousDomains.some(domain => url.hostname.includes(domain))) {
|
||||||
|
node.removeAttribute('href')
|
||||||
|
}
|
||||||
|
// Force external links to open in new tab
|
||||||
|
node.setAttribute('target', '_blank')
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Source Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import DOMPurify from 'isomorphic-dompurify'
|
||||||
|
|
||||||
|
export function sanitizeWithImageValidation(markdown: string): string {
|
||||||
|
return DOMPurify.sanitize(markdown, {
|
||||||
|
ALLOWED_TAGS: ['img', 'p', 'h1', 'h2', 'strong', 'em'],
|
||||||
|
ALLOWED_ATTR: ['src', 'alt', 'title', 'width', 'height'],
|
||||||
|
HOOKS: {
|
||||||
|
afterSanitizeAttributes: (node) => {
|
||||||
|
if (node.tagName === 'IMG') {
|
||||||
|
const src = node.getAttribute('src')
|
||||||
|
if (src) {
|
||||||
|
try {
|
||||||
|
const url = new URL(src)
|
||||||
|
// Only allow images from trusted domains
|
||||||
|
const trustedDomains = [
|
||||||
|
'images.example.com',
|
||||||
|
'cdn.example.com',
|
||||||
|
'storage.googleapis.com',
|
||||||
|
]
|
||||||
|
if (!trustedDomains.some(domain => url.hostname === domain)) {
|
||||||
|
node.removeAttribute('src')
|
||||||
|
node.setAttribute('alt', 'Image blocked: untrusted source')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Invalid URL, remove src
|
||||||
|
node.removeAttribute('src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### 1. Always Sanitize on Server
|
||||||
|
|
||||||
|
Never trust client-side sanitization alone:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// [ERROR] BAD: Only client-side sanitization
|
||||||
|
function onSubmit(data: FormValues) {
|
||||||
|
await saveDescription(data.description) // Raw user input
|
||||||
|
}
|
||||||
|
|
||||||
|
// [OK] GOOD: Server-side sanitization
|
||||||
|
'use server'
|
||||||
|
export async function saveDescription(description: string) {
|
||||||
|
const sanitized = sanitizeMarkdown(description)
|
||||||
|
await db.save(sanitized)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Validate Input Length
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const MAX_MARKDOWN_LENGTH = 50000 // 50KB
|
||||||
|
|
||||||
|
export async function saveDescription(description: string) {
|
||||||
|
if (description.length > MAX_MARKDOWN_LENGTH) {
|
||||||
|
throw new Error('Description too long')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeMarkdown(description)
|
||||||
|
await db.save(sanitized)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Store Both Raw and Sanitized
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function saveDescription(
|
||||||
|
entityId: string,
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
const sanitized = sanitizeMarkdown(description)
|
||||||
|
|
||||||
|
await db.entity.update({
|
||||||
|
where: { id: entityId },
|
||||||
|
data: {
|
||||||
|
descriptionRaw: description, // Original for editing
|
||||||
|
description: sanitized, // Sanitized for display
|
||||||
|
descriptionUpdatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Implement Rate Limiting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Ratelimit } from '@upstash/ratelimit'
|
||||||
|
import { Redis } from '@upstash/redis'
|
||||||
|
|
||||||
|
const ratelimit = new Ratelimit({
|
||||||
|
redis: Redis.fromEnv(),
|
||||||
|
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function saveDescription(
|
||||||
|
userId: string,
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
const { success } = await ratelimit.limit(userId)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error('Rate limit exceeded')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeMarkdown(description)
|
||||||
|
await db.save(sanitized)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Log Suspicious Content
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function sanitizeAndLog(
|
||||||
|
markdown: string,
|
||||||
|
userId: string
|
||||||
|
): string {
|
||||||
|
const original = markdown
|
||||||
|
const sanitized = DOMPurify.sanitize(markdown)
|
||||||
|
|
||||||
|
// If content was modified, it contained potentially malicious code
|
||||||
|
if (original !== sanitized) {
|
||||||
|
console.warn('Suspicious content detected:', {
|
||||||
|
userId,
|
||||||
|
timestamp: new Date(),
|
||||||
|
removed: original.length - sanitized.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optionally store for security review
|
||||||
|
logSecurityEvent({
|
||||||
|
type: 'SUSPICIOUS_CONTENT',
|
||||||
|
userId,
|
||||||
|
original: original.substring(0, 1000), // First 1KB
|
||||||
|
sanitized: sanitized.substring(0, 1000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Sanitization
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { sanitizeMarkdown } from './sanitize-markdown'
|
||||||
|
|
||||||
|
describe('sanitizeMarkdown', () => {
|
||||||
|
it('removes script tags', () => {
|
||||||
|
const input = '<script>alert("XSS")</script>Hello'
|
||||||
|
const output = sanitizeMarkdown(input)
|
||||||
|
expect(output).not.toContain('<script>')
|
||||||
|
expect(output).toContain('Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes event handlers', () => {
|
||||||
|
const input = '<img src="x" onerror="alert(1)">'
|
||||||
|
const output = sanitizeMarkdown(input)
|
||||||
|
expect(output).not.toContain('onerror')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows safe HTML', () => {
|
||||||
|
const input = '<p><strong>Bold</strong> and <em>italic</em></p>'
|
||||||
|
const output = sanitizeMarkdown(input)
|
||||||
|
expect(output).toContain('<strong>')
|
||||||
|
expect(output).toContain('<em>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes links', () => {
|
||||||
|
const input = '<a href="javascript:alert(1)">Click</a>'
|
||||||
|
const output = sanitizeMarkdown(input)
|
||||||
|
expect(output).not.toContain('javascript:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles nested tags', () => {
|
||||||
|
const input = '<div><script>alert(1)</script><p>Safe</p></div>'
|
||||||
|
const output = sanitizeMarkdown(input)
|
||||||
|
expect(output).not.toContain('<script>')
|
||||||
|
expect(output).toContain('Safe')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Markdown Editor Security', () => {
|
||||||
|
test('prevents XSS through markdown', async ({ page }) => {
|
||||||
|
await page.goto('/editor')
|
||||||
|
|
||||||
|
const xssPayload = '<script>window.xssExecuted = true</script>Hello'
|
||||||
|
|
||||||
|
await page.fill('[role="textbox"]', xssPayload)
|
||||||
|
await page.click('button:has-text("Save")')
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
// Check that script did not execute
|
||||||
|
const xssExecuted = await page.evaluate(() => {
|
||||||
|
return (window as any).xssExecuted
|
||||||
|
})
|
||||||
|
expect(xssExecuted).toBeUndefined()
|
||||||
|
|
||||||
|
// Check that content was sanitized
|
||||||
|
const preview = await page.locator('.preview').textContent()
|
||||||
|
expect(preview).toContain('Hello')
|
||||||
|
expect(preview).not.toContain('<script>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Attack Vectors
|
||||||
|
|
||||||
|
### Script Injection
|
||||||
|
```html
|
||||||
|
<!-- Attack -->
|
||||||
|
<script>alert('XSS')</script>
|
||||||
|
<img src=x onerror="alert('XSS')">
|
||||||
|
<svg onload="alert('XSS')">
|
||||||
|
|
||||||
|
<!-- Sanitized -->
|
||||||
|
<!-- Scripts and event handlers removed -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Link-based Attacks
|
||||||
|
```html
|
||||||
|
<!-- Attack -->
|
||||||
|
<a href="javascript:alert('XSS')">Click</a>
|
||||||
|
<a href="data:text/html,<script>alert('XSS')</script>">Click</a>
|
||||||
|
|
||||||
|
<!-- Sanitized -->
|
||||||
|
<a>Click</a> <!-- href removed -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Iframe Injection
|
||||||
|
```html
|
||||||
|
<!-- Attack -->
|
||||||
|
<iframe src="https://malicious.com"></iframe>
|
||||||
|
|
||||||
|
<!-- Sanitized -->
|
||||||
|
<!-- iframe tag removed completely -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sanitization Checklist
|
||||||
|
|
||||||
|
- [ ] Client-side sanitization with rehype-sanitize configured
|
||||||
|
- [ ] Server-side sanitization with DOMPurify
|
||||||
|
- [ ] Whitelist approach (allowed tags/attributes)
|
||||||
|
- [ ] Protocol restrictions (https only)
|
||||||
|
- [ ] Input length validation
|
||||||
|
- [ ] Rate limiting on save endpoints
|
||||||
|
- [ ] Logging of sanitized content
|
||||||
|
- [ ] Both raw and sanitized versions stored
|
||||||
|
- [ ] External links open in new tab with rel="noopener noreferrer"
|
||||||
|
- [ ] Image sources validated against trusted domains
|
||||||
|
- [ ] Unit tests for XSS prevention
|
||||||
|
- [ ] Integration tests with malicious payloads
|
||||||
|
- [ ] Security headers configured (CSP)
|
||||||
|
- [ ] Regular dependency updates for security patches
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
# Markdown Editor Theme Integration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Integrate @uiw/react-md-editor with shadcn/ui theming system for consistent light/dark mode support.
|
||||||
|
|
||||||
|
## Theme Switching
|
||||||
|
|
||||||
|
### Using next-themes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import MDEditor from '@uiw/react-md-editor'
|
||||||
|
|
||||||
|
export function ThemedMarkdownEditor({ value, onChange }) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-color-mode={theme === 'dark' ? 'dark' : 'light'}>
|
||||||
|
<MDEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
height={400}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### data-color-mode Attribute
|
||||||
|
|
||||||
|
The `data-color-mode` attribute controls the editor's theme:
|
||||||
|
- `light` - Light mode
|
||||||
|
- `dark` - Dark mode
|
||||||
|
- `auto` - System preference (default)
|
||||||
|
|
||||||
|
## CSS Variable Mapping
|
||||||
|
|
||||||
|
Map shadcn/ui CSS variables to editor styles:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* globals.css */
|
||||||
|
|
||||||
|
/* Editor Container */
|
||||||
|
.w-md-editor {
|
||||||
|
--md-editor-bg: hsl(var(--background));
|
||||||
|
--md-editor-color: hsl(var(--foreground));
|
||||||
|
--md-editor-border: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.w-md-editor-toolbar {
|
||||||
|
background-color: hsl(var(--muted) / 0.3);
|
||||||
|
border-bottom-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-toolbar button {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-toolbar button:hover {
|
||||||
|
background-color: hsl(var(--accent));
|
||||||
|
color: hsl(var(--accent-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-toolbar li.active button {
|
||||||
|
background-color: hsl(var(--accent));
|
||||||
|
color: hsl(var(--accent-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor Content */
|
||||||
|
.w-md-editor-content {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-text-pre,
|
||||||
|
.w-md-editor-text-input {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
caret-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview */
|
||||||
|
.w-md-editor-preview {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown {
|
||||||
|
background-color: transparent;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Blocks */
|
||||||
|
.wmde-markdown pre {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown code {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline Code */
|
||||||
|
.wmde-markdown :not(pre) > code {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquotes */
|
||||||
|
.wmde-markdown blockquote {
|
||||||
|
border-left-color: hsl(var(--primary) / 0.5);
|
||||||
|
background-color: hsl(var(--muted) / 0.5);
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.wmde-markdown a {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown a:hover {
|
||||||
|
color: hsl(var(--primary) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.wmde-markdown table {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown th,
|
||||||
|
.wmde-markdown td {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown th {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wmde-markdown tr:nth-child(even) {
|
||||||
|
background-color: hsl(var(--muted) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal Rule */
|
||||||
|
.wmde-markdown hr {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
.wmde-markdown h1,
|
||||||
|
.wmde-markdown h2,
|
||||||
|
.wmde-markdown h3,
|
||||||
|
.wmde-markdown h4,
|
||||||
|
.wmde-markdown h5,
|
||||||
|
.wmde-markdown h6 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tailwind Class Approach
|
||||||
|
|
||||||
|
Use Tailwind utility classes for theming:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import MDEditor from '@uiw/react-md-editor'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function MarkdownEditor({ value, onChange, className }) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-color-mode={theme === 'dark' ? 'dark' : 'light'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-input overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MDEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
height={400}
|
||||||
|
className="shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typography Integration
|
||||||
|
|
||||||
|
Integrate with Tailwind Typography (prose):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Preview with prose styling */
|
||||||
|
.w-md-editor-preview {
|
||||||
|
@apply prose prose-sm dark:prose-invert max-w-none p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override prose defaults with theme colors */
|
||||||
|
.w-md-editor-preview.prose {
|
||||||
|
--tw-prose-body: hsl(var(--foreground));
|
||||||
|
--tw-prose-headings: hsl(var(--foreground));
|
||||||
|
--tw-prose-links: hsl(var(--primary));
|
||||||
|
--tw-prose-bold: hsl(var(--foreground));
|
||||||
|
--tw-prose-code: hsl(var(--primary));
|
||||||
|
--tw-prose-pre-bg: hsl(var(--muted));
|
||||||
|
--tw-prose-quotes: hsl(var(--muted-foreground));
|
||||||
|
--tw-prose-quote-borders: hsl(var(--primary) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-preview.prose.dark {
|
||||||
|
--tw-prose-body: hsl(var(--foreground));
|
||||||
|
--tw-prose-headings: hsl(var(--foreground));
|
||||||
|
--tw-prose-links: hsl(var(--primary));
|
||||||
|
--tw-prose-bold: hsl(var(--foreground));
|
||||||
|
--tw-prose-code: hsl(var(--primary));
|
||||||
|
--tw-prose-pre-bg: hsl(var(--muted));
|
||||||
|
--tw-prose-quotes: hsl(var(--muted-foreground));
|
||||||
|
--tw-prose-quote-borders: hsl(var(--primary) / 0.5);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Component with Theme
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { ComponentProps, useEffect, useState } from 'react'
|
||||||
|
import rehypeSanitize from 'rehype-sanitize'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const MDEditor = dynamic(
|
||||||
|
() => import('@uiw/react-md-editor'),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
type MDEditorProps = ComponentProps<typeof MDEditor>
|
||||||
|
|
||||||
|
interface MarkdownEditorProps extends Omit<MDEditorProps, 'data-color-mode'> {
|
||||||
|
value: string
|
||||||
|
onChange?: (value?: string) => void
|
||||||
|
height?: number | string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
height = 400,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MarkdownEditorProps) {
|
||||||
|
const { theme, resolvedTheme } = useTheme()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-input bg-background',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
Loading editor...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMode = (resolvedTheme || theme) === 'dark' ? 'dark' : 'light'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-color-mode={colorMode}
|
||||||
|
className={cn('markdown-editor-wrapper', className)}
|
||||||
|
>
|
||||||
|
<MDEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
height={height}
|
||||||
|
previewOptions={{
|
||||||
|
rehypePlugins: [[rehypeSanitize]],
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dark Mode Specifics
|
||||||
|
|
||||||
|
### Handle System Preference
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function useEditorTheme() {
|
||||||
|
const { theme, systemTheme } = useTheme()
|
||||||
|
const [colorMode, setColorMode] = useState<'light' | 'dark'>('light')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const effectiveTheme = theme === 'system' ? systemTheme : theme
|
||||||
|
setColorMode(effectiveTheme === 'dark' ? 'dark' : 'light')
|
||||||
|
}, [theme, systemTheme])
|
||||||
|
|
||||||
|
return colorMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const colorMode = useEditorTheme()
|
||||||
|
|
||||||
|
<div data-color-mode={colorMode}>
|
||||||
|
<MDEditor {...props} />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handle Theme Transitions
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Smooth theme transitions */
|
||||||
|
.w-md-editor,
|
||||||
|
.w-md-editor-toolbar,
|
||||||
|
.w-md-editor-content,
|
||||||
|
.w-md-editor-preview,
|
||||||
|
.wmde-markdown,
|
||||||
|
.wmde-markdown * {
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Syntax Highlighting
|
||||||
|
|
||||||
|
Integrate with shadcn/ui color scheme:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import MDEditor from '@uiw/react-md-editor'
|
||||||
|
|
||||||
|
export function MarkdownEditor({ value, onChange }) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-color-mode={theme === 'dark' ? 'dark' : 'light'}>
|
||||||
|
<MDEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
previewOptions={{
|
||||||
|
components: {
|
||||||
|
code: ({ node, inline, className, children, ...props }) => {
|
||||||
|
return inline ? (
|
||||||
|
<code className="bg-muted text-primary px-1.5 py-0.5 rounded text-sm" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Theme Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
|
import { MarkdownEditor } from './MarkdownEditor'
|
||||||
|
|
||||||
|
describe('MarkdownEditor Theme', () => {
|
||||||
|
it('applies light theme', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="light">
|
||||||
|
<MarkdownEditor value="Test" onChange={() => {}} />
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('textbox').closest('[data-color-mode]')
|
||||||
|
expect(wrapper).toHaveAttribute('data-color-mode', 'light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies dark theme', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark">
|
||||||
|
<MarkdownEditor value="Test" onChange={() => {}} />
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('textbox').closest('[data-color-mode]')
|
||||||
|
expect(wrapper).toHaveAttribute('data-color-mode', 'dark')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Issue:** Theme not applying immediately
|
||||||
|
**Solution:** Ensure editor is mounted after theme is resolved
|
||||||
|
|
||||||
|
**Issue:** Flash of wrong theme
|
||||||
|
**Solution:** Add loading state while theme resolves
|
||||||
|
|
||||||
|
**Issue:** CSS variables not working
|
||||||
|
**Solution:** Verify CSS is imported in correct order (globals.css before editor CSS)
|
||||||
|
|
||||||
|
**Issue:** Dark mode colors not matching shadcn/ui
|
||||||
|
**Solution:** Use HSL values from CSS variables, not hardcoded colors
|
||||||
Reference in New Issue
Block a user