feat(knowledge): add markdown rendering (KNOW-004)
- Install marked, marked-highlight, marked-gfm-heading-id, sanitize-html - Create markdown utility with GFM support (tables, task lists, strikethrough) - Add code syntax highlighting with highlight.js - Implement XSS sanitization for security - Update knowledge service to use markdown renderer - Add comprehensive test suite (34 tests, all passing) - Generate IDs for headers for deep linking - Cache rendered HTML in database for performance
This commit is contained in:
@@ -36,9 +36,13 @@
|
||||
"better-auth": "^1.4.17",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.1",
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"slugify": "^1.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -49,7 +53,9 @@
|
||||
"@nestjs/testing": "^11.1.12",
|
||||
"@swc/core": "^1.10.18",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/highlight.js": "^10.1.0",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"express": "^5.2.1",
|
||||
"prisma": "^6.19.2",
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ConflictException,
|
||||
} from "@nestjs/common";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
import { marked } from "marked";
|
||||
import slugify from "slugify";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
||||
@@ -12,20 +11,15 @@ import type {
|
||||
KnowledgeEntryWithTags,
|
||||
PaginatedEntries,
|
||||
} from "./entities/knowledge-entry.entity";
|
||||
import { renderMarkdown } from "./utils/markdown";
|
||||
|
||||
/**
|
||||
* Service for managing knowledge entries
|
||||
*/
|
||||
@Injectable()
|
||||
export class KnowledgeService {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
// Configure marked for security and consistency
|
||||
marked.setOptions({
|
||||
gfm: true, // GitHub Flavored Markdown
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
});
|
||||
}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
|
||||
/**
|
||||
* Get all entries for a workspace (paginated and filterable)
|
||||
@@ -175,8 +169,8 @@ export class KnowledgeService {
|
||||
const baseSlug = this.generateSlug(createDto.title);
|
||||
const slug = await this.ensureUniqueSlug(workspaceId, baseSlug);
|
||||
|
||||
// Render markdown to HTML
|
||||
const contentHtml = await marked.parse(createDto.content);
|
||||
// Render markdown to HTML with sanitization
|
||||
const contentHtml = await renderMarkdown(createDto.content);
|
||||
|
||||
// Use transaction to ensure atomicity
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -299,7 +293,7 @@ export class KnowledgeService {
|
||||
// Render markdown if content is updated
|
||||
let contentHtml = existing.contentHtml;
|
||||
if (updateDto.content) {
|
||||
contentHtml = await marked.parse(updateDto.content);
|
||||
contentHtml = await renderMarkdown(updateDto.content);
|
||||
}
|
||||
|
||||
// Build update data object conditionally
|
||||
|
||||
121
apps/api/src/knowledge/utils/README.md
Normal file
121
apps/api/src/knowledge/utils/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Knowledge Module Utilities
|
||||
|
||||
## Markdown Rendering
|
||||
|
||||
### Overview
|
||||
|
||||
The `markdown.ts` utility provides secure markdown rendering with GFM (GitHub Flavored Markdown) support, syntax highlighting, and XSS protection.
|
||||
|
||||
### Features
|
||||
|
||||
- **GFM Support**: Tables, task lists, strikethrough, autolinks
|
||||
- **Syntax Highlighting**: Code blocks with language detection via highlight.js
|
||||
- **XSS Protection**: HTML sanitization using sanitize-html
|
||||
- **Header IDs**: Automatic ID generation for headers (for linking)
|
||||
- **Security**: Blocks dangerous HTML (scripts, iframes, event handlers)
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { renderMarkdown, markdownToPlainText } from './utils/markdown';
|
||||
|
||||
// Render markdown to HTML (async)
|
||||
const html = await renderMarkdown('# Hello **World**');
|
||||
// Result: <h1 id="hello-world">Hello <strong>World</strong></h1>
|
||||
|
||||
// Extract plain text (for search indexing)
|
||||
const plainText = await markdownToPlainText('# Hello **World**');
|
||||
// Result: "Hello World"
|
||||
```
|
||||
|
||||
### Supported Markdown Features
|
||||
|
||||
#### Basic Formatting
|
||||
- **Bold**: `**text**` or `__text__`
|
||||
- *Italic*: `*text*` or `_text_`
|
||||
- ~~Strikethrough~~: `~~text~~`
|
||||
- `Inline code`: `` `code` ``
|
||||
|
||||
#### Headers
|
||||
```markdown
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
```
|
||||
|
||||
#### Lists
|
||||
```markdown
|
||||
- Unordered list
|
||||
- Nested item
|
||||
|
||||
1. Ordered list
|
||||
2. Another item
|
||||
```
|
||||
|
||||
#### Task Lists
|
||||
```markdown
|
||||
- [ ] Unchecked task
|
||||
- [x] Completed task
|
||||
```
|
||||
|
||||
#### Tables
|
||||
```markdown
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
```
|
||||
|
||||
#### Code Blocks
|
||||
````markdown
|
||||
```typescript
|
||||
const greeting: string = "Hello";
|
||||
console.log(greeting);
|
||||
```
|
||||
````
|
||||
|
||||
#### Links and Images
|
||||
```markdown
|
||||
[Link text](https://example.com)
|
||||

|
||||
```
|
||||
|
||||
#### Blockquotes
|
||||
```markdown
|
||||
> This is a quote
|
||||
> Multi-line quote
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
The renderer implements multiple layers of security:
|
||||
|
||||
1. **HTML Sanitization**: Only allows safe HTML tags and attributes
|
||||
2. **URL Validation**: Blocks `javascript:` and other dangerous protocols
|
||||
3. **External Links**: Automatically adds `target="_blank"` and `rel="noopener noreferrer"`
|
||||
4. **Task Lists**: Checkboxes are disabled to prevent interaction
|
||||
5. **No Event Handlers**: Blocks `onclick`, `onload`, etc.
|
||||
6. **No Dangerous Tags**: Blocks `<script>`, `<iframe>`, `<object>`, `<embed>`
|
||||
|
||||
### Testing
|
||||
|
||||
Comprehensive test suite covers:
|
||||
- Basic markdown rendering
|
||||
- GFM features (tables, task lists, strikethrough)
|
||||
- Code syntax highlighting
|
||||
- Security (XSS prevention)
|
||||
- Edge cases (unicode, long content, nested structures)
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
pnpm test --filter=@mosaic/api -- markdown.spec.ts
|
||||
```
|
||||
|
||||
### Integration
|
||||
|
||||
The markdown renderer is integrated into the Knowledge Entry service:
|
||||
|
||||
1. **On Create**: Renders `content` to `contentHtml`
|
||||
2. **On Update**: Re-renders if content changes
|
||||
3. **Caching**: HTML is stored in database for performance
|
||||
|
||||
See `knowledge.service.ts` for implementation details.
|
||||
351
apps/api/src/knowledge/utils/markdown.spec.ts
Normal file
351
apps/api/src/knowledge/utils/markdown.spec.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
renderMarkdown,
|
||||
renderMarkdownSync,
|
||||
markdownToPlainText,
|
||||
} from "./markdown";
|
||||
|
||||
describe("Markdown Rendering", () => {
|
||||
describe("renderMarkdown", () => {
|
||||
it("should render basic markdown to HTML", async () => {
|
||||
const markdown = "# Hello World\n\nThis is **bold** text.";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain("Hello World");
|
||||
expect(html).toContain("<strong>bold</strong>");
|
||||
});
|
||||
|
||||
it("should handle empty input", async () => {
|
||||
const html = await renderMarkdown("");
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("should handle null/undefined input", async () => {
|
||||
expect(await renderMarkdown(null as any)).toBe("");
|
||||
expect(await renderMarkdown(undefined as any)).toBe("");
|
||||
});
|
||||
|
||||
it("should sanitize potentially dangerous HTML", async () => {
|
||||
const markdown = '<script>alert("XSS")</script>\n\n**Safe** content';
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("alert");
|
||||
expect(html).toContain("<strong>Safe</strong>");
|
||||
});
|
||||
|
||||
it("should sanitize onclick handlers", async () => {
|
||||
const markdown = '[Click me](javascript:alert("XSS"))';
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).not.toContain("javascript:");
|
||||
expect(html).not.toContain("onclick");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GFM Features", () => {
|
||||
it("should render tables", async () => {
|
||||
const markdown = `
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| Cell 3 | Cell 4 |
|
||||
`.trim();
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain("<thead>");
|
||||
expect(html).toContain("<tbody>");
|
||||
expect(html).toContain("<th>Header 1</th>");
|
||||
expect(html).toContain("<td>Cell 1</td>");
|
||||
});
|
||||
|
||||
it("should render strikethrough text", async () => {
|
||||
const markdown = "This is ~~deleted~~ text";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<del>deleted</del>");
|
||||
});
|
||||
|
||||
it("should render task lists", async () => {
|
||||
const markdown = `
|
||||
- [ ] Unchecked task
|
||||
- [x] Checked task
|
||||
`.trim();
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<input');
|
||||
expect(html).toContain('type="checkbox"');
|
||||
expect(html).toContain('disabled="disabled"'); // Should be disabled for safety
|
||||
});
|
||||
|
||||
it("should render autolinks", async () => {
|
||||
const markdown = "Visit https://example.com for more info";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Code Highlighting", () => {
|
||||
it("should render inline code", async () => {
|
||||
const markdown = "Use the `console.log()` function";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<code>");
|
||||
expect(html).toContain("console.log()");
|
||||
});
|
||||
|
||||
it("should render code blocks with language", async () => {
|
||||
const markdown = `
|
||||
\`\`\`typescript
|
||||
const greeting: string = "Hello";
|
||||
console.log(greeting);
|
||||
\`\`\`
|
||||
`.trim();
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<pre>");
|
||||
expect(html).toContain("<code");
|
||||
expect(html).toContain("language-typescript");
|
||||
// Should have syntax highlighting
|
||||
expect(html.length).toBeGreaterThan(markdown.length);
|
||||
});
|
||||
|
||||
it("should render code blocks without language", async () => {
|
||||
const markdown = `
|
||||
\`\`\`
|
||||
plain text code
|
||||
\`\`\`
|
||||
`.trim();
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<pre>");
|
||||
expect(html).toContain("<code");
|
||||
expect(html).toContain("plain text code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Links and Images", () => {
|
||||
it("should render links with target=_blank for external URLs", async () => {
|
||||
const markdown = "[External Link](https://example.com)";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it("should render images", async () => {
|
||||
const markdown = "";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('src="https://example.com/image.png"');
|
||||
expect(html).toContain('alt="Alt text"');
|
||||
});
|
||||
|
||||
it("should allow data URIs for images", async () => {
|
||||
const markdown = "";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('src="data:image/png;base64');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Headers and IDs", () => {
|
||||
it("should generate IDs for headers", async () => {
|
||||
const markdown = "# My Header Title";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<h1');
|
||||
expect(html).toContain('id="');
|
||||
});
|
||||
|
||||
it("should render all header levels", async () => {
|
||||
const markdown = `
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
`.trim();
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain("<h2");
|
||||
expect(html).toContain("<h3");
|
||||
expect(html).toContain("<h4");
|
||||
expect(html).toContain("<h5");
|
||||
expect(html).toContain("<h6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lists", () => {
|
||||
it("should render unordered lists", async () => {
|
||||
const markdown = `
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Nested item
|
||||
`.trim();
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<ul>");
|
||||
expect(html).toContain("<li>Item 1</li>");
|
||||
expect(html).toContain("Nested item");
|
||||
});
|
||||
|
||||
it("should render ordered lists", async () => {
|
||||
const markdown = `
|
||||
1. First
|
||||
2. Second
|
||||
3. Third
|
||||
`.trim();
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<ol>");
|
||||
expect(html).toContain("<li>First</li>");
|
||||
expect(html).toContain("<li>Second</li>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quotes and Formatting", () => {
|
||||
it("should render blockquotes", async () => {
|
||||
const markdown = "> This is a quote\n> Multi-line quote";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<blockquote>");
|
||||
expect(html).toContain("This is a quote");
|
||||
});
|
||||
|
||||
it("should render emphasis and strong", async () => {
|
||||
const markdown = "*italic* **bold** ***bold italic***";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<em>italic</em>");
|
||||
expect(html).toContain("<strong>bold</strong>");
|
||||
});
|
||||
|
||||
it("should render horizontal rules", async () => {
|
||||
const markdown = "Content\n\n---\n\nMore content";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<hr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMarkdownSync", () => {
|
||||
it("should render markdown synchronously", () => {
|
||||
const markdown = "# Sync Test\n\n**Bold** text";
|
||||
const html = renderMarkdownSync(markdown);
|
||||
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain("Sync Test");
|
||||
expect(html).toContain("<strong>Bold</strong>");
|
||||
});
|
||||
|
||||
it("should handle empty input synchronously", () => {
|
||||
const html = renderMarkdownSync("");
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("should sanitize XSS synchronously", () => {
|
||||
const markdown = '<script>alert("XSS")</script>';
|
||||
const html = renderMarkdownSync(markdown);
|
||||
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("alert");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToPlainText", () => {
|
||||
it("should extract plain text from markdown", async () => {
|
||||
const markdown = "# Header\n\n**Bold** and *italic* text";
|
||||
const plainText = await markdownToPlainText(markdown);
|
||||
|
||||
expect(plainText).toContain("Header");
|
||||
expect(plainText).toContain("Bold");
|
||||
expect(plainText).toContain("italic");
|
||||
expect(plainText).not.toContain("<h1");
|
||||
expect(plainText).not.toContain("<strong>");
|
||||
expect(plainText).not.toContain("<em>");
|
||||
});
|
||||
|
||||
it("should strip all HTML tags", async () => {
|
||||
const markdown = '[Link](https://example.com)\n\n';
|
||||
const plainText = await markdownToPlainText(markdown);
|
||||
|
||||
expect(plainText).not.toContain("<a");
|
||||
expect(plainText).not.toContain("<img");
|
||||
expect(plainText).toContain("Link");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Tests", () => {
|
||||
it("should block iframe injection", async () => {
|
||||
const markdown = '<iframe src="https://evil.com"></iframe>';
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
it("should block object/embed tags", async () => {
|
||||
const markdown = '<object data="malware.swf"></object>';
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).not.toContain("<object");
|
||||
});
|
||||
|
||||
it("should block event handlers in markdown links", async () => {
|
||||
const markdown = '[Click](# "onclick=alert(1)")';
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).not.toContain("onclick");
|
||||
});
|
||||
|
||||
it("should sanitize SVG with script", async () => {
|
||||
const markdown = '<svg><script>alert("XSS")</script></svg>';
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).not.toContain("<svg");
|
||||
expect(html).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle very long content", async () => {
|
||||
const markdown = "# Test\n\n" + "A ".repeat(10000);
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html.length).toBeGreaterThan(0);
|
||||
expect(html).toContain("<h1");
|
||||
});
|
||||
|
||||
it("should handle unicode characters", async () => {
|
||||
const markdown = "# 你好 World 🌍\n\n**Émojis**: 😀 ✨ 🚀";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("你好");
|
||||
expect(html).toContain("🌍");
|
||||
expect(html).toContain("😀");
|
||||
});
|
||||
|
||||
it("should handle nested markdown correctly", async () => {
|
||||
const markdown = "**Bold with _italic_ inside**";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain("<strong>");
|
||||
expect(html).toContain("<em>");
|
||||
});
|
||||
});
|
||||
});
|
||||
222
apps/api/src/knowledge/utils/markdown.ts
Normal file
222
apps/api/src/knowledge/utils/markdown.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { marked } from "marked";
|
||||
import { gfmHeadingId } from "marked-gfm-heading-id";
|
||||
import { markedHighlight } from "marked-highlight";
|
||||
import hljs from "highlight.js";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
/**
|
||||
* Configure marked with GFM, syntax highlighting, and security features
|
||||
*/
|
||||
function configureMarked(): void {
|
||||
// Add GFM heading ID extension
|
||||
marked.use(gfmHeadingId());
|
||||
|
||||
// Add syntax highlighting extension
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
langPrefix: "hljs language-",
|
||||
highlight(code: string, lang: string): string {
|
||||
const language = hljs.getLanguage(lang) ? lang : "plaintext";
|
||||
return hljs.highlight(code, { language }).value;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Configure marked options with GFM extensions
|
||||
marked.use({
|
||||
gfm: true, // GitHub Flavored Markdown
|
||||
breaks: false, // Don't convert \n to <br>
|
||||
pedantic: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize configuration
|
||||
configureMarked();
|
||||
|
||||
/**
|
||||
* Sanitization options for HTML output
|
||||
* Allows safe HTML tags while preventing XSS attacks
|
||||
*/
|
||||
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
// Text formatting
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"em",
|
||||
"u",
|
||||
"s",
|
||||
"del",
|
||||
"mark",
|
||||
"small",
|
||||
"sub",
|
||||
"sup",
|
||||
// Headers
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
// Lists
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
// Links and media
|
||||
"a",
|
||||
"img",
|
||||
// Code
|
||||
"code",
|
||||
"pre",
|
||||
"kbd",
|
||||
"samp",
|
||||
"var",
|
||||
// Tables
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tfoot",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
// Quotes and citations
|
||||
"blockquote",
|
||||
"cite",
|
||||
"q",
|
||||
// Structure
|
||||
"div",
|
||||
"span",
|
||||
"hr",
|
||||
// Task lists (input checkboxes)
|
||||
"input",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "target", "rel"], // Removed title to prevent event handler injection
|
||||
img: ["src", "alt", "title", "width", "height"],
|
||||
code: ["class"], // For syntax highlighting
|
||||
pre: ["class"],
|
||||
h1: ["id"],
|
||||
h2: ["id"],
|
||||
h3: ["id"],
|
||||
h4: ["id"],
|
||||
h5: ["id"],
|
||||
h6: ["id"],
|
||||
td: ["align"],
|
||||
th: ["align"],
|
||||
input: ["type", "checked", "disabled"], // For task lists
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
allowedSchemesByTag: {
|
||||
img: ["http", "https", "data"],
|
||||
},
|
||||
allowedClasses: {
|
||||
code: ["hljs", "language-*"],
|
||||
pre: ["hljs"],
|
||||
},
|
||||
allowedIframeHostnames: [], // No iframes allowed
|
||||
// Enforce target="_blank" and rel="noopener noreferrer" for external links
|
||||
transformTags: {
|
||||
a: (tagName: string, attribs: sanitizeHtml.Attributes) => {
|
||||
const href = attribs.href;
|
||||
if (href && (href.startsWith("http://") || href.startsWith("https://"))) {
|
||||
return {
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
tagName,
|
||||
attribs,
|
||||
};
|
||||
},
|
||||
// Disable task list checkboxes (make them read-only)
|
||||
input: (tagName: string, attribs: sanitizeHtml.Attributes) => {
|
||||
if (attribs.type === "checkbox") {
|
||||
return {
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
disabled: "disabled",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
tagName,
|
||||
attribs,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render markdown content to sanitized HTML
|
||||
* Supports GFM (tables, task lists, strikethrough) and syntax highlighting
|
||||
*
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Sanitized HTML string
|
||||
* @throws Error if rendering fails
|
||||
*/
|
||||
export async function renderMarkdown(markdown: string): Promise<string> {
|
||||
if (!markdown || typeof markdown !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse markdown to HTML
|
||||
const rawHtml = await marked.parse(markdown);
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
const safeHtml = sanitizeHtml(rawHtml, SANITIZE_OPTIONS);
|
||||
|
||||
return safeHtml;
|
||||
} catch (error) {
|
||||
// Log error but don't expose internal details
|
||||
console.error("Markdown rendering error:", error);
|
||||
throw new Error("Failed to render markdown content");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown synchronously (for simple use cases)
|
||||
* Note: Use async version when possible
|
||||
*
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Sanitized HTML string
|
||||
*/
|
||||
export function renderMarkdownSync(markdown: string): string {
|
||||
if (!markdown || typeof markdown !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse markdown to HTML (sync version)
|
||||
const rawHtml = marked.parse(markdown) as string;
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
const safeHtml = sanitizeHtml(rawHtml, SANITIZE_OPTIONS);
|
||||
|
||||
return safeHtml;
|
||||
} catch (error) {
|
||||
console.error("Markdown rendering error:", error);
|
||||
throw new Error("Failed to render markdown content");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from rendered markdown (extract plain text)
|
||||
* Useful for generating summaries or search indexes
|
||||
*
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Plain text without HTML tags
|
||||
*/
|
||||
export async function markdownToPlainText(markdown: string): Promise<string> {
|
||||
const html = await renderMarkdown(markdown);
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
});
|
||||
}
|
||||
133
pnpm-lock.yaml
generated
133
pnpm-lock.yaml
generated
@@ -68,15 +68,27 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.14.3
|
||||
version: 0.14.3
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1
|
||||
marked-gfm-heading-id:
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(marked@17.0.1)
|
||||
marked-highlight:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3(marked@17.0.1)
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
sanitize-html:
|
||||
specifier: ^2.17.0
|
||||
version: 2.17.0
|
||||
slugify:
|
||||
specifier: ^1.6.6
|
||||
version: 1.6.6
|
||||
@@ -102,9 +114,15 @@ importers:
|
||||
'@types/express':
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.6
|
||||
'@types/highlight.js':
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
'@types/node':
|
||||
specifier: ^22.13.4
|
||||
version: 22.19.7
|
||||
'@types/sanitize-html':
|
||||
specifier: ^2.16.0
|
||||
version: 2.16.0
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(vitest@4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0))
|
||||
@@ -1587,6 +1605,10 @@ packages:
|
||||
'@types/express@5.0.6':
|
||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||
|
||||
'@types/highlight.js@10.1.0':
|
||||
resolution: {integrity: sha512-77hF2dGBsOgnvZll1vymYiNUtqJ8cJfXPD6GG/2M0aLRc29PkvB7Au6sIDjIEFcSICBhCh2+Pyq6WSRS7LUm6A==}
|
||||
deprecated: This is a stub types definition. highlight.js provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
@@ -1621,6 +1643,9 @@ packages:
|
||||
'@types/react@19.2.10':
|
||||
resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==}
|
||||
|
||||
'@types/sanitize-html@2.16.0':
|
||||
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||
|
||||
@@ -2333,6 +2358,19 @@ packages:
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
dotenv@16.6.1:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2467,6 +2505,10 @@ packages:
|
||||
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -2731,6 +2773,9 @@ packages:
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
github-slugger@2.0.0:
|
||||
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -2769,6 +2814,10 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
html-encoding-sniffer@4.0.0:
|
||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2776,6 +2825,9 @@ packages:
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2858,6 +2910,10 @@ packages:
|
||||
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-plain-object@5.0.0:
|
||||
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
@@ -3055,6 +3111,16 @@ packages:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
marked-gfm-heading-id@4.1.3:
|
||||
resolution: {integrity: sha512-aR0i63LmFbuxU/gAgrgz1Ir+8HK6zAIFXMlckeKHpV+qKbYaOP95L4Ux5Gi+sKmCZU5qnN2rdKpvpb7PnUBIWg==}
|
||||
peerDependencies:
|
||||
marked: '>=13 <18'
|
||||
|
||||
marked-highlight@2.2.3:
|
||||
resolution: {integrity: sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==}
|
||||
peerDependencies:
|
||||
marked: '>=4 <18'
|
||||
|
||||
marked@17.0.1:
|
||||
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -3270,6 +3336,9 @@ packages:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
parse-srcset@1.0.2:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
@@ -3572,6 +3641,9 @@ packages:
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sanitize-html@2.17.0:
|
||||
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
@@ -5536,6 +5608,10 @@ snapshots:
|
||||
'@types/express-serve-static-core': 5.1.1
|
||||
'@types/serve-static': 2.2.0
|
||||
|
||||
'@types/highlight.js@10.1.0':
|
||||
dependencies:
|
||||
highlight.js: 11.11.1
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -5573,6 +5649,10 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/sanitize-html@2.16.0':
|
||||
dependencies:
|
||||
htmlparser2: 8.0.2
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.7
|
||||
@@ -6359,6 +6439,24 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
@@ -6406,6 +6504,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
@@ -6735,6 +6835,8 @@ snapshots:
|
||||
|
||||
github-from-package@0.0.0: {}
|
||||
|
||||
github-slugger@2.0.0: {}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -6770,12 +6872,21 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
html-encoding-sniffer@4.0.0:
|
||||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -6845,6 +6956,8 @@ snapshots:
|
||||
|
||||
is-interactive@1.0.0: {}
|
||||
|
||||
is-plain-object@5.0.0: {}
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
@@ -7037,6 +7150,15 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
marked-gfm-heading-id@4.1.3(marked@17.0.1):
|
||||
dependencies:
|
||||
github-slugger: 2.0.0
|
||||
marked: 17.0.1
|
||||
|
||||
marked-highlight@2.2.3(marked@17.0.1):
|
||||
dependencies:
|
||||
marked: 17.0.1
|
||||
|
||||
marked@17.0.1: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
@@ -7234,6 +7356,8 @@ snapshots:
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
parse-srcset@1.0.2: {}
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
@@ -7558,6 +7682,15 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sanitize-html@2.17.0:
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
escape-string-regexp: 4.0.0
|
||||
htmlparser2: 8.0.2
|
||||
is-plain-object: 5.0.0
|
||||
parse-srcset: 1.0.2
|
||||
postcss: 8.5.6
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
Reference in New Issue
Block a user