mardora 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/dist/chunk-3OCUX4OO.js +7690 -0
  4. package/dist/chunk-3OCUX4OO.js.map +1 -0
  5. package/dist/chunk-3ZOCCFDL.cjs +74 -0
  6. package/dist/chunk-3ZOCCFDL.cjs.map +1 -0
  7. package/dist/chunk-7JOEPNEV.cjs +7740 -0
  8. package/dist/chunk-7JOEPNEV.cjs.map +1 -0
  9. package/dist/chunk-BIKZQZ6W.js +33 -0
  10. package/dist/chunk-BIKZQZ6W.js.map +1 -0
  11. package/dist/chunk-EQJESPP2.js +234 -0
  12. package/dist/chunk-EQJESPP2.js.map +1 -0
  13. package/dist/chunk-G4SE26YY.js +70 -0
  14. package/dist/chunk-G4SE26YY.js.map +1 -0
  15. package/dist/chunk-KNDWF2DP.cjs +35 -0
  16. package/dist/chunk-KNDWF2DP.cjs.map +1 -0
  17. package/dist/chunk-MLBEBFHB.cjs +2971 -0
  18. package/dist/chunk-MLBEBFHB.cjs.map +1 -0
  19. package/dist/chunk-P7JFCYU3.js +905 -0
  20. package/dist/chunk-P7JFCYU3.js.map +1 -0
  21. package/dist/chunk-SWFUKJDO.cjs +243 -0
  22. package/dist/chunk-SWFUKJDO.cjs.map +1 -0
  23. package/dist/chunk-WFVCG4LD.cjs +926 -0
  24. package/dist/chunk-WFVCG4LD.cjs.map +1 -0
  25. package/dist/chunk-XL6WFGJT.js +2901 -0
  26. package/dist/chunk-XL6WFGJT.js.map +1 -0
  27. package/dist/editor/index.cjs +277 -0
  28. package/dist/editor/index.cjs.map +1 -0
  29. package/dist/editor/index.d.cts +186 -0
  30. package/dist/editor/index.d.ts +186 -0
  31. package/dist/editor/index.js +4 -0
  32. package/dist/editor/index.js.map +1 -0
  33. package/dist/index.cjs +405 -0
  34. package/dist/index.cjs.map +1 -0
  35. package/dist/index.d.cts +13 -0
  36. package/dist/index.d.ts +13 -0
  37. package/dist/index.js +8 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/lib/index.cjs +12 -0
  40. package/dist/lib/index.cjs.map +1 -0
  41. package/dist/lib/index.d.cts +16 -0
  42. package/dist/lib/index.d.ts +16 -0
  43. package/dist/lib/index.js +3 -0
  44. package/dist/lib/index.js.map +1 -0
  45. package/dist/mardora-DCwjomil.d.cts +640 -0
  46. package/dist/mardora-DCwjomil.d.ts +640 -0
  47. package/dist/plugins/index.cjs +104 -0
  48. package/dist/plugins/index.cjs.map +1 -0
  49. package/dist/plugins/index.d.cts +740 -0
  50. package/dist/plugins/index.d.ts +740 -0
  51. package/dist/plugins/index.js +7 -0
  52. package/dist/plugins/index.js.map +1 -0
  53. package/dist/preview/index.cjs +38 -0
  54. package/dist/preview/index.cjs.map +1 -0
  55. package/dist/preview/index.d.cts +101 -0
  56. package/dist/preview/index.d.ts +101 -0
  57. package/dist/preview/index.js +5 -0
  58. package/dist/preview/index.js.map +1 -0
  59. package/dist/types-NBsaxl4d.d.cts +71 -0
  60. package/dist/types-Pw2SWWAR.d.ts +71 -0
  61. package/package.json +92 -0
  62. package/src/editor/attachments/extension.ts +181 -0
  63. package/src/editor/attachments/format.ts +63 -0
  64. package/src/editor/attachments/index.ts +3 -0
  65. package/src/editor/attachments/types.ts +37 -0
  66. package/src/editor/heading-fold/config.ts +25 -0
  67. package/src/editor/heading-fold/extension.ts +268 -0
  68. package/src/editor/heading-fold/extract.ts +88 -0
  69. package/src/editor/heading-fold/index.ts +5 -0
  70. package/src/editor/heading-fold/theme.ts +85 -0
  71. package/src/editor/heading-fold/types.ts +24 -0
  72. package/src/editor/i18n.ts +13 -0
  73. package/src/editor/icons/index.ts +367 -0
  74. package/src/editor/index.ts +16 -0
  75. package/src/editor/mardora.ts +257 -0
  76. package/src/editor/media-lightbox-theme.ts +146 -0
  77. package/src/editor/media-lightbox.ts +125 -0
  78. package/src/editor/plugin.ts +294 -0
  79. package/src/editor/selection-toolbar/activation.ts +123 -0
  80. package/src/editor/selection-toolbar/commands.ts +279 -0
  81. package/src/editor/selection-toolbar/extension.ts +564 -0
  82. package/src/editor/selection-toolbar/i18n.ts +164 -0
  83. package/src/editor/selection-toolbar/index.ts +7 -0
  84. package/src/editor/selection-toolbar/menu.ts +252 -0
  85. package/src/editor/selection-toolbar/position.ts +43 -0
  86. package/src/editor/selection-toolbar/theme.ts +195 -0
  87. package/src/editor/selection-toolbar/types.ts +155 -0
  88. package/src/editor/slash/default-commands.ts +190 -0
  89. package/src/editor/slash/extension.ts +319 -0
  90. package/src/editor/slash/index.ts +7 -0
  91. package/src/editor/slash/insertions.ts +26 -0
  92. package/src/editor/slash/menu.ts +123 -0
  93. package/src/editor/slash/position.ts +61 -0
  94. package/src/editor/slash/query.ts +33 -0
  95. package/src/editor/slash/theme.ts +113 -0
  96. package/src/editor/slash/types.ts +40 -0
  97. package/src/editor/table-of-contents/extension.ts +202 -0
  98. package/src/editor/table-of-contents/extract.ts +53 -0
  99. package/src/editor/table-of-contents/index.ts +7 -0
  100. package/src/editor/table-of-contents/panel.ts +83 -0
  101. package/src/editor/table-of-contents/slug.ts +50 -0
  102. package/src/editor/table-of-contents/storage.ts +35 -0
  103. package/src/editor/table-of-contents/theme.ts +153 -0
  104. package/src/editor/table-of-contents/types.ts +44 -0
  105. package/src/editor/theme.ts +72 -0
  106. package/src/editor/utils.ts +176 -0
  107. package/src/editor/view-plugin.ts +189 -0
  108. package/src/index.ts +5 -0
  109. package/src/lib/index.ts +2 -0
  110. package/src/lib/input-handler.ts +47 -0
  111. package/src/plugins/code-plugin.theme.ts +545 -0
  112. package/src/plugins/code-plugin.ts +1892 -0
  113. package/src/plugins/emoji-plugin.ts +140 -0
  114. package/src/plugins/heading-plugin.ts +194 -0
  115. package/src/plugins/hr-plugin.ts +102 -0
  116. package/src/plugins/html-plugin.ts +353 -0
  117. package/src/plugins/image-plugin.ts +806 -0
  118. package/src/plugins/index.ts +71 -0
  119. package/src/plugins/inline-plugin.ts +311 -0
  120. package/src/plugins/link-plugin.ts +509 -0
  121. package/src/plugins/list-plugin.ts +492 -0
  122. package/src/plugins/math-plugin.ts +526 -0
  123. package/src/plugins/mermaid-plugin.ts +513 -0
  124. package/src/plugins/paragraph-plugin.ts +38 -0
  125. package/src/plugins/quote-plugin.ts +733 -0
  126. package/src/plugins/table-controls-theme.ts +126 -0
  127. package/src/plugins/table-controls.ts +423 -0
  128. package/src/plugins/table-model.ts +661 -0
  129. package/src/plugins/table-plugin.ts +2111 -0
  130. package/src/preview/context.ts +45 -0
  131. package/src/preview/css-generator.ts +64 -0
  132. package/src/preview/default-renderers.ts +29 -0
  133. package/src/preview/index.ts +29 -0
  134. package/src/preview/preview.ts +41 -0
  135. package/src/preview/renderer.ts +184 -0
  136. package/src/preview/syntax-theme.ts +112 -0
  137. package/src/preview/toc.ts +23 -0
  138. package/src/preview/types.ts +89 -0
