Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:46:28 +08:00
commit 280d358cb6
8 changed files with 2394 additions and 0 deletions

View 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
View 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
View 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": []
}
}

File diff suppressed because it is too large Load Diff

View 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 }

View 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 }

View 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

View File

@@ -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