feat(editor): Mermaid syntax highlighting, linter and autocomplete

- StreamLanguage tokenizer for Mermaid: diagram-type keywords, general
  keywords, directions, arrows/links, strings, %% comments, init
  directives and YAML frontmatter, with a dark-theme HighlightStyle
- live linter via mermaid.parse: maps parse errors to the right editor
  line (accounting for the frontmatter lines mermaid strips), with wavy
  underline, gutter marker and message tooltip
- autocomplete: Mermaid keyword/type/direction options plus starter
  diagram snippets offered on the first line; bracket closing
- dark-themed completion + diagnostic tooltips

Adds @codemirror/lint, @codemirror/autocomplete and @lezer/highlight.
This commit is contained in:
2026-05-22 16:46:43 +03:00
parent 29bf6438b3
commit b648ee904d
4 changed files with 414 additions and 1 deletions

3
package-lock.json generated
View File

@@ -8,10 +8,13 @@
"name": "mermix", "name": "mermix",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.6.0", "@codemirror/commands": "^6.6.0",
"@codemirror/language": "^6.10.2", "@codemirror/language": "^6.10.2",
"@codemirror/lint": "^6.8.0",
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.0", "@codemirror/view": "^6.28.0",
"@lezer/highlight": "^1.2.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",

View File

@@ -13,10 +13,13 @@
"app:build": "tauri build" "app:build": "tauri build"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.6.0", "@codemirror/commands": "^6.6.0",
"@codemirror/language": "^6.10.2", "@codemirror/language": "^6.10.2",
"@codemirror/lint": "^6.8.0",
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.0", "@codemirror/view": "^6.28.0",
"@lezer/highlight": "^1.2.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",

359
src/lib/cm-mermaid.ts Normal file
View File

