@llui/markdown-editor 0.3.0 → 0.5.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 (54) hide show
  1. package/dist/editor.d.ts +15 -2
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +12 -2
  4. package/dist/editor.js.map +1 -1
  5. package/dist/index.d.ts +6 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/block-drag.d.ts +74 -0
  10. package/dist/plugins/block-drag.d.ts.map +1 -0
  11. package/dist/plugins/block-drag.js +983 -0
  12. package/dist/plugins/block-drag.js.map +1 -0
  13. package/dist/plugins/code-language.d.ts +60 -0
  14. package/dist/plugins/code-language.d.ts.map +1 -0
  15. package/dist/plugins/code-language.js +308 -0
  16. package/dist/plugins/code-language.js.map +1 -0
  17. package/dist/plugins/frontmatter.d.ts +45 -0
  18. package/dist/plugins/frontmatter.d.ts.map +1 -0
  19. package/dist/plugins/frontmatter.js +281 -0
  20. package/dist/plugins/frontmatter.js.map +1 -0
  21. package/dist/plugins/overlay.d.ts +1 -0
  22. package/dist/plugins/overlay.d.ts.map +1 -1
  23. package/dist/plugins/overlay.js +7 -0
  24. package/dist/plugins/overlay.js.map +1 -1
  25. package/dist/plugins/wikilink.d.ts +110 -0
  26. package/dist/plugins/wikilink.d.ts.map +1 -0
  27. package/dist/plugins/wikilink.js +692 -0
  28. package/dist/plugins/wikilink.js.map +1 -0
  29. package/dist/styles/block-drag.css +118 -0
  30. package/dist/styles/editor.css +112 -0
  31. package/dist/transformers/code.d.ts +24 -0
  32. package/dist/transformers/code.d.ts.map +1 -0
  33. package/dist/transformers/code.js +167 -0
  34. package/dist/transformers/code.js.map +1 -0
  35. package/dist/transformers/gfm.d.ts.map +1 -1
  36. package/dist/transformers/gfm.js +7 -2
  37. package/dist/transformers/gfm.js.map +1 -1
  38. package/dist/transformers/registry.d.ts +3 -0
  39. package/dist/transformers/registry.d.ts.map +1 -1
  40. package/dist/transformers/registry.js +26 -0
  41. package/dist/transformers/registry.js.map +1 -1
  42. package/package.json +42 -28
  43. package/src/editor.ts +27 -4
  44. package/src/index.ts +58 -1
  45. package/src/plugins/block-drag.ts +1175 -0
  46. package/src/plugins/code-language.ts +378 -0
  47. package/src/plugins/frontmatter.ts +324 -0
  48. package/src/plugins/overlay.ts +7 -0
  49. package/src/plugins/wikilink.ts +843 -0
  50. package/src/styles/block-drag.css +118 -0
  51. package/src/styles/editor.css +112 -0
  52. package/src/transformers/code.ts +174 -0
  53. package/src/transformers/gfm.ts +6 -2
  54. package/src/transformers/registry.ts +27 -0
