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:
Jason Woltje
2026-01-29 16:57:57 -06:00
parent 4881d0698f
commit 287a0e2556
6 changed files with 839 additions and 12 deletions

View File

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

View File

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

View 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)
![Alt text](https://example.com/image.png)
```
#### 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.

View 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 = "![Alt text](https://example.com/image.png)";
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 = "![Image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)";
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![Image](image.png)';
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>");
});
});
});

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

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