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 955f8b38c8..a4f6405e7b 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 @@ -2,6 +2,7 @@ import { markdown as markdownLanguage } from '@codemirror/lang-markdown' import { shortcuts } from './shortcuts' import { languages } from '../index' import { Strikethrough, Table } from '@lezer/markdown' +import { Math } from './math' import { HighlightStyle, LanguageSupport, @@ -12,7 +13,7 @@ import { tags } from '@lezer/highlight' export const markdown = () => { const { language, support } = markdownLanguage({ codeLanguages: languages, - extensions: [Strikethrough, Table], + extensions: [Strikethrough, Table, Math], }) return new LanguageSupport(language, [ diff --git a/services/web/frontend/js/features/source-editor/languages/markdown/math.ts b/services/web/frontend/js/features/source-editor/languages/markdown/math.ts new file mode 100644 index 0000000000..4618b0ee29 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/markdown/math.ts @@ -0,0 +1,57 @@ +import { InlineContext, MarkdownConfig, InlineParser } from '@lezer/markdown' + +const DOLLAR = 36 /* '$' */ + +// Whitespace (including the start/end of the inline section, where `char` +// returns -1). +const isSpace = (code: number) => + code === -1 || code === 32 || code === 9 || code === 10 || code === 13 + +const isDigit = (code: number) => code >= 48 && code <= 57 + +const InlineMathDelim = { resolve: 'InlineMath', mark: 'InlineMathMark' } +const BlockMathDelim = { resolve: 'BlockMath', mark: 'BlockMathMark' } + +// Inline parsing runs over the whole paragraph, so `$$…$$` handles multi-line +// equations as long as there is no blank line inside them. +const blockMathParser: InlineParser = { + name: 'BlockMath', + parse(cx: InlineContext, next: number, pos: number) { + if (next !== DOLLAR || cx.char(pos + 1) !== DOLLAR) { + return -1 + } + return cx.addDelimiter(BlockMathDelim, pos, pos + 2, true, true) + }, + // Run before the default `Emphasis` parser, and (via `InlineMath`'s `after`) + // before the single-dollar inline parser, so `$$` is matched as a display + // delimiter rather than two empty inline ones. + before: 'Emphasis', +} + +const inlineMathParser: InlineParser = { + name: 'InlineMath', + parse(cx: InlineContext, next: number, pos: number) { + if (next !== DOLLAR) { + return -1 + } + const before = cx.char(pos - 1) + const after = cx.char(pos + 1) + const canOpen = !isSpace(after) + const canClose = !isSpace(before) && !isDigit(after) + if (!canOpen && !canClose) { + return -1 + } + return cx.addDelimiter(InlineMathDelim, pos, pos + 1, canOpen, canClose) + }, + // Run after `BlockMath` so `$$` is tried as a display delimiter first, but + // still before `Emphasis`. The default `Escape` parser consumes `\$` first. + after: 'BlockMath', +} + +export const Math: MarkdownConfig = { + // No `style` is set on these nodes: math is rendered by the visual editor + // (which slices the raw source), so the grammar change stays inert for + // source-mode syntax highlighting and non-experiment users. + defineNodes: ['InlineMath', 'InlineMathMark', 'BlockMath', 'BlockMathMark'], + parseInline: [blockMathParser, inlineMathParser], +}