@@ -0,0 +1,118 @@
1
+ /* Block drag-and-drop reordering (`blockDragPlugin`).
2
+ *
3
+ * Geometry is set inline by the plugin (measured, therefore dynamic); everything
4
+ * here is appearance only. Import alongside `editor.css`:
5
+ * import '@llui/markdown-editor/styles/block-drag.css'
6
+ */
7
+
8
+ [data-scope='md-block-drag'][data-part='handle'] {
9
+ display: flex;
10
+ align-items: center;
11
+ }
12
+
13
+ [data-scope='md-block-drag'][data-part='grip'] {
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ width: 22px;
18
+ height: 22px;
19
+ padding: 0;
20
+ border: 0;
21
+ border-radius: 4px;
22
+ background: transparent;
23
+ color: var(--md-muted, #9aa0a6);
24
+ font-size: 14px;
25
+ line-height: 1;
26
+ cursor: grab;
27
+ opacity: 0.7;
28
+ }
29
+
30
+ [data-scope='md-block-drag'][data-part='grip']:hover {
31
+ background: var(--md-hover, rgba(127, 127, 127, 0.14));
32
+ opacity: 1;
33
+ }
34
+
35
+ /* Keyboard users must be able to see the grip they have focused. */
36
+ [data-scope='md-block-drag'][data-part='grip']:focus-visible {
37
+ outline: 2px solid var(--md-accent, #3b82f6);
38
+ outline-offset: 1px;
39
+ opacity: 1;
40
+ }
41
+
42
+ /* Grab mode is a live, invisible-by-default mode — give it a strong affordance. */
43
+ [data-scope='md-block-drag'][data-part='grip'][data-grabbed] {
44
+ background: var(--md-accent, #3b82f6);
45
+ color: #fff;
46
+ cursor: grabbing;
47
+ opacity: 1;
48
+ }
49
+
50
+ [data-scope='md-block-drag'][data-part='indicator'] {
51
+ height: 2px;
52
+ border-radius: 1px;
53
+ background: var(--md-accent, #3b82f6);
54
+ pointer-events: none;
55
+ }
56
+
57
+ @media (prefers-reduced-motion: no-preference) {
58
+ [data-scope='md-block-drag'][data-part='grip'] {
59
+ transition:
60
+ opacity 120ms ease,
61
+ background-color 120ms ease;
62
+ }
63
+ }
64
+
65
+ /* Block-actions menu (grip click / right-click). Positioned inline by the plugin;
66
+ * appearance only here. */
67
+ [data-scope='md-block-drag'][data-part='menu'] {
68
+ min-width: 160px;
69
+ padding: 4px;
70
+ border: 1px solid var(--md-border, rgba(127, 127, 127, 0.2));
71
+ border-radius: var(--md-radius, 8px);
72
+ background: var(--md-surface, #fff);
73
+ color: var(--md-text, #1f2328);
74
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
75
+ font-size: 13px;
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 1px;
79
+ }
80
+
81
+ [data-scope='md-block-drag'][data-part='menu-label'] {
82
+ padding: 4px 8px 2px;
83
+ font-size: 11px;
84
+ font-weight: 600;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.04em;
87
+ color: var(--md-muted, #9aa0a6);
88
+ }
89
+
90
+ [data-scope='md-block-drag'][data-part='menu-item'] {
91
+ display: flex;
92
+ align-items: center;
93
+ width: 100%;
94
+ padding: 5px 8px;
95
+ border: 0;
96
+ border-radius: 5px;
97
+ background: transparent;
98
+ color: inherit;
99
+ font: inherit;
100
+ text-align: left;
101
+ cursor: pointer;
102
+ }
103
+
104
+ [data-scope='md-block-drag'][data-part='menu-item']:hover,
105
+ [data-scope='md-block-drag'][data-part='menu-item']:focus-visible {
106
+ background: var(--md-hover, rgba(127, 127, 127, 0.14));
107
+ outline: none;
108
+ }
109
+
110
+ [data-scope='md-block-drag'][data-part='menu-item']:focus-visible {
111
+ box-shadow: inset 0 0 0 2px var(--md-accent, #3b82f6);
112
+ }
113
+
114
+ [data-scope='md-block-drag'][data-part='menu-sep'] {
115
+ height: 1px;
116
+ margin: 4px 0;
117
+ background: var(--md-border, rgba(127, 127, 127, 0.2));
118
+ }
@@ -345,6 +345,40 @@
345
345
  background: var(--md-surface-2);
346
346
  }
347
347
 
348
+ /* ── Code-block language badge ────────────────────────────────────────── */
349
+ [data-scope='md-code-language'][data-part='bar'] {
350
+ padding: 0.15rem;
351
+ background: var(--md-surface);
352
+ border: 1px solid var(--md-border);
353
+ border-radius: 6px;
354
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
355
+ }
356
+ [data-scope='md-code-language'][data-part='input'] {
357
+ font: inherit;
358
+ font-family: var(--md-mono);
359
+ font-size: 0.72rem;
360
+ width: 8rem;
361
+ padding: 0.1rem 0.3rem;
362
+ color: var(--md-text);
363
+ background: transparent;
364
+ border: none;
365
+ outline: none;
366
+ }
367
+ [data-scope='md-code-language'][data-part='input']::placeholder {
368
+ color: var(--md-muted);
369
+ }
370
+
371
+ /* ── Wiki links ───────────────────────────────────────────────────────── */
372
+ [data-scope='md-wikilink'][data-part='link'] {
373
+ color: var(--md-primary);
374
+ text-decoration: none;
375
+ border-bottom: 1px solid var(--md-primary-soft);
376
+ cursor: pointer;
377
+ }
378
+ [data-scope='md-wikilink'][data-part='link']:hover {
379
+ border-bottom-color: var(--md-primary);
380
+ }
381
+
348
382
  /* ── Math block ───────────────────────────────────────────────────────── */
349
383
  [data-scope='md-math'][data-part='root'] {
350
384
  display: flex;
@@ -369,6 +403,36 @@
369
403
  text-align: center;
370
404
  }
371
405
 
406
+ /* ── Frontmatter block ────────────────────────────────────────────────── */
407
+ [data-scope='md-frontmatter'][data-part='root'] {
408
+ display: flex;
409
+ flex-direction: column;
410
+ gap: 0.4rem;
411
+ margin: 0 0 0.9rem;
412
+ padding: 0.6rem 0.8rem;
413
+ border: 1px dashed var(--md-border);
414
+ border-radius: 8px;
415
+ background: var(--md-surface-2);
416
+ }
417
+ [data-scope='md-frontmatter'] [data-part='source'] {
418
+ width: 100%;
419
+ box-sizing: border-box;
420
+ border: 0;
421
+ padding: 0;
422
+ resize: vertical;
423
+ background: transparent;
424
+ font-family: var(--md-mono);
425
+ font-size: 0.82rem;
426
+ line-height: 1.5;
427
+ white-space: pre;
428
+ overflow-x: auto;
429
+ color: var(--md-muted);
430
+ outline: none;
431
+ }
432
+ [data-scope='md-frontmatter'] [data-part='source']:focus {
433
+ color: var(--md-text);
434
+ }
435
+
372
436
  /* ── Mermaid block ────────────────────────────────────────────────────── */
373
437
  [data-scope='md-mermaid'][data-part='root'] {
374
438
  display: flex;
@@ -421,6 +485,54 @@
421
485
  background: var(--md-primary-soft);
422
486
  }
423
487
 
488
+ /* ── Document-link (wikilink) search + reference panel ────────────────── */
489
+ [data-scope='md-wikilink'][data-part='panel'] {
490
+ display: flex;
491
+ min-width: 22rem;
492
+ max-width: 34rem;
493
+ background: var(--md-surface);
494
+ border: 1px solid var(--md-border);
495
+ border-radius: 8px;
496
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
497
+ overflow: hidden;
498
+ }
499
+ [data-scope='md-wikilink'][data-part='results'] {
500
+ flex: 0 0 14rem;
501
+ max-height: 18rem;
502
+ overflow: auto;
503
+ padding: 0.25rem;
504
+ border-right: 1px solid var(--md-border);
505
+ }
506
+ [data-scope='md-wikilink'][data-part='result'] {
507
+ padding: 0.4rem 0.6rem;
508
+ border-radius: 5px;
509
+ cursor: pointer;
510
+ }
511
+ [data-scope='md-wikilink'][data-part='result'][data-active] {
512
+ background: var(--md-primary-soft);
513
+ }
514
+ [data-scope='md-wikilink'][data-part='result-title'] {
515
+ font-size: 0.88rem;
516
+ color: var(--md-text);
517
+ }
518
+ [data-scope='md-wikilink'][data-part='result-snippet'] {
519
+ font-size: 0.78rem;
520
+ color: var(--md-muted);
521
+ white-space: nowrap;
522
+ overflow: hidden;
523
+ text-overflow: ellipsis;
524
+ }
525
+ [data-scope='md-wikilink'][data-part='preview'] {
526
+ flex: 1 1 auto;
527
+ max-height: 18rem;
528
+ overflow: auto;
529
+ padding: 0.6rem 0.75rem;
530
+ font-size: 0.82rem;
531
+ line-height: 1.5;
532
+ color: var(--md-text-soft, var(--md-muted));
533
+ white-space: pre-wrap;
534
+ }
535
+
424
536
  /* ── Context menu ─────────────────────────────────────────────────────── */
425
537
  [data-scope='md-context'][data-part='backdrop'] {
426
538
  position: fixed;
@@ -0,0 +1,174 @@
1
+ // The fenced-code-block transformer.
2
+ //
3
+ // ## Why this package does not use `@lexical/markdown`'s `CODE`
4
+ //
5
+ // Upstream's `CODE_START_REGEX` captures the info string with `([\w-]+)?`, i.e.
6
+ // a SINGLE word-ish token. Everything after that token stays on the line and is
7
+ // pushed into the code block's CONTENT:
8
+ //
9
+ // ```lance table → language 'lance', body 'table\nsum(x)' (corrupt)
10
+ // ```c++ → language 'c', body '++\nint main()' (corrupt)
11
+ //
12
+ // That is silent data loss on perfectly ordinary markdown. CommonMark defines
13
+ // the info string as *the rest of the line*, trimmed — so
14
+ // {@link CODE_INFO_TRANSFORMER} captures exactly that and hands it to
15
+ // `CodeNode.setLanguage` untouched.
16
+ //
17
+ // This lives in `transformers/` (not in `plugins/code-language.ts`) so that it
18
+ // is the DEFAULT in `GFM_TRANSFORMERS` rather than an opt-in a consumer has to
19
+ // remember to order ahead of `corePlugin()`. `codeLanguagePlugin()` contributes
20
+ // this same object reference, so the registry's reference de-duplication
21
+ // collapses the two contributions into one and plugin order cannot change the
22
+ // parse. See `test/composition.test.ts`.
23
+ //
24
+ // ## The language is an opaque label
25
+ //
26
+ // The package depends on `@lexical/code-core`, not `@lexical/code`, to keep
27
+ // Prism out of the bundle. Nothing interprets the info string — it is stored,
28
+ // shown, edited, and re-emitted verbatim. That opacity is precisely what lets an
29
+ // arbitrary token like `lance table` survive a round-trip.
30
+
31
+ import { $createTextNode, type ElementNode, type LexicalNode } from 'lexical'
32
+ import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code-core'
33
+ import type { MultilineElementTransformer } from '@lexical/markdown'
34
+
35
+ /**
36
+ * Canonicalize a fence info string.
37
+ *
38
+ * CommonMark's info string is the remainder of the opening-fence line with the
39
+ * surrounding whitespace stripped; a blank one means "no language". Two
40
+ * characters are removed rather than preserved, because keeping them would emit
41
+ * markdown that no longer re-imports to the same block:
42
+ *
43
+ * - a backtick — illegal in a backtick-fenced info string (it would terminate
44
+ * or corrupt the fence);
45
+ * - a newline — it would end the fence line entirely.
46
+ *
47
+ * Everything else survives verbatim, including spaces (`'lance table'`) and
48
+ * punctuation (`'c++'`, `'objective-c'`).
49
+ */
50
+ export function normalizeCodeInfo(raw: string | null | undefined): string | null {
51
+ if (raw === null || raw === undefined) return null
52
+ const cleaned = raw
53
+ .replace(/`/g, '')
54
+ .replace(/[\r\n]+/g, ' ')
55
+ .trim()
56
+ return cleaned === '' ? null : cleaned
57
+ }
58
+
59
+ /** The opening fence: its backtick run, then the raw info string (rest of line). */
60
+ const FENCE_START = /^([ \t]*`{3,})(.*)$/
61
+ /** A closing fence of at least `length` backticks on a line of its own. */
62
+ const fenceEndFor = (length: number): RegExp => new RegExp(`^[ \\t]*\`{${length},}[ \\t]*$`)
63
+
64
+ /**
65
+ * The narrowest fence that can enclose `code` without being terminated early:
66
+ * one backtick longer than the longest fence-like run inside it.
67
+ */
68
+ function fenceFor(code: string): string {
69
+ const runs = code.match(/`{3,}/g)
70
+ const longest = runs ? Math.max(...runs.map((run) => run.length)) : 0
71
+ return '`'.repeat(Math.max(3, longest + 1))
72
+ }
73
+
74
+ /**
75
+ * A self-closing opening fence (```` ```code``` ````): the closing run sits on
76
+ * the SAME line, so the span between the fences is CONTENT and there is no info
77
+ * string. Returns that content, or `null` when the line is an ordinary opening
78
+ * fence.
79
+ *
80
+ * Shared by BOTH directions on purpose. It used to live only in
81
+ * `handleImportAfterStartMatch`, so the import path and the typed-shortcut
82
+ * `replace` path parsed the same text two different ways: importing
83
+ * '```inline```' gave a language-less block containing `inline`, while TYPING it
84
+ * and pressing Enter gave an EMPTY block labelled 'inline' — the word was gone.
85
+ * The divergence was reachable because `FENCE_START`'s `(.*)$` consumes the
86
+ * whole line, which always satisfies the shortcut engine's `match[0].length ===
87
+ * matchLength` gate. (Upstream's narrower `CODE_START_REGEX` never matched this
88
+ * shape, so widening the capture is what introduced it.)
89
+ */
90
+ function singleLineFenceContent(fence: string, rest: string): string | null {
91
+ const closing = rest.match(new RegExp(`\`{${fence.length},}[ \t]*$`))
92
+ return closing && closing.index !== undefined ? rest.slice(0, closing.index) : null
93
+ }
94
+
95
+ /** Append a code block carrying `language` and `lines` to `parent`. */
96
+ function $appendCodeNode(
97
+ parent: ElementNode,
98
+ language: string | null,
99
+ lines: readonly string[],
100
+ ): void {
101
+ const node = $createCodeNode(language ?? undefined)
102
+ const code = lines.join('\n')
103
+ if (code !== '') node.append($createTextNode(code))
104
+ parent.append(node)
105
+ }
106
+
107
+ /**
108
+ * A drop-in replacement for `@lexical/markdown`'s `CODE` that treats the whole
109
+ * remainder of the opening-fence line as the info string (CommonMark's rule)
110
+ * instead of a single `[\w-]+` token.
111
+ */
112
+ export const CODE_INFO_TRANSFORMER: MultilineElementTransformer = {
113
+ dependencies: [CodeNode],
114
+ export: (node: LexicalNode): string | null => {
115
+ if (!$isCodeNode(node)) return null
116
+ const code = node.getTextContent()
117
+ const fence = fenceFor(code)
118
+ const info = normalizeCodeInfo(node.getLanguage()) ?? ''
119
+ return fence + info + (code === '' ? '' : '\n' + code) + '\n' + fence
120
+ },
121
+ handleImportAfterStartMatch: ({ lines, rootNode, startLineIndex, startMatch }) => {
122
+ const fence = (startMatch[1] ?? '```').trim()
123
+ const rest = startMatch[2] ?? ''
124
+
125
+ // Single-line form (```code```) — see `singleLineFenceContent`. Matches
126
+ // `@lexical/markdown`, and matches CommonMark, which forbids a backtick
127
+ // inside the info string.
128
+ const inline = singleLineFenceContent(fence, rest)
129
+ if (inline !== null) {
130
+ $appendCodeNode(rootNode, null, [inline])
131
+ return [true, startLineIndex]
132
+ }
133
+
134
+ const language = normalizeCodeInfo(rest)
135
+ const fenceEnd = fenceEndFor(fence.length)
136
+ for (let i = startLineIndex + 1; i < lines.length; i++) {
137
+ if (fenceEnd.test(lines[i] as string)) {
138
+ $appendCodeNode(rootNode, language, lines.slice(startLineIndex + 1, i))
139
+ return [true, i]
140
+ }
141
+ }
142
+ // Unterminated fence: consume to the end of the document.
143
+ $appendCodeNode(rootNode, language, lines.slice(startLineIndex + 1))
144
+ return [true, lines.length - 1]
145
+ },
146
+ regExpEnd: { optional: true, regExp: /^[ \t]*`{3,}[ \t]*$/ },
147
+ regExpStart: FENCE_START,
148
+ // Import is fully handled above; `replace` serves the typed-shortcut path
149
+ // (`registerMarkdownShortcuts` calls it with the trailing siblings) and the
150
+ // paste path (`children === null`, lines supplied).
151
+ replace: (rootNode, children, startMatch, _endMatch, linesInBetween, isImport) => {
152
+ const fence = (startMatch[1] ?? '```').trim()
153
+ const rest = startMatch[2] ?? ''
154
+ // The SAME single-line rule the import path applies, so the two agree.
155
+ const inline = singleLineFenceContent(fence, rest)
156
+ if (inline !== null) {
157
+ const node = $createCodeNode()
158
+ if (inline !== '') node.append($createTextNode(inline))
159
+ rootNode.replace(node)
160
+ if (!isImport) node.select(0, 0)
161
+ return
162
+ }
163
+ const language = normalizeCodeInfo(rest)
164
+ if (!children) {
165
+ $appendCodeNode(rootNode, language, linesInBetween ?? [])
166
+ return
167
+ }
168
+ const node = $createCodeNode(language ?? undefined)
169
+ node.append(...children)
170
+ rootNode.replace(node)
171
+ if (!isImport) node.select(0, 0)
172
+ },
173
+ type: 'multiline-element',
174
+ }
@@ -15,7 +15,6 @@ import {
15
15
  UNORDERED_LIST,
16
16
  ORDERED_LIST,
17
17
  CHECK_LIST,
18
- CODE,
19
18
  BOLD_ITALIC_STAR,
20
19
  BOLD_ITALIC_UNDERSCORE,
21
20
  BOLD_STAR,
@@ -27,6 +26,7 @@ import {
27
26
  HIGHLIGHT,
28
27
  LINK,
29
28
  } from '@lexical/markdown'
29
+ import { CODE_INFO_TRANSFORMER } from './code.js'
30
30
 
31
31
  /** Node classes required to render the GFM superset. */
32
32
  export const GFM_NODES: ReadonlyArray<Klass<LexicalNode>> = [
@@ -71,7 +71,11 @@ export const GFM_TRANSFORMERS: readonly Transformer[] = [
71
71
  CHECK_LIST,
72
72
  UNORDERED_LIST,
73
73
  ORDERED_LIST,
74
- CODE,
74
+ // NOT `@lexical/markdown`'s `CODE`: that one captures the info string as a
75
+ // single `[\w-]+` token and pushes the remainder of the fence line into the
76
+ // code body, silently corrupting ```c++ and ```lance table. See
77
+ // `transformers/code.ts`.
78
+ CODE_INFO_TRANSFORMER,
75
79
  ...INLINE_TEXT_TRANSFORMERS,
76
80
  LINK,
77
81
  ]
@@ -15,6 +15,28 @@ const TYPE_RANK: Record<string, number> = {
15
15
  'text-match': 3,
16
16
  }
17
17
 
18
+ /**
19
+ * Explicit ordering between transformers of the SAME rank.
20
+ *
21
+ * Within a rank Lexical falls back to array position, which for text-match
22
+ * transformers means `findOutermostTextMatchTransformer` breaks a tie — two
23
+ * transformers matching at the SAME start index — by plugin array order. That
24
+ * makes round-trip fidelity depend on the order a consumer happens to list
25
+ * plugins in, which is exactly the failure mode the registry exists to prevent.
26
+ *
27
+ * A transformer that must be consulted ahead of a same-rank peer declares a
28
+ * LOWER precedence here (default 0). This is a side table rather than a field on
29
+ * the transformer because the colliding peers include upstream's `LINK`, which
30
+ * this package does not own and cannot annotate.
31
+ */
32
+ const precedence = new WeakMap<Transformer, number>()
33
+
34
+ /** Declare that `transformer` must be consulted before same-rank peers with a
35
+ * higher value. Call at module scope, beside the transformer's definition. */
36
+ export function setTransformerPrecedence(transformer: Transformer, value: number): void {
37
+ precedence.set(transformer, value)
38
+ }
39
+
18
40
  /** Stable-sort transformers into the order Lexical expects. */
19
41
  export function orderTransformers(transformers: readonly Transformer[]): Transformer[] {
20
42
  return [...transformers].sort((a, b) => {
@@ -25,6 +47,11 @@ export function orderTransformers(transformers: readonly Transformer[]): Transfo
25
47
  // Longer trigger (`***`) before shorter (`**`, `*`).
26
48
  return b.tag.length - a.tag.length
27
49
  }
50
+ const pa = precedence.get(a) ?? 0
51
+ const pb = precedence.get(b) ?? 0
52
+ if (pa !== pb) return pa - pb
53
+ // Equal precedence: `Array.prototype.sort` is specified stable, so the
54
+ // plugin array order is preserved — the documented default.
28
55
  return 0
29
56
  })
30
57
  }