feat(file-viewer): syntax highlighting for code files and chat code blocks
Nightly Build / build (push) Successful in 8s

Vendor highlight.js (core + python, javascript, typescript, json, yaml,
bash) and highlight the file viewer's text kind (computed once per load)
plus fenced blocks in renderMarkdown. Colors come from new --syn-* CSS
variables aliased to the existing palette, so dark mode follows.
This commit is contained in:
Daniele
2026-08-11 10:41:50 +01:00
parent 4d1b1e63be
commit 402c9ffe50
16 changed files with 240 additions and 3 deletions
+6 -2
View File
@@ -2,6 +2,7 @@ import { LitElement } from 'lit';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { t } from './i18n.js';
import { highlightMarkdownCodeBlocks } from './highlight.js';
marked.use({ breaks: true, gfm: true });
@@ -36,13 +37,16 @@ export function renderMarkdown(text) {
// `target` is not in DOMPurify's default attribute allow-list, so the
// external-link hook above needs it whitelisted here to survive sanitization.
const html = DOMPurify.sanitize(marked.parse(text ?? ''), { ADD_ATTR: ['target'] });
// Syntax-highlight fenced code blocks whose language we support (hljs escapes
// its own output; unknown fences stay plain).
const highlighted = highlightMarkdownCodeBlocks(html);
// Wrap fenced code blocks in .md-code-wrap so a copy button can float over
// them on hover. `<pre>` reaches this point only from a marked code block —
// a literal one in the source text is escaped by sanitize, so the string
// replace cannot wrap anything else.
const btn = `<button type="button" class="md-code-copy" title="${t('chat.copy_code')}"><i class="bi bi-clipboard"></i></button>`;
return html.replaceAll('<pre>', `<div class="md-code-wrap">${btn}<pre>`)
.replaceAll('</pre>', '</pre></div>');
return highlighted.replaceAll('<pre>', `<div class="md-code-wrap">${btn}<pre>`)
.replaceAll('</pre>', '</pre></div>');
}
// One delegated listener serves every copy button renderMarkdown has ever
+72
View File
@@ -0,0 +1,72 @@
import hljs from '../vendor/hljs/core.min.js';
import DOMPurify from 'dompurify';
import python from '../vendor/hljs/languages/python.min.js';
import javascript from '../vendor/hljs/languages/javascript.min.js';
import typescript from '../vendor/hljs/languages/typescript.min.js';
import json from '../vendor/hljs/languages/json.min.js';
import yaml from '../vendor/hljs/languages/yaml.min.js';
import bash from '../vendor/hljs/languages/bash.min.js';
hljs.registerLanguage('python', python);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('json', json);
hljs.registerLanguage('yaml', yaml);
hljs.registerLanguage('bash', bash);
// File extension → hljs language. Extensions not listed here render as plain
// text (never auto-detected: guessing wrong is worse than no colors).
const LANG_FOR_EXT = {
py: 'python',
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
ts: 'typescript', tsx: 'typescript',
json: 'json',
yml: 'yaml', yaml: 'yaml',
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
};
// Markdown fence info string → hljs language (common aliases included).
const LANG_FOR_FENCE = {
py: 'python', python: 'python',
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', javascript: 'javascript',
ts: 'typescript', tsx: 'typescript', typescript: 'typescript',
json: 'json',
yml: 'yaml', yaml: 'yaml',
sh: 'bash', bash: 'bash', shell: 'bash', zsh: 'bash',
};
export function codeLangForExt(ext) {
return LANG_FOR_EXT[ext] ?? null;
}
/**
* Highlight `code` as `lang`, returning sanitized HTML (hljs escapes the input
* itself; DOMPurify is belt-and-braces). Returns null when the language is not
* registered, so callers can fall back to plain text.
*/
export function highlightCode(code, lang) {
if (!lang || !hljs.getLanguage(lang)) return null;
return DOMPurify.sanitize(hljs.highlight(code, { language: lang }).value);
}
/**
* Post-process sanitized marked HTML: highlight every `<pre><code
* class="language-…">` whose fence maps to a registered language, in place.
* Unknown fences are left as the plain (already escaped) text marked emitted.
*/
export function highlightMarkdownCodeBlocks(htmlStr) {
if (!htmlStr.includes('language-')) return htmlStr;
const tpl = document.createElement('template');
tpl.innerHTML = htmlStr;
let changed = false;
for (const code of tpl.content.querySelectorAll('pre > code[class*="language-"]')) {
const fence = [...code.classList].find(c => c.startsWith('language-'))?.slice(9);
const lang = LANG_FOR_FENCE[fence];
const out = lang && highlightCode(code.textContent ?? '', lang);
if (!out) continue;
code.innerHTML = out;
code.classList.add('hljs');
changed = true;
}
return changed ? tpl.innerHTML : htmlStr;
}