@@ -0,0 +1,1892 @@
1
+ import { Decoration, EditorView, KeyBinding, WidgetType } from "@codemirror/view";
2
+ import { EditorState, Extension, Transaction, TransactionSpec } from "@codemirror/state";
3
+ import { LanguageDescription, syntaxTree } from "@codemirror/language";
4
+ import { DecorationContext, DecorationPlugin } from "../editor/plugin";
5
+ import { toggleMarkdownStyle } from "../editor";
6
+ import { Parser, SyntaxNode } from "@lezer/common";
7
+ import { Highlighter, highlightCode } from "@lezer/highlight";
8
+ import { languages } from "@codemirror/language-data";
9
+ import { createWrapSelectionInputHandler } from "../lib";
10
+ import { codePluginTheme as theme } from "./code-plugin.theme";
11
+
12
+ // ============================================================================
13
+ // Constants
14
+ // ============================================================================
15
+
16
+ /** Copy icon SVG (clipboard) */
17
+ const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
18
+
19
+ /** Checkmark icon SVG (success state) */
20
+ export const CODE_COPY_SUCCESS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"></path></svg>`;
21
+
22
+ /** Chevron icon SVG */
23
+ const CHEVRON_DOWN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg>`;
24
+
25
+ /** Search icon SVG */
26
+ const SEARCH_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg>`;
27
+
28
+ /** Language selected icon SVG */
29
+ const LANGUAGE_SELECTED_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>`;
30
+
31
+ /** Delay before resetting copy button state (ms) */
32
+ const COPY_RESET_DELAY = 2000;
33
+
34
+ /** Code fence marker in markdown blocks */
35
+ const CODE_FENCE = "```";
36
+
37
+ /** Regex for quoted code info values like title="file.ts" */
38
+ const QUOTED_INFO_PATTERN = /(\w+)="([^"]*)"/g;
39
+
40
+ /** Regex for /pattern/ with optional instance selectors (/pattern/1-3,5) */
41
+ const TEXT_HIGHLIGHT_PATTERN = /\/([^/]+)\/(?:(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?/g;
42
+
43
+ const codeLanguageOptions = [
44
+ ["Plain text", ""],
45
+ ["Bash", "bash"],
46
+ ["C", "c"],
47
+ ["C++", "cpp"],
48
+ ["C#", "csharp"],
49
+ ["CSS", "css"],
50
+ ["Go", "go"],
51
+ ["HTML", "html"],
52
+ ["Java", "java"],
53
+ ["JavaScript", "javascript"],
54
+ ["JSON", "json"],
55
+ ["Markdown", "markdown"],
56
+ ["Python", "python"],
57
+ ["Ruby", "ruby"],
58
+ ["Rust", "rust"],
59
+ ["Shell", "shell"],
60
+ ["SQL", "sql"],
61
+ ["Swift", "swift"],
62
+ ["TypeScript", "typescript"],
63
+ ["TSX", "tsx"],
64
+ ["Vue", "vue"],
65
+ ["YAML", "yaml"],
66
+ ] as const;
67
+
68
+ const codeLanguageAliases: Record<string, string> = {
69
+ bash: "Bash",
70
+ c: "C",
71
+ cpp: "C++",
72
+ "c++": "C++",
73
+ csharp: "C#",
74
+ "c#": "C#",
75
+ css: "CSS",
76
+ go: "Go",
77
+ html: "HTML",
78
+ java: "Java",
79
+ javascript: "JavaScript",
80
+ js: "JavaScript",
81
+ json: "JSON",
82
+ markdown: "Markdown",
83
+ md: "Markdown",
84
+ python: "Python",
85
+ py: "Python",
86
+ ruby: "Ruby",
87
+ rust: "Rust",
88
+ sql: "SQL",
89
+ swift: "Swift",
90
+ typescript: "TypeScript",
91
+ ts: "TypeScript",
92
+ tsx: "TSX",
93
+ vue: "Vue",
94
+ yaml: "YAML",
95
+ yml: "YAML",
96
+ };
97
+
98
+ function isCodeInfoDirective(token: string): boolean {
99
+ const normalizedToken = token.toLowerCase();
100
+ return (
101
+ /^(?:line-numbers|linenumbers|showlinenumbers)(?:\{\d+\})?$/.test(normalizedToken) ||
102
+ normalizedToken === "copy" ||
103
+ normalizedToken === "diff" ||
104
+ normalizedToken.startsWith("{") ||
105
+ normalizedToken.startsWith("/") ||
106
+ normalizedToken.startsWith("title=") ||
107
+ normalizedToken.startsWith("caption=")
108
+ );
109
+ }
110
+
111
+ export function replaceCodeInfoLanguage(codeInfo: string, nextLanguage: string): string {
112
+ const trimmedInfo = codeInfo.trim();
113
+ const normalizedLanguage = nextLanguage.trim();
114
+ const tokens = trimmedInfo ? trimmedInfo.split(/\s+/) : [];
115
+
116
+ if (tokens.length > 0 && tokens[0] && !isCodeInfoDirective(tokens[0])) {
117
+ tokens.shift();
118
+ }
119
+
120
+ return [normalizedLanguage, ...tokens].filter(Boolean).join(" ");
121
+ }
122
+
123
+ export function getCodeInfoLanguageTokenLength(codeInfo: string): number {
124
+ const match = codeInfo.match(/^(\S+)(\s*)/);
125
+ const token = match?.[1];
126
+ if (!token || isCodeInfoDirective(token)) return 0;
127
+ return token.length + (match[2]?.length ?? 0);
128
+ }
129
+
130
+ export function encodeCodeCopyPayload(code: string): string {
131
+ if (typeof btoa !== "undefined") {
132
+ return btoa(encodeURIComponent(code));
133
+ }
134
+
135
+ return Buffer.from(encodeURIComponent(code), "utf8").toString("base64");
136
+ }
137
+
138
+ export function decodeCodeCopyPayload(payload: string): string {
139
+ const decoded = typeof atob !== "undefined" ? atob(payload) : Buffer.from(payload, "base64").toString("utf8");
140
+ return decodeURIComponent(decoded);
141
+ }
142
+
143
+ function getClipboardApi(documentRef: Document): Clipboard | undefined {
144
+ return documentRef.defaultView?.navigator.clipboard ?? (typeof navigator !== "undefined" ? navigator.clipboard : undefined);
145
+ }
146
+
147
+ function copyTextWithTextAreaFallback(text: string, documentRef: Document): boolean {
148
+ const textArea = documentRef.createElement("textarea");
149
+ textArea.value = text;
150
+ textArea.setAttribute("readonly", "true");
151
+ textArea.style.position = "fixed";
152
+ textArea.style.left = "-9999px";
153
+ textArea.style.top = "0";
154
+ documentRef.body.appendChild(textArea);
155
+ textArea.select();
156
+ textArea.setSelectionRange(0, text.length);
157
+
158
+ try {
159
+ return documentRef.execCommand("copy");
160
+ } finally {
161
+ textArea.remove();
162
+ }
163
+ }
164
+
165
+ export async function copyCodeTextToClipboard(
166
+ text: string,
167
+ documentRef: Document | undefined = typeof document !== "undefined" ? document : undefined
168
+ ): Promise<void> {
169
+ if (!documentRef) {
170
+ throw new Error("A browser document is required to copy code text");
171
+ }
172
+
173
+ const clipboard = getClipboardApi(documentRef);
174
+ if (clipboard?.writeText) {
175
+ try {
176
+ await clipboard.writeText(text);
177
+ return;
178
+ } catch {
179
+ // Fall back to a selected textarea to force a text/plain clipboard payload.
180
+ }
181
+ }
182
+
183
+ if (copyTextWithTextAreaFallback(text, documentRef)) {
184
+ return;
185
+ }
186
+
187
+ throw new Error("Unable to copy code text");
188
+ }
189
+
190
+ function markCodeCopyButtonCopied(copyBtn: HTMLButtonElement): void {
191
+ copyBtn.classList.add("copied");
192
+ copyBtn.innerHTML = CODE_COPY_SUCCESS_ICON;
193
+ setTimeout(() => {
194
+ copyBtn.classList.remove("copied");
195
+ copyBtn.innerHTML = COPY_ICON;
196
+ }, COPY_RESET_DELAY);
197
+ }
198
+
199
+ export function bindCodeCopyButtons(root: HTMLElement | Document): () => void {
200
+ const onClick = (event: Event) => {
201
+ const target = event.target instanceof Element ? event.target : null;
202
+ const copyBtn = target?.closest<HTMLButtonElement>(".cm-mardora-code-copy-btn[data-code]");
203
+ if (!copyBtn || !root.contains(copyBtn)) return;
204
+
205
+ const codePayload = copyBtn.dataset.code ?? "";
206
+ const code = copyBtn.dataset.encoded === "true" ? decodeCodeCopyPayload(codePayload) : codePayload;
207
+
208
+ event.preventDefault();
209
+ event.stopPropagation();
210
+ void copyCodeTextToClipboard(code, copyBtn.ownerDocument).then(() => {
211
+ markCodeCopyButtonCopied(copyBtn);
212
+ });
213
+ };
214
+
215
+ root.addEventListener("click", onClick);
216
+ return () => root.removeEventListener("click", onClick);
217
+ }
218
+
219
+ export interface CodeFenceAutoCloseInput {
220
+ text: string;
221
+ from: number;
222
+ to: number;
223
+ lineFrom: number;
224
+ lineTo: number;
225
+ lineText: string;
226
+ selectionEmpty: boolean;
227
+ }
228
+
229
+ export interface CodeFenceAutoCloseResult {
230
+ changes: { from: number; to: number; insert: string };
231
+ selection: { anchor: number };
232
+ }
233
+
234
+ export function resolveCodeFenceAutoClose(input: CodeFenceAutoCloseInput): CodeFenceAutoCloseResult | null {
235
+ if (input.from !== input.to || !input.selectionEmpty) {
236
+ return null;
237
+ }
238
+
239
+ const cursorOffset = input.from - input.lineFrom;
240
+ const beforeCursor = input.lineText.slice(0, cursorOffset);
241
+ const afterCursor = input.lineText.slice(cursorOffset);
242
+ const openingMatch =
243
+ input.text === "`" ? beforeCursor.match(/^(\s*)``$/) : input.text === CODE_FENCE ? beforeCursor.match(/^(\s*)$/) : null;
244
+
245
+ if (!openingMatch || afterCursor.trim() !== "") {
246
+ return null;
247
+ }
248
+
249
+ const indent = openingMatch[1] ?? "";
250
+ const insert = `${indent}${CODE_FENCE}\n${indent}\n${indent}${CODE_FENCE}`;
251
+ const anchor = input.lineFrom + indent.length + CODE_FENCE.length + 1 + indent.length;
252
+
253
+ return {
254
+ changes: { from: input.lineFrom, to: input.lineTo, insert },
255
+ selection: { anchor },
256
+ };
257
+ }
258
+
259
+ function isInsideFencedCode(state: EditorState, pos: number, lineFrom: number): boolean {
260
+ const inspectPos = pos > lineFrom ? pos - 1 : pos;
261
+ let node: SyntaxNode | null = syntaxTree(state).resolveInner(inspectPos, -1);
262
+
263
+ while (node) {
264
+ if (node.name === "FencedCode") return true;
265
+ node = node.parent;
266
+ }
267
+
268
+ return false;
269
+ }
270
+
271
+ export function resolveCodeFenceAutoCloseTransaction(transaction: Transaction): TransactionSpec | null {
272
+ if (!transaction.docChanged || !transaction.startState.selection.main.empty || transaction.startState.selection.ranges.length !== 1) {
273
+ return null;
274
+ }
275
+
276
+ let autoClose: TransactionSpec | null = null;
277
+
278
+ transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
279
+ if (autoClose) return;
280
+
281
+ const line = transaction.startState.doc.lineAt(fromA);
282
+ if (isInsideFencedCode(transaction.startState, fromA, line.from)) return;
283
+
284
+ const result = resolveCodeFenceAutoClose({
285
+ text: inserted.toString(),
286
+ from: fromA,
287
+ to: toA,
288
+ lineFrom: line.from,
289
+ lineTo: line.to,
290
+ lineText: line.text,
291
+ selectionEmpty: true,
292
+ });
293
+
294
+ if (!result) return;
295
+
296
+ const userEvent = transaction.annotation(Transaction.userEvent);
297
+ autoClose = {
298
+ changes: result.changes,
299
+ selection: result.selection,
300
+ scrollIntoView: true,
301
+ filter: false,
302
+ ...(userEvent ? { userEvent } : {}),
303
+ };
304
+ });
305
+
306
+ return autoClose;
307
+ }
308
+
309
+ function createCodeFenceAutoCloseTransactionFilter(): Extension {
310
+ return EditorState.transactionFilter.of((transaction) => resolveCodeFenceAutoCloseTransaction(transaction) ?? transaction);
311
+ }
312
+
313
+ function createCodeFenceAutoCloseInputHandler(): Extension {
314
+ return EditorView.inputHandler.of((view, from, to, text) => {
315
+ const selectionEmpty = view.state.selection.ranges.length === 1 && view.state.selection.main.empty;
316
+ const line = view.state.doc.lineAt(from);
317
+ const result = resolveCodeFenceAutoClose({
318
+ text,
319
+ from,
320
+ to,
321
+ lineFrom: line.from,
322
+ lineTo: line.to,
323
+ lineText: line.text,
324
+ selectionEmpty,
325
+ });
326
+
327
+ if (!result) {
328
+ return false;
329
+ }
330
+
331
+ view.dispatch({
332
+ changes: result.changes,
333
+ selection: result.selection,
334
+ scrollIntoView: true,
335
+ });
336
+
337
+ return true;
338
+ });
339
+ }
340
+
341
+ function createCodeFenceAutoCloseBeforeInputHandler(): Extension {
342
+ return EditorView.domEventHandlers({
343
+ beforeinput(event, view) {
344
+ const inputEvent = event as InputEvent;
345
+ if (inputEvent.inputType !== "insertText") {
346
+ return false;
347
+ }
348
+
349
+ const text = inputEvent.data ?? "";
350
+ const { from, to } = view.state.selection.main;
351
+ const selectionEmpty = view.state.selection.ranges.length === 1 && view.state.selection.main.empty;
352
+ const line = view.state.doc.lineAt(from);
353
+ const result = resolveCodeFenceAutoClose({
354
+ text,
355
+ from,
356
+ to,
357
+ lineFrom: line.from,
358
+ lineTo: line.to,
359
+ lineText: line.text,
360
+ selectionEmpty,
361
+ });
362
+
363
+ if (!result) {
364
+ return false;
365
+ }
366
+
367
+ event.preventDefault();
368
+ view.dispatch({
369
+ changes: result.changes,
370
+ selection: result.selection,
371
+ scrollIntoView: true,
372
+ });
373
+
374
+ return true;
375
+ },
376
+ });
377
+ }
378
+
379
+ function formatLanguageLabel(language: string): string {
380
+ const normalized = language.trim().toLowerCase();
381
+ if (!normalized) return "Text";
382
+ return codeLanguageAliases[normalized] ?? language;
383
+ }
384
+
385
+ interface ToolbarElement extends HTMLElement {
386
+ __mardoraDestroyToolbar?: () => void;
387
+ }
388
+
389
+ interface PreviewRenderContext {
390
+ sliceDoc(from: number, to: number): string;
391
+ sanitize(html: string): string;
392
+ syntaxHighlighters?: readonly Highlighter[];
393
+ }
394
+
395
+ // ============================================================================
396
+ // Decorations
397
+ // ============================================================================
398
+
399
+ /** Mark and line decorations for code elements */
400
+ const codeMarkDecorations = {
401
+ // Inline code
402
+ "inline-code": Decoration.mark({ class: "cm-mardora-code-inline" }),
403
+ "inline-mark": Decoration.replace({}),
404
+
405
+ // Fenced code block
406
+ "code-block-line": Decoration.line({ class: "cm-mardora-code-block-line" }),
407
+ "code-block-line-start": Decoration.line({ class: "cm-mardora-code-block-line-start" }),
408
+ "code-block-line-end": Decoration.line({ class: "cm-mardora-code-block-line-end" }),
409
+ "code-block-single-line": Decoration.line({ class: "cm-mardora-code-block-single-line" }),
410
+ "code-block-rendered": Decoration.line({ class: "cm-mardora-code-block-rendered" }),
411
+ "code-fence-line": Decoration.line({ class: "cm-mardora-code-fence-line" }),
412
+ "code-fence": Decoration.mark({ class: "cm-mardora-code-fence" }),
413
+ "code-hidden": Decoration.replace({}),
414
+
415
+ // Highlights
416
+ "code-line-highlight": Decoration.line({ class: "cm-mardora-code-line-highlight" }),
417
+ "code-text-highlight": Decoration.mark({ class: "cm-mardora-code-text-highlight" }),
418
+
419
+ // Diff preview
420
+ "diff-line-add": Decoration.line({ class: "cm-mardora-code-line-diff-add" }),
421
+ "diff-line-del": Decoration.line({ class: "cm-mardora-code-line-diff-del" }),
422
+ "diff-sign-add": Decoration.mark({ class: "cm-mardora-code-diff-sign-add" }),
423
+ "diff-sign-del": Decoration.mark({ class: "cm-mardora-code-diff-sign-del" }),
424
+ "diff-mod-add": Decoration.mark({ class: "cm-mardora-code-diff-mod-add" }),
425
+ "diff-mod-del": Decoration.mark({ class: "cm-mardora-code-diff-mod-del" }),
426
+ "diff-escape-hidden": Decoration.replace({}),
427
+ };
428
+
429
+ /**
430
+ * Text highlight definition
431
+ * Matches text or regex patterns with optional instance selection
432
+ */
433
+ export interface TextHighlight {
434
+ /** The pattern to match (regex without slashes) */
435
+ pattern: string;
436
+ /** Specific instances to highlight (e.g., [3,5] or range [3,4,5]) */
437
+ instances?: number[];
438
+ }
439
+
440
+ /**
441
+ * Properties extracted from CodeInfo string
442
+ *
443
+ * Example: ```tsx line-numbers{5} title="hello.tsx" caption="Example" copy {2-4,5} /Hello/3-5
444
+ */
445
+ export interface CodeBlockProperties {
446
+ /** Language identifier (first token) */
447
+ language: string;
448
+ /** Show line numbers, optionally starting from a specific number */
449
+ showLineNumbers?: number | boolean;
450
+ /** Title to display */
451
+ title?: string;
452
+ /** Caption to display */
453
+ caption?: string;
454
+ /** Show copy button */
455
+ copy?: boolean;
456
+ /** Enable diff preview mode */
457
+ diff?: boolean;
458
+ /** Lines to highlight (e.g., [2,3,4,5,9]) */
459
+ highlightLines?: number[];
460
+ /** Text patterns to highlight with optional instance selection */
461
+ highlightText?: TextHighlight[];
462
+ }
463
+
464
+ type DiffLineKind = "normal" | "addition" | "deletion";
465
+
466
+ interface DiffLineState {
467
+ kind: DiffLineKind;
468
+ content: string;
469
+ contentOffset: number;
470
+ escapedMarker: boolean;
471
+ modificationRanges?: Array<[number, number]>;
472
+ }
473
+
474
+ interface DiffDisplayLineNumbers {
475
+ oldLine: number | null;
476
+ newLine: number | null;
477
+ }
478
+
479
+ // ============================================================================
480
+ // Widgets
481
+ // ============================================================================
482
+
483
+ /**
484
+ * Widget for the compact code block hover toolbar.
485
+ */
486
+ class CodeBlockToolbarWidget extends WidgetType {
487
+ constructor(
488
+ private props: CodeBlockProperties,
489
+ private codeContent: string,
490
+ private codeInfo: string,
491
+ private openingLineFrom: number,
492
+ private openingLineTo: number,
493
+ private openingFence: string,
494
+ private forceVisible: boolean
495
+ ) {
496
+ super();
497
+ }
498
+
499
+ /** Creates the toolbar DOM element with language switcher and copy button. */
500
+ toDOM(view: EditorView): HTMLElement {
501
+ const toolbar = document.createElement("div") as ToolbarElement;
502
+ toolbar.className = "cm-mardora-code-toolbar";
503
+ if (this.forceVisible) {
504
+ toolbar.classList.add("is-visible");
505
+ }
506
+ toolbar.addEventListener("mousedown", (event) => {
507
+ event.preventDefault();
508
+ event.stopPropagation();
509
+ });
510
+ toolbar.addEventListener("click", (event) => {
511
+ event.stopPropagation();
512
+ });
513
+
514
+ const languageControl = this.createLanguageControl(view, toolbar);
515
+ toolbar.appendChild(languageControl);
516
+
517
+ if (this.props.copy !== false) {
518
+ const copyBtn = document.createElement("button");
519
+ copyBtn.className = "cm-mardora-code-copy-btn";
520
+ copyBtn.type = "button";
521
+ copyBtn.title = "Copy code";
522
+ copyBtn.setAttribute("aria-label", "Copy code");
523
+ copyBtn.innerHTML = COPY_ICON;
524
+
525
+ copyBtn.addEventListener("click", (e) => {
526
+ e.preventDefault();
527
+ e.stopPropagation();
528
+ void copyCodeTextToClipboard(this.codeContent, copyBtn.ownerDocument).then(() => {
529
+ markCodeCopyButtonCopied(copyBtn);
530
+ });
531
+ });
532
+
533
+ toolbar.appendChild(copyBtn);
534
+ }
535
+
536
+ requestAnimationFrame(() => this.bindHoverLines(toolbar));
537
+ return toolbar;
538
+ }
539
+
540
+ /** Checks equality for widget reuse optimization. */
541
+ override eq(other: CodeBlockToolbarWidget): boolean {
542
+ return (
543
+ this.props.title === other.props.title &&
544
+ this.props.language === other.props.language &&
545
+ this.props.copy === other.props.copy &&
546
+ this.codeContent === other.codeContent &&
547
+ this.codeInfo === other.codeInfo &&
548
+ this.openingLineFrom === other.openingLineFrom &&
549
+ this.openingLineTo === other.openingLineTo &&
550
+ this.openingFence === other.openingFence &&
551
+ this.forceVisible === other.forceVisible
552
+ );
553
+ }
554
+
555
+ override destroy(dom: HTMLElement): void {
556
+ const toolbar = dom as ToolbarElement;
557
+ toolbar.__mardoraDestroyToolbar?.();
558
+ }
559
+
560
+ /** Allow click events to propagate for toolbar interaction. */
561
+ override ignoreEvent(): boolean {
562
+ return false;
563
+ }
564
+
565
+ private createLanguageControl(view: EditorView, toolbar: ToolbarElement): HTMLElement {
566
+ const control = document.createElement("div");
567
+ control.className = "cm-mardora-code-language-control";
568
+
569
+ const button = document.createElement("button");
570
+ button.className = "cm-mardora-code-language-button";
571
+ button.type = "button";
572
+ button.setAttribute("aria-haspopup", "listbox");
573
+ button.setAttribute("aria-expanded", "false");
574
+ button.innerHTML = `<span>${this.escapeHtml(formatLanguageLabel(this.props.language))}</span>${CHEVRON_DOWN_ICON}`;
575
+
576
+ const menu = document.createElement("div");
577
+ menu.className = "cm-mardora-code-language-menu";
578
+ menu.setAttribute("role", "listbox");
579
+ menu.hidden = true;
580
+
581
+ const searchWrap = document.createElement("label");
582
+ searchWrap.className = "cm-mardora-code-language-search";
583
+ const searchInput = document.createElement("input");
584
+ searchInput.type = "search";
585
+ searchInput.placeholder = "Search...";
586
+ searchInput.autocomplete = "off";
587
+ searchInput.spellcheck = false;
588
+ const searchIcon = document.createElement("span");
589
+ searchIcon.className = "cm-mardora-code-language-search-icon";
590
+ searchIcon.innerHTML = SEARCH_ICON;
591
+ searchWrap.append(searchInput, searchIcon);
592
+
593
+ const list = document.createElement("div");
594
+ list.className = "cm-mardora-code-language-list";
595
+ menu.append(searchWrap, list);
596
+ control.append(button, menu);
597
+
598
+ const closeMenu = () => {
599
+ menu.hidden = true;
600
+ toolbar.classList.remove("is-menu-open");
601
+ button.setAttribute("aria-expanded", "false");
602
+ toolbar.ownerDocument.removeEventListener("pointerdown", handleOutsidePointerDown, true);
603
+ };
604
+
605
+ const renderList = () => {
606
+ const query = searchInput.value.trim().toLowerCase();
607
+ list.textContent = "";
608
+
609
+ for (const [label, value] of codeLanguageOptions) {
610
+ const searchable = `${label} ${value}`.toLowerCase();
611
+ if (query && !searchable.includes(query)) continue;
612
+
613
+ const item = document.createElement("button");
614
+ item.className = "cm-mardora-code-language-item";
615
+ item.type = "button";
616
+ item.setAttribute("role", "option");
617
+ item.setAttribute("data-language", value);
618
+ const selected = this.props.language.trim().toLowerCase() === value.toLowerCase();
619
+ item.setAttribute("aria-selected", String(selected));
620
+ item.innerHTML = `<span>${this.escapeHtml(label)}</span>${selected ? LANGUAGE_SELECTED_ICON : ""}`;
621
+ item.addEventListener("click", (event) => {
622
+ event.preventDefault();
623
+ event.stopPropagation();
624
+ const nextInfo = replaceCodeInfoLanguage(this.codeInfo, value);
625
+ view.dispatch({
626
+ changes: {
627
+ from: this.openingLineFrom,
628
+ to: this.openingLineTo,
629
+ insert: `${this.openingFence}${nextInfo ? nextInfo : ""}`,
630
+ },
631
+ selection: view.state.selection,
632
+ scrollIntoView: false,
633
+ });
634
+ closeMenu();
635
+ view.focus();
636
+ });
637
+ list.appendChild(item);
638
+ }
639
+ };
640
+
641
+ const openMenu = () => {
642
+ renderList();
643
+ menu.hidden = false;
644
+ toolbar.classList.add("is-menu-open");
645
+ button.setAttribute("aria-expanded", "true");
646
+ toolbar.ownerDocument.addEventListener("pointerdown", handleOutsidePointerDown, true);
647
+ searchInput.focus();
648
+ };
649
+
650
+ const handleOutsidePointerDown = (event: PointerEvent) => {
651
+ if (toolbar.contains(event.target as Node | null)) return;
652
+ closeMenu();
653
+ };
654
+
655
+ button.addEventListener("click", (event) => {
656
+ event.preventDefault();
657
+ event.stopPropagation();
658
+ if (menu.hidden) {
659
+ openMenu();
660
+ } else {
661
+ closeMenu();
662
+ }
663
+ });
664
+
665
+ searchInput.addEventListener("input", renderList);
666
+ searchInput.addEventListener("keydown", (event) => {
667
+ if (event.key === "Escape") {
668
+ event.preventDefault();
669
+ closeMenu();
670
+ view.focus();
671
+ }
672
+ });
673
+
674
+ toolbar.__mardoraDestroyToolbar = () => closeMenu();
675
+ return control;
676
+ }
677
+
678
+ private bindHoverLines(toolbar: HTMLElement): void {
679
+ const firstLine = toolbar.closest(".cm-line");
680
+ if (!(firstLine instanceof HTMLElement)) return;
681
+ const editorRoot = firstLine.closest(".cm-editor");
682
+ if (!(editorRoot instanceof HTMLElement)) return;
683
+
684
+ const codeLines: HTMLElement[] = [];
685
+ let current: Element | null = firstLine;
686
+
687
+ while (current instanceof HTMLElement && current.classList.contains("cm-mardora-code-block-line")) {
688
+ codeLines.push(current);
689
+ if (current.classList.contains("cm-mardora-code-block-line-end")) break;
690
+ current = current.nextElementSibling;
691
+ }
692
+
693
+ const rectContains = (rect: DOMRect, event: MouseEvent) =>
694
+ event.clientX >= rect.left &&
695
+ event.clientX <= rect.right &&
696
+ event.clientY >= rect.top &&
697
+ event.clientY <= rect.bottom;
698
+
699
+ const updateVisibility = (event: MouseEvent) => {
700
+ const overCodeBlock = codeLines.some((line) => rectContains(line.getBoundingClientRect(), event));
701
+ const overToolbar = rectContains(toolbar.getBoundingClientRect(), event);
702
+ if (this.forceVisible || overCodeBlock || overToolbar || toolbar.classList.contains("is-menu-open")) {
703
+ toolbar.classList.add("is-visible");
704
+ } else {
705
+ toolbar.classList.remove("is-visible");
706
+ }
707
+ };
708
+ const hide = () => {
709
+ if (this.forceVisible) {
710
+ toolbar.classList.add("is-visible");
711
+ return;
712
+ }
713
+ if (!toolbar.classList.contains("is-menu-open")) toolbar.classList.remove("is-visible");
714
+ };
715
+
716
+ editorRoot.addEventListener("mousemove", updateVisibility);
717
+ editorRoot.addEventListener("mouseleave", hide);
718
+
719
+ const existingDestroy = (toolbar as ToolbarElement).__mardoraDestroyToolbar;
720
+ (toolbar as ToolbarElement).__mardoraDestroyToolbar = () => {
721
+ existingDestroy?.();
722
+ editorRoot.removeEventListener("mousemove", updateVisibility);
723
+ editorRoot.removeEventListener("mouseleave", hide);
724
+ };
725
+ }
726
+
727
+ private escapeHtml(value: string): string {
728
+ return value
729
+ .replace(/&/g, "&amp;")
730
+ .replace(/</g, "&lt;")
731
+ .replace(/>/g, "&gt;")
732
+ .replace(/"/g, "&quot;")
733
+ .replace(/'/g, "&#39;");
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Widget for code block caption.
739
+ * Displays descriptive text below the code block.
740
+ */
741
+ class CodeBlockCaptionWidget extends WidgetType {
742
+ constructor(private caption: string) {
743
+ super();
744
+ }
745
+
746
+ /** Creates the caption DOM element. */
747
+ toDOM(): HTMLElement {
748
+ const captionEl = document.createElement("div");
749
+ captionEl.className = "cm-mardora-code-caption";
750
+ captionEl.textContent = this.caption;
751
+ return captionEl;
752
+ }
753
+
754
+ /** Checks equality for widget reuse optimization. */
755
+ override eq(other: CodeBlockCaptionWidget): boolean {
756
+ return this.caption === other.caption;
757
+ }
758
+
759
+ /** Allow click events to propagate for caption interaction. */
760
+ override ignoreEvent(): boolean {
761
+ return false;
762
+ }
763
+ }
764
+
765
+ // ============================================================================
766
+ // Plugin
767
+ // ============================================================================
768
+
769
+ /**
770
+ * CodePlugin - Handles inline code and fenced code blocks.
771
+ *
772
+ * **Inline code:** `code`
773
+ * Hides backticks when cursor is not in range.
774
+ *
775
+ * **Fenced code blocks:**
776
+ * Supports syntax highlighting, line numbers, line/text highlighting,
777
+ * title, caption, and copy button via CodeInfo properties.
778
+ *
779
+ * @example
780
+ * ```tsx line-numbers{5} title="example.tsx" {2-4} /pattern/
781
+ * const x = 1;
782
+ * ```
783
+ */
784
+ export class CodePlugin extends DecorationPlugin {
785
+ readonly name = "code";
786
+ readonly version = "1.0.0";
787
+ override decorationPriority = 25;
788
+ override readonly requiredNodes = ["InlineCode", "FencedCode", "CodeMark", "CodeInfo", "CodeText"] as const;
789
+ private readonly parserCache = new Map<string, Promise<Parser | null>>();
790
+
791
+ /**
792
+ * Plugin theme
793
+ */
794
+ override get theme() {
795
+ return theme;
796
+ }
797
+
798
+ /**
799
+ * Keyboard shortcuts for code formatting
800
+ */
801
+ override getKeymap(): KeyBinding[] {
802
+ return [
803
+ {
804
+ key: "Mod-e",
805
+ run: toggleMarkdownStyle("`"),
806
+ preventDefault: true,
807
+ },
808
+ {
809
+ key: "Mod-Shift-e",
810
+ run: (view) => this.toggleCodeBlock(view),
811
+ preventDefault: true,
812
+ },
813
+ ];
814
+ }
815
+
816
+ /**
817
+ * Intercepts backtick typing to wrap selected text as inline code.
818
+ *
819
+ * If user types '`' while text is selected, wraps each selected range
820
+ * with backticks (selected -> `selected`).
821
+ */
822
+ override getExtensions(): Extension[] {
823
+ return [
824
+ createCodeFenceAutoCloseTransactionFilter(),
825
+ createCodeFenceAutoCloseBeforeInputHandler(),
826
+ createCodeFenceAutoCloseInputHandler(),
827
+ createWrapSelectionInputHandler({ "`": "`" }),
828
+ ];
829
+ }
830
+
831
+ /**
832
+ * Toggle code block on current line or selected lines
833
+ */
834
+ private toggleCodeBlock(view: EditorView): boolean {
835
+ const { state } = view;
836
+ const { from, to } = state.selection.main;
837
+
838
+ // Get all lines in selection
839
+ const startLine = state.doc.lineAt(from);
840
+ const endLine = state.doc.lineAt(to);
841
+
842
+ // Check if lines are already in a code block
843
+ const prevLineNum = startLine.number > 1 ? startLine.number - 1 : startLine.number;
844
+ const nextLineNum = endLine.number < state.doc.lines ? endLine.number + 1 : endLine.number;
845
+
846
+ const prevLine = state.doc.line(prevLineNum);
847
+ const nextLine = state.doc.line(nextLineNum);
848
+
849
+ const isWrapped =
850
+ prevLine.text.trim().startsWith(CODE_FENCE) &&
851
+ nextLine.text.trim() === CODE_FENCE &&
852
+ prevLineNum !== startLine.number &&
853
+ nextLineNum !== endLine.number;
854
+
855
+ if (isWrapped) {
856
+ // Remove the fence lines
857
+ view.dispatch({
858
+ changes: [
859
+ { from: prevLine.from, to: prevLine.to + 1, insert: "" }, // Remove opening fence + newline
860
+ { from: nextLine.from - 1, to: nextLine.to, insert: "" }, // Remove newline + closing fence
861
+ ],
862
+ });
863
+ } else {
864
+ // Wrap with code fence
865
+ const openFence = `${CODE_FENCE}\n`;
866
+ const closeFence = `\n${CODE_FENCE}`;
867
+
868
+ view.dispatch({
869
+ changes: [
870
+ { from: startLine.from, insert: openFence },
871
+ { from: endLine.to, insert: closeFence },
872
+ ],
873
+ selection: { anchor: startLine.from + openFence.length, head: endLine.to + openFence.length },
874
+ });
875
+ }
876
+
877
+ return true;
878
+ }
879
+
880
+ /**
881
+ * Parse CodeInfo string into structured properties
882
+ *
883
+ * @param codeInfo - The raw CodeInfo string (e.g., "tsx line-numbers{5} title=\"hello.tsx\" copy {2-4,5} /Hello/3-5")
884
+ * @returns Parsed CodeBlockProperties object
885
+ *
886
+ * @example
887
+ * ```typescript
888
+ * parseCodeInfo("tsx line-numbers{5} title=\"hello.tsx\" copy {2-4,5} /Hello/3-5")
889
+ * ```
890
+ *
891
+ * Returns:
892
+ * ```json
893
+ * {
894
+ * language: "tsx",
895
+ * lineNumbers: 5,
896
+ * title: "hello.tsx",
897
+ * copy: true,
898
+ * diff: false,
899
+ * highlightLines: [2,3,4,5],
900
+ * highlightText: [{ pattern: "Hello", instances: [3,4,5] }]
901
+ * }
902
+ * ```
903
+ */
904
+ parseCodeInfo(codeInfo: string): CodeBlockProperties {
905
+ const props: CodeBlockProperties = { language: "" };
906
+
907
+ if (!codeInfo || !codeInfo.trim()) {
908
+ return props;
909
+ }
910
+
911
+ let remaining = codeInfo.trim();
912
+
913
+ // Extract language (first token), but only when it isn't a known directive.
914
+ const firstTokenMatch = remaining.match(/^([^\s]+)/);
915
+ if (firstTokenMatch && firstTokenMatch[1]) {
916
+ const firstToken = firstTokenMatch[1];
917
+ if (!isCodeInfoDirective(firstToken)) {
918
+ props.language = firstToken;
919
+ remaining = remaining.slice(firstToken.length).trim();
920
+ }
921
+ }
922
+
923
+ // Extract quoted values (title="..." caption="...")
924
+ let quotedMatch;
925
+ while ((quotedMatch = QUOTED_INFO_PATTERN.exec(remaining)) !== null) {
926
+ const key = quotedMatch[1]?.toLowerCase();
927
+ const value = quotedMatch[2];
928
+
929
+ if (key === "title" && value !== undefined) {
930
+ props.title = value;
931
+ } else if (key === "caption" && value !== undefined) {
932
+ props.caption = value;
933
+ }
934
+ }
935
+ // Remove matched quoted values
936
+ remaining = remaining.replace(QUOTED_INFO_PATTERN, "").trim();
937
+
938
+ // Check for line numbers with optional start value.
939
+ // Supports both `line-numbers` and legacy `showLineNumbers` tokens.
940
+ const lineNumbersMatch = remaining.match(/\b(?:line-numbers|lineNumbers|showLineNumbers)(?:\{(\d+)\})?/i);
941
+ if (lineNumbersMatch) {
942
+ if (lineNumbersMatch[1]) {
943
+ props.showLineNumbers = parseInt(lineNumbersMatch[1], 10);
944
+ } else {
945
+ props.showLineNumbers = true;
946
+ }
947
+ remaining = remaining.replace(lineNumbersMatch[0], "").trim();
948
+ }
949
+
950
+ // Check for copy flag
951
+ if (/\bcopy\b/.test(remaining)) {
952
+ props.copy = true;
953
+ remaining = remaining.replace(/\bcopy\b/, "").trim();
954
+ }
955
+
956
+ // Check for diff flag
957
+ if (/\bdiff\b/.test(remaining)) {
958
+ props.diff = true;
959
+ remaining = remaining.replace(/\bdiff\b/, "").trim();
960
+ }
961
+
962
+ // Extract line highlights {2-4,5,9}
963
+ const lineHighlightMatch = remaining.match(/\{([^}]+)\}/);
964
+ if (lineHighlightMatch && lineHighlightMatch[1]) {
965
+ const highlightLines = this.parseNumberList(lineHighlightMatch[1]);
966
+
967
+ if (highlightLines.length > 0) {
968
+ props.highlightLines = highlightLines;
969
+ }
970
+ remaining = remaining.replace(lineHighlightMatch[0], "").trim();
971
+ }
972
+
973
+ // Extract text/regex highlights /pattern/ or /pattern/3-5 or /pattern/3,5
974
+ let textMatch;
975
+ const highlightText: TextHighlight[] = [];
976
+
977
+ while ((textMatch = TEXT_HIGHLIGHT_PATTERN.exec(remaining)) !== null) {
978
+ if (!textMatch[1]) continue;
979
+ const highlight: TextHighlight = {
980
+ pattern: textMatch[1],
981
+ };
982
+
983
+ // Parse instance selection if present
984
+ if (textMatch[2]) {
985
+ const instances = this.parseNumberList(textMatch[2]);
986
+
987
+ if (instances.length > 0) {
988
+ highlight.instances = instances;
989
+ }
990
+ }
991
+
992
+ highlightText.push(highlight);
993
+ }
994
+
995
+ if (highlightText.length > 0) {
996
+ props.highlightText = highlightText;
997
+ }
998
+
999
+ return props;
1000
+ }
1001
+
1002
+ /**
1003
+ * Build decorations for inline code and fenced code blocks.
1004
+ * Handles line numbers, highlights, header/caption widgets, and fence visibility.
1005
+ */
1006
+ buildDecorations(ctx: DecorationContext): void {
1007
+ const tree = syntaxTree(ctx.view.state);
1008
+
1009
+ tree.iterate({
1010
+ enter: (node) => {
1011
+ if (node.name === "InlineCode") {
1012
+ this.decorateInlineCode(node, ctx);
1013
+ return;
1014
+ }
1015
+
1016
+ if (node.name === "FencedCode") {
1017
+ this.decorateFencedCode(node, ctx);
1018
+ }
1019
+ },
1020
+ });
1021
+ }
1022
+
1023
+ private decorateInlineCode(node: { from: number; to: number; node: SyntaxNode }, ctx: DecorationContext): void {
1024
+ const { from, to } = node;
1025
+ ctx.decorations.push(codeMarkDecorations["inline-code"].range(from, to));
1026
+
1027
+ if (ctx.selectionOverlapsRange(from, to)) {
1028
+ return;
1029
+ }
1030
+
1031
+ for (let child = node.node.firstChild; child; child = child.nextSibling) {
1032
+ if (child.name === "CodeMark") {
1033
+ ctx.decorations.push(codeMarkDecorations["inline-mark"].range(child.from, child.to));
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ private decorateFencedCode(node: { from: number; to: number; node: SyntaxNode }, ctx: DecorationContext): void {
1039
+ const { view, decorations } = ctx;
1040
+ const nodeLineStart = view.state.doc.lineAt(node.from);
1041
+ const nodeLineEnd = view.state.doc.lineAt(node.to);
1042
+ const cursorInRange = ctx.selectionOverlapsRange(nodeLineStart.from, nodeLineEnd.to);
1043
+
1044
+ let infoProps: CodeBlockProperties = { language: "" };
1045
+ let codeContent = "";
1046
+ let codeInfo = "";
1047
+
1048
+ for (let child = node.node.firstChild; child; child = child.nextSibling) {
1049
+ if (child.name === "CodeInfo") {
1050
+ codeInfo = view.state.sliceDoc(child.from, child.to).trim();
1051
+ infoProps = this.parseCodeInfo(codeInfo);
1052
+ }
1053
+ if (child.name === "CodeText") {
1054
+ codeContent = view.state.sliceDoc(child.from, child.to);
1055
+ }
1056
+ }
1057
+
1058
+ const openingLineText = view.state.sliceDoc(nodeLineStart.from, nodeLineStart.to);
1059
+ const openingFenceMatch = openingLineText.match(/^(\s*)(```+|~~~+)/);
1060
+ const openingFence = openingFenceMatch ? `${openingFenceMatch[1] ?? ""}${openingFenceMatch[2] ?? CODE_FENCE}` : CODE_FENCE;
1061
+
1062
+ const codeLines: string[] = [];
1063
+ for (let i = nodeLineStart.number + 1; i <= nodeLineEnd.number - 1; i++) {
1064
+ const codeLine = view.state.doc.line(i);
1065
+ codeLines.push(view.state.sliceDoc(codeLine.from, codeLine.to));
1066
+ }
1067
+
1068
+ const totalCodeLines = nodeLineEnd.number - nodeLineStart.number - 1;
1069
+ const startLineNum = typeof infoProps.showLineNumbers === "number" ? infoProps.showLineNumbers : 1;
1070
+ const maxLineNum = startLineNum + totalCodeLines - 1;
1071
+ const lineNumWidth = Math.max(String(maxLineNum).length, String(startLineNum).length);
1072
+ const highlightInstanceCounters = new Array(infoProps.highlightText?.length ?? 0).fill(0);
1073
+
1074
+ const diffStates = infoProps.diff ? this.analyzeDiffLines(codeLines) : [];
1075
+ const diffDisplayLineNumbers = infoProps.diff ? this.computeDiffDisplayLineNumbers(diffStates, startLineNum) : [];
1076
+ const displayLineNumbers = infoProps.diff
1077
+ ? diffDisplayLineNumbers.map((numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index)
1078
+ : codeLines.map((_, index) => startLineNum + index);
1079
+ const diffHighlightLineNumbers = infoProps.diff
1080
+ ? this.computeDiffDisplayLineNumbers(diffStates, startLineNum).map(
1081
+ (numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index
1082
+ )
1083
+ : [];
1084
+ const maxOldDiffLineNum = diffDisplayLineNumbers.reduce((max, numbers) => {
1085
+ const oldLine = numbers.oldLine ?? 0;
1086
+ return oldLine > max ? oldLine : max;
1087
+ }, startLineNum);
1088
+ const maxNewDiffLineNum = diffDisplayLineNumbers.reduce((max, numbers) => {
1089
+ const newLine = numbers.newLine ?? 0;
1090
+ return newLine > max ? newLine : max;
1091
+ }, startLineNum);
1092
+ const diffOldLineNumWidth = Math.max(String(startLineNum).length, String(maxOldDiffLineNum).length);
1093
+ const diffNewLineNumWidth = Math.max(String(startLineNum).length, String(maxNewDiffLineNum).length);
1094
+
1095
+ const shouldShowCaption = !cursorInRange && !!infoProps.caption;
1096
+
1097
+ const firstContentLineNumber = nodeLineStart.number + 1;
1098
+ const lastContentLineNumber = nodeLineEnd.number - 1;
1099
+ const toolbarLineNumber =
1100
+ firstContentLineNumber <= lastContentLineNumber ? firstContentLineNumber : nodeLineStart.number;
1101
+ const toolbarLine = view.state.doc.line(toolbarLineNumber);
1102
+
1103
+ decorations.push(
1104
+ Decoration.widget({
1105
+ widget: new CodeBlockToolbarWidget(
1106
+ infoProps,
1107
+ codeContent,
1108
+ codeInfo,
1109
+ nodeLineStart.from,
1110
+ nodeLineStart.to,
1111
+ openingFence,
1112
+ cursorInRange
1113
+ ),
1114
+ block: false,
1115
+ side: -1,
1116
+ }).range(toolbarLine.from)
1117
+ );
1118
+
1119
+ let codeLineIndex = 0;
1120
+ for (let lineNumber = nodeLineStart.number; lineNumber <= nodeLineEnd.number; lineNumber++) {
1121
+ const line = view.state.doc.line(lineNumber);
1122
+ const isFenceLine = lineNumber === nodeLineStart.number || lineNumber === nodeLineEnd.number;
1123
+ const relativeLineNum = displayLineNumbers[codeLineIndex] ?? startLineNum + codeLineIndex;
1124
+
1125
+ if (isFenceLine) {
1126
+ decorations.push(codeMarkDecorations["code-fence-line"].range(line.from));
1127
+ continue;
1128
+ }
1129
+
1130
+ decorations.push(codeMarkDecorations["code-block-line"].range(line.from));
1131
+ if (!cursorInRange) {
1132
+ decorations.push(codeMarkDecorations["code-block-rendered"].range(line.from));
1133
+ }
1134
+
1135
+ if (lineNumber === firstContentLineNumber) {
1136
+ decorations.push(codeMarkDecorations["code-block-line-start"].range(line.from));
1137
+ }
1138
+
1139
+ if (lineNumber === lastContentLineNumber) {
1140
+ decorations.push(codeMarkDecorations["code-block-line-end"].range(line.from));
1141
+ if (shouldShowCaption) {
1142
+ decorations.push(Decoration.line({ class: "cm-mardora-code-block-has-caption" }).range(line.from));
1143
+ }
1144
+ }
1145
+
1146
+ if (firstContentLineNumber === lastContentLineNumber) {
1147
+ decorations.push(codeMarkDecorations["code-block-single-line"].range(line.from));
1148
+ }
1149
+
1150
+ if (!isFenceLine && infoProps.showLineNumbers && !infoProps.diff) {
1151
+ decorations.push(
1152
+ Decoration.line({
1153
+ class: "cm-mardora-code-line-numbered",
1154
+ attributes: {
1155
+ "data-line-num": String(relativeLineNum),
1156
+ style: `--line-num-width: ${lineNumWidth}ch`,
1157
+ },
1158
+ }).range(line.from)
1159
+ );
1160
+ }
1161
+
1162
+ if (!isFenceLine && infoProps.showLineNumbers && infoProps.diff) {
1163
+ const diffLineNumbers = diffDisplayLineNumbers[codeLineIndex];
1164
+ const diffState = diffStates[codeLineIndex];
1165
+ const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
1166
+ decorations.push(
1167
+ Decoration.line({
1168
+ class: "cm-mardora-code-line-numbered-diff",
1169
+ attributes: {
1170
+ "data-line-num-old": diffLineNumbers?.oldLine != null ? String(diffLineNumbers.oldLine) : "",
1171
+ "data-line-num-new": diffLineNumbers?.newLine != null ? String(diffLineNumbers.newLine) : "",
1172
+ "data-diff-marker": diffMarker,
1173
+ style: `--line-num-old-width: ${diffOldLineNumWidth}ch; --line-num-new-width: ${diffNewLineNumWidth}ch`,
1174
+ },
1175
+ }).range(line.from)
1176
+ );
1177
+ }
1178
+
1179
+ if (!isFenceLine && infoProps.diff) {
1180
+ this.decorateDiffLine(line, codeLineIndex, diffStates, cursorInRange, !infoProps.showLineNumbers, decorations);
1181
+ }
1182
+
1183
+ if (!isFenceLine && infoProps.highlightLines) {
1184
+ const highlightLineNumber = infoProps.diff
1185
+ ? (diffHighlightLineNumbers[codeLineIndex] ?? codeLineIndex + 1)
1186
+ : startLineNum + codeLineIndex;
1187
+ if (infoProps.highlightLines.includes(highlightLineNumber)) {
1188
+ decorations.push(codeMarkDecorations["code-line-highlight"].range(line.from));
1189
+ }
1190
+ }
1191
+
1192
+ if (!isFenceLine && infoProps.highlightText?.length) {
1193
+ this.decorateTextHighlights(
1194
+ line.from,
1195
+ view.state.sliceDoc(line.from, line.to),
1196
+ infoProps.highlightText,
1197
+ highlightInstanceCounters,
1198
+ decorations
1199
+ );
1200
+ }
1201
+
1202
+ if (!isFenceLine) {
1203
+ codeLineIndex++;
1204
+ }
1205
+ }
1206
+
1207
+ this.decorateFenceMarkers(node.node, decorations);
1208
+
1209
+ if (!cursorInRange && infoProps.caption) {
1210
+ decorations.push(
1211
+ Decoration.widget({
1212
+ widget: new CodeBlockCaptionWidget(infoProps.caption),
1213
+ block: false,
1214
+ side: 1,
1215
+ }).range(nodeLineEnd.to)
1216
+ );
1217
+ }
1218
+ }
1219
+
1220
+ private decorateFenceMarkers(
1221
+ node: SyntaxNode,
1222
+ decorations: DecorationContext["decorations"]
1223
+ ): void {
1224
+ for (let child = node.firstChild; child; child = child.nextSibling) {
1225
+ if (child.name === "CodeMark") {
1226
+ decorations.push(codeMarkDecorations["code-hidden"].range(child.from, child.to));
1227
+ continue;
1228
+ }
1229
+
1230
+ if (child.name === "CodeInfo") {
1231
+ decorations.push(codeMarkDecorations["code-hidden"].range(child.from, child.to));
1232
+ }
1233
+ }
1234
+ }
1235
+
1236
+ private decorateDiffLine(
1237
+ line: { from: number; to: number },
1238
+ codeLineIndex: number,
1239
+ diffStates: DiffLineState[],
1240
+ cursorInRange: boolean,
1241
+ showDiffMarkerGutter: boolean,
1242
+ decorations: DecorationContext["decorations"]
1243
+ ): void {
1244
+ const diffState = diffStates[codeLineIndex];
1245
+ const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
1246
+
1247
+ if (showDiffMarkerGutter) {
1248
+ decorations.push(
1249
+ Decoration.line({
1250
+ class: "cm-mardora-code-line-diff-gutter",
1251
+ attributes: {
1252
+ "data-diff-marker": diffMarker,
1253
+ },
1254
+ }).range(line.from)
1255
+ );
1256
+ }
1257
+
1258
+ if (diffState?.kind === "addition") {
1259
+ decorations.push(codeMarkDecorations["diff-line-add"].range(line.from));
1260
+ if (cursorInRange && line.to > line.from) {
1261
+ decorations.push(codeMarkDecorations["diff-sign-add"].range(line.from, line.from + 1));
1262
+ }
1263
+ }
1264
+
1265
+ if (diffState?.kind === "deletion") {
1266
+ decorations.push(codeMarkDecorations["diff-line-del"].range(line.from));
1267
+ if (cursorInRange && line.to > line.from) {
1268
+ decorations.push(codeMarkDecorations["diff-sign-del"].range(line.from, line.from + 1));
1269
+ }
1270
+ }
1271
+
1272
+ if (
1273
+ !cursorInRange &&
1274
+ line.to > line.from &&
1275
+ (diffState?.escapedMarker || diffState?.kind === "addition" || diffState?.kind === "deletion")
1276
+ ) {
1277
+ decorations.push(codeMarkDecorations["diff-escape-hidden"].range(line.from, line.from + 1));
1278
+ }
1279
+
1280
+ if (diffState?.modificationRanges?.length) {
1281
+ for (const [start, end] of diffState.modificationRanges) {
1282
+ const rangeFrom = line.from + diffState.contentOffset + start;
1283
+ const rangeTo = line.from + diffState.contentOffset + end;
1284
+ if (rangeTo > rangeFrom) {
1285
+ decorations.push(
1286
+ (diffState.kind === "addition"
1287
+ ? codeMarkDecorations["diff-mod-add"]
1288
+ : codeMarkDecorations["diff-mod-del"]
1289
+ ).range(rangeFrom, rangeTo)
1290
+ );
1291
+ }
1292
+ }
1293
+ }
1294
+ }
1295
+
1296
+ private decorateTextHighlights(
1297
+ lineFrom: number,
1298
+ lineText: string,
1299
+ highlights: TextHighlight[],
1300
+ instanceCounters: number[],
1301
+ decorations: DecorationContext["decorations"]
1302
+ ): void {
1303
+ for (const [highlightIndex, textHighlight] of highlights.entries()) {
1304
+ try {
1305
+ const regex = new RegExp(textHighlight.pattern, "g");
1306
+ let match: RegExpExecArray | null;
1307
+
1308
+ while ((match = regex.exec(lineText)) !== null) {
1309
+ instanceCounters[highlightIndex] = (instanceCounters[highlightIndex] ?? 0) + 1;
1310
+ const globalMatchIndex = instanceCounters[highlightIndex];
1311
+ const shouldHighlight = !textHighlight.instances || textHighlight.instances.includes(globalMatchIndex);
1312
+
1313
+ if (shouldHighlight) {
1314
+ const matchFrom = lineFrom + match.index;
1315
+ const matchTo = matchFrom + match[0].length;
1316
+ decorations.push(codeMarkDecorations["code-text-highlight"].range(matchFrom, matchTo));
1317
+ }
1318
+ }
1319
+ } catch {
1320
+ // Invalid regex; ignore this highlight pattern.
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ /**
1326
+ * Render code elements to HTML for static preview.
1327
+ * Applies syntax highlighting using @lezer/highlight.
1328
+ */
1329
+ override async renderToHTML(node: SyntaxNode, _children: string, ctx: PreviewRenderContext): Promise<string | null> {
1330
+ // Hide CodeMark (backticks)
1331
+ if (node.name === "CodeMark") {
1332
+ return "";
1333
+ }
1334
+
1335
+ // Inline code
1336
+ if (node.name === "InlineCode") {
1337
+ // Extract content without backticks
1338
+ let content = ctx.sliceDoc(node.from, node.to);
1339
+ // Remove leading and trailing backticks
1340
+ const match = content.match(/^`+(.+?)`+$/s);
1341
+ if (match && match[1]) {
1342
+ content = match[1];
1343
+ }
1344
+ return `<code class="cm-mardora-code-inline" style="padding: 0.1rem 0.25rem">${this.escapeHtml(content)}</code>`;
1345
+ }
1346
+
1347
+ // Fenced code block
1348
+ if (node.name === "FencedCode") {
1349
+ const content = ctx.sliceDoc(node.from, node.to);
1350
+ const lines = content.split("\n");
1351
+
1352
+ // Extract info string from first line (everything after ```)
1353
+ const firstLine = lines[0] || "";
1354
+ const infoMatch = firstLine.match(/^```(.*)$/);
1355
+ const infoString = infoMatch?.[1]?.trim() || "";
1356
+
1357
+ // Parse properties from info string
1358
+ const props = this.parseCodeInfo(infoString);
1359
+
1360
+ // Get code content (without fence lines)
1361
+ const codeLines = lines.slice(1, -1);
1362
+ const code = codeLines.join("\n");
1363
+
1364
+ // Build HTML parts
1365
+ let html = "";
1366
+
1367
+ // Wrapper container
1368
+ html += `<div class="cm-mardora-code-container">`;
1369
+
1370
+ html += `<div class="cm-mardora-code-toolbar">`;
1371
+ html += `<button class="cm-mardora-code-language-button" type="button"${props.language ? ` data-lang="${this.escapeAttribute(props.language)}"` : ""}>`;
1372
+ html += `<span>${this.escapeHtml(formatLanguageLabel(props.language))}</span>${CHEVRON_DOWN_ICON}`;
1373
+ html += `</button>`;
1374
+ if (props.copy !== false) {
1375
+ // Encode code as base64 to safely store in data attribute (preserves newlines and special chars)
1376
+ const encodedCode = encodeCodeCopyPayload(code);
1377
+ html += `<button class="cm-mardora-code-copy-btn" type="button" title="Copy code" data-code="${encodedCode}" data-encoded="true">`;
1378
+ html += COPY_ICON;
1379
+ html += `</button>`;
1380
+ }
1381
+ html += `</div>`;
1382
+
1383
+ // Calculate line number info
1384
+ const startLineNum = typeof props.showLineNumbers === "number" ? props.showLineNumbers : 1;
1385
+ const previewHighlightCounters = new Array(props.highlightText?.length ?? 0).fill(0);
1386
+ const diffStates = props.diff ? this.analyzeDiffLines(codeLines) : [];
1387
+ const previewDiffLineNumbers = props.diff ? this.computeDiffDisplayLineNumbers(diffStates, startLineNum) : [];
1388
+ const previewLineNumbers = props.diff
1389
+ ? previewDiffLineNumbers.map((numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index)
1390
+ : codeLines.map((_, index) => startLineNum + index);
1391
+ const previewHighlightLineNumbers = props.diff
1392
+ ? this.computeDiffDisplayLineNumbers(diffStates, startLineNum).map(
1393
+ (numbers, index) => numbers.newLine ?? numbers.oldLine ?? startLineNum + index
1394
+ )
1395
+ : [];
1396
+ const lineNumWidth = String(Math.max(...previewLineNumbers, startLineNum)).length;
1397
+ const previewOldLineNumWidth = String(
1398
+ Math.max(...previewDiffLineNumbers.map((numbers) => numbers.oldLine ?? 0), startLineNum)
1399
+ ).length;
1400
+ const previewNewLineNumWidth = String(
1401
+ Math.max(...previewDiffLineNumbers.map((numbers) => numbers.newLine ?? 0), startLineNum)
1402
+ ).length;
1403
+ const previewContentLines = props.diff ? diffStates.map((state) => state.content) : codeLines;
1404
+ const highlightedLines = await this.highlightCodeLines(
1405
+ previewContentLines.join("\n"),
1406
+ props.language || "",
1407
+ ctx.syntaxHighlighters
1408
+ );
1409
+
1410
+ // Code block with line processing
1411
+ const hasCaption = props.caption ? " cm-mardora-code-block-has-caption" : "";
1412
+ html += `<pre class="cm-mardora-code-block${hasCaption}"${props.language ? ` data-lang="${this.escapeAttribute(props.language)}"` : ""}>`;
1413
+ html += `<code>`;
1414
+
1415
+ // Process each line
1416
+ codeLines.forEach((line, index) => {
1417
+ const lineNum = previewLineNumbers[index] ?? startLineNum + index;
1418
+ const highlightLineNumber = props.diff
1419
+ ? (previewHighlightLineNumbers[index] ?? startLineNum + index)
1420
+ : startLineNum + index;
1421
+ const isHighlighted = props.highlightLines?.includes(highlightLineNumber);
1422
+ const diffState = props.diff ? diffStates[index] : undefined;
1423
+ const diffLineNumbers = props.diff ? previewDiffLineNumbers[index] : undefined;
1424
+
1425
+ // Line classes
1426
+ const lineClasses: string[] = ["cm-mardora-code-line"];
1427
+ if (isHighlighted) lineClasses.push("cm-mardora-code-line-highlight");
1428
+ if (props.showLineNumbers) {
1429
+ lineClasses.push(props.diff ? "cm-mardora-code-line-numbered-diff" : "cm-mardora-code-line-numbered");
1430
+ }
1431
+ if (diffState?.kind === "addition") lineClasses.push("cm-mardora-code-line-diff-add");
1432
+ if (diffState?.kind === "deletion") lineClasses.push("cm-mardora-code-line-diff-del");
1433
+
1434
+ // Line attributes
1435
+ const lineAttrs: string[] = [`class="${lineClasses.join(" ")}"`];
1436
+ if (props.showLineNumbers && !props.diff) {
1437
+ lineAttrs.push(`data-line-num="${lineNum}"`);
1438
+ lineAttrs.push(`style="--line-num-width: ${lineNumWidth}ch"`);
1439
+ }
1440
+ if (props.diff) {
1441
+ const diffMarker = diffState?.kind === "addition" ? "+" : diffState?.kind === "deletion" ? "-" : " ";
1442
+ if (props.showLineNumbers) {
1443
+ lineAttrs.push(`data-line-num-old="${diffLineNumbers?.oldLine != null ? diffLineNumbers.oldLine : ""}"`);
1444
+ lineAttrs.push(`data-line-num-new="${diffLineNumbers?.newLine != null ? diffLineNumbers.newLine : ""}"`);
1445
+ lineAttrs.push(`data-diff-marker="${diffMarker}"`);
1446
+ lineAttrs.push(
1447
+ `style="--line-num-old-width: ${previewOldLineNumWidth}ch; --line-num-new-width: ${previewNewLineNumWidth}ch"`
1448
+ );
1449
+ } else {
1450
+ lineAttrs.push(`data-diff-marker="${diffMarker}"`);
1451
+ lineClasses.push("cm-mardora-code-line-diff-gutter");
1452
+ lineAttrs[0] = `class="${lineClasses.join(" ")}"`;
1453
+ }
1454
+ }
1455
+
1456
+ // Highlight text content
1457
+ const highlightedLine = highlightedLines[index] ?? this.escapeHtml(previewContentLines[index] ?? line);
1458
+ let lineContent = highlightedLine;
1459
+
1460
+ if (diffState) {
1461
+ lineContent = this.renderDiffPreviewLine(diffState, highlightedLine);
1462
+ }
1463
+
1464
+ // Apply text highlights
1465
+ if (props.highlightText && props.highlightText.length > 0) {
1466
+ lineContent = this.applyTextHighlights(lineContent, props.highlightText, previewHighlightCounters);
1467
+ }
1468
+
1469
+ html += `<span ${lineAttrs.join(" ")}>${lineContent || " "}</span>`;
1470
+ });
1471
+
1472
+ html += `</code></pre>`;
1473
+
1474
+ // Caption
1475
+ if (props.caption) {
1476
+ html += `<div class="cm-mardora-code-caption">${this.escapeHtml(props.caption)}</div>`;
1477
+ }
1478
+
1479
+ // Close wrapper container
1480
+ html += `</div>`;
1481
+
1482
+ return html;
1483
+ }
1484
+
1485
+ // Hide CodeInfo and CodeText - they're handled by FencedCode
1486
+ if (node.name === "CodeInfo" || node.name === "CodeText") {
1487
+ return "";
1488
+ }
1489
+
1490
+ return null;
1491
+ }
1492
+
1493
+ /** Parse comma-separated numbers and ranges (e.g. "1,3-5") into [1,3,4,5]. */
1494
+ private parseNumberList(value: string): number[] {
1495
+ const result: number[] = [];
1496
+
1497
+ for (const part of value.split(",")) {
1498
+ const trimmed = part.trim();
1499
+ const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/);
1500
+
1501
+ if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {
1502
+ const start = parseInt(rangeMatch[1], 10);
1503
+ const end = parseInt(rangeMatch[2], 10);
1504
+ for (let i = start; i <= end; i++) {
1505
+ result.push(i);
1506
+ }
1507
+ continue;
1508
+ }
1509
+
1510
+ if (/^\d+$/.test(trimmed)) {
1511
+ result.push(parseInt(trimmed, 10));
1512
+ }
1513
+ }
1514
+
1515
+ return result;
1516
+ }
1517
+
1518
+ /**
1519
+ * Highlight a single line of code using the language's Lezer parser.
1520
+ * Falls back to sanitized plain text if the language is not supported.
1521
+ */
1522
+ private async highlightCodeLines(
1523
+ code: string,
1524
+ lang: string,
1525
+ syntaxHighlighters?: readonly Highlighter[]
1526
+ ): Promise<string[]> {
1527
+ const rawLines = code.split("\n");
1528
+ if (!lang || !code) {
1529
+ return rawLines.map((line) => this.escapeHtml(line));
1530
+ }
1531
+
1532
+ const parser = await this.resolveLanguageParser(lang);
1533
+ if (!parser) {
1534
+ return rawLines.map((line) => this.escapeHtml(line));
1535
+ }
1536
+
1537
+ try {
1538
+ const tree = parser.parse(code);
1539
+ const highlightedLines: string[] = [""];
1540
+
1541
+ highlightCode(
1542
+ code,
1543
+ tree,
1544
+ syntaxHighlighters && syntaxHighlighters.length > 0 ? syntaxHighlighters : [],
1545
+ (text, classes) => {
1546
+ const chunk = classes
1547
+ ? `<span class="${this.escapeAttribute(classes)}">${this.escapeHtml(text)}</span>`
1548
+ : this.escapeHtml(text);
1549
+ highlightedLines[highlightedLines.length - 1] += chunk;
1550
+ },
1551
+ () => {
1552
+ highlightedLines.push("");
1553
+ }
1554
+ );
1555
+
1556
+ return rawLines.map((line, index) => highlightedLines[index] || this.escapeHtml(line));
1557
+ } catch {
1558
+ return rawLines.map((line) => this.escapeHtml(line));
1559
+ }
1560
+ }
1561
+
1562
+ private async resolveLanguageParser(lang: string): Promise<Parser | null> {
1563
+ const normalizedLang = this.normalizeLanguage(lang);
1564
+ if (!normalizedLang) return null;
1565
+
1566
+ const cached = this.parserCache.get(normalizedLang);
1567
+ if (cached) return cached;
1568
+
1569
+ const parserPromise = (async () => {
1570
+ const langDesc = LanguageDescription.matchLanguageName(languages, normalizedLang, true);
1571
+
1572
+ if (!langDesc) return null;
1573
+
1574
+ if (langDesc.support) {
1575
+ return langDesc.support.language.parser;
1576
+ }
1577
+
1578
+ if (typeof langDesc.load === "function") {
1579
+ try {
1580
+ const support = await langDesc.load();
1581
+ return support.language.parser;
1582
+ } catch {
1583
+ return null;
1584
+ }
1585
+ }
1586
+
1587
+ return null;
1588
+ })();
1589
+
1590
+ this.parserCache.set(normalizedLang, parserPromise);
1591
+ return parserPromise;
1592
+ }
1593
+
1594
+ private normalizeLanguage(lang: string): string {
1595
+ const normalized = lang.trim().toLowerCase();
1596
+ if (!normalized) return "";
1597
+
1598
+ const normalizedMap: Record<string, string> = {
1599
+ "c++": "cpp",
1600
+ "c#": "csharp",
1601
+ "f#": "fsharp",
1602
+ py: "python",
1603
+ js: "javascript",
1604
+ ts: "typescript",
1605
+ sh: "shell",
1606
+ };
1607
+
1608
+ return normalizedMap[normalized] ?? normalized;
1609
+ }
1610
+
1611
+ private escapeHtml(value: string): string {
1612
+ return value
1613
+ .replace(/&/g, "&amp;")
1614
+ .replace(/</g, "&lt;")
1615
+ .replace(/>/g, "&gt;")
1616
+ .replace(/"/g, "&quot;")
1617
+ .replace(/'/g, "&#39;");
1618
+ }
1619
+
1620
+ private escapeAttribute(value: string): string {
1621
+ return this.escapeHtml(value).replace(/`/g, "&#96;");
1622
+ }
1623
+
1624
+ private analyzeDiffLines(lines: string[]): DiffLineState[] {
1625
+ const states = lines.map((line) => this.parseDiffLineState(line));
1626
+
1627
+ let index = 0;
1628
+ while (index < states.length) {
1629
+ if (states[index]?.kind !== "deletion") {
1630
+ index++;
1631
+ continue;
1632
+ }
1633
+
1634
+ const deletionStart = index;
1635
+ while (index < states.length && states[index]?.kind === "deletion") {
1636
+ index++;
1637
+ }
1638
+ const deletionEnd = index;
1639
+
1640
+ const additionStart = index;
1641
+ while (index < states.length && states[index]?.kind === "addition") {
1642
+ index++;
1643
+ }
1644
+ const additionEnd = index;
1645
+
1646
+ if (additionStart === additionEnd) {
1647
+ continue;
1648
+ }
1649
+
1650
+ const pairCount = Math.min(deletionEnd - deletionStart, additionEnd - additionStart);
1651
+ for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
1652
+ const deletionState = states[deletionStart + pairIndex];
1653
+ const additionState = states[additionStart + pairIndex];
1654
+
1655
+ if (!deletionState || !additionState) {
1656
+ continue;
1657
+ }
1658
+
1659
+ const ranges = this.computeChangedRanges(deletionState.content, additionState.content);
1660
+ if (ranges.oldRanges.length > 0) {
1661
+ deletionState.modificationRanges = ranges.oldRanges;
1662
+ }
1663
+ if (ranges.newRanges.length > 0) {
1664
+ additionState.modificationRanges = ranges.newRanges;
1665
+ }
1666
+ }
1667
+ }
1668
+
1669
+ return states;
1670
+ }
1671
+
1672
+ private computeDiffDisplayLineNumbers(states: DiffLineState[], startLineNum: number): DiffDisplayLineNumbers[] {
1673
+ const numbers: DiffDisplayLineNumbers[] = [];
1674
+ let oldLineNumber = startLineNum;
1675
+ let newLineNumber = startLineNum;
1676
+
1677
+ for (const state of states) {
1678
+ if (state.kind === "deletion") {
1679
+ numbers.push({ oldLine: oldLineNumber, newLine: null });
1680
+ oldLineNumber++;
1681
+ continue;
1682
+ }
1683
+
1684
+ if (state.kind === "addition") {
1685
+ numbers.push({ oldLine: null, newLine: newLineNumber });
1686
+ newLineNumber++;
1687
+ continue;
1688
+ }
1689
+
1690
+ numbers.push({ oldLine: oldLineNumber, newLine: newLineNumber });
1691
+ oldLineNumber++;
1692
+ newLineNumber++;
1693
+ }
1694
+
1695
+ return numbers;
1696
+ }
1697
+
1698
+ private parseDiffLineState(line: string): DiffLineState {
1699
+ const escapedMarker = line.startsWith("\\+") || line.startsWith("\\-");
1700
+
1701
+ if (escapedMarker) {
1702
+ return {
1703
+ kind: "normal",
1704
+ content: line.slice(1),
1705
+ contentOffset: 1,
1706
+ escapedMarker: true,
1707
+ };
1708
+ }
1709
+
1710
+ if (line.startsWith("+")) {
1711
+ return {
1712
+ kind: "addition",
1713
+ content: line.slice(1),
1714
+ contentOffset: 1,
1715
+ escapedMarker: false,
1716
+ };
1717
+ }
1718
+
1719
+ if (line.startsWith("-")) {
1720
+ return {
1721
+ kind: "deletion",
1722
+ content: line.slice(1),
1723
+ contentOffset: 1,
1724
+ escapedMarker: false,
1725
+ };
1726
+ }
1727
+
1728
+ return {
1729
+ kind: "normal",
1730
+ content: line,
1731
+ contentOffset: 0,
1732
+ escapedMarker: false,
1733
+ };
1734
+ }
1735
+
1736
+ private computeChangedRanges(
1737
+ oldText: string,
1738
+ newText: string
1739
+ ): { oldRanges: Array<[number, number]>; newRanges: Array<[number, number]> } {
1740
+ let prefix = 0;
1741
+ while (prefix < oldText.length && prefix < newText.length && oldText[prefix] === newText[prefix]) {
1742
+ prefix++;
1743
+ }
1744
+
1745
+ let oldSuffix = oldText.length;
1746
+ let newSuffix = newText.length;
1747
+ while (oldSuffix > prefix && newSuffix > prefix && oldText[oldSuffix - 1] === newText[newSuffix - 1]) {
1748
+ oldSuffix--;
1749
+ newSuffix--;
1750
+ }
1751
+
1752
+ const oldRanges: Array<[number, number]> = [];
1753
+ const newRanges: Array<[number, number]> = [];
1754
+
1755
+ if (oldSuffix > prefix) {
1756
+ oldRanges.push([prefix, oldSuffix]);
1757
+ }
1758
+ if (newSuffix > prefix) {
1759
+ newRanges.push([prefix, newSuffix]);
1760
+ }
1761
+
1762
+ return { oldRanges, newRanges };
1763
+ }
1764
+
1765
+ private renderDiffPreviewLine(diffState: DiffLineState, highlightedContent: string): string {
1766
+ const modClass =
1767
+ diffState.kind === "addition"
1768
+ ? "cm-mardora-code-diff-mod-add"
1769
+ : diffState.kind === "deletion"
1770
+ ? "cm-mardora-code-diff-mod-del"
1771
+ : "";
1772
+
1773
+ const baseHighlightedContent = highlightedContent || this.escapeHtml(diffState.content);
1774
+
1775
+ const contentHtml =
1776
+ diffState.modificationRanges && modClass
1777
+ ? this.applyRangesToHighlightedHTML(baseHighlightedContent, diffState.modificationRanges, modClass)
1778
+ : baseHighlightedContent;
1779
+
1780
+ return contentHtml || " ";
1781
+ }
1782
+
1783
+ private applyRangesToHighlightedHTML(
1784
+ htmlContent: string,
1785
+ ranges: Array<[number, number]>,
1786
+ className: string
1787
+ ): string {
1788
+ const normalizedRanges = ranges
1789
+ .map(([start, end]) => [Math.max(0, start), Math.max(0, end)] as [number, number])
1790
+ .filter(([start, end]) => end > start)
1791
+ .sort((a, b) => a[0] - b[0]);
1792
+
1793
+ if (normalizedRanges.length === 0 || !htmlContent) {
1794
+ return htmlContent;
1795
+ }
1796
+
1797
+ const isInsideRange = (position: number) => {
1798
+ for (const [start, end] of normalizedRanges) {
1799
+ if (position >= start && position < end) return true;
1800
+ if (position < start) return false;
1801
+ }
1802
+ return false;
1803
+ };
1804
+
1805
+ let result = "";
1806
+ let htmlIndex = 0;
1807
+ let textPosition = 0;
1808
+ let markOpen = false;
1809
+
1810
+ while (htmlIndex < htmlContent.length) {
1811
+ const char = htmlContent[htmlIndex];
1812
+
1813
+ if (char === "<") {
1814
+ const tagEnd = htmlContent.indexOf(">", htmlIndex);
1815
+ if (tagEnd === -1) {
1816
+ result += htmlContent.slice(htmlIndex);
1817
+ break;
1818
+ }
1819
+ result += htmlContent.slice(htmlIndex, tagEnd + 1);
1820
+ htmlIndex = tagEnd + 1;
1821
+ continue;
1822
+ }
1823
+
1824
+ let token = char;
1825
+ if (char === "&") {
1826
+ const entityEnd = htmlContent.indexOf(";", htmlIndex);
1827
+ if (entityEnd !== -1) {
1828
+ token = htmlContent.slice(htmlIndex, entityEnd + 1);
1829
+ htmlIndex = entityEnd + 1;
1830
+ } else {
1831
+ htmlIndex += 1;
1832
+ }
1833
+ } else {
1834
+ htmlIndex += 1;
1835
+ }
1836
+
1837
+ const shouldMark = isInsideRange(textPosition);
1838
+
1839
+ if (shouldMark && !markOpen) {
1840
+ result += `<mark class="${className}">`;
1841
+ markOpen = true;
1842
+ }
1843
+ if (!shouldMark && markOpen) {
1844
+ result += "</mark>";
1845
+ markOpen = false;
1846
+ }
1847
+
1848
+ result += token;
1849
+ textPosition += 1;
1850
+ }
1851
+
1852
+ if (markOpen) {
1853
+ result += "</mark>";
1854
+ }
1855
+
1856
+ return result;
1857
+ }
1858
+
1859
+ /**
1860
+ * Apply text highlights (regex patterns) to already syntax-highlighted HTML.
1861
+ * Wraps matched patterns in `<mark>` elements.
1862
+ */
1863
+ private applyTextHighlights(htmlContent: string, highlights: TextHighlight[], instanceCounters?: number[]): string {
1864
+ let result = htmlContent;
1865
+
1866
+ for (const [highlightIndex, highlight] of highlights.entries()) {
1867
+ try {
1868
+ // Create regex from pattern
1869
+ const regex = new RegExp(`(${highlight.pattern})`, "g");
1870
+ let matchCount = instanceCounters?.[highlightIndex] ?? 0;
1871
+
1872
+ result = result.replace(regex, (match) => {
1873
+ matchCount++;
1874
+ // Check if this instance should be highlighted
1875
+ const shouldHighlight = !highlight.instances || highlight.instances.includes(matchCount);
1876
+ if (shouldHighlight) {
1877
+ return `<mark class="cm-mardora-code-text-highlight">${match}</mark>`;
1878
+ }
1879
+ return match;
1880
+ });
1881
+
1882
+ if (instanceCounters) {
1883
+ instanceCounters[highlightIndex] = matchCount;
1884
+ }
1885
+ } catch {
1886
+ // Invalid regex, skip
1887
+ }
1888
+ }
1889
+
1890
+ return result;
1891
+ }
1892
+ }