From b78e42272e8b95936d23d7b8bfa5bbb07e96d4d0 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 31 Mar 2026 10:30:44 +0200 Subject: [PATCH] feat: add markdown syntax highlighting in raw editor Wire up @codemirror/lang-markdown with a custom HighlightStyle to highlight headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code in the raw CodeMirror editor. The custom frontmatter plugin is kept for YAML highlighting; its heading decoration is removed in favour of the language-based parser. Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + pnpm-lock.yaml | 3 ++ src/extensions/frontmatterHighlight.test.ts | 16 ------- src/extensions/frontmatterHighlight.ts | 16 ------- src/extensions/markdownHighlight.test.ts | 51 +++++++++++++++++++++ src/extensions/markdownHighlight.ts | 28 +++++++++++ src/hooks/useCodeMirror.ts | 2 + 7 files changed, 85 insertions(+), 32 deletions(-) create mode 100644 src/extensions/markdownHighlight.test.ts create mode 100644 src/extensions/markdownHighlight.ts diff --git a/package.json b/package.json index c138249a..c0a2f9a3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lezer/highlight": "^1.2.3", "@mantine/core": "^8.3.14", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 430220db..25ec5ebf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.4) + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 '@mantine/core': specifier: ^8.3.14 version: 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) diff --git a/src/extensions/frontmatterHighlight.test.ts b/src/extensions/frontmatterHighlight.test.ts index 24e100fc..33c66828 100644 --- a/src/extensions/frontmatterHighlight.test.ts +++ b/src/extensions/frontmatterHighlight.test.ts @@ -39,22 +39,6 @@ describe('frontmatterHighlightPlugin', () => { parent.remove() }) - it('applies heading class to markdown headings', () => { - const { view, parent } = createView('# Heading One\n\nSome text\n\n## Heading Two') - const headings = parent.querySelectorAll('.cm-md-heading') - expect(headings.length).toBeGreaterThanOrEqual(2) - view.destroy() - parent.remove() - }) - - it('does not apply heading class to plain text', () => { - const { view, parent } = createView('Just some plain text\nAnother line') - const headings = parent.querySelectorAll('.cm-md-heading') - expect(headings.length).toBe(0) - view.destroy() - parent.remove() - }) - it('handles content without frontmatter', () => { const { view, parent } = createView('# Just a heading\n\nNo frontmatter here.') const delimiters = parent.querySelectorAll('.cm-frontmatter-delimiter') diff --git a/src/extensions/frontmatterHighlight.ts b/src/extensions/frontmatterHighlight.ts index 1af79e1c..9cf0aab6 100644 --- a/src/extensions/frontmatterHighlight.ts +++ b/src/extensions/frontmatterHighlight.ts @@ -4,7 +4,6 @@ import { RangeSetBuilder } from '@codemirror/state' const frontmatterDelimiter = Decoration.mark({ class: 'cm-frontmatter-delimiter' }) const frontmatterKey = Decoration.mark({ class: 'cm-frontmatter-key' }) const frontmatterValue = Decoration.mark({ class: 'cm-frontmatter-value' }) -const markdownHeading = Decoration.mark({ class: 'cm-md-heading' }) function findFrontmatterEnd(doc: { lines: number; line(n: number): { text: string } }): number { if (doc.lines < 1) return -1 @@ -27,8 +26,6 @@ function buildDecorations(view: EditorView): DecorationSet { if (i <= fmEnd) { decorateFrontmatterLine(builder, line.from, text, i === 1 || i === fmEnd) - } else { - decorateMarkdownLine(builder, line.from, text) } } @@ -60,16 +57,6 @@ function decorateFrontmatterLine( } } -function decorateMarkdownLine( - builder: RangeSetBuilder, - from: number, - text: string, -): void { - if (/^#{1,6}\s/.test(text)) { - builder.add(from, from + text.length, markdownHeading) - } -} - export const frontmatterHighlightPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet @@ -89,12 +76,9 @@ export function frontmatterHighlightTheme() { const keyColor = '#c9383e' const valueColor = '#2a7e4f' const delimiterColor = '#c9383e' - const headingColor = '#0969da' - return EditorView.baseTheme({ '.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' }, '.cm-frontmatter-key': { color: keyColor }, '.cm-frontmatter-value': { color: valueColor }, - '.cm-md-heading': { color: headingColor, fontWeight: '600' }, }) } diff --git a/src/extensions/markdownHighlight.test.ts b/src/extensions/markdownHighlight.test.ts new file mode 100644 index 00000000..60fc4e35 --- /dev/null +++ b/src/extensions/markdownHighlight.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { markdownLanguage } from './markdownHighlight' + +function createView(doc: string) { + const parent = document.createElement('div') + document.body.appendChild(parent) + const state = EditorState.create({ + doc, + extensions: [markdownLanguage()], + }) + const view = new EditorView({ state, parent }) + return { view, parent } +} + +describe('markdownLanguage', () => { + it('returns a valid extension', () => { + const ext = markdownLanguage() + expect(ext).toBeDefined() + expect(Array.isArray(ext)).toBe(true) + }) + + it('creates an editor without errors', () => { + const { view, parent } = createView('# Heading\n\n**bold** and *italic*\n\n- list item') + expect(view.state.doc.toString()).toContain('# Heading') + view.destroy() + parent.remove() + }) + + it('parses markdown content with mixed syntax', () => { + const doc = [ + '# Title', + '', + 'Some **bold** and *italic* text.', + '', + '- item one', + '- item two', + '', + '[a link](http://example.com)', + '', + '> a blockquote', + '', + '`inline code`', + ].join('\n') + const { view, parent } = createView(doc) + expect(view.state.doc.lines).toBe(12) + view.destroy() + parent.remove() + }) +}) diff --git a/src/extensions/markdownHighlight.ts b/src/extensions/markdownHighlight.ts new file mode 100644 index 00000000..81fcf781 --- /dev/null +++ b/src/extensions/markdownHighlight.ts @@ -0,0 +1,28 @@ +import { markdown } from '@codemirror/lang-markdown' +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { tags } from '@lezer/highlight' +import type { Extension } from '@codemirror/state' + +const markdownHighlightStyle = HighlightStyle.define([ + { tag: tags.heading1, color: '#0969da', fontWeight: '700', fontSize: '1.4em' }, + { tag: tags.heading2, color: '#0969da', fontWeight: '700', fontSize: '1.25em' }, + { tag: tags.heading3, color: '#0969da', fontWeight: '600', fontSize: '1.1em' }, + { tag: tags.heading4, color: '#0969da', fontWeight: '600' }, + { tag: tags.heading5, color: '#0969da', fontWeight: '600' }, + { tag: tags.heading6, color: '#0969da', fontWeight: '600' }, + { tag: tags.strong, fontWeight: '700' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strikethrough, textDecoration: 'line-through' }, + { tag: tags.link, color: '#0969da', textDecoration: 'underline' }, + { tag: tags.url, color: '#0969da' }, + { tag: tags.monospace, color: '#c9383e', backgroundColor: 'rgba(175,184,193,0.15)', borderRadius: '3px' }, + { tag: tags.list, color: '#c9383e' }, + { tag: tags.quote, color: '#636c76', fontStyle: 'italic' }, + { tag: tags.separator, color: '#636c76' }, + { tag: tags.processingInstruction, color: '#c9383e', fontWeight: '600' }, + { tag: tags.contentSeparator, color: '#c9383e', fontWeight: '600' }, +]) + +export function markdownLanguage(): Extension { + return [markdown(), syntaxHighlighting(markdownHighlightStyle)] +} diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index fd84d007..717c1062 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -3,6 +3,7 @@ import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirro import { EditorState } from '@codemirror/state' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight' +import { markdownLanguage } from '../extensions/markdownHighlight' import { zoomCursorFix } from '../extensions/zoomCursorFix' const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' @@ -109,6 +110,7 @@ export function useCodeMirror( keymap.of([...defaultKeymap, ...historyKeymap]), buildSaveKeymap(callbacksRef), buildBaseTheme(), + markdownLanguage(), frontmatterHighlightTheme(), frontmatterHighlightPlugin, zoomCursorFix(),