diff --git a/bun.lock b/bun.lock index 3f74829..819f89d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "dependencies": { "dompurify": "^3.3.3", + "highlight.js": "^11.11.1", "svelte": "^5.53.11", }, "devDependencies": { @@ -111,6 +112,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], diff --git a/package.json b/package.json index 0be7515..25b10d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "dompurify": "^3.3.3", + "highlight.js": "^11.11.1", "svelte": "^5.53.11" }, "devDependencies": { diff --git a/src/components/PlainTextWithCode.svelte b/src/components/PlainTextWithCode.svelte index 7ef40f6..855922e 100644 --- a/src/components/PlainTextWithCode.svelte +++ b/src/components/PlainTextWithCode.svelte @@ -1,10 +1,16 @@ {#each segments as segment} @@ -17,7 +23,13 @@ {:else if segment.kind == 'inlineCode'} {segment.text} {:else} -
{segment.text}
+ {@const result = highlightCodeBlock(segment.text, segment.language)} + + {#if result.kind == 'highlighted'} +
{@html sanitizeHTML(result.html)}
+ {:else} +
{result.text}
+ {/if} {/if} {/each} @@ -47,6 +59,41 @@ white-space: pre; } + pre code :global(.hljs-keyword), + pre code :global(.hljs-selector-tag), + pre code :global(.hljs-title.function_) { + color: #9d174d; + } + + pre code :global(.hljs-built_in), + pre code :global(.hljs-title.class_), + pre code :global(.hljs-type) { + color: #7c3aed; + } + + pre code :global(.hljs-string), + pre code :global(.hljs-attr), + pre code :global(.hljs-symbol) { + color: #047857; + } + + pre code :global(.hljs-number), + pre code :global(.hljs-literal) { + color: #b45309; + } + + pre code :global(.hljs-comment), + pre code :global(.hljs-quote) { + color: #6b7280; + font-style: italic; + } + + pre code :global(.hljs-meta), + pre code :global(.hljs-tag), + pre code :global(.hljs-name) { + color: #0369a1; + } + @media (prefers-color-scheme: dark) { code { background-color: rgba(255, 255, 255, 0.12); @@ -55,5 +102,39 @@ pre { background-color: rgba(255, 255, 255, 0.1); } + + pre code :global(.hljs-keyword), + pre code :global(.hljs-selector-tag), + pre code :global(.hljs-title.function_) { + color: #f472b6; + } + + pre code :global(.hljs-built_in), + pre code :global(.hljs-title.class_), + pre code :global(.hljs-type) { + color: #c4b5fd; + } + + pre code :global(.hljs-string), + pre code :global(.hljs-attr), + pre code :global(.hljs-symbol) { + color: #6ee7b7; + } + + pre code :global(.hljs-number), + pre code :global(.hljs-literal) { + color: #fbbf24; + } + + pre code :global(.hljs-comment), + pre code :global(.hljs-quote) { + color: #9ca3af; + } + + pre code :global(.hljs-meta), + pre code :global(.hljs-tag), + pre code :global(.hljs-name) { + color: #7dd3fc; + } } diff --git a/src/utils/code_highlighter.ts b/src/utils/code_highlighter.ts new file mode 100644 index 0000000..2f1d77e --- /dev/null +++ b/src/utils/code_highlighter.ts @@ -0,0 +1,82 @@ +import highlightJs from 'highlight.js/lib/core'; + +import bash from 'highlight.js/lib/languages/bash'; +import c from 'highlight.js/lib/languages/c'; +import clojure from 'highlight.js/lib/languages/clojure'; +import cpp from 'highlight.js/lib/languages/cpp'; +import csharp from 'highlight.js/lib/languages/csharp'; +import css from 'highlight.js/lib/languages/css'; +import dart from 'highlight.js/lib/languages/dart'; +import elixir from 'highlight.js/lib/languages/elixir'; +import go from 'highlight.js/lib/languages/go'; +import java from 'highlight.js/lib/languages/java'; +import javascript from 'highlight.js/lib/languages/javascript'; +import json from 'highlight.js/lib/languages/json'; +import kotlin from 'highlight.js/lib/languages/kotlin'; +import markdown from 'highlight.js/lib/languages/markdown'; +import nix from 'highlight.js/lib/languages/nix'; +import objectivec from 'highlight.js/lib/languages/objectivec'; +import ocaml from 'highlight.js/lib/languages/ocaml'; +import perl from 'highlight.js/lib/languages/perl'; +import php from 'highlight.js/lib/languages/php'; +import powershell from 'highlight.js/lib/languages/powershell'; +import python from 'highlight.js/lib/languages/python'; +import r from 'highlight.js/lib/languages/r'; +import ruby from 'highlight.js/lib/languages/ruby'; +import rust from 'highlight.js/lib/languages/rust'; +import sql from 'highlight.js/lib/languages/sql'; +import swift from 'highlight.js/lib/languages/swift'; +import typescript from 'highlight.js/lib/languages/typescript'; +import xml from 'highlight.js/lib/languages/xml'; +import yaml from 'highlight.js/lib/languages/yaml'; + +const languages = { + bash, c, clojure, cpp, csharp, css, dart, elixir, go, java, javascript, json, kotlin, markdown, nix, + objectivec, ocaml, perl, php, powershell, python, r, ruby, rust, sql, swift, typescript, xml, yaml +}; + +for (let [name, language] of Object.entries(languages)) { + highlightJs.registerLanguage(name, language); +} + +const languageAliases: Record = { + 'c#': 'csharp', + 'c++': 'cpp', + cplusplus: 'cpp', + cs: 'csharp', + csharp: 'csharp', + html: 'xml', + js: 'javascript', + md: 'markdown', + objc: 'objectivec', + py: 'python', + rs: 'rust', + sh: 'bash', + shell: 'bash', + ts: 'typescript', + yml: 'yaml' +}; + +type HighlightedCode = + | { kind: 'highlighted', html: string } + | { kind: 'plain', text: string }; + +export function highlightCodeBlock(code: string, language: string | undefined): HighlightedCode { + let normalizedLanguage = normalizeLanguage(language); + + if (!normalizedLanguage || !highlightJs.getLanguage(normalizedLanguage)) { + return { kind: 'plain', text: code }; + } + + return { + kind: 'highlighted', + html: highlightJs.highlight(code, { language: normalizedLanguage, ignoreIllegals: true }).value + }; +} + +function normalizeLanguage(language: string | undefined): string | undefined { + if (!language) { return undefined; } + + let lowercase = language.toLowerCase(); + return languageAliases[lowercase] ?? lowercase; +} diff --git a/src/utils/code_markup_parser.ts b/src/utils/code_markup_parser.ts index 2a1edeb..f003105 100644 --- a/src/utils/code_markup_parser.ts +++ b/src/utils/code_markup_parser.ts @@ -1,6 +1,6 @@ import type { ByteSlice, Facet } from '../../lib/rich_text_lite.js'; -type Segment = { +export type Segment = { kind: 'text' | 'inlineCode' | 'codeBlock'; text: string; start: number; @@ -16,7 +16,7 @@ type Line = { } const encoder = new TextEncoder(); -const codeBlockOpen = /^```([A-Za-z0-9\+\.\-]+)?$/; +const codeBlockOpen = /^```([A-Za-z0-9\+\.\-#]+)?$/; const codeBlockClose = /^```$/; export class CodeMarkupParser {