🚧 Added support for markdown preview math

This commit is contained in:
2025-12-03 00:45:01 +08:00
parent 17f3351cea
commit fc5639d7bd
15 changed files with 1063 additions and 576 deletions

View File

@@ -44,6 +44,7 @@
"@replit/codemirror-lang-svelte": "^6.0.0",
"@toml-tools/lexer": "^1.0.0",
"@toml-tools/parser": "^1.0.0",
"@types/katex": "^0.16.7",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
@@ -51,6 +52,7 @@
"groovy-beautify": "^0.0.17",
"hsl-matcher": "^1.2.4",
"java-parser": "^3.0.1",
"katex": "^0.16.25",
"linguist-languages": "^9.1.0",
"marked": "^17.0.1",
"mermaid": "^11.12.1",
@@ -2831,6 +2833,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/katex": {
"version": "0.16.7",
"resolved": "https://registry.npmmirror.com/@types/katex/-/katex-0.16.7.tgz",
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",

View File

@@ -58,6 +58,7 @@
"@replit/codemirror-lang-svelte": "^6.0.0",
"@toml-tools/lexer": "^1.0.0",
"@toml-tools/parser": "^1.0.0",
"@types/katex": "^0.16.7",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
@@ -65,6 +66,7 @@
"groovy-beautify": "^0.0.17",
"hsl-matcher": "^1.2.4",
"java-parser": "^3.0.1",
"katex": "^0.16.25",
"linguist-languages": "^9.1.0",
"marked": "^17.0.1",
"mermaid": "^11.12.1",

View File

