feat(editor): improve syntax highlighting for Typst and Quarto documents
Build and Deploy Verso / deploy (push) Successful in 10m50s
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:
co-authored by
Claude Sonnet 4.6
parent
489bdb01ec
commit
f9788a1c69
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
+120
@@ -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),
|
||||
])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user