From 287a0e2556d01faff8c29fef35ddc3e9c4cb73dd Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 16:57:57 -0600 Subject: [PATCH] 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 --- apps/api/package.json | 6 + apps/api/src/knowledge/knowledge.service.ts | 18 +- apps/api/src/knowledge/utils/README.md | 121 ++++++ apps/api/src/knowledge/utils/markdown.spec.ts | 351 ++++++++++++++++++ apps/api/src/knowledge/utils/markdown.ts | 222 +++++++++++ pnpm-lock.yaml | 133 +++++++ 6 files changed, 839 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/knowledge/utils/README.md create mode 100644 apps/api/src/knowledge/utils/markdown.spec.ts create mode 100644 apps/api/src/knowledge/utils/markdown.ts diff --git a/apps/api/package.json b/apps/api/package.json index bef908f..56a2245 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/knowledge/knowledge.service.ts b/apps/api/src/knowledge/knowledge.service.ts index ea3560c..10b420d 100644 --- a/apps/api/src/knowledge/knowledge.service.ts +++ b/apps/api/src/knowledge/knowledge.service.ts @@ -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 diff --git a/apps/api/src/knowledge/utils/README.md b/apps/api/src/knowledge/utils/README.md new file mode 100644 index 0000000..b1a1886 --- /dev/null +++ b/apps/api/src/knowledge/utils/README.md @@ -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:

Hello World

+ +// 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 `\n\n**Safe** content'; + const html = await renderMarkdown(markdown); + + expect(html).not.toContain("'; + const html = renderMarkdownSync(markdown); + + expect(html).not.toContain("'; + const html = await renderMarkdown(markdown); + + expect(html).not.toContain(""); + }); + }); + + 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(" { + 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(""); + expect(html).toContain(""); + }); + }); +}); diff --git a/apps/api/src/knowledge/utils/markdown.ts b/apps/api/src/knowledge/utils/markdown.ts new file mode 100644 index 0000000..55203c4 --- /dev/null +++ b/apps/api/src/knowledge/utils/markdown.ts @@ -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
+ 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 { + 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 { + const html = await renderMarkdown(markdown); + return sanitizeHtml(html, { + allowedTags: [], + allowedAttributes: {}, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62a9e90..3553c9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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