Implement syntax highlighting for markdown code blocks using Prism.js

Co-authored-by: alexmickelson <43245625+alexmickelson@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-07-18 15:48:05 +00:00
parent 6db2c5b66b
commit 5359c7a4be
5 changed files with 233 additions and 0 deletions

17
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@trpc/react-query": "11.4.3", "@trpc/react-query": "11.4.3",
"@trpc/server": "11.4.3", "@trpc/server": "11.4.3",
"@trpc/tanstack-react-query": "^11.4.3", "@trpc/tanstack-react-query": "^11.4.3",
"@types/prismjs": "^1.26.5",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript-eslint/parser": "^8.37.0", "@typescript-eslint/parser": "^8.37.0",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -27,6 +28,7 @@
"marked-katex-extension": "^5.1.5", "marked-katex-extension": "^5.1.5",
"mcp-handler": "^1.0.0", "mcp-handler": "^1.0.0",
"next": "^15.3.5", "next": "^15.3.5",
"prismjs": "^1.30.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
@@ -2861,6 +2863,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/prismjs": {
"version": "1.26.5",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.8", "version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
@@ -8009,6 +8017,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

@@ -23,6 +23,7 @@
"@trpc/react-query": "11.4.3", "@trpc/react-query": "11.4.3",
"@trpc/server": "11.4.3", "@trpc/server": "11.4.3",
"@trpc/tanstack-react-query": "^11.4.3", "@trpc/tanstack-react-query": "^11.4.3",
"@types/prismjs": "^1.26.5",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript-eslint/parser": "^8.37.0", "@typescript-eslint/parser": "^8.37.0",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -32,6 +33,7 @@
"marked-katex-extension": "^5.1.5", "marked-katex-extension": "^5.1.5",
"mcp-handler": "^1.0.0", "mcp-handler": "^1.0.0",
"next": "^15.3.5", "next": "^15.3.5",
"prismjs": "^1.30.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",

View File

@@ -76,6 +76,83 @@ code {
@apply text-wrap; @apply text-wrap;
} }
/* Syntax highlighting styles for code blocks */
pre code {
@apply block bg-transparent p-0 m-0 rounded-none;
}
pre {
@apply bg-gray-900 p-4 rounded-lg overflow-x-auto font-mono text-sm leading-relaxed;
}
/* Prism.js syntax highlighting styles */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
@apply text-gray-500;
}
.token.punctuation {
@apply text-gray-400;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
@apply text-red-400;
}
.token.boolean,
.token.number {
@apply text-orange-400;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
@apply text-green-400;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string,
.token.variable {
@apply text-yellow-400;
}
.token.atrule,
.token.attr-value,
.token.function,
.token.class-name {
@apply text-blue-400;
}
.token.keyword {
@apply text-purple-400;
}
.token.regex,
.token.important {
@apply text-orange-400;
}
.token.important,
.token.bold {
@apply font-bold;
}
.token.italic {
@apply italic;
}
p { p {
@apply mb-3; @apply mb-3;
} }

View File

@@ -3,6 +3,25 @@ import { marked, MarkedExtension } from "marked";
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { LocalCourseSettings } from "@/models/local/localCourseSettings"; import { LocalCourseSettings } from "@/models/local/localCourseSettings";
import markedKatex from "marked-katex-extension"; import markedKatex from "marked-katex-extension";
import Prism from "prismjs";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-python";
import "prismjs/components/prism-java";
import "prismjs/components/prism-c";
import "prismjs/components/prism-cpp";
import "prismjs/components/prism-csharp";
import "prismjs/components/prism-go";
import "prismjs/components/prism-rust";
import "prismjs/components/prism-sql";
import "prismjs/components/prism-json";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-markdown";
import "prismjs/components/prism-css";
import "prismjs/components/prism-markup";
const mermaidExtension = { const mermaidExtension = {
name: "mermaid", name: "mermaid",
@@ -38,6 +57,32 @@ marked.use(
marked.use({ extensions: [mermaidExtension] }); marked.use({ extensions: [mermaidExtension] });
// Configure syntax highlighting for code blocks
marked.use({
renderer: {
code(token: any) {
const code = token.text;
const language = token.lang || '';
if (language && Prism.languages[language]) {
try {
const highlighted = Prism.highlight(code, Prism.languages[language], language);
return `<pre class="language-${language}"><code class="language-${language}">${highlighted}</code></pre>`;
} catch (error) {
console.warn(`Syntax highlighting failed for language: ${language}`, error);
// Fallback to plain code block with escaped HTML
const escapedCode = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<pre><code>${escapedCode}</code></pre>`;
}
}
// Fallback to plain code block with escaped HTML
const escapedCode = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<pre><code>${escapedCode}</code></pre>`;
}
}
});
export function extractImageSources(htmlString: string) { export function extractImageSources(htmlString: string) {
const srcUrls = []; const srcUrls = [];
const regex = /<img[^>]+src=["']?([^"'>]+)["']?/g; const regex = /<img[^>]+src=["']?([^"'>]+)["']?/g;

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { markdownToHtmlNoImages } from "../htmlMarkdownUtils";
describe("Syntax Highlighting Tests", () => {
it("should highlight JavaScript code blocks", () => {
const markdown = `\`\`\`javascript
function hello() {
console.log("Hello, World!");
}
\`\`\``;
const html = markdownToHtmlNoImages(markdown);
// Check that the code block has proper structure
expect(html).toContain('<pre class="language-javascript">');
expect(html).toContain('<code class="language-javascript">');
expect(html).toContain('function');
expect(html).toContain('console'); // Just check for console, not console.log
// Check that syntax highlighting tokens are present
expect(html).toContain('<span class="token');
});
it("should highlight TypeScript code blocks", () => {
const markdown = `\`\`\`typescript
interface User {
name: string;
age: number;
}
\`\`\``;
const html = markdownToHtmlNoImages(markdown);
expect(html).toContain('<pre class="language-typescript">');
expect(html).toContain('<code class="language-typescript">');
expect(html).toContain('interface');
expect(html).toContain('string');
expect(html).toContain('number');
expect(html).toContain('<span class="token');
});
it("should highlight Python code blocks", () => {
const markdown = `\`\`\`python
def hello_world():
print("Hello, World!")
\`\`\``;
const html = markdownToHtmlNoImages(markdown);
expect(html).toContain('<pre class="language-python">');
expect(html).toContain('<code class="language-python">');
expect(html).toContain('def');
expect(html).toContain('print');
expect(html).toContain('<span class="token');
});
it("should handle unknown languages gracefully", () => {
const markdown = `\`\`\`unknownlang
some code here
\`\`\``;
const html = markdownToHtmlNoImages(markdown);
// Should fallback to plain code block
expect(html).toContain('<pre><code>');
expect(html).toContain('some code here');
expect(html).not.toContain('<span class="token');
});
it("should handle code blocks without language specification", () => {
const markdown = `\`\`\`
plain code block
\`\`\``;
const html = markdownToHtmlNoImages(markdown);
// Should fallback to plain code block
expect(html).toContain('<pre><code>');
expect(html).toContain('plain code block');
expect(html).not.toContain('<span class="token');
});
it("should preserve inline code formatting", () => {
const markdown = `Here is some \`inline code\` in a paragraph.`;
const html = markdownToHtmlNoImages(markdown);
// Inline code should not be affected by syntax highlighting
expect(html).toContain('<code>inline code</code>');
expect(html).not.toContain('<pre>');
});
});