diff --git a/services/web/frontend/js/features/source-editor/languages/markdown/index.ts b/services/web/frontend/js/features/source-editor/languages/markdown/index.ts index c4d987aea1..d80c7e3bfd 100644 --- a/services/web/frontend/js/features/source-editor/languages/markdown/index.ts +++ b/services/web/frontend/js/features/source-editor/languages/markdown/index.ts @@ -1,7 +1,8 @@ import { markdown as markdownLanguage } from '@codemirror/lang-markdown' +import { yamlFrontmatter } from '@codemirror/lang-yaml' import { shortcuts } from './shortcuts' import { languages } from '../index' -import { Strikethrough, Frontmatter } from '@lezer/markdown' +import { Strikethrough } from '@lezer/markdown' import { HighlightStyle, LanguageSupport, @@ -10,23 +11,25 @@ import { import { tags } from '@lezer/highlight' import { markdownDocumentOutline } from './document-outline' import { quartoCompletions } from './complete' -import { yamlFrontmatterHighlighting } from './yaml-frontmatter' export const markdown = () => { - const { language, support } = markdownLanguage({ + const mdLS = markdownLanguage({ codeLanguages: languages, - // Frontmatter teaches the Markdown grammar to recognise the leading ---/--- - // block as a YAML header rather than mis-parsing it as Setext headings. - extensions: [Strikethrough, Frontmatter], + extensions: [Strikethrough], }) - return new LanguageSupport(language, [ - support, + // yamlFrontmatter wraps the Markdown language so that the leading ---/--- + // block is parsed as real YAML (with full Lezer YAML highlighting) and the + // rest of the document is still parsed as Markdown. It replaces our + // previous regex-based ViewPlugin and the manual Frontmatter extension. + const quartoLS = yamlFrontmatter({ content: mdLS }) + + return new LanguageSupport(quartoLS.language, [ + quartoLS.support, shortcuts(), syntaxHighlighting(markdownHighlightStyle), markdownDocumentOutline, - language.data.of({ autocomplete: quartoCompletions }), - yamlFrontmatterHighlighting, + mdLS.language.data.of({ autocomplete: quartoCompletions }), ]) } diff --git a/services/web/frontend/js/features/source-editor/languages/markdown/yaml-frontmatter.ts b/services/web/frontend/js/features/source-editor/languages/markdown/yaml-frontmatter.ts deleted file mode 100644 index ea7f11f521..0000000000 --- a/services/web/frontend/js/features/source-editor/languages/markdown/yaml-frontmatter.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { RangeSetBuilder } from '@codemirror/state' -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate, -} from '@codemirror/view' - -// Matches YAML boolean/null literals -const YAML_BOOL_RE = /^(true|false|yes|no|on|off|null|~)$/i -// Matches plain integers and floats -const YAML_NUMBER_RE = /^-?(?:0x[\da-fA-F]+|0o[0-7]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/ - -function valueClass(value: string): string { - if (value.startsWith('"') || value.startsWith("'")) return 'tok-string' - if (YAML_BOOL_RE.test(value)) return 'tok-keyword' - if (YAML_NUMBER_RE.test(value)) return 'tok-number' - return 'tok-attributeValue' -} - -// Find the first ':' that acts as a YAML key-value separator — it must be -// followed by a space, tab, or end-of-string (not '/' as in URLs). -function findKeyColon(text: string): number { - let inSingle = false - let inDouble = false - for (let i = 0; i < text.length; i++) { - const c = text[i] - if (c === "'" && !inDouble) { inSingle = !inSingle; continue } - if (c === '"' && !inSingle) { inDouble = !inDouble; continue } - if (inSingle || inDouble) continue - if (c === ':') { - const next = text[i + 1] - if (next === undefined || next === ' ' || next === '\t') return i - } - } - return -1 -} - -function buildDecorations(view: EditorView): DecorationSet { - const doc = view.state.doc - const builder = new RangeSetBuilder() - - if (doc.lines < 2) return builder.finish() - - // Only activate when the document starts with a YAML frontmatter block - if (doc.line(1).text.trim() !== '---') return builder.finish() - - let fmEndLine = -1 - for (let n = 2; n <= doc.lines; n++) { - const lineText = doc.line(n).text.trim() - if (lineText === '---' || lineText === '...') { - fmEndLine = n - break - } - } - if (fmEndLine < 0) return builder.finish() - - // Color the opening '---' delimiter - const openLine = doc.line(1) - builder.add(openLine.from, openLine.to, Decoration.mark({ class: 'tok-comment' })) - - // Decorate content lines 2..(fmEndLine - 1) - for (let n = 2; n < fmEndLine; n++) { - const line = doc.line(n) - const text = line.text - const trimmed = text.trimStart() - if (!trimmed) continue - - const indent = text.length - trimmed.length - const base = line.from + indent - - // YAML comment - if (trimmed[0] === '#') { - builder.add(base, line.to, Decoration.mark({ class: 'tok-comment' })) - continue - } - - // Key: value — use tok-typeName for keys (35/41 theme coverage, distinct - // colour in most themes) and type-appropriate classes for values. - const colonPos = findKeyColon(trimmed) - if (colonPos <= 0) continue - - const key = trimmed.slice(0, colonPos).trimEnd() - if (key) { - builder.add(base, base + key.length, Decoration.mark({ class: 'tok-typeName' })) - } - - const afterColon = trimmed.slice(colonPos + 1) - const leadingSpace = afterColon.length - afterColon.trimStart().length - const value = afterColon.trimStart().trimEnd() - if (value) { - const valFrom = base + colonPos + 1 + leadingSpace - builder.add(valFrom, valFrom + value.length, Decoration.mark({ class: valueClass(value) })) - } - } - - // Color the closing '---' / '...' delimiter - const closeLine = doc.line(fmEndLine) - builder.add(closeLine.from, closeLine.to, Decoration.mark({ class: 'tok-comment' })) - - return builder.finish() -} - -// Highlights YAML key/value/comment lines inside a Quarto/Pandoc frontmatter -// block (the region delimited by leading and trailing '---' lines). -export const yamlFrontmatterHighlighting = ViewPlugin.fromClass( - class { - decorations: DecorationSet - constructor(view: EditorView) { - this.decorations = buildDecorations(view) - } - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - this.decorations = buildDecorations(update.view) - } - } - }, - { decorations: v => v.decorations } -) diff --git a/services/web/package.json b/services/web/package.json index 7ae7a2ba51..a86bf268e7 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -206,6 +206,7 @@ "@codemirror/commands": "6.10.1", "@codemirror/lang-markdown": "6.5.0", "@codemirror/lang-python": "6.2.1", + "@codemirror/lang-yaml": "6.1.3", "@codemirror/language": "6.12.1", "@codemirror/lint": "6.9.2", "@codemirror/search": "6.5.8",