@@ -10,6 +10,8 @@ import {StandardSQL} from "@codemirror/lang-sql";
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
import {Subscript, Superscript, Table} from "@lezer/markdown";
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
import {javaLanguage} from "@codemirror/lang-java";
import {phpLanguage} from "@codemirror/lang-php";
@@ -116,7 +118,7 @@ export const LANGUAGES: LanguageInfo[] = [
}),
new LanguageInfo("md", "Markdown", markdown({
base: markdownLanguage,
extensions: [Subscript, Superscript, Highlight, Footnote, Table],
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
completeHTMLTags: true,
pasteURLAsLink: true,
htmlTagLanguage: html({

View File

@@ -12,6 +12,8 @@ import { horizontalRule } from './plugins/horizontal-rule';
import { inlineCode } from './plugins/inline-code';
import { subscriptSuperscript } from './plugins/subscript-superscript';
import { highlight } from './plugins/highlight';
import { insert } from './plugins/insert';
import { math } from './plugins/math';
import { footnote } from './plugins/footnote';
/**
@@ -31,6 +33,8 @@ export const markdownExtensions: Extension = [
inlineCode(),
subscriptSuperscript(),
highlight(),
insert(),
math(),
footnote(),
];

View File

@@ -216,7 +216,7 @@ class FootnoteRefWidget extends WidgetType {
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-footnote-ref';
span.textContent = String(this.index);
span.textContent = `[${this.index}]`;
span.dataset.footnoteId = this.id;
if (!this.hasDefinition) {
@@ -249,7 +249,7 @@ class InlineFootnoteWidget extends WidgetType {
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-inline-footnote-ref';
span.textContent = String(this.index);
span.textContent = `[${this.index}]`;
span.dataset.footnoteContent = this.content;
span.dataset.footnoteIndex = String(this.index);
@@ -265,6 +265,31 @@ class InlineFootnoteWidget extends WidgetType {
}
}
/**
* Widget to display footnote definition label.
*/
class FootnoteDefLabelWidget extends WidgetType {
constructor(readonly id: string) {
super();
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-footnote-def-label';
span.textContent = `[${this.id}]`;
span.dataset.footnoteId = this.id;
return span;
}
eq(other: FootnoteDefLabelWidget): boolean {
return this.id === other.id;
}
ignoreEvent(): boolean {
return false;
}
}
// ============================================================================
// Decorations
// ============================================================================
@@ -318,15 +343,18 @@ function buildDecorations(view: EditorView): DecorationSet {
const labelNode = node.getChild('FootnoteDefinitionLabel');
if (!cursorInRange && marks.length >= 2 && labelNode) {
// Hide the [^ and ]: marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[1].from, marks[1].to));
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
// Style the label as definition marker
// Hide the entire [^id]: part
decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to));
// Add widget to show [id]
const widget = new FootnoteDefLabelWidget(id);
decorations.push(
Decoration.mark({
class: 'cm-footnote-def-label',
}).range(labelNode.from, labelNode.to)
Decoration.widget({
widget,
side: 1,
}).range(marks[1].to)
);
}
}

View File

@@ -1,66 +0,0 @@
import { parseMixed } from '@lezer/common';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { Element, MarkdownExtension } from '@lezer/markdown';
import { foldInside, foldNodeProp, StreamLanguage } from '@codemirror/language';
import { styleTags, tags } from '@lezer/highlight';
// A frontmatter fence usually consists of a seperator used three times.
// These can be: ---, +++, ===, etc.
// FIXME: make this configurable
const frontMatterFence = /^---\s*$/m;
/**
* Ixora frontmatter plugin for Markdown.
*/
export const frontmatter: MarkdownExtension = {
defineNodes: [{ name: 'Frontmatter', block: true }, 'FrontmatterMark'],
props: [
styleTags({
Frontmatter: [tags.documentMeta, tags.monospace],
FrontmatterMark: tags.processingInstruction
}),
foldNodeProp.add({
Frontmatter: foldInside,
// Marks don't need to be folded
FrontmatterMark: () => null
})
],
wrap: parseMixed((node) => {
const { parser } = StreamLanguage.define(yaml);
if (node.type.name === 'Frontmatter') {
return {
parser,
overlay: [{ from: node.from + 4, to: node.to - 4 }]
};
} else {
return null;
}
}),
parseBlock: [
{
name: 'Fronmatter',
before: 'HorizontalRule',
parse: (cx, line) => {
let end: number = 0;
const children = new Array<Element>();
if (cx.lineStart === 0 && frontMatterFence.test(line.text)) {
// 4 is the length of the frontmatter fence (---\n).
children.push(cx.elt('FrontmatterMark', 0, 4));
while (cx.nextLine()) {
if (frontMatterFence.test(line.text)) {
end = cx.lineStart + 4;
break;
}
}
if (end > 0) {
children.push(cx.elt('FrontmatterMark', end - 4, end));
cx.addElement(cx.elt('Frontmatter', 0, end, children));
}
return true;
} else {
return false;
}
}
}
]
};

View File

@@ -116,19 +116,27 @@ const imageHoverTooltip = hoverTooltip(
arrow: true,
create: () => {
const dom = document.createElement('div');
dom.className = 'cm-image-tooltip';
dom.className = 'cm-image-tooltip cm-image-loading';
const spinner = document.createElement('span');
spinner.className = 'cm-image-spinner';
const imgEl = document.createElement('img');
imgEl.src = img.src;
imgEl.alt = img.alt;
imgEl.onload = () => {
dom.classList.remove('cm-image-loading');
};
imgEl.onerror = () => {
spinner.remove();
imgEl.remove();
dom.textContent = 'Failed to load image';
dom.classList.remove('cm-image-loading');
dom.classList.add('cm-image-tooltip-error');
};
dom.append(imgEl);
dom.append(spinner, imgEl);
return { dom };
}
};
@@ -150,15 +158,50 @@ const theme = EditorView.baseTheme({
},
'.cm-image-indicator:hover': { opacity: '1' },
'.cm-image-tooltip': {
background: 'var(--bg-secondary)',
position: 'relative',
background: `
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)
`,
backgroundColor: '#fff',
backgroundSize: '12px 12px',
backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0px',
border: '1px solid var(--border-color)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
'& img': {
display: 'block',
maxWidth: '60vw',
maxHeight: '50vh'
maxHeight: '50vh',
opacity: '1',
transition: 'opacity 0.15s ease-out'
}
},
'.cm-image-loading': {
minWidth: '48px',
minHeight: '48px',
'& img': { opacity: '0' }
},
'.cm-image-spinner': {
position: 'absolute',
top: '50%',
left: '50%',
width: '16px',
height: '16px',
marginTop: '-8px',
marginLeft: '-8px',
border: '2px solid #ccc',
borderTopColor: '#666',
borderRadius: '50%',
animation: 'cm-spin 0.5s linear infinite'
},
'.cm-image-tooltip:not(.cm-image-loading) .cm-image-spinner': {
display: 'none'
},
'@keyframes cm-spin': {
to: { transform: 'rotate(360deg)' }
},
'.cm-image-tooltip-error': {
padding: '16px 24px',
fontSize: '12px',
@@ -169,8 +212,8 @@ const theme = EditorView.baseTheme({
borderBottomColor: 'var(--border-color) !important'
},
'.cm-tooltip-arrow:after': {
borderTopColor: 'var(--bg-secondary) !important',
borderBottomColor: 'var(--bg-secondary) !important'
borderTopColor: '#fff !important',
borderBottomColor: '#fff !important'
}
});

View File

@@ -0,0 +1,114 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Insert plugin using syntax tree.
*
* Uses the custom Insert extension to detect:
* - Insert: ++text++ → renders as inserted text (underline)
*
* Examples:
* - This is ++inserted++ text → This is <ins>inserted</ins> text
* - Please ++review this section++ carefully
*/
export const insert = (): Extension => [
insertPlugin,
baseTheme
];
/**
* Build decorations for insert using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Insert nodes
if (type.name === 'Insert') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ++ characters)
const marks = node.getChildren('InsertMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ++ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply insert style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-insert'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class InsertPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const insertPlugin = ViewPlugin.fromClass(
InsertPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for insert.
* Uses underline decoration for inserted text.
*/
const baseTheme = EditorView.baseTheme({
'.cm-insert': {
textDecoration: 'underline',
}
});

View File

@@ -0,0 +1,358 @@
/**
* Math plugin for CodeMirror using KaTeX.
*
* Features:
* - Renders inline math $...$ as inline formula
* - Renders block math $$...$$ as block formula
* - Block math: lines remain, content hidden, formula overlays on top
* - Shows source when cursor is inside
*/
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import { isCursorInRange, invisibleDecoration } from '../util';
// ============================================================================
// Inline Math Widget
// ============================================================================
/**
* Widget to display inline math formula.
*/
class InlineMathWidget extends WidgetType {
private html: string;
private error: string | null = null;
constructor(readonly latex: string) {
super();
try {
this.html = katex.renderToString(latex, {
throwOnError: true,
displayMode: false,
output: 'html'
});
} catch (e) {
this.error = e instanceof Error ? e.message : 'Render error';
this.html = '';
}
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-inline-math';
if (this.error) {
span.textContent = this.latex;
span.title = this.error;
} else {
span.innerHTML = this.html;
}
return span;
}
eq(other: InlineMathWidget): boolean {
return this.latex === other.latex;
}
ignoreEvent(): boolean {
return false;
}
}
// ============================================================================
// Block Math Widget
// ============================================================================
/**
* Widget to display block math formula.
* Uses absolute positioning to overlay on source lines.
*/
class BlockMathWidget extends WidgetType {
private html: string;
private error: string | null = null;
constructor(
readonly latex: string,
readonly lineCount: number = 1,
readonly lineHeight: number = 22
) {
super();
try {
this.html = katex.renderToString(latex, {
throwOnError: false,
displayMode: true,
output: 'html'
});
} catch (e) {
this.error = e instanceof Error ? e.message : 'Render error';
this.html = '';
}
}
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-block-math-container';
// Set height to cover all source lines
const height = this.lineCount * this.lineHeight;
container.style.height = `${height}px`;
const inner = document.createElement('div');
inner.className = 'cm-block-math';
if (this.error) {
inner.textContent = this.latex;
inner.title = this.error;
} else {
inner.innerHTML = this.html;
}
container.appendChild(inner);
return container;
}
eq(other: BlockMathWidget): boolean {
return this.latex === other.latex && this.lineCount === other.lineCount;
}
ignoreEvent(): boolean {
return false;
}
}
// ============================================================================
// Decorations
// ============================================================================
/**
* Build decorations for math formulas.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle inline math
if (type.name === 'InlineMath') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const marks = node.getChildren('InlineMathMark');
if (!cursorInRange && marks.length >= 2) {
// Get latex content (without $ marks)
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
// Hide the entire syntax
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
// Add widget at the end
decorations.push(
Decoration.widget({
widget: new InlineMathWidget(latex),
side: 1
}).range(nodeTo)
);
}
}
// Handle block math ($$...$$)
if (type.name === 'BlockMath') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const marks = node.getChildren('BlockMathMark');
if (!cursorInRange && marks.length >= 2) {
// Get latex content (without $$ marks)
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
// Calculate line info
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
const lineCount = endLine.number - startLine.number + 1;
const lineHeight = view.defaultLineHeight;
// Check if block math spans multiple lines
const hasLineBreak = lineCount > 1;
if (hasLineBreak) {
// For multi-line: use line decorations to hide content
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
decorations.push(
Decoration.line({
class: 'cm-block-math-line'
}).range(line.from)
);
}
// Add widget on the first line (positioned absolutely)
decorations.push(
Decoration.widget({
widget: new BlockMathWidget(latex, lineCount, lineHeight),
side: -1
}).range(startLine.from)
);
} else {
// Single line: make content transparent, overlay widget
decorations.push(
Decoration.mark({
class: 'cm-block-math-content-hidden'
}).range(nodeFrom, nodeTo)
);
// Add widget at the start (positioned absolutely)
decorations.push(
Decoration.widget({
widget: new BlockMathWidget(latex, 1, lineHeight),
side: -1
}).range(nodeFrom)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
// ============================================================================
// Plugin
// ============================================================================
/**
* Math plugin with optimized update detection.
*/
class MathPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const mathPlugin = ViewPlugin.fromClass(
MathPlugin,
{
decorations: (v) => v.decorations
}
);
// ============================================================================
// Theme
// ============================================================================
/**
* Base theme for math.
*/
const baseTheme = EditorView.baseTheme({
// Inline math
'.cm-inline-math': {
display: 'inline',
verticalAlign: 'baseline',
},
'.cm-inline-math .katex': {
fontSize: 'inherit',
},
// Block math container - absolute positioned to overlay on source
'.cm-block-math-container': {
position: 'absolute',
left: '0',
right: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
pointerEvents: 'none',
zIndex: '1',
},
// Block math inner
'.cm-block-math': {
display: 'inline-block',
textAlign: 'center',
pointerEvents: 'auto',
},
'.cm-block-math .katex-display': {
margin: '0',
},
'.cm-block-math .katex': {
fontSize: '1.1em',
},
// Hidden line content for block math (text transparent but line preserved)
// Use high specificity to override rainbow brackets and other plugins
'.cm-line.cm-block-math-line': {
color: 'transparent !important',
caretColor: 'transparent',
},
'.cm-line.cm-block-math-line span': {
color: 'transparent !important',
},
// Override rainbow brackets in hidden math lines
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
color: 'transparent !important',
},
// Hidden content for single-line block math
'.cm-block-math-content-hidden': {
color: 'transparent !important',
},
'.cm-block-math-content-hidden span': {
color: 'transparent !important',
},
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
color: 'transparent !important',
},
});
// ============================================================================
// Export
// ============================================================================
/**
* Math extension.
*
* Features:
* - Parses inline math $...$ and block math $$...$$
* - Renders formulas using KaTeX
* - Block math preserves line structure, overlays rendered formula
* - Shows source when cursor is inside
*/
export const math = (): Extension => [
mathPlugin,
baseTheme
];
export default math;

View File

@@ -1,42 +0,0 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
/**
* Basic table information extracted from syntax tree.
*/
export interface TableInfo {
/** Starting position in document */
from: number;
/** End position in document */
to: number;
/** Raw markdown text */
rawText: string;
}
/**
* Extract all tables from the editor state.
*/
export function extractTablesFromState(state: EditorState): TableInfo[] {
const tables: TableInfo[] = [];
const seen = new Set<string>();
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (type.name !== 'Table') return;
// Deduplicate
const key = `${from}:${to}`;
if (seen.has(key)) return;
seen.add(key);
const rawText = state.doc.sliceString(from, to);
// Need at least 2 lines (header + delimiter)
if (rawText.split('\n').length < 2) return;
tables.push({ from, to, rawText });
}
});
return tables;
}

