feat(file-viewer): syntax highlighting for code files and chat code blocks
Nightly Build / build (push) Successful in 8s
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:
+6
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user