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",
|
"better-auth": "^1.4.17",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
|
"marked-highlight": "^2.2.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
"slugify": "^1.6.6"
|
"slugify": "^1.6.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -49,7 +53,9 @@
|
|||||||
"@nestjs/testing": "^11.1.12",
|
"@nestjs/testing": "^11.1.12",
|
||||||
"@swc/core": "^1.10.18",
|
"@swc/core": "^1.10.18",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/highlight.js": "^10.1.0",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { EntryStatus } from "@prisma/client";
|
import { EntryStatus } from "@prisma/client";
|
||||||
import { marked } from "marked";
|
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import type { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
import type { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
||||||
@@ -12,20 +11,15 @@ import type {
|
|||||||
KnowledgeEntryWithTags,
|
KnowledgeEntryWithTags,
|
||||||
PaginatedEntries,
|
PaginatedEntries,
|
||||||
} from "./entities/knowledge-entry.entity";
|
} from "./entities/knowledge-entry.entity";
|
||||||
|
import { renderMarkdown } from "./utils/markdown";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for managing knowledge entries
|
* Service for managing knowledge entries
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KnowledgeService {
|
export class KnowledgeService {
|
||||||
constructor(private readonly prisma: PrismaService) {
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
// Configure marked for security and consistency
|
|
||||||
marked.setOptions({
|
|
||||||
gfm: true, // GitHub Flavored Markdown
|
|
||||||
breaks: false,
|
|
||||||
pedantic: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all entries for a workspace (paginated and filterable)
|
* Get all entries for a workspace (paginated and filterable)
|
||||||
@@ -175,8 +169,8 @@ export class KnowledgeService {
|
|||||||
const baseSlug = this.generateSlug(createDto.title);
|
const baseSlug = this.generateSlug(createDto.title);
|
||||||
const slug = await this.ensureUniqueSlug(workspaceId, baseSlug);
|
const slug = await this.ensureUniqueSlug(workspaceId, baseSlug);
|
||||||
|
|
||||||
// Render markdown to HTML
|
// Render markdown to HTML with sanitization
|
||||||
const contentHtml = await marked.parse(createDto.content);
|
const contentHtml = await renderMarkdown(createDto.content);
|
||||||
|
|
||||||
// Use transaction to ensure atomicity
|
// Use transaction to ensure atomicity
|
||||||
const result = await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -299,7 +293,7 @@ export class KnowledgeService {
|
|||||||
// Render markdown if content is updated
|
// Render markdown if content is updated
|
||||||
let contentHtml = existing.contentHtml;
|
let contentHtml = existing.contentHtml;
|
||||||
if (updateDto.content) {
|
if (updateDto.content) {
|
||||||
contentHtml = await marked.parse(updateDto.content);
|
contentHtml = await renderMarkdown(updateDto.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build update data object conditionally
|
// 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:
|
class-validator:
|
||||||
specifier: ^0.14.3
|
specifier: ^0.14.3
|
||||||
version: 0.14.3
|
version: 0.14.3
|
||||||
|
highlight.js:
|
||||||
|
specifier: ^11.11.1
|
||||||
|
version: 11.11.1
|
||||||
marked:
|
marked:
|
||||||
specifier: ^17.0.1
|
specifier: ^17.0.1
|
||||||
version: 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:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
sanitize-html:
|
||||||
|
specifier: ^2.17.0
|
||||||
|
version: 2.17.0
|
||||||
slugify:
|
slugify:
|
||||||
specifier: ^1.6.6
|
specifier: ^1.6.6
|
||||||
version: 1.6.6
|
version: 1.6.6
|
||||||
@@ -102,9 +114,15 @@ importers:
|
|||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
|
'@types/highlight.js':
|
||||||
|
specifier: ^10.1.0
|
||||||
|
version: 10.1.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.13.4
|
specifier: ^22.13.4
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
|
'@types/sanitize-html':
|
||||||
|
specifier: ^2.16.0
|
||||||
|
version: 2.16.0
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^4.0.18
|
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))
|
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':
|
'@types/express@5.0.6':
|
||||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
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':
|
'@types/http-errors@2.0.5':
|
||||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||||
|
|
||||||
@@ -1621,6 +1643,9 @@ packages:
|
|||||||
'@types/react@19.2.10':
|
'@types/react@19.2.10':
|
||||||
resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==}
|
resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==}
|
||||||
|
|
||||||
|
'@types/sanitize-html@2.16.0':
|
||||||
|
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
|
||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||||
|
|
||||||
@@ -2333,6 +2358,19 @@ packages:
|
|||||||
dom-accessibility-api@0.6.3:
|
dom-accessibility-api@0.6.3:
|
||||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
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:
|
dotenv@16.6.1:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2467,6 +2505,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
|
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
entities@4.5.0:
|
||||||
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
entities@6.0.1:
|
entities@6.0.1:
|
||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
@@ -2731,6 +2773,9 @@ packages:
|
|||||||
github-from-package@0.0.0:
|
github-from-package@0.0.0:
|
||||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||||
|
|
||||||
|
github-slugger@2.0.0:
|
||||||
|
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
||||||
|
|
||||||
glob-parent@6.0.2:
|
glob-parent@6.0.2:
|
||||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -2769,6 +2814,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
html-encoding-sniffer@4.0.0:
|
||||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2776,6 +2825,9 @@ packages:
|
|||||||
html-escaper@2.0.2:
|
html-escaper@2.0.2:
|
||||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||||
|
|
||||||
|
htmlparser2@8.0.2:
|
||||||
|
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2858,6 +2910,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
||||||
engines: {node: '>=8'}
|
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:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
@@ -3055,6 +3111,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
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:
|
marked@17.0.1:
|
||||||
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
@@ -3270,6 +3336,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
parse-srcset@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||||
|
|
||||||
parse5@7.3.0:
|
parse5@7.3.0:
|
||||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||||
|
|
||||||
@@ -3572,6 +3641,9 @@ packages:
|
|||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
sanitize-html@2.17.0:
|
||||||
|
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
@@ -5536,6 +5608,10 @@ snapshots:
|
|||||||
'@types/express-serve-static-core': 5.1.1
|
'@types/express-serve-static-core': 5.1.1
|
||||||
'@types/serve-static': 2.2.0
|
'@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/http-errors@2.0.5': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -5573,6 +5649,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/sanitize-html@2.16.0':
|
||||||
|
dependencies:
|
||||||
|
htmlparser2: 8.0.2
|
||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.7
|
'@types/node': 22.19.7
|
||||||
@@ -6359,6 +6439,24 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
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@16.6.1: {}
|
||||||
|
|
||||||
dotenv@17.2.3: {}
|
dotenv@17.2.3: {}
|
||||||
@@ -6406,6 +6504,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.0
|
tapable: 2.3.0
|
||||||
|
|
||||||
|
entities@4.5.0: {}
|
||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
@@ -6735,6 +6835,8 @@ snapshots:
|
|||||||
|
|
||||||
github-from-package@0.0.0: {}
|
github-from-package@0.0.0: {}
|
||||||
|
|
||||||
|
github-slugger@2.0.0: {}
|
||||||
|
|
||||||
glob-parent@6.0.2:
|
glob-parent@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -6770,12 +6872,21 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
highlight.js@11.11.1: {}
|
||||||
|
|
||||||
html-encoding-sniffer@4.0.0:
|
html-encoding-sniffer@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-encoding: 3.1.1
|
whatwg-encoding: 3.1.1
|
||||||
|
|
||||||
html-escaper@2.0.2: {}
|
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:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -6845,6 +6956,8 @@ snapshots:
|
|||||||
|
|
||||||
is-interactive@1.0.0: {}
|
is-interactive@1.0.0: {}
|
||||||
|
|
||||||
|
is-plain-object@5.0.0: {}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
is-promise@4.0.0: {}
|
is-promise@4.0.0: {}
|
||||||
@@ -7037,6 +7150,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
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: {}
|
marked@17.0.1: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
@@ -7234,6 +7356,8 @@ snapshots:
|
|||||||
json-parse-even-better-errors: 2.3.1
|
json-parse-even-better-errors: 2.3.1
|
||||||
lines-and-columns: 1.2.4
|
lines-and-columns: 1.2.4
|
||||||
|
|
||||||
|
parse-srcset@1.0.2: {}
|
||||||
|
|
||||||
parse5@7.3.0:
|
parse5@7.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 6.0.1
|
entities: 6.0.1
|
||||||
@@ -7558,6 +7682,15 @@ snapshots:
|
|||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
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:
|
saxes@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user