@lexical/mdast 0.0.0-bootstrap.0 → 0.47.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.
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {MdastBlockMatch} from './MdastStream';
10
+ import type {CompiledMdast} from './types';
11
+ import type {ElementNode, LexicalEditor, LexicalNode, TextNode} from 'lexical';
12
+
13
+ import {$createCodeNode, $isCodeNode} from '@lexical/code-core';
14
+ import {
15
+ $createListItemNode,
16
+ $createListNode,
17
+ $isListItemNode,
18
+ $isListNode,
19
+ } from '@lexical/list';
20
+ import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
21
+ import {
22
+ $addUpdateTag,
23
+ $getNodeByKey,
24
+ $getSelection,
25
+ $isParagraphNode,
26
+ $isRangeSelection,
27
+ $isRootOrShadowRoot,
28
+ $isTextNode,
29
+ $setState,
30
+ COLLABORATION_TAG,
31
+ COMMAND_PRIORITY_BEFORE_EDITOR,
32
+ COMPOSITION_END_TAG,
33
+ HISTORIC_TAG,
34
+ HISTORY_PUSH_TAG,
35
+ KEY_ENTER_COMMAND,
36
+ mergeRegister,
37
+ } from 'lexical';
38
+
39
+ import {$append, $listTypeFromMdast} from './handlers';
40
+ import {MarkdownStreamScanner} from './MdastStream';
41
+ import {codeFenceState, codeMetaState} from './state';
42
+
43
+ /**
44
+ * Block markers are at most a few characters (`'###### '`, `' 999. '`,
45
+ * `'- [x] '`); when the caret is past this column a space cannot complete a
46
+ * block marker, so the micromark scan is skipped entirely.
47
+ */
48
+ const MAX_BLOCK_MARKER_LENGTH = 24;
49
+
50
+ /** Removes the first `n` characters from the leading text nodes of `element`. */
51
+ function $stripLeading(element: ElementNode, n: number): void {
52
+ let remaining = n;
53
+ let child = element.getFirstChild();
54
+ while (remaining > 0 && child) {
55
+ if (!$isTextNode(child)) {
56
+ break;
57
+ }
58
+ const text = child.getTextContent();
59
+ if (text.length <= remaining) {
60
+ const next = child.getNextSibling();
61
+ remaining -= text.length;
62
+ child.remove();
63
+ child = next;
64
+ } else {
65
+ child.setTextContent(text.slice(remaining));
66
+ remaining = 0;
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Replaces `paragraph` with the block construct described by `match`, keeping
73
+ * the content that followed the marker.
74
+ */
75
+ function $applyBlock(paragraph: ElementNode, match: MdastBlockMatch): boolean {
76
+ // Capture the fence before the marker is stripped from the paragraph.
77
+ const fence =
78
+ match.kind === 'code'
79
+ ? paragraph.getTextContent().match(/^[ \t]*(`{3,}|~{3,})/)
80
+ : null;
81
+ $stripLeading(paragraph, match.markerLength);
82
+ const remaining = paragraph.getChildren();
83
+
84
+ if (match.kind === 'code') {
85
+ const code = $createCodeNode(match.node.lang || undefined);
86
+ // Keep the typed fence and info-string tail so export reproduces them.
87
+ if (fence) {
88
+ $setState(code, codeFenceState, fence[1]);
89
+ }
90
+ if (match.node.meta) {
91
+ $setState(code, codeMetaState, match.node.meta);
92
+ }
93
+ $append(code, remaining);
94
+ paragraph.replace(code);
95
+ code.selectStart();
96
+ return true;
97
+ }
98
+
99
+ let target: ElementNode;
100
+ let selectInto: ElementNode;
101
+ if (match.kind === 'heading') {
102
+ const heading = $append(
103
+ $createHeadingNode(`h${match.node.depth}`),
104
+ remaining,
105
+ );
106
+ target = heading;
107
+ selectInto = heading;
108
+ } else if (match.kind === 'blockquote') {
109
+ const quote = $append($createQuoteNode(), remaining);
110
+ target = quote;
111
+ selectInto = quote;
112
+ } else {
113
+ const listNode = match.node;
114
+ const listType = $listTypeFromMdast(listNode);
115
+ const start =
116
+ listNode.ordered && listNode.start != null ? listNode.start : 1;
117
+ const list = $createListNode(listType, start);
118
+ const firstItem = listNode.children[0];
119
+ const checked =
120
+ firstItem &&
121
+ firstItem.type === 'listItem' &&
122
+ typeof firstItem.checked === 'boolean'
123
+ ? firstItem.checked
124
+ : undefined;
125
+ const item = $append($createListItemNode(checked), remaining);
126
+ $append(list, [item]);
127
+ target = list;
128
+ selectInto = item;
129
+ }
130
+
131
+ paragraph.replace(target);
132
+ selectInto.selectStart();
133
+ return true;
134
+ }
135
+
136
+ /**
137
+ * Cheap check that `text` (up to the caret) could plausibly contain a closed
138
+ * inline construct ending in `closeChar`, before paying for a micromark parse.
139
+ * A closing `)` needs a `](` link infix; any other delimiter needs an earlier
140
+ * occurrence of itself to act as the opener.
141
+ */
142
+ function mayCloseInlineConstruct(text: string, closeChar: string): boolean {
143
+ return closeChar === ')'
144
+ ? text.lastIndexOf('](') > 0
145
+ : text.lastIndexOf(closeChar, text.length - 2) !== -1;
146
+ }
147
+
148
+ /**
149
+ * Materializes the inline construct ending at `anchorOffset` inside
150
+ * `anchorNode`, replacing the raw markdown span with formatted Lexical nodes.
151
+ */
152
+ function $applyInline(
153
+ anchorNode: TextNode,
154
+ anchorOffset: number,
155
+ scanner: MarkdownStreamScanner,
156
+ ): boolean {
157
+ const upTo = anchorNode.getTextContent().slice(0, anchorOffset);
158
+ const inlineNode = scanner.scanInline(upTo);
159
+ if (!inlineNode || !inlineNode.position) {
160
+ return false;
161
+ }
162
+ const start = inlineNode.position.start.offset ?? 0;
163
+ const end = anchorOffset;
164
+
165
+ let target: TextNode;
166
+ if (start <= 0) {
167
+ [target] = anchorNode.splitText(end);
168
+ } else {
169
+ const parts = anchorNode.splitText(start, end);
170
+ target = parts.length === 3 ? parts[1] : parts[parts.length - 1];
171
+ }
172
+
173
+ const lexicalNodes = scanner.importInline(inlineNode);
174
+ if (lexicalNodes.length === 0) {
175
+ return false;
176
+ }
177
+
178
+ let prev: LexicalNode = lexicalNodes[0];
179
+ target.replace(prev);
180
+ for (let i = 1; i < lexicalNodes.length; i++) {
181
+ prev.insertAfter(lexicalNodes[i]);
182
+ prev = lexicalNodes[i];
183
+ }
184
+
185
+ if ($isTextNode(prev)) {
186
+ const size = prev.getTextContentSize();
187
+ prev.select(size, size);
188
+ } else {
189
+ prev.selectNext(0, 0);
190
+ }
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * Matches a GFM task-list checkbox marker (`[ ] `, `[x] `) typed at line
196
+ * start. Exactly one character is required between the brackets, matching
197
+ * micromark's gfm-task-list-item grammar — `[]` is not a checkbox.
198
+ */
199
+ const CHECKBOX_REGEX = /^\[([ xX])\]\s$/;
200
+
201
+ /**
202
+ * Handles a checkbox marker typed at the start of a line. micromark only
203
+ * recognizes a task-list item once it already has a list-item prefix, so by
204
+ * the time the user types `[ ] ` the paragraph is usually already a bullet
205
+ * list item (from the earlier `- ` shortcut). This promotes that item — or a
206
+ * bare paragraph — to a Lexical check list.
207
+ */
208
+ function $tryCheckbox(
209
+ anchorNode: TextNode,
210
+ parent: ElementNode,
211
+ anchorOffset: number,
212
+ ): boolean {
213
+ if (parent.getFirstChild() !== anchorNode) {
214
+ return false;
215
+ }
216
+ const prefix = parent.getTextContent().slice(0, anchorOffset);
217
+ const match = prefix.match(CHECKBOX_REGEX);
218
+ if (!match) {
219
+ return false;
220
+ }
221
+ const checked = match[1].toLowerCase() === 'x';
222
+
223
+ if ($isListItemNode(parent)) {
224
+ const list = parent.getParent();
225
+ if (!$isListNode(list) || list.getListType() === 'number') {
226
+ return false;
227
+ }
228
+ $stripLeading(parent, match[0].length);
229
+ list.setListType('check');
230
+ parent.setChecked(checked);
231
+ return true;
232
+ }
233
+
234
+ if ($isShortcutParagraph(parent, anchorNode)) {
235
+ $stripLeading(parent, match[0].length);
236
+ const remaining = parent.getChildren();
237
+ const list = $createListNode('check');
238
+ const item = $append($createListItemNode(checked), remaining);
239
+ $append(list, [item]);
240
+ parent.replace(list);
241
+ item.selectStart();
242
+ return true;
243
+ }
244
+
245
+ return false;
246
+ }
247
+
248
+ function $isShortcutParagraph(
249
+ node: LexicalNode,
250
+ anchorNode: TextNode,
251
+ ): boolean {
252
+ return (
253
+ $isParagraphNode(node) &&
254
+ $isRootOrShadowRoot(node.getParent()) &&
255
+ node.getFirstChild() === anchorNode
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Registers streaming Markdown shortcuts on `editor` from the
261
+ * {@link CompiledMdast} registry. As the user types, the current line/inline
262
+ * buffer is fed back through micromark (the same parser as full-document
263
+ * import) and recognized constructs are transformed in place:
264
+ *
265
+ * - Block markers (`# `, `> `, `- `, `1. `, `- [ ] `) convert the paragraph
266
+ * into the matching Lexical block as soon as the trailing space is typed.
267
+ * - A marker-only line (`` ```lang ``, `## `, `- `) converts on
268
+ * <kbd>Enter</kbd>.
269
+ * - Inline constructs (`*em*`, `**strong**`, `` `code` ``, `~~del~~`,
270
+ * `[text](url)`, plus registered `inlineShortcutTypes`) convert when their
271
+ * closing delimiter is typed.
272
+ *
273
+ * Wired up by {@link MdastShortcutsExtension}; this is an internal helper, not
274
+ * part of the package's public API.
275
+ */
276
+ export function registerMarkdownShortcuts(
277
+ editor: LexicalEditor,
278
+ compiled: CompiledMdast,
279
+ ): () => void {
280
+ const scanner = new MarkdownStreamScanner(compiled);
281
+ const inlineTriggers = scanner.inlineTriggers;
282
+ // Composition end fires per IME commit (every CJK syllable, dead-key
283
+ // resolve, ...). Only enter the transformer pass when the just-committed
284
+ // character can plausibly close a trigger.
285
+ const compositionEndTriggers = new Set<string>([' ', ...inlineTriggers]);
286
+
287
+ return mergeRegister(
288
+ editor.registerUpdateListener(
289
+ ({tags, dirtyLeaves, editorState, prevEditorState}) => {
290
+ // Ignore updates from collaboration and undo/redo (changes already
291
+ // calculated), and anything that dirtied no leaves (pure selection
292
+ // moves) before paying for any editor-state reads.
293
+ if (
294
+ dirtyLeaves.size === 0 ||
295
+ tags.has(COLLABORATION_TAG) ||
296
+ tags.has(HISTORIC_TAG)
297
+ ) {
298
+ return;
299
+ }
300
+ // If the editor is still composing we must wait for the commit.
301
+ if (editor.isComposing()) {
302
+ return;
303
+ }
304
+ // A composition commit lands without moving the selection (and may
305
+ // commit several characters at once), so it bypasses the typed-one-
306
+ // character heuristics below.
307
+ const isCompositionEnd = tags.has(COMPOSITION_END_TAG);
308
+
309
+ const selection = editorState.read($getSelection);
310
+ const prevSelection = prevEditorState.read($getSelection);
311
+ if (
312
+ !$isRangeSelection(selection) ||
313
+ !$isRangeSelection(prevSelection) ||
314
+ !selection.isCollapsed() ||
315
+ (selection.is(prevSelection) && !isCompositionEnd)
316
+ ) {
317
+ return;
318
+ }
319
+ const anchorKey = selection.anchor.key;
320
+ const anchorOffset = selection.anchor.offset;
321
+ const anchorNode = editorState._nodeMap.get(anchorKey);
322
+ if (!$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey)) {
323
+ return;
324
+ }
325
+ // Only react to a single typed character: the caret must have
326
+ // advanced exactly one position (or sit right after the first
327
+ // character of a fresh node). This keeps paste, drag-drop, and
328
+ // deletions — which can leave the caret after a delimiter — from
329
+ // firing destructive transforms.
330
+ if (
331
+ !isCompositionEnd &&
332
+ anchorOffset !== 1 &&
333
+ !(
334
+ prevSelection.anchor.key === anchorKey &&
335
+ anchorOffset === prevSelection.anchor.offset + 1
336
+ )
337
+ ) {
338
+ return;
339
+ }
340
+ const textContent = editorState.read(() => anchorNode.getTextContent());
341
+ const typedChar = textContent[anchorOffset - 1];
342
+ if (isCompositionEnd && !compositionEndTriggers.has(typedChar)) {
343
+ return;
344
+ }
345
+ if (typedChar !== ' ' && !inlineTriggers.has(typedChar)) {
346
+ return;
347
+ }
348
+ // Cheap prefilter: an inline construct needs an opener earlier in the
349
+ // text; skip the micromark parse when there is none.
350
+ if (
351
+ typedChar !== ' ' &&
352
+ !mayCloseInlineConstruct(
353
+ textContent.slice(0, anchorOffset),
354
+ typedChar,
355
+ )
356
+ ) {
357
+ return;
358
+ }
359
+
360
+ editor.update(() => {
361
+ const node = $getNodeByKey(anchorKey);
362
+ if (!$isTextNode(node) || node.hasFormat('code')) {
363
+ // Per CommonMark, code spans take precedence over any other
364
+ // inline construct; never transform inside one.
365
+ return;
366
+ }
367
+ const parent = node.getParent();
368
+ if (parent === null || $isCodeNode(parent)) {
369
+ return;
370
+ }
371
+
372
+ let transformed = false;
373
+ if (typedChar === ' ') {
374
+ if (
375
+ scanner.supportsTaskListItems &&
376
+ $tryCheckbox(node, parent, anchorOffset)
377
+ ) {
378
+ transformed = true;
379
+ } else if (
380
+ anchorOffset <= MAX_BLOCK_MARKER_LENGTH &&
381
+ $isShortcutParagraph(parent, node)
382
+ ) {
383
+ // The marker must end exactly at the caret, so scanning the
384
+ // prefix is sufficient (and cheaper than the whole line).
385
+ const match = scanner.scanBlock(
386
+ node.getTextContent().slice(0, anchorOffset),
387
+ );
388
+ if (
389
+ match &&
390
+ match.kind !== 'code' &&
391
+ match.markerLength === anchorOffset
392
+ ) {
393
+ transformed = $applyBlock(parent, match);
394
+ }
395
+ }
396
+ } else {
397
+ transformed = $applyInline(node, anchorOffset, scanner);
398
+ }
399
+
400
+ if (transformed) {
401
+ $addUpdateTag(HISTORY_PUSH_TAG);
402
+ }
403
+ });
404
+ },
405
+ ),
406
+ editor.registerCommand(
407
+ KEY_ENTER_COMMAND,
408
+ event => {
409
+ if (event !== null && event.shiftKey) {
410
+ return false;
411
+ }
412
+ const selection = $getSelection();
413
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
414
+ return false;
415
+ }
416
+ const anchorNode = selection.anchor.getNode();
417
+ if (!$isTextNode(anchorNode) || anchorNode.hasFormat('code')) {
418
+ return false;
419
+ }
420
+ const parent = anchorNode.getParent();
421
+ const anchorOffset = selection.anchor.offset;
422
+ if (
423
+ parent === null ||
424
+ $isCodeNode(parent) ||
425
+ !$isShortcutParagraph(parent, anchorNode) ||
426
+ anchorOffset !== anchorNode.getTextContentSize()
427
+ ) {
428
+ return false;
429
+ }
430
+ const match = scanner.scanBlock(parent.getTextContent());
431
+ // Only convert when the whole line is the marker (`## `, `- `,
432
+ // '```lang title=x'). A line with content after the marker was either
433
+ // already converted at the trailing space, or deliberately reverted
434
+ // with undo — Enter must not re-convert it.
435
+ if (
436
+ match &&
437
+ match.markerLength === anchorOffset &&
438
+ $applyBlock(parent, match)
439
+ ) {
440
+ if (event !== null) {
441
+ event.preventDefault();
442
+ }
443
+ return true;
444
+ }
445
+ return false;
446
+ },
447
+ // The lowest priority that still pre-empts the default rich-text Enter
448
+ // handler: prepended to the editor-priority queue, so every listener at
449
+ // LOW and above (and none of the defaults) runs first.
450
+ COMMAND_PRIORITY_BEFORE_EDITOR,
451
+ ),
452
+ );
453
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {CompiledMdast, MdastNode} from './types';
10
+ import type {LexicalNode} from 'lexical';
11
+ import type {Blockquote, Code, Heading, List, PhrasingContent} from 'mdast';
12
+
13
+ import {fromMarkdown} from 'mdast-util-from-markdown';
14
+
15
+ import {createNodeImporter} from './MdastImport';
16
+
17
+ export type MdastBlockMatch =
18
+ | {kind: 'heading'; node: Heading; markerLength: number}
19
+ | {kind: 'blockquote'; node: Blockquote; markerLength: number}
20
+ | {kind: 'list'; node: List; markerLength: number}
21
+ | {kind: 'code'; node: Code; markerLength: number};
22
+
23
+ /**
24
+ * Returns the offset of the first content character inside a block construct,
25
+ * i.e. the length of the leading block marker (`"## "`, `"- "`, `"> "`, ...).
26
+ * When the construct has no content yet (the user has only typed the marker)
27
+ * the whole `line` is the marker.
28
+ *
29
+ * The walk descends through *container* levels only (blockquote -> paragraph,
30
+ * list -> listItem -> paragraph) and stops at the first inline child of a
31
+ * paragraph or heading — descending further would treat inline delimiters
32
+ * (`**`, `[`, `` ` ``) as part of the block marker.
33
+ */
34
+ function contentStartOffset(node: MdastNode, line: string): number {
35
+ let current: MdastNode = node;
36
+ for (;;) {
37
+ if (current.type === 'heading' || current.type === 'paragraph') {
38
+ const first = current.children[0];
39
+ return first && first.position && first.position.start.offset != null
40
+ ? first.position.start.offset
41
+ : line.length;
42
+ }
43
+ if (!('children' in current) || current.children.length === 0) {
44
+ return line.length;
45
+ }
46
+ current = current.children[0];
47
+ }
48
+ }
49
+
50
+ /**
51
+ * `MarkdownStreamScanner` is the streaming heart of the shortcut engine. Each
52
+ * keystroke feeds the growing line/inline buffer back through micromark (via
53
+ * `mdast-util-from-markdown`), so shortcut recognition uses the *exact* same
54
+ * grammar — and the same enabled extensions — as full-document import. There
55
+ * is no second, divergent set of regular expressions to keep in sync.
56
+ *
57
+ * It is constructed from the {@link CompiledMdast} registry assembled by
58
+ * {@link MdastImportExtension}, so it stays in lock-step with whatever feature
59
+ * extensions are enabled — including the inline construct types and trigger
60
+ * characters contributed via `inlineShortcutTypes` / `inlineShortcutTriggers`.
61
+ */
62
+ export class MarkdownStreamScanner {
63
+ private readonly compiled: CompiledMdast;
64
+ private readonly importNode: (
65
+ node: MdastNode,
66
+ format: number,
67
+ ) => LexicalNode[];
68
+ /**
69
+ * Whether the registry's grammar recognizes GFM task-list items (i.e.
70
+ * `MdastTaskListExtension` contributed `gfmTaskListItem`). Probed by
71
+ * parsing rather than configured, so it can never drift from the grammar.
72
+ */
73
+ readonly supportsTaskListItems: boolean;
74
+
75
+ constructor(compiled: CompiledMdast) {
76
+ this.compiled = compiled;
77
+ this.importNode = createNodeImporter(compiled, '').$importNode;
78
+ const probe = this.parse('- [x] a').children[0];
79
+ this.supportsTaskListItems =
80
+ probe != null &&
81
+ probe.type === 'list' &&
82
+ probe.children[0].checked === true;
83
+ }
84
+
85
+ /** Characters that can close an inline construct for this registry. */
86
+ get inlineTriggers(): ReadonlySet<string> {
87
+ return this.compiled.inlineShortcutTriggers;
88
+ }
89
+
90
+ private parse(value: string) {
91
+ return fromMarkdown(value, {
92
+ extensions: this.compiled.micromarkExtensions,
93
+ mdastExtensions: this.compiled.mdastExtensions,
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Materializes an mdast inline node into Lexical nodes using the same
99
+ * import handlers as the full-document importer.
100
+ */
101
+ importInline(node: MdastNode): LexicalNode[] {
102
+ return this.importNode(node, 0);
103
+ }
104
+
105
+ /**
106
+ * Recognizes a block-level construct at the start of `line`. Returns the
107
+ * matched construct together with the marker length, or `null`.
108
+ */
109
+ scanBlock(line: string): MdastBlockMatch | null {
110
+ if (line.trim() === '') {
111
+ return null;
112
+ }
113
+ const first = this.parse(line).children[0];
114
+ // Only offer shortcuts for constructs the registry can actually import:
115
+ // in a granular setup (e.g. no blockquote extension) the syntax should
116
+ // stay literal rather than materialize an unregistered node.
117
+ if (!first || !this.compiled.importHandlers.has(first.type)) {
118
+ return null;
119
+ }
120
+ switch (first.type) {
121
+ case 'heading':
122
+ return {
123
+ kind: 'heading',
124
+ markerLength: contentStartOffset(first, line),
125
+ node: first,
126
+ };
127
+ case 'blockquote':
128
+ return {
129
+ kind: 'blockquote',
130
+ markerLength: contentStartOffset(first, line),
131
+ node: first,
132
+ };
133
+ case 'list':
134
+ return {
135
+ kind: 'list',
136
+ markerLength: contentStartOffset(first, line),
137
+ node: first,
138
+ };
139
+ case 'code':
140
+ // A code construct recognized from a single line is just the opening
141
+ // fence (possibly with a language); the entire line is the marker.
142
+ return {kind: 'code', markerLength: line.length, node: first};
143
+ default:
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Recognizes an inline construct (emphasis, strong, strikethrough, inline
150
+ * code, link, plus any registered `inlineShortcutTypes`) whose closing
151
+ * delimiter falls exactly at the end of `value` (the text up to the caret).
152
+ * Returns the mdast node, or `null`.
153
+ */
154
+ scanInline(value: string): PhrasingContent | null {
155
+ if (value.length === 0) {
156
+ return null;
157
+ }
158
+ const tree = this.parse(value);
159
+ const lastBlock = tree.children[tree.children.length - 1];
160
+ if (!lastBlock || lastBlock.type !== 'paragraph') {
161
+ return null;
162
+ }
163
+ const last = lastBlock.children[lastBlock.children.length - 1];
164
+ if (
165
+ !last ||
166
+ !last.position ||
167
+ last.position.end.offset !== value.length ||
168
+ !this.compiled.inlineShortcutTypes.has(last.type)
169
+ ) {
170
+ return null;
171
+ }
172
+ // Guard against firing mid-delimiter: while typing `**bold*` the parser
173
+ // briefly sees emphasis (`*bold*`) closing at the caret with a stray `*`
174
+ // in front. If the opening delimiter is immediately preceded by the same
175
+ // delimiter character, defer until the user finishes the longer run.
176
+ const start = last.position.start.offset ?? 0;
177
+ const openChar = value[start];
178
+ if (
179
+ start > 0 &&
180
+ value[start - 1] === openChar &&
181
+ this.compiled.inlineShortcutTriggers.has(openChar)
182
+ ) {
183
+ return null;
184
+ }
185
+ return last;
186
+ }
187
+ }