View File

@@ -14,33 +14,173 @@
* - This is text^[inline footnote content] with inline footnote.
*/
import { MarkdownConfig, Line, BlockContext } from '@lezer/markdown';
import { MarkdownConfig, Line, BlockContext, InlineContext } from '@lezer/markdown';
import { CharCode, isFootnoteIdChar } from '../util';
/**
* ASCII character codes for parsing.
* Parse inline footnote ^[content].
*
* @param cx - Inline context
* @param pos - Start position (at ^)
* @returns Position after element, or -1 if no match
*/
const enum Ch {
OpenBracket = 91, // [
CloseBracket = 93, // ]
Caret = 94, // ^
Colon = 58, // :
Space = 32,
Tab = 9,
Newline = 10,
function parseInlineFootnote(cx: InlineContext, pos: number): number {
const end = cx.end;
// Minimum: ^[ + content + ] = at least 4 chars
if (end < pos + 3) return -1;
// Track bracket depth for nested brackets
let bracketDepth = 1;
let hasContent = false;
const contentStart = pos + 2;
for (let i = contentStart; i < end; i++) {
const char = cx.char(i);
// Don't allow newlines
if (char === CharCode.Newline) return -1;
// Track bracket depth
if (char === CharCode.OpenBracket) {
bracketDepth++;
} else if (char === CharCode.CloseBracket) {
bracketDepth--;
if (bracketDepth === 0) {
// Found closing bracket - must have content
if (!hasContent) return -1;
// Create element with marks and content
return cx.addElement(cx.elt('InlineFootnote', pos, i + 1, [
cx.elt('InlineFootnoteMark', pos, contentStart),
cx.elt('InlineFootnoteContent', contentStart, i),
cx.elt('InlineFootnoteMark', i, i + 1)
]));
}
} else {
hasContent = true;
}
}
return -1;
}
/**
* Check if a character is valid for footnote ID.
* Allows: letters, numbers, underscore, hyphen
* Parse footnote reference [^id].
*
* @param cx - Inline context
* @param pos - Start position (at [)
* @returns Position after element, or -1 if no match
*/
function isFootnoteIdChar(code: number): boolean {
return (
(code >= 48 && code <= 57) || // 0-9
(code >= 65 && code <= 90) || // A-Z
(code >= 97 && code <= 122) || // a-z
code === 95 || // _
code === 45 // -
);
function parseFootnoteReference(cx: InlineContext, pos: number): number {
const end = cx.end;
// Minimum: [^ + id + ] = at least 4 chars
if (end < pos + 3) return -1;
let hasValidId = false;
const labelStart = pos + 2;
for (let i = labelStart; i < end; i++) {
const char = cx.char(i);
// Found closing bracket
if (char === CharCode.CloseBracket) {
if (!hasValidId) return -1;
// Create element with marks and label
return cx.addElement(cx.elt('FootnoteReference', pos, i + 1, [
cx.elt('FootnoteReferenceMark', pos, labelStart),
cx.elt('FootnoteReferenceLabel', labelStart, i),
cx.elt('FootnoteReferenceMark', i, i + 1)
]));
}
// Don't allow newlines
if (char === CharCode.Newline) return -1;
// Validate id character using O(1) lookup table
if (isFootnoteIdChar(char)) {
hasValidId = true;
} else {
return -1;
}
}
return -1;
}
/**
* Parse footnote definition [^id]: content.
*
* @param cx - Block context
* @param line - Current line
* @returns True if parsed successfully
*/
function parseFootnoteDefinition(cx: BlockContext, line: Line): boolean {
const text = line.text;
const len = text.length;
// Minimum: [^id]: = at least 5 chars
if (len < 5) return false;
// Find ]: pattern - use O(1) lookup for ID chars
let labelEnd = 2;
while (labelEnd < len) {
const char = text.charCodeAt(labelEnd);
if (char === CharCode.CloseBracket) {
// Check for : after ]
if (labelEnd + 1 < len && text.charCodeAt(labelEnd + 1) === CharCode.Colon) {
break;
}
return false;
}
// Use O(1) lookup table
if (!isFootnoteIdChar(char)) return false;
labelEnd++;
}
// Validate ]: was found
if (labelEnd >= len ||
text.charCodeAt(labelEnd) !== CharCode.CloseBracket ||
text.charCodeAt(labelEnd + 1) !== CharCode.Colon) {
return false;
}
// Calculate positions (all at once to avoid repeated arithmetic)
const start = cx.lineStart;
const openMarkEnd = start + 2;
const labelEndPos = start + labelEnd;
const closeMarkEnd = start + labelEnd + 2;
// Skip optional space after :
let contentOffset = labelEnd + 2;
if (contentOffset < len) {
const spaceChar = text.charCodeAt(contentOffset);
if (spaceChar === CharCode.Space || spaceChar === CharCode.Tab) {
contentOffset++;
}
}
// Build children array
const children = [
cx.elt('FootnoteDefinitionMark', start, openMarkEnd),
cx.elt('FootnoteDefinitionLabel', openMarkEnd, labelEndPos),
cx.elt('FootnoteDefinitionMark', labelEndPos, closeMarkEnd)
];
// Add content if present
if (contentOffset < len) {
children.push(cx.elt('FootnoteDefinitionContent', start + contentOffset, start + len));
}
// Create and add block element
cx.addElement(cx.elt('FootnoteDefinition', start, start + len, children));
cx.nextLine();
return true;
}
/**
@@ -76,119 +216,26 @@ export const Footnote: MarkdownConfig = {
],
parseInline: [
// Inline footnote must be parsed before Superscript to handle ^[ pattern
{
name: 'InlineFootnote',
parse(cx, next, pos) {
// Check for ^[ pattern
if (next !== Ch.Caret || cx.char(pos + 1) !== Ch.OpenBracket) {
// Fast path: must start with ^[
if (next !== CharCode.Caret || cx.char(pos + 1) !== CharCode.OpenBracket) {
return -1;
}
// Find the closing ]
// Content can contain any characters except unbalanced brackets and newlines
let end = pos + 2;
let bracketDepth = 1; // We're inside one [
let hasContent = false;
while (end < cx.end) {
const char = cx.char(end);
// Don't allow newlines in inline footnotes
if (char === Ch.Newline) {
return -1;
}
// Track bracket depth for nested brackets
if (char === Ch.OpenBracket) {
bracketDepth++;
} else if (char === Ch.CloseBracket) {
bracketDepth--;
if (bracketDepth === 0) {
// Found the closing bracket
if (!hasContent) {
return -1; // Empty inline footnote
}
// Create the element with marks and content
const children = [
// Opening mark ^[
cx.elt('InlineFootnoteMark', pos, pos + 2),
// Content
cx.elt('InlineFootnoteContent', pos + 2, end),
// Closing mark ]
cx.elt('InlineFootnoteMark', end, end + 1),
];
const element = cx.elt('InlineFootnote', pos, end + 1, children);
return cx.addElement(element);
}
} else {
hasContent = true;
}
end++;
}
return -1;
return parseInlineFootnote(cx, pos);
},
// Parse before Superscript to avoid ^[ being misinterpreted
before: 'Superscript',
},
{
name: 'FootnoteReference',
parse(cx, next, pos) {
// Check for [^ pattern
if (next !== Ch.OpenBracket || cx.char(pos + 1) !== Ch.Caret) {
// Fast path: must start with [^
if (next !== CharCode.OpenBracket || cx.char(pos + 1) !== CharCode.Caret) {
return -1;
}
// Find the closing ]
let end = pos + 2;
let hasValidId = false;
while (end < cx.end) {
const char = cx.char(end);
// Found closing bracket
if (char === Ch.CloseBracket) {
if (!hasValidId) {
return -1; // Empty footnote reference
}
// Create the element with marks and label
const children = [
// Opening mark [^
cx.elt('FootnoteReferenceMark', pos, pos + 2),
// Label (the id)
cx.elt('FootnoteReferenceLabel', pos + 2, end),
// Closing mark ]
cx.elt('FootnoteReferenceMark', end, end + 1),
];
const element = cx.elt('FootnoteReference', pos, end + 1, children);
return cx.addElement(element);
}
// Don't allow newlines
if (char === Ch.Newline) {
return -1;
}
// Validate id characters
if (isFootnoteIdChar(char)) {
hasValidId = true;
} else {
// Invalid character in footnote id
return -1;
}
end++;
}
return -1;
return parseFootnoteReference(cx, pos);
},
// Parse before links to avoid conflicts
before: 'Link',
},
],
@@ -197,90 +244,16 @@ export const Footnote: MarkdownConfig = {
{
name: 'FootnoteDefinition',
parse(cx: BlockContext, line: Line): boolean {
// Must start at the beginning of a line
// Check for [^ pattern
const text = line.text;
if (text.charCodeAt(0) !== Ch.OpenBracket ||
text.charCodeAt(1) !== Ch.Caret) {
// Fast path: must start with [^
if (line.text.charCodeAt(0) !== CharCode.OpenBracket ||
line.text.charCodeAt(1) !== CharCode.Caret) {
return false;
}
// Find ]: pattern
let labelEnd = 2;
while (labelEnd < text.length) {
const char = text.charCodeAt(labelEnd);
if (char === Ch.CloseBracket) {
// Check for : after ]
if (labelEnd + 1 < text.length &&
text.charCodeAt(labelEnd + 1) === Ch.Colon) {
break;
}
return false;
}
if (!isFootnoteIdChar(char)) {
return false;
}
labelEnd++;
}
// Must have found ]:
if (labelEnd >= text.length ||
text.charCodeAt(labelEnd) !== Ch.CloseBracket ||
text.charCodeAt(labelEnd + 1) !== Ch.Colon) {
return false;
}
// Calculate positions
const start = cx.lineStart;
const openMarkEnd = start + 2; // [^
const labelStart = openMarkEnd;
const labelEndPos = start + labelEnd;
const closeMarkStart = labelEndPos;
const closeMarkEnd = start + labelEnd + 2; // ]:
const contentStart = closeMarkEnd;
// Skip optional space after :
let contentOffset = labelEnd + 2;
if (contentOffset < text.length &&
(text.charCodeAt(contentOffset) === Ch.Space ||
text.charCodeAt(contentOffset) === Ch.Tab)) {
contentOffset++;
}
// Build the element
const children = [
// Opening mark [^
cx.elt('FootnoteDefinitionMark', start, openMarkEnd),
// Label
cx.elt('FootnoteDefinitionLabel', labelStart, labelEndPos),
// Closing mark ]:
cx.elt('FootnoteDefinitionMark', closeMarkStart, closeMarkEnd),
];
// Add content if present
const contentText = text.slice(contentOffset);
if (contentText.length > 0) {
children.push(
cx.elt('FootnoteDefinitionContent', start + contentOffset, start + text.length)
);
}
// Create the block element
const element = cx.elt('FootnoteDefinition', start, start + text.length, children);
cx.addElement(element);
// Move to next line
cx.nextLine();
return true;
return parseFootnoteDefinition(cx, line);
},
// Parse before other block elements
before: 'LinkReference',
},
],
};
export default Footnote;

View File

@@ -10,10 +10,10 @@
*/
import { MarkdownConfig } from '@lezer/markdown';
import { CharCode, createPairedDelimiterParser } from '../util';
/**
* Highlight extension for Lezer Markdown.
*
* Defines:
* - Highlight: The container node for highlighted content
* - HighlightMark: The == delimiter marks
@@ -23,50 +23,16 @@ export const Highlight: MarkdownConfig = {
{ name: 'Highlight' },
{ name: 'HighlightMark' }
],
parseInline: [{
name: 'Highlight',
parse(cx, next, pos) {
// Check for == delimiter (= is ASCII 61)
if (next !== 61 || cx.char(pos + 1) !== 61) {
return -1;
}
// Don't match === or more (horizontal rule or other constructs)
if (cx.char(pos + 2) === 61) {
return -1;
}
// Look for closing == delimiter
for (let i = pos + 2; i < cx.end - 1; i++) {
const char = cx.char(i);
// Don't allow newlines within highlight
if (char === 10) { // newline
return -1;
}
// Found potential closing ==
if (char === 61 && cx.char(i + 1) === 61) {
// Make sure it's not ===
if (i + 2 < cx.end && cx.char(i + 2) === 61) {
continue;
}
// Create the element with marks
const element = cx.elt('Highlight', pos, i + 2, [
cx.elt('HighlightMark', pos, pos + 2),
cx.elt('HighlightMark', i, i + 2)
]);
return cx.addElement(element);
}
}
return -1;
},
// Parse after emphasis to avoid conflicts with other inline parsers
after: 'Emphasis'
}]
parseInline: [
createPairedDelimiterParser({
name: 'Highlight',
nodeName: 'Highlight',
markName: 'HighlightMark',
delimChar: CharCode.Equal,
isDouble: true,
after: 'Emphasis'
})
]
};
export default Highlight;

View File

@@ -0,0 +1,41 @@
/**
* Insert extension for Lezer Markdown parser.
*
* Parses ++insert++ syntax for inserted/underlined text.
*
* Syntax: ++text++ → renders as inserted text (underline)
*
* Example:
* - This is ++inserted++ text → This is <ins>inserted</ins> text
*/
import { MarkdownConfig } from '@lezer/markdown';
import { CharCode, createPairedDelimiterParser } from '../util';
/**
* Insert extension for Lezer Markdown.
*
* Uses optimized factory function for O(n) single-pass parsing.
*
* Defines:
* - Insert: The container node for inserted content
* - InsertMark: The ++ delimiter marks
*/
export const Insert: MarkdownConfig = {
defineNodes: [
{ name: 'Insert' },
{ name: 'InsertMark' }
],
parseInline: [
createPairedDelimiterParser({
name: 'Insert',
nodeName: 'Insert',
markName: 'InsertMark',
delimChar: CharCode.Plus,
isDouble: true,
after: 'Emphasis'
})
]
};
export default Insert;

View File

@@ -0,0 +1,146 @@
/**
* Math extension for Lezer Markdown parser.
*
* Parses LaTeX math syntax:
* - Inline math: $E=mc^2$ → renders as inline formula
* - Block math: $$...$$ → renders as block formula (can be multi-line)
*/
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
import { CharCode } from '../util';
/**
* Parse block math ($$...$$).
* Allows multi-line content and handles escaped $.
*
* @param cx - Inline context
* @param pos - Start position (at first $)
* @returns Position after element, or -1 if no match
*/
function parseBlockMath(cx: InlineContext, pos: number): number {
const end = cx.end;
// Don't match $$$ or more
if (cx.char(pos + 2) === CharCode.Dollar) return -1;
// Minimum: $$ + content + $$ = at least 5 chars
const minEnd = pos + 4;
if (end < minEnd) return -1;
// Search for closing $$
const searchEnd = end - 1;
for (let i = pos + 2; i < searchEnd; i++) {
const char = cx.char(i);
// Skip escaped $ (backslash followed by any char)
if (char === CharCode.Backslash) {
i++; // Skip next char
continue;
}
// Found potential closing $$
if (char === CharCode.Dollar) {
const nextChar = cx.char(i + 1);
if (nextChar !== CharCode.Dollar) continue;
// Don't match $$$
if (i + 2 < end && cx.char(i + 2) === CharCode.Dollar) continue;
// Ensure content exists
if (i === pos + 2) return -1;
// Create element with marks
return cx.addElement(cx.elt('BlockMath', pos, i + 2, [
cx.elt('BlockMathMark', pos, pos + 2),
cx.elt('BlockMathMark', i, i + 2)
]));
}
}
return -1;
}
/**
* Parse inline math ($...$).
* Single line only, handles escaped $.
*
* @param cx - Inline context
* @param pos - Start position (at $)
* @returns Position after element, or -1 if no match
*/
function parseInlineMath(cx: InlineContext, pos: number): number {
const end = cx.end;
// Don't match if preceded by backslash (escaped)
if (pos > 0 && cx.char(pos - 1) === CharCode.Backslash) return -1;
// Minimum: $ + content + $ = at least 3 chars
if (end < pos + 2) return -1;
// Search for closing $
for (let i = pos + 1; i < end; i++) {
const char = cx.char(i);
// Newline not allowed in inline math
if (char === CharCode.Newline) return -1;
// Skip escaped $
if (char === CharCode.Backslash && i + 1 < end && cx.char(i + 1) === CharCode.Dollar) {
i++; // Skip next char
continue;
}
// Found potential closing $
if (char === CharCode.Dollar) {
// Don't match $$
if (i + 1 < end && cx.char(i + 1) === CharCode.Dollar) continue;
// Ensure content exists
if (i === pos + 1) return -1;
// Create element with marks
return cx.addElement(cx.elt('InlineMath', pos, i + 1, [
cx.elt('InlineMathMark', pos, pos + 1),
cx.elt('InlineMathMark', i, i + 1)
]));
}
}
return -1;
}
/**
* Math extension for Lezer Markdown.
*
* Defines:
* - InlineMath: Inline math formula $...$
* - InlineMathMark: The $ delimiter marks for inline
* - BlockMath: Block math formula $$...$$
* - BlockMathMark: The $$ delimiter marks for block
*/
export const Math: MarkdownConfig = {
defineNodes: [
{ name: 'InlineMath' },
{ name: 'InlineMathMark' },
{ name: 'BlockMath' },
{ name: 'BlockMathMark' }
],
parseInline: [
{
name: 'Math',
parse(cx, next, pos) {
// Fast path: must start with $
if (next !== CharCode.Dollar) return -1;
// Check for $$ (block math) vs $ (inline math)
const isBlock = cx.char(pos + 1) === CharCode.Dollar;
return isBlock ? parseBlockMath(cx, pos) : parseInlineMath(cx, pos);
},
// Parse after emphasis to avoid conflicts
after: 'Emphasis'
}
]
};
export default Math;

View File

@@ -1,34 +1,133 @@
import { foldedRanges, syntaxTree } from '@codemirror/language';
import type { SyntaxNodeRef, TreeCursor } from '@lezer/common';
import { Decoration, EditorView } from '@codemirror/view';
import {
EditorState,
SelectionRange,
CharCategory,
findClusterBreak
} from '@codemirror/state';
// ============================================================================
// Type Definitions (ProseMark style)
// ============================================================================
import { Decoration } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import type { InlineContext, InlineParser } from '@lezer/markdown';
/**
* A range-like object with from and to properties.
* ASCII character codes for common delimiters.
*/
export interface RangeLike {
from: number;
to: number;
export const enum CharCode {
Space = 32,
Tab = 9,
Newline = 10,
Backslash = 92,
Dollar = 36, // $
Plus = 43, // +
Equal = 61, // =
OpenBracket = 91, // [
CloseBracket = 93, // ]
Caret = 94, // ^
Colon = 58, // :
Hyphen = 45, // -
Underscore = 95, // _
}
/**
* Pre-computed lookup table for footnote ID characters.
* Valid characters: 0-9, A-Z, a-z, _, -
* Uses Uint8Array for memory efficiency and O(1) lookup.
*/
const FOOTNOTE_ID_CHARS = new Uint8Array(128);
// Initialize lookup table (0-9: 48-57, A-Z: 65-90, a-z: 97-122, _: 95, -: 45)
for (let i = 48; i <= 57; i++) FOOTNOTE_ID_CHARS[i] = 1; // 0-9
for (let i = 65; i <= 90; i++) FOOTNOTE_ID_CHARS[i] = 1; // A-Z
for (let i = 97; i <= 122; i++) FOOTNOTE_ID_CHARS[i] = 1; // a-z
FOOTNOTE_ID_CHARS[95] = 1; // _
FOOTNOTE_ID_CHARS[45] = 1; // -
/**
* O(1) check if a character is valid for footnote ID.
* @param code - ASCII character code
* @returns True if valid footnote ID character
*/
export function isFootnoteIdChar(code: number): boolean {
return code < 128 && FOOTNOTE_ID_CHARS[code] === 1;
}
/**
* Configuration for paired delimiter parser factory.
*/
export interface PairedDelimiterConfig {
/** Parser name */
name: string;
/** Node name for the container element */
nodeName: string;
/** Node name for the delimiter marks */
markName: string;
/** First delimiter character code */
delimChar: number;
/** Whether delimiter is doubled (e.g., == vs =) */
isDouble: true;
/** Whether to allow newlines in content */
allowNewlines?: boolean;
/** Parse order - after which parser */
after?: string;
/** Parse order - before which parser */
before?: string;
}
/**
* Factory function to create a paired delimiter inline parser.
* Optimized with:
* - Fast path early return
* - Minimal function calls in loop
* - Pre-computed delimiter length
*
* @param config - Parser configuration
* @returns InlineParser for MarkdownConfig
*/
export function createPairedDelimiterParser(config: PairedDelimiterConfig): InlineParser {
const { name, nodeName, markName, delimChar, allowNewlines = false, after, before } = config;
const delimLen = 2; // Always double delimiter for these parsers
return {
name,
parse(cx: InlineContext, next: number, pos: number): number {
// Fast path: check first character
if (next !== delimChar) return -1;
// Check second delimiter character
if (cx.char(pos + 1) !== delimChar) return -1;
// Don't match triple delimiter (e.g., ===, +++)
if (cx.char(pos + 2) === delimChar) return -1;
// Calculate search bounds
const searchEnd = cx.end - 1;
const contentStart = pos + delimLen;
// Look for closing delimiter
for (let i = contentStart; i < searchEnd; i++) {
const char = cx.char(i);
// Check for newline (unless allowed)
if (!allowNewlines && char === CharCode.Newline) return -1;
// Found potential closing delimiter
if (char === delimChar && cx.char(i + 1) === delimChar) {
// Don't match triple delimiter
if (i + 2 < cx.end && cx.char(i + 2) === delimChar) continue;
// Create element with marks
return cx.addElement(cx.elt(nodeName, pos, i + delimLen, [
cx.elt(markName, pos, contentStart),
cx.elt(markName, i, i + delimLen)
]));
}
}
return -1;
},
...(after && { after }),
...(before && { before })
};
}
/**
* Tuple representation of a range [from, to].
*/
export type RangeTuple = [number, number];
// ============================================================================
// Range Utilities
// ============================================================================
/**
* Check if two ranges overlap (touch or intersect).
* Based on the visual diagram on https://stackoverflow.com/a/25369187
@@ -44,46 +143,6 @@ export function checkRangeOverlap(
return range1[0] <= range2[1] && range2[0] <= range1[1];
}
/**
* Check if two range-like objects touch or overlap.
* ProseMark-style range comparison.
*
* @param a - First range
* @param b - Second range
* @returns True if ranges touch
*/
export function rangeTouchesRange(a: RangeLike, b: RangeLike): boolean {
return a.from <= b.to && b.from <= a.to;
}
/**
* Check if a selection touches a range.
*
* @param selection - Array of selection ranges
* @param range - Range to check against
* @returns True if any selection touches the range
*/
export function selectionTouchesRange(
selection: readonly SelectionRange[],
range: RangeLike
): boolean {
return selection.some((sel) => rangeTouchesRange(sel, range));
}
/**
* Check if a range is inside another range (subset).
*
* @param parent - Parent (bigger) range
* @param child - Child (smaller) range
* @returns True if child is inside parent
*/
export function checkRangeSubset(
parent: RangeTuple,
child: RangeTuple
): boolean {
return child[0] >= parent[0] && child[1] <= parent[1];
}
/**
* Check if any of the editor cursors is in the given range.
*
@@ -100,159 +159,12 @@ export function isCursorInRange(
);
}
// ============================================================================
// Tree Iteration Utilities
// ============================================================================
/**
* Iterate over the syntax tree in the visible ranges of the document.
*
* @param view - Editor view
* @param iterateFns - Object with `enter` and `leave` iterate function
*/
export function iterateTreeInVisibleRanges(
view: EditorView,
iterateFns: {
enter(node: SyntaxNodeRef): boolean | void;
leave?(node: SyntaxNodeRef): void;
}
): void {
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({ ...iterateFns, from, to });
}
}
/**
* Iterate through child nodes of a cursor.
* ProseMark-style tree traversal.
*
* @param cursor - Tree cursor to iterate
* @param enter - Callback function, return true to stop iteration
*/
export function iterChildren(
cursor: TreeCursor,
enter: (cursor: TreeCursor) => boolean | undefined
): void {
if (!cursor.firstChild()) return;
do {
if (enter(cursor)) break;
} while (cursor.nextSibling());
cursor.parent();
}
// ============================================================================
// Line Utilities
// ============================================================================
/**
* Returns the lines of the editor that are in the given range and not folded.
* This function is useful for adding line decorations to each line of a block node.
*
* @param view - Editor view
* @param from - Start of the range
* @param to - End of the range
* @returns A list of line blocks that are in the range
*/
export function editorLines(
view: EditorView,
from: number,
to: number
) {
let lines = view.viewportLineBlocks.filter((block) =>
checkRangeOverlap([block.from, block.to], [from, to])
);
const folded = foldedRanges(view.state).iter();
while (folded.value) {
lines = lines.filter(
(line) =>
!checkRangeOverlap(
[folded.from, folded.to],
[line.from, line.to]
)
);
folded.next();
}
return lines;
}
/**
* Get line numbers for a range.
*
* @param state - Editor state
* @param from - Start position
* @param to - End position
* @returns Array of line numbers
*/
export function getLineNumbers(
state: EditorState,
from: number,
to: number
): number[] {
const startLine = state.doc.lineAt(from).number;
const endLine = state.doc.lineAt(to).number;
const lines: number[] = [];
for (let i = startLine; i <= endLine; i++) {
lines.push(i);
}
return lines;
}
// ============================================================================
// Word Utilities (ProseMark style)
// ============================================================================
/**
* Get the "WORD" at a position (vim-style WORD, including non-whitespace).
*
* @param state - Editor state
* @param pos - Position in document
* @returns Selection range of the WORD, or null if at whitespace
*/
export function stateWORDAt(
state: EditorState,
pos: number
): SelectionRange | null {
const { text, from, length } = state.doc.lineAt(pos);
const cat = state.charCategorizer(pos);
let start = pos - from;
let end = pos - from;
while (start > 0) {
const prev = findClusterBreak(text, start, false);
if (cat(text.slice(prev, start)) === CharCategory.Space) break;
start = prev;
}
while (end < length) {
const next = findClusterBreak(text, end);
if (cat(text.slice(end, next)) === CharCategory.Space) break;
end = next;
}
return start === end
? null
: { from: start + from, to: end + from } as SelectionRange;
}
// ============================================================================
// Decoration Utilities
// ============================================================================
/**
* Decoration to simply hide anything (replace with nothing).
*/
export const invisibleDecoration = Decoration.replace({});
// ============================================================================
// Slug Generation
// ============================================================================
/**
* Class for generating unique slugs from heading contents.
*/
@@ -288,5 +200,3 @@ export class Slugger {
this.occurrences.clear();
}
}