@llui/markdown-editor 0.2.14 → 0.4.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 (70) hide show
  1. package/dist/editor.d.ts +22 -2
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +45 -6
  4. package/dist/editor.js.map +1 -1
  5. package/dist/effects.d.ts +0 -1
  6. package/dist/effects.d.ts.map +1 -1
  7. package/dist/effects.js +4 -1
  8. package/dist/effects.js.map +1 -1
  9. package/dist/index.d.ts +9 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +18 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/plugins/block-drag.d.ts +73 -0
  14. package/dist/plugins/block-drag.d.ts.map +1 -0
  15. package/dist/plugins/block-drag.js +760 -0
  16. package/dist/plugins/block-drag.js.map +1 -0
  17. package/dist/plugins/code-language.d.ts +60 -0
  18. package/dist/plugins/code-language.d.ts.map +1 -0
  19. package/dist/plugins/code-language.js +308 -0
  20. package/dist/plugins/code-language.js.map +1 -0
  21. package/dist/plugins/frontmatter.d.ts +45 -0
  22. package/dist/plugins/frontmatter.d.ts.map +1 -0
  23. package/dist/plugins/frontmatter.js +281 -0
  24. package/dist/plugins/frontmatter.js.map +1 -0
  25. package/dist/plugins/overlay.d.ts +1 -0
  26. package/dist/plugins/overlay.d.ts.map +1 -1
  27. package/dist/plugins/overlay.js +7 -0
  28. package/dist/plugins/overlay.js.map +1 -1
  29. package/dist/plugins/table.d.ts.map +1 -1
  30. package/dist/plugins/table.js +77 -15
  31. package/dist/plugins/table.js.map +1 -1
  32. package/dist/plugins/wikilink.d.ts +91 -0
  33. package/dist/plugins/wikilink.d.ts.map +1 -0
  34. package/dist/plugins/wikilink.js +467 -0
  35. package/dist/plugins/wikilink.js.map +1 -0
  36. package/dist/styles/block-drag.css +63 -0
  37. package/dist/styles/editor.css +64 -6
  38. package/dist/theme.d.ts +2 -5
  39. package/dist/theme.d.ts.map +1 -1
  40. package/dist/theme.js +15 -11
  41. package/dist/theme.js.map +1 -1
  42. package/dist/transformers/code.d.ts +24 -0
  43. package/dist/transformers/code.d.ts.map +1 -0
  44. package/dist/transformers/code.js +167 -0
  45. package/dist/transformers/code.js.map +1 -0
  46. package/dist/transformers/gfm.d.ts +9 -1
  47. package/dist/transformers/gfm.d.ts.map +1 -1
  48. package/dist/transformers/gfm.js +16 -4
  49. package/dist/transformers/gfm.js.map +1 -1
  50. package/dist/transformers/registry.d.ts +3 -0
  51. package/dist/transformers/registry.d.ts.map +1 -1
  52. package/dist/transformers/registry.js +26 -0
  53. package/dist/transformers/registry.js.map +1 -1
  54. package/package.json +45 -31
  55. package/src/editor.ts +77 -9
  56. package/src/effects.ts +4 -2
  57. package/src/index.ts +61 -9
  58. package/src/plugins/block-drag.ts +912 -0
  59. package/src/plugins/code-language.ts +378 -0
  60. package/src/plugins/frontmatter.ts +324 -0
  61. package/src/plugins/overlay.ts +7 -0
  62. package/src/plugins/table.ts +87 -13
  63. package/src/plugins/wikilink.ts +553 -0
  64. package/src/styles/block-drag.css +63 -0
  65. package/src/styles/editor.css +64 -6
  66. package/src/theme.ts +15 -11
  67. package/src/transformers/code.ts +174 -0
  68. package/src/transformers/gfm.ts +16 -4
  69. package/src/transformers/registry.ts +27 -0
  70. package/dist/__llui_deps.json +0 -274
