feat(editor): improve syntax highlighting for Typst and Quarto documents
Build and Deploy Verso / deploy (push) Successful in 10m50s

Typst: heading tokenizer now colors the entire heading line (not just the
'=' prefix), and bold/italic markers (*/_) map to strong/emphasis tags
rather than the generic operator tag. A typstHighlightStyle applies
bold/italic formatting even when the active theme lacks .tok-heading.

Quarto: enable @lezer/markdown's Frontmatter extension so the YAML header
is no longer mis-parsed as Setext headings. A new ViewPlugin decorates
frontmatter lines with type-appropriate CSS classes: keys (tok-typeName),
string/bool/number values, comments, and the --- delimiter markers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-07 17:42:51 +00:00
co-authored by Claude Sonnet 4.6
parent 489bdb01ec
commit f9788a1c69
3 changed files with 160 additions and 12 deletions
@@ -1,7 +1,7 @@
import { markdown as markdownLanguage } from '@codemirror/lang-markdown'
import { shortcuts } from './shortcuts'
import { languages } from '../index'
import { Strikethrough } from '@lezer/markdown'
import { Strikethrough, Frontmatter } from '@lezer/markdown'
import {
HighlightStyle,
LanguageSupport,
@@ -10,11 +10,14 @@ 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({
codeLanguages: languages,
extensions: [Strikethrough],
// 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],
})
return new LanguageSupport(language, [
@@ -23,6 +26,7 @@ export const markdown = () => {
syntaxHighlighting(markdownHighlightStyle),
markdownDocumentOutline,
language.data.of({ autocomplete: quartoCompletions }),
yamlFrontmatterHighlighting,
])
}
@@ -0,0 +1,120 @@
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<Decoration>()
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 }
)
@@ -2,6 +2,8 @@ import {
StreamLanguage,
StreamParser,
LanguageSupport,
HighlightStyle,
syntaxHighlighting,
} from '@codemirror/language'
import { tags as t } from '@lezer/highlight'
import { typstCompletions } from './complete'
@@ -32,17 +34,18 @@ const atoms = new Set(['none', 'auto', 'true', 'false'])
type TypstState = {
inBlockComment: boolean
inHeading: boolean
}
// A lightweight stream tokenizer for Typst. It is not a full grammar, but it
// gives sensible highlighting for the common constructs: comments, strings,
// headings, code (#... functions and keywords), math delimiters, labels,
// references, numbers with units and markup emphasis markers. Token names are
// mapped to standard highlight tags via `tokenTable`, so the editor's global
// class highlighter themes them automatically (light + dark).
// references, numbers with units and markup bold/italic markers. Token names
// are mapped to standard highlight tags via `tokenTable`, so the editor's
// global class highlighter themes them automatically (light + dark).
const parser: StreamParser<TypstState> = {
startState() {
return { inBlockComment: false }
return { inBlockComment: false, inHeading: false }
},
token(stream, state) {
@@ -55,6 +58,13 @@ const parser: StreamParser<TypstState> = {
return 'comment'
}
// Color the heading title text (everything after the '= ' prefix)
if (state.inHeading) {
stream.skipToEnd()
state.inHeading = false
return 'heading'
}
if (stream.eatSpace()) {
return null
}
@@ -74,8 +84,10 @@ const parser: StreamParser<TypstState> = {
return 'string'
}
// Headings: one or more '=' at the start of a line
// Headings: one or more '=' at the start of a line followed by a space.
// Set inHeading so the title text on the same line is also colored.
if (stream.sol() && stream.match(/=+\s/)) {
state.inHeading = true
return 'heading'
}
@@ -117,14 +129,14 @@ const parser: StreamParser<TypstState> = {
}
// Numbers, optionally with a unit
if (stream.match(/\d+(?:\.\d+)?(?:pt|mm|cm|in|em|fr|deg|%)?/)) {
if (stream.match(/\d+(?:\.\d+)?(?:pt|mm|cm|in|em|fr|deg|rad|%)?/)) {
return 'number'
}
// Markup emphasis markers
if (stream.eat('*') || stream.eat('_')) {
return 'operator'
}
// Markup bold (*) and italic (_) — mapped to strong/emphasis rather than
// the generic operator tag so themes and typstHighlightStyle can style them.
if (stream.eat('*')) return 'strong'
if (stream.eat('_')) return 'emphasis'
stream.next()
return null
@@ -141,6 +153,8 @@ const parser: StreamParser<TypstState> = {
ref: t.labelName,
operator: t.operator,
atom: t.atom,
strong: t.strong,
emphasis: t.emphasis,
},
languageData: {
@@ -149,11 +163,21 @@ const parser: StreamParser<TypstState> = {
},
}
// Provides formatting (bold/italic) for key structural tokens even when the
// active theme doesn't define a colour for .tok-heading / .tok-strong etc.
// Mirrors the approach used for Markdown.
const typstHighlightStyle = HighlightStyle.define([
{ tag: t.heading, fontWeight: 'bold' },
{ tag: t.strong, fontWeight: 'bold' },
{ tag: t.emphasis, fontStyle: 'italic' },
])
export const TypstLanguage = StreamLanguage.define(parser)
export const typst = () => {
return new LanguageSupport(TypstLanguage, [
TypstLanguage.data.of({ autocomplete: typstCompletions }),
typstDocumentOutline,
syntaxHighlighting(typstHighlightStyle),
])
}