@@ -0,0 +1,359 @@
import type { Extension } from '@codemirror/state';
import { HighlightStyle, StreamLanguage, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { linter, lintGutter, type Diagnostic } from '@codemirror/lint';
import {
autocompletion,
snippetCompletion,
type Completion,
type CompletionContext,
type CompletionResult,
} from '@codemirror/autocomplete';
import mermaid from 'mermaid';
// ---------------------------------------------------------------------------
// Vocabulary
// ---------------------------------------------------------------------------
/** Diagram-type openers (also offered as snippet anchors in autocomplete). */
const DIAGRAM_TYPES = [
'flowchart',
'graph',
'sequenceDiagram',
'classDiagram-v2',
'classDiagram',
'stateDiagram-v2',
'stateDiagram',
'erDiagram',
'journey',
'gantt',
'pie',
'quadrantChart',
'requirementDiagram',
'gitGraph',
'mindmap',
'timeline',
'sankey-beta',
'xychart-beta',
'block-beta',
'packet-beta',
'architecture-beta',
'kanban',
'radar',
'treemap',
'zenuml',
'C4Context',
'C4Container',
'C4Component',
'C4Dynamic',
'C4Deployment',
];
/** General keywords shared across diagram types. */
const KEYWORDS = [
'participant',
'actor',
'as',
'activate',
'deactivate',
'note',
'over',
'left',
'right',
'of',
'loop',
'alt',
'else',
'opt',
'par',
'and',
'critical',
'break',
'rect',
'box',
'end',
'autonumber',
'link',
'links',
'subgraph',
'direction',
'click',
'call',
'href',
'callback',
'class',
'classDef',
'cssClass',
'style',
'linkStyle',
'state',
'namespace',
'choice',
'fork',
'join',
'title',
'accTitle',
'accDescr',
'section',
'dateFormat',
'axisFormat',
'tickInterval',
'excludes',
'includes',
'todayMarker',
'weekday',
'done',
'active',
'crit',
'milestone',
'after',
'branch',
'checkout',
'merge',
'commit',
'cherry-pick',
'order',
'tag',
'type',
'requirement',
'functionalRequirement',
'performanceRequirement',
'interfaceRequirement',
'physicalRequirement',
'designConstraint',
'element',
'satisfies',
'contains',
'copies',
'derives',
'refines',
'traces',
'verifies',
'showData',
'Person',
'System',
'Container',
'Component',
'Rel',
'Boundary',
'beta',
];
const DIRECTIONS = ['TB', 'TD', 'BT', 'RL', 'LR'];
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function alternation(words: string[]): RegExp {
const sorted = [...words].sort((a, b) => b.length - a.length).map(escapeRe);
return new RegExp(`^(?:${sorted.join('|')})(?![\\w-])`);
}
const TYPE_RE = alternation(DIAGRAM_TYPES);
const KEYWORD_RE = alternation(KEYWORDS);
const DIR_RE = new RegExp(`^(?:${DIRECTIONS.join('|')})(?![\\w-])`);
// Links / arrows: -->, ---, ==>, -.->, --x, --o, <-->, ~~~ etc.
const ARROW_RE = /^(?:<{0,2}[-=.]{2,}[->ox]{0,2}|~~~+|x--x|o--o)/;
// ---------------------------------------------------------------------------
// Syntax highlighting (StreamLanguage)
// ---------------------------------------------------------------------------
interface MermaidState {
fm: boolean; // inside YAML frontmatter block
}
const mermaidLanguage = StreamLanguage.define<MermaidState>({
name: 'mermaid',
startState: () => ({ fm: false }),
token(stream, state) {
// Frontmatter fences (`---`) toggle a YAML block at the top of the file.
if (stream.sol() && stream.match(/^---[ \t]*$/)) {
state.fm = !state.fm;
return 'meta';
}
if (state.fm) {
if (stream.match(/^[ \t]*[\w-]+[ \t]*:/)) return 'keyword';
stream.skipToEnd();
return 'string';
}
if (stream.eatSpace()) return null;
// Comments & init directives.
if (stream.match(/^%%\{.*?\}%%/)) return 'meta';
if (stream.match(/^%%.*/)) return 'comment';
// Quoted strings.
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
// Links / arrows before words (arrows never start with a letter).
if (stream.match(ARROW_RE)) return 'operator';
// Keywords, diagram types and directions.
if (stream.match(TYPE_RE)) return 'keyword';
if (stream.match(KEYWORD_RE)) return 'keyword';
if (stream.match(DIR_RE)) return 'direction';
if (stream.match(/^\d+(?:\.\d+)?/)) return 'number';
if (stream.match(/^[A-Za-z_]\w*/)) return 'variable';
if (stream.match(/^[[\]{}()]/)) return 'bracket';
if (stream.match(/^[|]/)) return 'operator';
if (stream.match(/^[:;,.&#@]/)) return 'punct';
stream.next();
return null;
},
tokenTable: {
keyword: t.keyword,
direction: t.atom,
number: t.number,
string: t.string,
comment: t.lineComment,
operator: t.operator,
variable: t.variableName,
meta: t.meta,
bracket: t.bracket,
punct: t.punctuation,
},
});
const mermaidHighlight = HighlightStyle.define([
{ tag: t.keyword, color: '#c4b5fd', fontWeight: '600' },
{ tag: t.atom, color: '#f0a868' },
{ tag: t.number, color: '#79c0ff' },
{ tag: t.string, color: '#7ee787' },
{ tag: t.lineComment, color: '#6b7280', fontStyle: 'italic' },
{ tag: t.operator, color: '#ff9d7b' },
{ tag: t.variableName, color: '#e6e9ef' },
{ tag: t.meta, color: '#d29922' },
{ tag: t.bracket, color: '#9aa3b2' },
{ tag: t.punctuation, color: '#9aa3b2' },
]);
// ---------------------------------------------------------------------------
// Linter (mermaid.parse)
// ---------------------------------------------------------------------------
function cleanMessage(raw: string): string {
// Trim the giant "Expecting ..., got ..." tail that jison errors append.
const firstLine = raw.split('\n')[0].trim();
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}` : firstLine || raw.trim();
}
function extractLine(err: unknown): number | undefined {
const anyErr = err as { hash?: { loc?: { first_line?: number }; line?: number }; message?: string };
const loc = anyErr?.hash?.loc?.first_line ?? anyErr?.hash?.line;
if (typeof loc === 'number' && loc > 0) return loc;
const msg = anyErr?.message ?? String(err);
const m = msg.match(/(?:on line|line)\s+(\d+)/i);
return m ? parseInt(m[1], 10) : undefined;
}
/**
* Mermaid removes a leading `---\n…\n---` YAML frontmatter block before
* parsing, so the line numbers it reports are shifted up by that many lines.
* Returns the number of lines to add back.
*/
function frontmatterOffset(code: string): number {
const m = code.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/);
if (!m) return 0;
return m[0].replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n').length;
}
const mermaidLinter = linter(
async (view): Promise<Diagnostic[]> => {
const code = view.state.doc.toString();
if (!code.trim()) return [];
try {
await mermaid.parse(code);
return [];
} catch (err) {
const message = cleanMessage(
(err as { message?: string })?.message ?? String(err)
);
const doc = view.state.doc;
const reported = extractLine(err);
const line = reported ? reported + frontmatterOffset(code) : undefined;
if (line && line >= 1 && line <= doc.lines) {
const l = doc.line(line);
return [{ from: l.from, to: l.to, severity: 'error', message }];
}
// Unknown location — flag the first non-empty line.
const first = doc.line(1);
return [{ from: first.from, to: first.to, severity: 'error', message }];
}
},
{ delay: 450 }
);
// ---------------------------------------------------------------------------
// Autocomplete (keywords + starter snippets)
// ---------------------------------------------------------------------------
const SNIPPETS: Completion[] = [
snippetCompletion(
'flowchart ${TD}\n ${A}[${Start}] --> ${B}[${End}]',
{ label: 'flowchart', type: 'class', detail: 'starter', info: 'Flowchart skeleton' }
),
snippetCompletion(
'sequenceDiagram\n participant ${A}\n participant ${B}\n ${A}->>${B}: ${message}',
{ label: 'sequenceDiagram', type: 'class', detail: 'starter', info: 'Sequence diagram skeleton' }
),
snippetCompletion(
'classDiagram\n class ${Name} {\n +${field}\n +${method}()\n }',
{ label: 'classDiagram', type: 'class', detail: 'starter', info: 'Class diagram skeleton' }
),
snippetCompletion(
'stateDiagram-v2\n [*] --> ${State1}\n ${State1} --> [*]',
{ label: 'stateDiagram-v2', type: 'class', detail: 'starter', info: 'State diagram skeleton' }
),
snippetCompletion(
'erDiagram\n ${CUSTOMER} ||--o{ ${ORDER} : ${places}',
{ label: 'erDiagram', type: 'class', detail: 'starter', info: 'Entity-relationship skeleton' }
),
snippetCompletion(
'gantt\n title ${Project}\n dateFormat YYYY-MM-DD\n section ${Phase}\n ${Task} :a1, ${2024-01-01}, ${7d}',
{ label: 'gantt', type: 'class', detail: 'starter', info: 'Gantt chart skeleton' }
),
snippetCompletion('pie\n title ${Title}\n "${A}" : ${40}\n "${B}" : ${60}', {
label: 'pie',
type: 'class',
detail: 'starter',
info: 'Pie chart skeleton',
}),
];
const KEYWORD_OPTIONS: Completion[] = [
...DIAGRAM_TYPES.map((label) => ({ label, type: 'type' as const })),
...KEYWORDS.map((label) => ({ label, type: 'keyword' as const })),
...DIRECTIONS.map((label) => ({ label, type: 'constant' as const })),
];
function mermaidCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/[\w-]+/);
if (!word || (word.from === word.to && !context.explicit)) return null;
// On the very first line, lead with starter snippets.
const onFirstLine = context.state.doc.lineAt(context.pos).number === 1;
const options = onFirstLine ? [...SNIPPETS, ...KEYWORD_OPTIONS] : KEYWORD_OPTIONS;
return { from: word.from, options, validFor: /^[\w-]*$/ };
}
// ---------------------------------------------------------------------------
// Public bundle
// ---------------------------------------------------------------------------
/** All editor extensions providing Mermaid highlighting, linting and hints. */
export function mermaidSupport(): Extension[] {
return [
mermaidLanguage,
syntaxHighlighting(mermaidHighlight),
mermaidLinter,
lintGutter(),
autocompletion({ override: [mermaidCompletions], icons: true }),
];
}

View File

@@ -17,7 +17,10 @@
indentWithTab, indentWithTab,
} from '@codemirror/commands'; } from '@codemirror/commands';
import { bracketMatching, indentOnInput } from '@codemirror/language'; import { bracketMatching, indentOnInput } from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
import { store } from '../store.svelte'; import { store } from '../store.svelte';
import { mermaidSupport } from '../cm-mermaid';
let host: HTMLDivElement; let host: HTMLDivElement;
let view: EditorView | undefined; let view: EditorView | undefined;
@@ -48,6 +51,43 @@
backgroundColor: 'var(--accent-soft) !important', backgroundColor: 'var(--accent-soft) !important',
}, },
'.cm-matchingBracket': { backgroundColor: 'var(--accent-soft)', outline: 'none' }, '.cm-matchingBracket': { backgroundColor: 'var(--accent-soft)', outline: 'none' },
// Autocomplete popup
'.cm-tooltip': {
backgroundColor: 'var(--bg-elev-2)',
border: '1px solid var(--border-strong)',
borderRadius: '6px',
color: 'var(--text)',
boxShadow: '0 8px 28px rgba(0,0,0,0.45)',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul': { fontFamily: 'var(--mono)', maxHeight: '16em' },
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: 'var(--accent)',
color: '#fff',
},
'.cm-completionIcon': { color: 'var(--text-dim)', paddingRight: '0.6em' },
'.cm-completionDetail': { color: 'var(--text-faint)', fontStyle: 'normal' },
'.cm-completionInfo': {
backgroundColor: 'var(--bg-elev-2)',
border: '1px solid var(--border-strong)',
borderRadius: '6px',
color: 'var(--text-dim)',
},
// Lint diagnostics
'.cm-diagnostic': {
fontFamily: 'var(--mono)',
fontSize: '12px',
borderLeft: '3px solid var(--red)',
padding: '6px 8px',
},
'.cm-diagnostic-error': { borderLeftColor: 'var(--red)' },
'.cm-lintRange-error': {
backgroundImage: 'none',
textDecoration: 'underline wavy var(--red)',
textDecorationSkipInk: 'none',
},
'.cm-lint-marker-error': { content: '""' },
}, },
{ dark: true } { dark: true }
); );
@@ -65,8 +105,16 @@
drawSelection(), drawSelection(),
indentOnInput(), indentOnInput(),
bracketMatching(), bracketMatching(),
closeBrackets(),
highlightActiveLine(), highlightActiveLine(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), ...mermaidSupport(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...lintKeymap,
indentWithTab,
]),
theme, theme,
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.updateListener.of((u) => { EditorView.updateListener.of((u) => {