@@ -0,0 +1,324 @@
1
+ // YAML frontmatter plugin — the leading `---` … `---` metadata block.
2
+ //
3
+ // ## Why the block is an OPAQUE STRING, not parsed YAML
4
+ //
5
+ // Deliberate choice, and the requirement that drove it is round-trip fidelity:
6
+ // what the author wrote must come back out byte-for-byte. Two options exist —
7
+ // parse YAML properly, or don't parse it at all — and only the second is
8
+ // achievable here:
9
+ //
10
+ // - Parsing correctly means a real YAML 1.2 implementation (anchors, aliases,
11
+ // tags, flow/block collections, the five scalar styles, folded/literal block
12
+ // scalars with their indentation-indicator and chomping rules). Nothing
13
+ // smaller is *correct*, and a correct parser is not enough by itself: a
14
+ // round-trip also needs a comment- and style-preserving EMITTER, because
15
+ // `parse` → `stringify` normalizes quoting, key order, indentation and drops
16
+ // comments outright. That is a large dependency (this package deliberately
17
+ // dropped Prism from `@lexical/code` for exactly this reason — see
18
+ // `code-language.ts`) to buy a feature the editor does not need: it never
19
+ // interprets frontmatter, it only has to preserve it.
20
+ //
21
+ // - The naive middle ground — split each line on the first `:` into a
22
+ // `Record<string, string>` — is what the reference implementation this was
23
+ // modelled on does, and it is strictly WORSE than not parsing. Nested maps,
24
+ // sequences, block scalars, quoted values containing `:`, comments and blank
25
+ // lines are all silently flattened or destroyed. A lossy parse presented as
26
+ // structured data is a data-loss bug wearing a feature's clothes.
27
+ //
28
+ // So the block is stored verbatim as a single `source` string and re-emitted
29
+ // between two fences. Consumers that *want* structure own that choice: hand
30
+ // `$getFrontmatter()` to whatever YAML library they already ship, or supply
31
+ // `render` to draw a preview. The editor stays honest about not understanding it.
32
+ //
33
+ // The one unavoidable deviation from byte-for-byte: `$convertFromMarkdownString`
34
+ // runs `normalizeMarkdown`, which `trimEnd()`s every line BEFORE any transformer
35
+ // is consulted, so trailing whitespace inside the block is lost. That happens
36
+ // upstream of every transformer and cannot be intercepted from one. YAML treats
37
+ // trailing spaces as insignificant, so nothing meaningful changes. Likewise a
38
+ // body that is empty or only blank lines normalizes to the canonical `---\n---`.
39
+ //
40
+ // ## The `---` collision with `hrPlugin`
41
+ //
42
+ // `hrPlugin`'s ELEMENT transformer matches `/^(---|\*\*\*|___)\s*$/`, so before
43
+ // this plugin existed a leading frontmatter block parsed as a horizontal rule
44
+ // followed by loose prose. Telling consumers to drop `hrPlugin` is not a fix:
45
+ // `---` is legitimately both things, and which one it is depends on POSITION and
46
+ // CLOSURE, not on which plugin is loaded.
47
+ //
48
+ // The resolution is structural, not ordering-based. `@lexical/markdown`'s
49
+ // importer tries EVERY multiline-element transformer on a line before ANY
50
+ // element transformer (`$importMultiline` runs first in `$importMarkdownNodes`;
51
+ // export mirrors it with `[...multilineElement, ...element]`). This is therefore
52
+ // a `multiline-element` transformer, which means it is consulted ahead of the
53
+ // `hr` element transformer no matter where the two plugins sit in the array —
54
+ // plugin ORDER cannot change the outcome, and neither can loading or omitting
55
+ // `hrPlugin`.
56
+ //
57
+ // Claiming the line is then narrowed by `handleImportAfterStartMatch`, which
58
+ // returns `null` — "not mine, try the next transformer" — unless BOTH hold:
59
+ //
60
+ // 1. the fence is on line 0 (frontmatter is only frontmatter at the very top
61
+ // of the document), and
62
+ // 2. a closing `---` line exists somewhere below it.
63
+ //
64
+ // Declining falls through to `hrPlugin` (when loaded), so a `---` that is
65
+ // genuinely a thematic break — mid-document, or an unclosed one on line 1 —
66
+ // still becomes an `<hr>`. `replace` returns `false` so that TYPING `---` in the
67
+ // editor is likewise left to the hr shortcut; frontmatter is only ever created
68
+ // by importing markdown or by calling `$setFrontmatter`.
69
+ //
70
+ // The block closes at the FIRST subsequent line that is exactly `---`, which is
71
+ // the rule Jekyll / gray-matter / Hugo apply. A `---` *inside* a value (e.g.
72
+ // `title: a --- b`) is not a whole line and is preserved untouched.
73
+
74
+ import { $getRoot, type LexicalEditor, type LexicalNode } from 'lexical'
75
+ import type { MultilineElementTransformer } from '@lexical/markdown'
76
+ import {
77
+ $createLLuiDecoratorNode,
78
+ $isLLuiDecoratorNode,
79
+ LLuiDecoratorNode,
80
+ decoratorBridge,
81
+ } from '@llui/lexical'
82
+ import { div, textarea, type Mountable, type Signal } from '@llui/dom'
83
+ import type { MarkdownPlugin } from './types.js'
84
+ import { renderedPreview, type PreviewRender } from './_preview.js'
85
+
86
+ /** The decorator bridge id for the frontmatter block. */
87
+ export const FRONTMATTER_BRIDGE_TYPE = 'frontmatter'
88
+
89
+ /** The frontmatter node's payload: the block body, verbatim, with no fences. */
90
+ export interface FrontmatterData {
91
+ /** The raw text between the opening and closing `---`. Never interpreted. */
92
+ source: string
93
+ }
94
+
95
+ function isFrontmatterData(value: unknown): value is FrontmatterData {
96
+ return (
97
+ typeof value === 'object' &&
98
+ value !== null &&
99
+ typeof (value as FrontmatterData).source === 'string'
100
+ )
101
+ }
102
+
103
+ /** A line consisting solely of `---` (trailing tabs/spaces tolerated — they are
104
+ * not content, and `normalizeMarkdown` has usually already trimmed them). */
105
+ const FENCE = /^---[ \t]*$/
106
+
107
+ /** Render the fences back around an opaque body. An empty (or blank-only) body
108
+ * collapses to the canonical two-line form so the result stays idempotent. */
109
+ export function serializeFrontmatter(source: string): string {
110
+ return source === '' ? '---\n---' : `---\n${source}\n---`
111
+ }
112
+
113
+ /**
114
+ * The index of the closing fence, or `-1` when `lines` does not open a
115
+ * frontmatter block (no fence on line 0) or never closes one.
116
+ *
117
+ * The single definition of "is this frontmatter?", shared by the importer and
118
+ * {@link splitFrontmatter} so the two can never disagree. A returned index is
119
+ * always `>= 1`, which is the property the importer's anti-rewind invariant
120
+ * rests on (see `handleImportAfterStartMatch`).
121
+ */
122
+ function closingFenceIndex(lines: readonly string[]): number {
123
+ const first = lines[0]
124
+ if (first === undefined || !FENCE.test(first)) return -1
125
+ for (let i = 1; i < lines.length; i++) {
126
+ const line = lines[i]
127
+ if (line !== undefined && FENCE.test(line)) return i
128
+ }
129
+ return -1
130
+ }
131
+
132
+ /**
133
+ * Split a leading frontmatter block off a markdown string: `[body, rest]`, or
134
+ * `null` when the document has none (no line-0 fence, or no closing fence).
135
+ * Exported because a consumer often needs the metadata BEFORE building an
136
+ * editor — the same predicate the importer uses, so the two never disagree.
137
+ */
138
+ export function splitFrontmatter(markdown: string): [source: string, rest: string] | null {
139
+ const lines = markdown.split('\n')
140
+ const end = closingFenceIndex(lines)
141
+ if (end === -1) return null
142
+ return [lines.slice(1, end).join('\n'), lines.slice(end + 1).join('\n')]
143
+ }
144
+
145
+ // ── Document accessors (run inside `editor.read` / `editor.update`) ──────────
146
+
147
+ /** The frontmatter node, if the document starts with one. */
148
+ function $frontmatterNode(): LexicalNode | null {
149
+ const first = $getRoot().getFirstChild()
150
+ return $isLLuiDecoratorNode(first) && first.getBridgeType() === FRONTMATTER_BRIDGE_TYPE
151
+ ? first
152
+ : null
153
+ }
154
+
155
+ /** The document's frontmatter body, or `null` when it has none. */
156
+ export function $getFrontmatter(): string | null {
157
+ const node = $frontmatterNode()
158
+ if (!node || !$isLLuiDecoratorNode(node)) return null
159
+ const data = node.getData()
160
+ return isFrontmatterData(data) ? data.source : null
161
+ }
162
+
163
+ /**
164
+ * Set (or, with `null`, remove) the document's frontmatter. The block is always
165
+ * kept as the FIRST child of the root — it is only frontmatter there, and the
166
+ * exporter relies on that position (see the transformer's `export`).
167
+ */
168
+ export function $setFrontmatter(source: string | null): void {
169
+ const existing = $frontmatterNode()
170
+ if (source === null) {
171
+ existing?.remove()
172
+ return
173
+ }
174
+ if (existing && $isLLuiDecoratorNode(existing)) {
175
+ existing.setData({ source })
176
+ return
177
+ }
178
+ const node = $createLLuiDecoratorNode(FRONTMATTER_BRIDGE_TYPE, { source })
179
+ const root = $getRoot()
180
+ const first = root.getFirstChild()
181
+ if (first) first.insertBefore(node)
182
+ else root.append(node)
183
+ }
184
+
185
+ // ── Transformer ─────────────────────────────────────────────────────────────
186
+
187
+ const FRONTMATTER_TRANSFORMER: MultilineElementTransformer = {
188
+ dependencies: [LLuiDecoratorNode],
189
+ export: (node: LexicalNode): string | null => {
190
+ if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== FRONTMATTER_BRIDGE_TYPE) return null
191
+ const data = node.getData()
192
+ if (!isFrontmatterData(data)) return null
193
+ // Only the document's FIRST block can be emitted as fences: `---` anywhere
194
+ // else re-imports as a thematic break, which would silently destroy the
195
+ // metadata on the next round-trip. A stray block (dragged out of position,
196
+ // pasted into a quote, …) degrades to a visible ```yaml code fence instead —
197
+ // lossy in node type, but the text survives and the author can see why.
198
+ if ($getRoot().getFirstChild() !== node) return '```yaml\n' + data.source + '\n```'
199
+ return serializeFrontmatter(data.source)
200
+ },
201
+ regExpStart: FENCE,
202
+ // Never consulted: `handleImportAfterStartMatch` below always returns a
203
+ // decision, so the default open/close scan is not reached. Declared because
204
+ // the shape requires the pair to be meaningful together.
205
+ regExpEnd: FENCE,
206
+ handleImportAfterStartMatch: ({ lines, rootNode, startLineIndex }) => {
207
+ // Frontmatter exists only at the very top of the document. Anywhere else a
208
+ // `---` is a thematic break — decline so `hrPlugin` (or plain prose) gets it.
209
+ //
210
+ // INVARIANT — do not relax this guard without also fixing the scan below.
211
+ // `$importMarkdownNodes` assigns `i = shiftedIndex` from the returned tuple,
212
+ // so a returned index <= `startLineIndex` REWINDS its loop and the importer
213
+ // spins forever (not a wrong parse — a hang). The scan starts at line 1, so
214
+ // pinning `startLineIndex` to 0 is what makes every returned index strictly
215
+ // greater. A positional relaxation must start the scan at `startLineIndex + 1`.
216
+ if (startLineIndex !== 0) return null
217
+ const end = closingFenceIndex(lines)
218
+ if (end !== -1) {
219
+ // Verbatim body: no trimming, no interpretation, blank lines preserved.
220
+ const source = lines.slice(1, end).join('\n')
221
+ rootNode.append($createLLuiDecoratorNode(FRONTMATTER_BRIDGE_TYPE, { source }))
222
+ return [true, end]
223
+ }
224
+ // Unclosed: this is a lone `---` on line 1, i.e. a horizontal rule.
225
+ return null
226
+ },
227
+ // Typing `---` must never produce frontmatter — returning false hands the
228
+ // markdown shortcut back to `hrPlugin`. Use `$setFrontmatter` to create one.
229
+ replace: () => false,
230
+ type: 'multiline-element',
231
+ }
232
+
233
+ export { FRONTMATTER_TRANSFORMER }
234
+
235
+ // ── Plugin ──────────────────────────────────────────────────────────────────
236
+
237
+ export interface FrontmatterPluginOptions {
238
+ /** Render the raw block to a preview (e.g. parse with your own YAML library
239
+ * and draw a table). Return a DOM `Node` (mounted directly) or a **trusted**
240
+ * HTML string — see `renderedPreview`'s security note. */
241
+ render?: PreviewRender
242
+ /** Accessible label for the raw-source editor (default `'Frontmatter'`). */
243
+ label?: string
244
+ /** Placeholder shown for an empty block (default `'key: value'`). */
245
+ placeholder?: string
246
+ /** Show the raw source editor. Set false for a `render`-only presentation
247
+ * (the block still round-trips; it just isn't editable in place). Default true. */
248
+ editable?: boolean
249
+ }
250
+
251
+ /** Keep editor-level key/paste handling out of the frontmatter island. */
252
+ const stop = (e: Event): void => e.stopPropagation()
253
+
254
+ export function frontmatterPlugin(opts: FrontmatterPluginOptions = {}): MarkdownPlugin {
255
+ const editable = opts.editable ?? true
256
+
257
+ const bridge = decoratorBridge<FrontmatterData>(FRONTMATTER_BRIDGE_TYPE, (data, api) => {
258
+ const children: Mountable[] = []
259
+ if (editable) {
260
+ children.push(
261
+ // A <textarea>, NOT a contenteditable div: newlines are load-bearing in
262
+ // YAML, and a contenteditable's `textContent` drops the <br>/<div> line
263
+ // structure the browser inserts, so committing from one silently
264
+ // flattens a multi-line block into a single line.
265
+ textarea({
266
+ 'data-scope': 'md-frontmatter',
267
+ 'data-part': 'source',
268
+ 'aria-label': opts.label ?? 'Frontmatter',
269
+ placeholder: opts.placeholder ?? 'key: value',
270
+ spellcheck: 'false',
271
+ autocapitalize: 'off',
272
+ autocomplete: 'off',
273
+ value: data.at('source') as Signal<string>,
274
+ // Grow with the content so the whole block stays visible.
275
+ rows: (data.at('source') as Signal<string>).map((source) =>
276
+ String(Math.max(1, source.split('\n').length)),
277
+ ),
278
+ onKeyDown: stop,
279
+ onBeforeInput: stop,
280
+ onPaste: stop,
281
+ // Commit on blur only: while the caret is inside, the node data is
282
+ // deliberately stale so the reactive `value` binding cannot rewrite
283
+ // what is being typed.
284
+ onBlur: (e: FocusEvent) => {
285
+ const source = (e.target as HTMLTextAreaElement).value
286
+ if (source !== data.peek().source) api.update({ source })
287
+ },
288
+ }),
289
+ )
290
+ }
291
+ if (opts.render) {
292
+ children.push(renderedPreview(data.at('source') as Signal<string>, opts.render))
293
+ }
294
+ return [
295
+ div(
296
+ { 'data-scope': 'md-frontmatter', 'data-part': 'root', contenteditable: 'false' },
297
+ children,
298
+ ),
299
+ ]
300
+ })
301
+
302
+ return {
303
+ name: 'frontmatter',
304
+ nodes: [LLuiDecoratorNode],
305
+ decorators: [bridge],
306
+ transformers: [FRONTMATTER_TRANSFORMER],
307
+ items: [
308
+ {
309
+ id: 'frontmatter',
310
+ label: 'Frontmatter',
311
+ icon: 'frontmatter',
312
+ group: 'insert',
313
+ keywords: ['frontmatter', 'yaml', 'metadata', 'front matter'],
314
+ // Idempotent: a document already carrying frontmatter is left alone
315
+ // rather than having its metadata blanked.
316
+ run: (editor: LexicalEditor) =>
317
+ editor.update(() => {
318
+ if ($getFrontmatter() === null) $setFrontmatter('')
319
+ }),
320
+ surfaces: ['slash', 'context'],
321
+ },
322
+ ],
323
+ }
324
+ }
@@ -18,6 +18,8 @@
18
18
  // 61 — context menu — pointer-anchored
