From 0a6ebf6ba8ff0f141b531b15bcf96fd8c0d235f3 Mon Sep 17 00:00:00 2001 From: dawn <90008@gaze.systems> Date: Wed, 20 May 2026 13:56:03 +0300 Subject: [PATCH] syntax highlighting --- bun.lock | 3 + package-lock.json | 25 ++++ package.json | 1 + src/components/repo.tsx | 163 ++++++++++++++++++++++++-- src/index.css | 254 +++++++++++++++++++++++++++++++++++----- src/pages/repo/code.tsx | 10 +- 6 files changed, 415 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 46443bf..4c485ce 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "dompurify": "^3.4.3", "fflate": "^0.8.2", + "highlight.js": "^11.11.1", "lucide-solid": "^1.16.0", "marked": "^18.0.3", "solid-js": "^1.9.12", @@ -208,6 +209,8 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], diff --git a/package-lock.json b/package-lock.json index 3cda079..96d2cd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,11 @@ "name": "app", "version": "0.0.0", "dependencies": { + "@atcute/atproto": "^4.0.0", "@atcute/client": "^5.0.0", "@atcute/identity": "^2.0.0", + "@atcute/identity-resolver": "^2.0.0", + "@atcute/microcosm": "^2.0.0", "@atcute/oauth-browser-client": "^4.0.0", "@atcute/tangled": "^2.0.1", "@solidjs/router": "^0.16.1", @@ -17,6 +20,7 @@ "clsx": "^2.1.1", "dompurify": "^3.4.3", "fflate": "^0.8.2", + "highlight.js": "^11.11.1", "lucide-solid": "^1.16.0", "marked": "^18.0.3", "solid-js": "^1.9.12" @@ -93,6 +97,18 @@ "esm-env": "^1.2.2" } }, + "node_modules/@atcute/microcosm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@atcute/microcosm/-/microcosm-2.0.0.tgz", + "integrity": "sha512-G5m9ctgVOsa3QSKYLbDMjufBSsGB8Wloht28xj9cmEL/R6w4LWglu5scqSFa6h9NojnwN37h62b7wWzTd10icQ==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.0" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, "node_modules/@atcute/multibase": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@atcute/multibase/-/multibase-1.2.0.tgz", @@ -1245,6 +1261,15 @@ "node": ">=6.9.0" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-entities": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", diff --git a/package.json b/package.json index bba4293..30303eb 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "clsx": "^2.1.1", "dompurify": "^3.4.3", "fflate": "^0.8.2", + "highlight.js": "^11.11.1", "lucide-solid": "^1.16.0", "marked": "^18.0.3", "solid-js": "^1.9.12" diff --git a/src/components/repo.tsx b/src/components/repo.tsx index 99e0b10..b0ccb7f 100644 --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import DOMPurify from 'dompurify'; +import hljs from 'highlight.js/lib/common'; import { ChevronRight, Circle, @@ -481,8 +482,154 @@ const splitCodeLines = (value: string): string[] => { return normalized.length === 0 ? [''] : normalized.split('\n'); }; -export const CodeView: Component<{ text: string; id?: string; wrap?: boolean; maxHeight?: string; diff?: boolean }> = (props) => { +const escapeHtml = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const languageAliases: Record = { + cjs: 'javascript', + conf: 'ini', + cts: 'typescript', + cxx: 'cpp', + dockerfile: 'bash', + env: 'ini', + h: 'c', + hpp: 'cpp', + htm: 'xml', + html: 'xml', + js: 'javascript', + jsonc: 'json', + jsx: 'javascript', + ksh: 'bash', + m: 'objectivec', + markdown: 'markdown', + md: 'markdown', + mjs: 'javascript', + mm: 'objectivec', + mts: 'typescript', + patch: 'diff', + pl: 'perl', + pm: 'perl', + pyw: 'python', + rake: 'ruby', + rs: 'rust', + sh: 'bash', + svg: 'xml', + toml: 'ini', + ts: 'typescript', + tsx: 'typescript', + txt: 'plaintext', + yml: 'yaml', + zsh: 'bash', +}; + +const filenameLanguages: Record = { + dockerfile: 'bash', + gemfile: 'ruby', + makefile: 'makefile', + rakefile: 'ruby', +}; + +const languageFromPath = (path?: string) => { + if (!path) return undefined; + + const filename = path.split('/').pop()?.toLowerCase() ?? ''; + if (filenameLanguages[filename]) return filenameLanguages[filename]; + + const extension = filename.includes('.') ? filename.split('.').pop() : filename; + if (!extension) return undefined; + + return languageAliases[extension] ?? extension; +}; + +const resolveHighlightLanguage = (language?: string, path?: string) => { + const candidate = (language ?? languageFromPath(path))?.toLowerCase(); + if (!candidate || candidate === 'plaintext' || candidate === 'text') return undefined; + return hljs.getLanguage(candidate) ? candidate : undefined; +}; + +const closeOpenSpans = (count: number) => ''.repeat(count); + +const splitHighlightedHtmlLines = (html: string): string[] => { + const lineParts = html.split('\n'); + const activeTags: string[] = []; + + return lineParts.map((linePart) => { + const prefix = activeTags.join(''); + + for (const tag of linePart.match(/<\/?span\b[^>]*>/g) ?? []) { + if (tag.startsWith(' { + const normalized = text.endsWith('\n') ? text.slice(0, -1) : text; + const fallback = () => splitCodeLines(normalized).map(escapeHtml); + + if (!language) return fallback(); + + try { + const highlighted = hljs.highlight(normalized, { language, ignoreIllegals: true }).value; + return splitHighlightedHtmlLines(highlighted); + } catch { + return fallback(); + } +}; + +const diffMetadataLine = (line: string) => + line.startsWith('diff --git ') || + line.startsWith('index ') || + line.startsWith('---') || + line.startsWith('+++') || + line.startsWith('@@') || + line.startsWith('Binary files ') || + line.startsWith('new file mode ') || + line.startsWith('deleted file mode ') || + line.startsWith('old mode ') || + line.startsWith('new mode ') || + line.startsWith('similarity index ') || + line.startsWith('dissimilarity index ') || + line.startsWith('rename from ') || + line.startsWith('rename to '); + +const highlightDiffLine = (line: string, language?: string) => { + if (diffMetadataLine(line)) return escapeHtml(line); + + const marker = line[0]; + const isChangedLine = marker === '+' || marker === '-' || marker === ' '; + if (!isChangedLine) return escapeHtml(line); + + const content = line.slice(1); + const highlighted = highlightCodeLines(content, language)[0] ?? escapeHtml(content); + return `${escapeHtml(marker)}${highlighted}`; +}; + +export const CodeView: Component<{ + text: string; + id?: string; + wrap?: boolean; + maxHeight?: string; + diff?: boolean; + path?: string; + language?: string; +}> = (props) => { const lines = createMemo(() => splitCodeLines(props.text)); + const highlightedLines = createMemo(() => { + const language = resolveHighlightLanguage(props.language, props.path); + if (props.diff) return lines().map((line) => highlightDiffLine(line, language)); + return highlightCodeLines(props.text, language); + }); const lineClass = (line: string) => props.diff ? clsx( @@ -504,7 +651,7 @@ export const CodeView: Component<{ text: string; id?: string; wrap?: boolean; ma {index() + 1} - {line || ' '} + )} @@ -588,7 +735,7 @@ export const DiffView: Component<{ patch: string; maxHeight?: string; wrap?: boo
- + {files().length} changed file{files().length === 1 ? '' : 's'}
@@ -619,10 +766,10 @@ export const DiffView: Component<{ patch: string; maxHeight?: string; wrap?: boo
- {file.path} + {file.path}
- + )} @@ -646,10 +793,10 @@ export const DiffView: Component<{ patch: string; maxHeight?: string; wrap?: boo
- {file.path} + {file.path}
- + )} @@ -876,7 +1023,7 @@ export const PullDiffView: Component<{ patch: string; roundLabel: string; histor
...
- + )} diff --git a/src/index.css b/src/index.css index 92134ad..bf11307 100644 --- a/src/index.css +++ b/src/index.css @@ -1063,7 +1063,7 @@ details[open] > .untangled-language-bar { .untangled-blob-file-info button { border: 0; background: transparent; - color: rgb(243 244 246); + color: inherit; padding: 0; text-decoration: none; } @@ -1103,7 +1103,7 @@ details[open] > .untangled-language-bar { justify-content: space-between; flex-wrap: wrap; gap: 1rem; - border-bottom: 1px solid rgb(55 65 81); + border-bottom: 1px solid rgb(229 231 235); margin-bottom: 0.75rem; padding: 0 0 0.5rem; font-size: 0.875rem; @@ -1114,9 +1114,9 @@ details[open] > .untangled-language-bar { align-items: center; width: fit-content; border-radius: 0.25rem; - background: rgb(17 24 39); + background: rgb(243 244 246); padding: 0.25rem 0.5rem; - color: rgb(209 213 219); + color: rgb(55 65 81); font-family: IBMPlexMono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; font-size: 0.75rem; @@ -1135,12 +1135,26 @@ details[open] > .untangled-language-bar { } .untangled-code-view { + --untangled-code-fg: #4c4f69; + --untangled-code-muted: #8c8fa1; + --untangled-code-keyword: #8839ef; + --untangled-code-constant: #fe640b; + --untangled-code-declaration: #d20f39; + --untangled-code-namespace: #179299; + --untangled-code-name: #1e66f5; + --untangled-code-builtin: #04a5e5; + --untangled-code-class: #df8e1d; + --untangled-code-variable: #dc8a78; + --untangled-code-string: #40a02b; + --untangled-code-comment: #9ca0b0; + --untangled-code-operator: #04a5e5; display: table; min-width: 100%; font-family: IBMPlexMono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; font-size: 0.875rem; line-height: 1.55; white-space: pre; + color: var(--untangled-code-fg); } .untangled-code-view-wrap { @@ -1148,6 +1162,24 @@ details[open] > .untangled-language-bar { overflow-wrap: anywhere; } +@media (prefers-color-scheme: dark) { + .untangled-code-view { + --untangled-code-fg: #cad3f5; + --untangled-code-muted: #8087a2; + --untangled-code-keyword: #c6a0f6; + --untangled-code-constant: #f5a97f; + --untangled-code-declaration: #ed8796; + --untangled-code-namespace: #8bd5ca; + --untangled-code-name: #8aadf4; + --untangled-code-builtin: #91d7e3; + --untangled-code-class: #eed49f; + --untangled-code-variable: #f4dbd6; + --untangled-code-string: #a6da95; + --untangled-code-comment: #6e738d; + --untangled-code-operator: #91d7e3; + } +} + .untangled-code-line { display: table-row; } @@ -1160,7 +1192,7 @@ details[open] > .untangled-language-bar { .untangled-code-line-number { width: 1%; padding: 0 0.75rem 0 0.5rem; - color: rgb(148 163 184); + color: var(--untangled-code-muted); text-align: right; text-decoration: none; user-select: none; @@ -1172,25 +1204,135 @@ details[open] > .untangled-language-bar { .untangled-code-line-content { padding-right: 1.5rem; - color: rgb(203 213 225); + color: var(--untangled-code-fg); +} + +.untangled-code-diff-op { + display: inline-block; + min-width: 1ch; + user-select: none; +} + +.untangled-code-line-content .hljs-keyword, +.untangled-code-line-content .hljs-selector-tag, +.untangled-code-line-content .hljs-name, +.untangled-code-line-content .hljs-tag { + color: var(--untangled-code-keyword); +} + +.untangled-code-line-content .hljs-literal, +.untangled-code-line-content .hljs-number, +.untangled-code-line-content .hljs-symbol, +.untangled-code-line-content .hljs-bullet { + color: var(--untangled-code-constant); +} + +.untangled-code-line-content .hljs-built_in, +.untangled-code-line-content .hljs-builtin-name, +.untangled-code-line-content .hljs-type { + color: var(--untangled-code-builtin); +} + +.untangled-code-line-content .hljs-title, +.untangled-code-line-content .hljs-title.function_, +.untangled-code-line-content .hljs-function .hljs-title, +.untangled-code-line-content .hljs-selector-id { + color: var(--untangled-code-name); +} + +.untangled-code-line-content .hljs-title.class_, +.untangled-code-line-content .hljs-class .hljs-title, +.untangled-code-line-content .hljs-section, +.untangled-code-line-content .hljs-selector-class { + color: var(--untangled-code-class); +} + +.untangled-code-line-content .hljs-variable, +.untangled-code-line-content .hljs-template-variable, +.untangled-code-line-content .hljs-attr, +.untangled-code-line-content .hljs-attribute, +.untangled-code-line-content .hljs-property, +.untangled-code-line-content .hljs-params { + color: var(--untangled-code-variable); +} + +.untangled-code-line-content .hljs-string, +.untangled-code-line-content .hljs-regexp, +.untangled-code-line-content .hljs-template-tag, +.untangled-code-line-content .hljs-template-variable { + color: var(--untangled-code-string); +} + +.untangled-code-line-content .hljs-comment, +.untangled-code-line-content .hljs-quote { + color: var(--untangled-code-comment); + font-style: italic; +} + +.untangled-code-line-content .hljs-doctag, +.untangled-code-line-content .hljs-meta { + color: var(--untangled-code-comment); + font-style: italic; +} + +.untangled-code-line-content .hljs-operator, +.untangled-code-line-content .hljs-subst, +.untangled-code-line-content .hljs-link { + color: var(--untangled-code-operator); +} + +.untangled-code-line-content .hljs-deletion { + color: var(--untangled-code-declaration); +} + +.untangled-code-line-content .hljs-addition { + color: var(--untangled-code-string); +} + +.untangled-code-line-content .hljs-emphasis { + font-style: italic; +} + +.untangled-code-line-content .hljs-strong { + font-weight: 700; } .untangled-code-line-add .untangled-code-line-number, .untangled-code-line-add .untangled-code-line-content { - background: rgb(20 83 45 / 0.35); - color: rgb(187 247 208); + background: rgb(220 252 231); + color: rgb(21 128 61); } .untangled-code-line-del .untangled-code-line-number, .untangled-code-line-del .untangled-code-line-content { - background: rgb(127 29 29 / 0.35); - color: rgb(254 202 202); + background: rgb(254 226 226); + color: rgb(185 28 28); } .untangled-code-line-hunk .untangled-code-line-number, .untangled-code-line-hunk .untangled-code-line-content { - background: rgb(30 58 138 / 0.3); - color: rgb(191 219 254); + background: rgb(219 234 254); + color: rgb(30 64 175); +} + +@media (prefers-color-scheme: dark) { + .untangled-code-line-add .untangled-code-line-number, + .untangled-code-line-add .untangled-code-line-content { + background: rgb(20 83 45 / 0.35); + color: rgb(187 247 208); + } + + .untangled-code-line-del .untangled-code-line-number, + .untangled-code-line-del .untangled-code-line-content { + background: rgb(127 29 29 / 0.35); + color: rgb(254 202 202); + } + + .untangled-code-line-hunk .untangled-code-line-number, + .untangled-code-line-hunk .untangled-code-line-content { + background: rgb(30 58 138 / 0.3); + color: rgb(191 219 254); + } } .untangled-diff-view { @@ -1201,9 +1343,9 @@ details[open] > .untangled-language-bar { .untangled-diff-panel { overflow: hidden; - border: 1px solid rgb(55 65 81); + border: 1px solid rgb(229 231 235); border-radius: 0.25rem; - background: rgb(31 41 55); + background: rgb(255 255 255); } .untangled-diff-toolbar { @@ -1211,8 +1353,8 @@ details[open] > .untangled-language-bar { align-items: center; justify-content: space-between; gap: 1rem; - background: rgb(55 65 81 / 0.5); - border-bottom: 1px solid rgb(55 65 81); + background: rgb(241 245 249); + border-bottom: 1px solid rgb(229 231 235); padding: 0.5rem 0.75rem; } @@ -1222,23 +1364,23 @@ details[open] > .untangled-language-bar { align-items: center; gap: 0.375rem; min-height: 2rem; - border: 1px solid rgb(55 65 81); + border: 1px solid rgb(209 213 219); border-radius: 0.25rem; - background: rgb(17 24 39 / 0.35); + background: rgb(255 255 255); padding: 0.25rem 0.625rem; font-size: 0.875rem; - color: rgb(229 231 235); + color: rgb(31 41 55); } .untangled-diff-tool-button:hover, .untangled-diff-mode-button:not(:disabled):hover { - background: rgb(55 65 81 / 0.65); + background: rgb(249 250 251); } .untangled-diff-mode-group { display: inline-flex; align-items: center; - border: 1px solid rgb(55 65 81); + border: 1px solid rgb(209 213 219); border-radius: 0.25rem; overflow: hidden; } @@ -1249,11 +1391,11 @@ details[open] > .untangled-language-bar { } .untangled-diff-mode-group .untangled-diff-mode-button + .untangled-diff-mode-button { - border-left: 1px solid rgb(55 65 81); + border-left: 1px solid rgb(209 213 219); } .untangled-diff-mode-button-active { - background: rgb(55 65 81); + background: rgb(243 244 246); } .untangled-diff-mode-button:disabled { @@ -1270,9 +1412,9 @@ details[open] > .untangled-language-bar { .untangled-diff-file { overflow: hidden; - border: 1px solid rgb(55 65 81); + border: 1px solid rgb(229 231 235); border-radius: 0.25rem; - background: rgb(31 41 55); + background: rgb(255 255 255); } .untangled-diff-file-header { @@ -1282,8 +1424,8 @@ details[open] > .untangled-language-bar { gap: 1rem; padding: 0.625rem 0.875rem; cursor: pointer; - background: rgb(17 24 39 / 0.35); - border-bottom: 1px solid rgb(55 65 81); + background: rgb(255 255 255); + border-bottom: 1px solid rgb(229 231 235); list-style: none; } @@ -1316,7 +1458,7 @@ details[open] > .untangled-language-bar { gap: 0.5rem; padding: 0.5rem 0.25rem; font-size: 0.875rem; - color: rgb(209 213 219); + color: rgb(55 65 81); } .untangled-diff-show-more-summary::-webkit-details-marker { @@ -1356,6 +1498,53 @@ details[open] > .untangled-language-bar { margin-top: 0.5rem; } +@media (prefers-color-scheme: dark) { + .untangled-diff-panel { + border-color: rgb(55 65 81); + background: rgb(31 41 55); + } + + .untangled-diff-toolbar { + border-bottom-color: rgb(55 65 81); + background: rgb(55 65 81 / 0.5); + } + + .untangled-diff-tool-button, + .untangled-diff-mode-button { + border-color: rgb(55 65 81); + background: rgb(17 24 39 / 0.35); + color: rgb(229 231 235); + } + + .untangled-diff-tool-button:hover, + .untangled-diff-mode-button:not(:disabled):hover { + background: rgb(55 65 81 / 0.65); + } + + .untangled-diff-mode-group, + .untangled-diff-mode-group .untangled-diff-mode-button + .untangled-diff-mode-button { + border-color: rgb(55 65 81); + } + + .untangled-diff-mode-button-active { + background: rgb(55 65 81); + } + + .untangled-diff-file { + border-color: rgb(55 65 81); + background: rgb(31 41 55); + } + + .untangled-diff-file-header { + border-bottom-color: rgb(55 65 81); + background: rgb(17 24 39 / 0.35); + } + + .untangled-diff-show-more-summary { + color: rgb(209 213 219); + } +} + .z-60 { z-index: 60; } @@ -1406,6 +1595,15 @@ details[open] > .untangled-language-bar { .untangled-blob-card { background: rgb(31 41 55); } + + .untangled-blob-commit-panel { + border-bottom-color: rgb(55 65 81); + } + + .untangled-blob-commit-link { + background: rgb(17 24 39); + color: rgb(209 213 219); + } } @media (max-width: 767px) { diff --git a/src/pages/repo/code.tsx b/src/pages/repo/code.tsx index bcd2718..b7b4c01 100644 --- a/src/pages/repo/code.tsx +++ b/src/pages/repo/code.tsx @@ -927,12 +927,12 @@ export const BlobPage: Component = () => { > {(did) => } - + {commit().author?.name || 'unknown author'} - {commit().message.trim().split('\n')[0]} - · - {formatRelativeTime(commit().when)} + {commit().message.trim().split('\n')[0]} + · + {formatRelativeTime(commit().when)}
{ - +

-- 2.51.2