forked from mirror/crumbs-original
Closes #90. Code blocks and inline code inherited the typography plugin's defaults, which shrank them to ~86% of an already-monospace body and painted them --bg-surface — the same colour as the default note card — so a snippet was only recognisable by being smaller. Long lines also grew a horizontal scrollbar inside card previews. - New --code-bg surface (one step below the page in light mode, page-level ink in dark) with a subtle border, and code keeps the surrounding text size. Decorative backtick pseudo-elements dropped now that the chip carries the signal. - Card previews clip wide code instead of scrolling; editor and share page keep horizontal scroll. - Syntax highlighting via one shared lowlight instance feeding both the TipTap editor (extension-code-block-lowlight) and the markdown-it renderer, so a fence looks identical in the editor, on cards and on the share page. Only highlight.js "common" grammars are bundled. - No auto-detection: highlight.js misreads note-sized snippets too often (JavaScript as CSS, SQL as VB.NET). Unlabelled fences stay plain; the language comes from the fence info string or a toolbar picker shown while the cursor is in a code block. - Six --hl-* token colours per theme; a unit test parses app.css and asserts AA contrast against --code-bg and mutual distinguishability. - Sanitizer now allows only language-* / hljs-* classes on code and span. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
109 lines
3.9 KiB
TypeScript
109 lines
3.9 KiB
TypeScript
import { common, createLowlight } from 'lowlight';
|
|
|
|
/**
|
|
* One lowlight instance shared by the TipTap editor (live decorations) and the
|
|
* markdown-it renderer (card previews, share page), so a fence is coloured
|
|
* identically wherever it shows up.
|
|
*
|
|
* There is deliberately no language auto-detection. highlight.js guesses
|
|
* wrongly on note-sized snippets often enough (JavaScript read as CSS, SQL as
|
|
* VB.NET) that a wrong palette is worse than none. An unlabelled fence renders
|
|
* as plain text on the code surface; the language comes from the fence info
|
|
* string (```ts) or the toolbar picker.
|
|
*/
|
|
export const lowlight = createLowlight(common);
|
|
|
|
type HighlightRoot = ReturnType<typeof lowlight.highlight>;
|
|
type HighlightNode = HighlightRoot['children'][number];
|
|
|
|
/** Languages offered by the editor's picker. Aliases (js, py, sh…) still highlight. */
|
|
export const CODE_LANGUAGES: readonly { id: string; label: string }[] = [
|
|
{ id: 'bash', label: 'Bash' },
|
|
{ id: 'c', label: 'C' },
|
|
{ id: 'cpp', label: 'C++' },
|
|
{ id: 'csharp', label: 'C#' },
|
|
{ id: 'css', label: 'CSS' },
|
|
{ id: 'diff', label: 'Diff' },
|
|
{ id: 'go', label: 'Go' },
|
|
{ id: 'graphql', label: 'GraphQL' },
|
|
{ id: 'xml', label: 'HTML / XML' },
|
|
{ id: 'ini', label: 'INI / TOML' },
|
|
{ id: 'java', label: 'Java' },
|
|
{ id: 'javascript', label: 'JavaScript' },
|
|
{ id: 'json', label: 'JSON' },
|
|
{ id: 'kotlin', label: 'Kotlin' },
|
|
{ id: 'less', label: 'Less' },
|
|
{ id: 'lua', label: 'Lua' },
|
|
{ id: 'makefile', label: 'Makefile' },
|
|
{ id: 'markdown', label: 'Markdown' },
|
|
{ id: 'objectivec', label: 'Objective-C' },
|
|
{ id: 'perl', label: 'Perl' },
|
|
{ id: 'php', label: 'PHP' },
|
|
{ id: 'python', label: 'Python' },
|
|
{ id: 'r', label: 'R' },
|
|
{ id: 'ruby', label: 'Ruby' },
|
|
{ id: 'rust', label: 'Rust' },
|
|
{ id: 'scss', label: 'SCSS' },
|
|
{ id: 'shell', label: 'Shell session' },
|
|
{ id: 'sql', label: 'SQL' },
|
|
{ id: 'swift', label: 'Swift' },
|
|
{ id: 'typescript', label: 'TypeScript' },
|
|
{ id: 'vbnet', label: 'VB.NET' },
|
|
{ id: 'wasm', label: 'WebAssembly' },
|
|
{ id: 'yaml', label: 'YAML' }
|
|
];
|
|
|
|
export function isKnownLanguage(lang: string | null | undefined): lang is string {
|
|
return typeof lang === 'string' && lang.length > 0 && lowlight.registered(lang);
|
|
}
|
|
|
|
/**
|
|
* Highlighted HTML (spans with `hljs-*` classes, text escaped) for a code
|
|
* block, or `null` when the language is absent or unknown so the caller can
|
|
* fall back to plain escaped text.
|
|
*/
|
|
export function highlightCode(code: string, lang: string | null | undefined): string | null {
|
|
if (!isKnownLanguage(lang)) return null;
|
|
return toHtml(lowlight.highlight(lang, code).children);
|
|
}
|
|
|
|
function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// lowlight emits only `element` (span with a className list) and `text`
|
|
// nodes, so a full hast serializer would be dead weight here.
|
|
function toHtml(nodes: HighlightNode[]): string {
|
|
let html = '';
|
|
for (const node of nodes) {
|
|
if (node.type === 'text') {
|
|
html += escapeHtml(node.value);
|
|
} else if (node.type === 'element') {
|
|
const classes = node.properties?.className;
|
|
const classAttr = Array.isArray(classes) && classes.length ? ` class="${classes.join(' ')}"` : '';
|
|
html += `<span${classAttr}>${toHtml(node.children as HighlightNode[])}</span>`;
|
|
}
|
|
}
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* What `@tiptap/extension-code-block-lowlight` needs from a lowlight instance,
|
|
* with auto-detection stubbed out so an unlabelled block stays plain (see the
|
|
* note at the top of this file).
|
|
*/
|
|
export const editorLowlight = {
|
|
highlight: (language: string, value: string) => lowlight.highlight(language, value),
|
|
highlightAuto: (value: string): HighlightRoot => ({
|
|
type: 'root',
|
|
children: [{ type: 'text', value }],
|
|
data: { language: undefined, relevance: 0 }
|
|
}),
|
|
listLanguages: () => lowlight.listLanguages(),
|
|
registered: (aliasOrLanguage: string) => lowlight.registered(aliasOrLanguage)
|
|
};
|