19
19
  // 62 — floating selection toolbar — selection-anchored bubble
20
20
  // 63 — table tools — element-anchored, sits over a table
21
+ // 64 — code-block language badge — element-anchored, may sit INSIDE a
22
+ // table cell, so it must clear 63
21
23
  // The values live with each plugin's view; this comment is the source of truth.
22
24
 
23
25
  import {
@@ -37,6 +39,11 @@ export const OVERLAY_Z = {
37
39
  contextMenu: 61,
38
40
  floatingToolbar: 62,
39
41
  tableTools: 63,
42
+ // Strictly above `tableTools`: a fenced code block can live inside a table
43
+ // cell, in which case both surfaces are open at once. At an equal z-index the
44
+ // clickable one was decided by portal insertion order — i.e. by plugin array
45
+ // order — which is exactly the no-tie rule this scale exists to prevent.
46
+ codeLanguage: 64,
40
47
  } as const
41
48
 
42
49
  const toMountables = (r: Renderable): Mountable[] => [...r]
@@ -7,9 +7,10 @@
7
7
 
8
8
  import {
9
9
  $createParagraphNode,
10
- $createTextNode,
11
10
  $getSelection,
12
11
  $isRangeSelection,
12
+ type ElementFormatType,
13
+ type ElementNode,
13
14
  type LexicalNode,
14
15
  } from 'lexical'
15
16
  import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
@@ -30,12 +31,26 @@ import {
30
31
  TableNode,
31
32
  TableRowNode,
32
33
  } from '@lexical/table'
33
- import type { MultilineElementTransformer } from '@lexical/markdown'
34
+ import {
35
+ $convertFromMarkdownString,
36
+ LINK,
37
+ type MultilineElementTransformer,
38
+ type Transformer,
39
+ } from '@lexical/markdown'
34
40
  import { button, text } from '@llui/dom'
35
41
  import { definePluginUI } from './ui.js'
36
42
  import { OVERLAY_Z, hideOverlay, onViewportChange, overlayRoot } from './overlay.js'
43
+ import { INLINE_TEXT_TRANSFORMERS } from '../transformers/gfm.js'
37
44
  import type { MarkdownPlugin } from './types.js'
38
45
 
46
+ /** Transformers used to parse/serialize a single table cell's INLINE content
47
+ * (bold/italic/strikethrough/inline-code and links). No block/element or
48
+ * multiline transformers, so a cell's markdown is always a single inline run —
49
+ * this is what makes bold/link cells survive an export∘import round-trip instead
50
+ * of being flattened to bare text. `LINK` needs `LinkNode`, which the core plugin
51
+ * registers (tables are used alongside it). */
52
+ const CELL_TRANSFORMERS: readonly Transformer[] = [...INLINE_TEXT_TRANSFORMERS, LINK]
53
+
39
54
  interface TableToolsState {
40
55
  open: boolean
41
56
  x: number
@@ -107,26 +122,71 @@ function isSeparator(line: string | undefined): boolean {
107
122
  return !!line && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes('-')
108
123
  }
109
124
 
125
+ /** Is `line` a GFM table row? GFM makes the leading and trailing pipes OPTIONAL
126
+ * (`a | b` is as valid as `| a | b |`); the only requirement is at least one
127
+ * unescaped cell delimiter. The old `\|.*\|` test rejected valid pipe-less-edge
128
+ * rows, truncating imported tables. */
110
129
  function isRow(line: string | undefined): boolean {
111
- return !!line && /^\s*\|.*\|\s*$/.test(line.trim())
130
+ if (line === undefined) return false
131
+ const trimmed = line.trim()
132
+ return trimmed !== '' && /(?<!\\)\|/.test(trimmed)
112
133
  }
113
134
 
114
- function makeCell(textValue: string, header: boolean): TableCellNode {
135
+ /** Map a GFM separator cell (`:---`, `:---:`, `---:`, `---`) to a cell alignment. */
136
+ function parseAlignment(separatorCell: string): ElementFormatType {
137
+ const t = separatorCell.trim()
138
+ const left = t.startsWith(':')
139
+ const right = t.endsWith(':')
140
+ if (left && right) return 'center'
141
+ if (right) return 'right'
142
+ if (left) return 'left'
143
+ return ''
144
+ }
145
+
146
+ /** Map a cell's alignment back to its GFM separator token. */
147
+ function alignmentSeparator(format: ElementFormatType): string {
148
+ switch (format) {
149
+ case 'center':
150
+ return ':---:'
151
+ case 'right':
152
+ case 'end':
153
+ return '---:'
154
+ case 'left':
155
+ case 'start':
156
+ return ':---'
157
+ default:
158
+ return '---'
159
+ }
160
+ }
161
+
162
+ /** Build a cell from its inline markdown, parsing bold/italic/link/etc. through
163
+ * {@link CELL_TRANSFORMERS} (not a bare text node) and applying its column's
164
+ * alignment. */
165
+ function makeCell(cellMarkdown: string, header: boolean, align: ElementFormatType): TableCellNode {
115
166
  const cell = $createTableCellNode(
116
167
  header ? TableCellHeaderStates.COLUMN : TableCellHeaderStates.NO_STATUS,
117
168
  )
118
- cell.append($createParagraphNode().append($createTextNode(textValue)))
169
+ // Parse the cell's inline markdown into a paragraph of formatted text nodes.
170
+ $convertFromMarkdownString(cellMarkdown, [...CELL_TRANSFORMERS], cell)
171
+ // A blank cell yields no paragraph — keep the cell a well-formed single-paragraph.
172
+ if (cell.getChildrenSize() === 0) cell.append($createParagraphNode())
173
+ if (align !== '') cell.setFormat(align)
119
174
  return cell
120
175
  }
121
176
 
122
- function buildTable(header: string[], body: string[][]): TableNode {
177
+ function buildTable(
178
+ header: string[],
179
+ body: string[][],
180
+ aligns: readonly ElementFormatType[],
181
+ ): TableNode {
123
182
  const table = $createTableNode()
124
183
  const headerRow = $createTableRowNode()
125
- for (const h of header) headerRow.append(makeCell(h, true))
184
+ header.forEach((h, i) => headerRow.append(makeCell(h, true, aligns[i] ?? '')))
126
185
  table.append(headerRow)
127
186
  for (const row of body) {
128
187
  const tr = $createTableRowNode()
129
- for (let i = 0; i < header.length; i++) tr.append(makeCell(row[i] ?? '', false))
188
+ for (let i = 0; i < header.length; i++)
189
+ tr.append(makeCell(row[i] ?? '', false, aligns[i] ?? ''))
130
190
  table.append(tr)
131
191
  }
132
192
  return table
@@ -134,29 +194,42 @@ function buildTable(header: string[], body: string[][]): TableNode {
134
194
 
135
195
  const TABLE_TRANSFORMER: MultilineElementTransformer = {
136
196
  dependencies: [TableNode, TableRowNode, TableCellNode],
137
- export: (node: LexicalNode): string | null => {
197
+ export: (node: LexicalNode, traverseChildren: (node: ElementNode) => string): string | null => {
138
198
  if (!$isTableNode(node)) return null
139
199
  const rows = node.getChildren() as TableRowNode[]
140
200
  if (rows.length === 0) return null
141
201
  const lines: string[] = []
142
202
  rows.forEach((row, ri) => {
143
203
  const cells = row.getChildren() as TableCellNode[]
144
- const texts = cells.map((c) => c.getTextContent().replace(/\|/g, '\\|').replace(/\n/g, ' '))
204
+ // Serialize each cell's INLINE content through the full transformer set
205
+ // (so bold/italic/links survive), then escape `|` and flatten newlines so
206
+ // the cell can't break the row.
207
+ const texts = cells.map((c) =>
208
+ traverseChildren(c).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim(),
209
+ )
145
210
  lines.push('| ' + texts.join(' | ') + ' |')
146
- if (ri === 0) lines.push('| ' + texts.map(() => '---').join(' | ') + ' |')
211
+ if (ri === 0) {
212
+ // Reconstruct the alignment row from the header cells' alignments.
213
+ const seps = cells.map((c) => alignmentSeparator(c.getFormatType()))
214
+ lines.push('| ' + seps.join(' | ') + ' |')
215
+ }
147
216
  })
148
217
  return lines.join('\n')
149
218
  },
150
- regExpStart: /^\s*\|(.+)\|\s*$/,
219
+ // Leading/trailing pipes are optional in GFM; require only one internal pipe.
220
+ // A false positive is harmless — the header is only accepted when the NEXT line
221
+ // is a delimiter row (checked below).
222
+ regExpStart: /^\s*\|?.*(?<!\\)\|.*\|?\s*$/,
151
223
  handleImportAfterStartMatch: ({ lines, rootNode, startLineIndex }) => {
152
224
  if (!isSeparator(lines[startLineIndex + 1])) return null // header must be followed by a separator
225
+ const aligns = splitRow(lines[startLineIndex + 1]!).map(parseAlignment)
153
226
  let end = startLineIndex + 2
154
227
  const body: string[][] = []
155
228
  while (end < lines.length && isRow(lines[end]) && !isSeparator(lines[end])) {
156
229
  body.push(splitRow(lines[end]!))
157
230
  end++
158
231
  }
159
- rootNode.append(buildTable(splitRow(lines[startLineIndex]!), body))
232
+ rootNode.append(buildTable(splitRow(lines[startLineIndex]!), body, aligns))
160
233
  return [true, end - 1]
161
234
  },
162
235
  // Import is handled manually above; a multiline transformer still needs replace.
@@ -274,6 +347,7 @@ export function tablePlugin(): MarkdownPlugin {
274
347
  ['', ''],
275
348
  ['', ''],
276
349
  ],
350
+ ['', ''],
277
351
  ),
278
352
  ),
279
353
  ),