@byline/richtext-lexical 5.2.0 → 6.0.1

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 (123) hide show
  1. package/dist/config.d.ts +1 -1
  2. package/dist/field/apply-value-plugin.js +31 -6
  3. package/dist/field/capabilities/filter-transformers.d.ts +29 -0
  4. package/dist/field/capabilities/filter-transformers.js +8 -0
  5. package/dist/field/capabilities/registered-node-types.d.ts +29 -0
  6. package/dist/field/capabilities/registered-node-types.js +7 -0
  7. package/dist/field/config/built-in-extension-names.d.ts +42 -0
  8. package/dist/field/config/built-in-extension-names.js +12 -2
  9. package/dist/field/config/default-extensions.js +4 -0
  10. package/dist/field/config/default.js +8 -8
  11. package/dist/field/config/editor-config-context.d.ts +2 -2
  12. package/dist/field/config/editor-config-context.js +10 -13
  13. package/dist/field/config/resolve-editor-config.js +3 -3
  14. package/dist/field/config/root-dependencies.d.ts +26 -0
  15. package/dist/field/config/root-dependencies.js +8 -0
  16. package/dist/field/config/types.d.ts +61 -8
  17. package/dist/field/editor-context.js +44 -27
  18. package/dist/field/editor.js +15 -8
  19. package/dist/field/extensions/core-nodes/core-nodes-extension.d.ts +24 -0
  20. package/dist/field/extensions/core-nodes/core-nodes-extension.js +11 -0
  21. package/dist/field/extensions/heading/heading-extension.d.ts +20 -0
  22. package/dist/field/extensions/heading/heading-extension.js +9 -0
  23. package/dist/field/extensions/quote/quote-extension.d.ts +16 -0
  24. package/dist/field/extensions/quote/quote-extension.js +9 -0
  25. package/dist/field/hooks/use-markdown-toggle.d.ts +15 -0
  26. package/dist/field/hooks/use-markdown-toggle.js +12 -4
  27. package/dist/field/hooks/use-platform-modifier.d.ts +16 -0
  28. package/dist/field/hooks/use-platform-modifier.js +8 -0
  29. package/dist/field/markdown/transformers.d.ts +1 -0
  30. package/dist/field/markdown/transformers.js +5 -4
  31. package/dist/field/nodes/index.d.ts +11 -1
  32. package/dist/field/nodes/index.js +2 -2
  33. package/dist/field/normalize/adapted-notice.css +34 -0
  34. package/dist/field/normalize/adapted-notice.d.ts +29 -0
  35. package/dist/field/normalize/adapted-notice.js +35 -0
  36. package/dist/field/normalize/content-labels.d.ts +15 -0
  37. package/dist/field/normalize/content-labels.js +19 -0
  38. package/dist/field/normalize/declared-conversions.d.ts +131 -0
  39. package/dist/field/normalize/declared-conversions.js +93 -0
  40. package/dist/field/normalize/normalization-status.d.ts +33 -0
  41. package/dist/field/normalize/normalization-status.js +28 -0
  42. package/dist/field/normalize/normalize-value.d.ts +38 -0
  43. package/dist/field/normalize/normalize-value.js +132 -0
  44. package/dist/field/normalize/use-notice-text.d.ts +11 -0
  45. package/dist/field/normalize/use-notice-text.js +34 -0
  46. package/dist/field/plugins/toolbar-plugin/index.d.ts +23 -0
  47. package/dist/field/plugins/toolbar-plugin/index.js +104 -78
  48. package/dist/field/test-support/build-test-editor.js +49 -0
  49. package/dist/field/test-support/capture-editor.js +7 -0
  50. package/dist/field/test-support/frame-control.js +40 -0
  51. package/dist/field/test-support/jsdom-setup.js +21 -0
  52. package/dist/index.d.ts +7 -1
  53. package/dist/index.js +6 -1
  54. package/dist/scan/capability-manifest.d.ts +30 -0
  55. package/dist/scan/capability-manifest.js +43 -0
  56. package/dist/scan/scan-documents.d.ts +29 -0
  57. package/dist/scan/scan-documents.js +39 -0
  58. package/dist/scan/types.d.ts +63 -0
  59. package/dist/scan/types.js +1 -0
  60. package/dist/scan.d.ts +25 -0
  61. package/dist/scan.js +1 -0
  62. package/package.json +14 -5
  63. package/src/config.ts +1 -1
  64. package/src/field/apply-value-plugin.tsx +71 -5
  65. package/src/field/capabilities/filter-transformers.test.tsx +71 -0
  66. package/src/field/capabilities/filter-transformers.ts +42 -0
  67. package/src/field/capabilities/markdown-behaviour.test.tsx +188 -0
  68. package/src/field/capabilities/no-blanket-registration.test.tsx +86 -0
  69. package/src/field/capabilities/registered-node-types.test.tsx +29 -0
  70. package/src/field/capabilities/registered-node-types.ts +36 -0
  71. package/src/field/config/built-in-extension-names.test.node.ts +38 -14
  72. package/src/field/config/built-in-extension-names.ts +29 -1
  73. package/src/field/config/default-extensions.ts +6 -0
  74. package/src/field/config/default.ts +8 -8
  75. package/src/field/config/editor-config-context.tsx +14 -9
  76. package/src/field/config/list-removal.test.tsx +44 -0
  77. package/src/field/config/resolve-editor-config.test.node.ts +27 -6
  78. package/src/field/config/resolve-editor-config.ts +3 -3
  79. package/src/field/config/root-dependencies.ts +32 -0
  80. package/src/field/config/types.ts +61 -17
  81. package/src/field/editor-component.test.tsx +18 -3
  82. package/src/field/editor-context.regression.test.tsx +124 -0
  83. package/src/field/editor-context.tsx +90 -22
  84. package/src/field/editor.tsx +22 -12
  85. package/src/field/extensions/core-nodes/core-nodes-extension.test.tsx +13 -0
  86. package/src/field/extensions/core-nodes/core-nodes-extension.ts +32 -0
  87. package/src/field/extensions/heading/heading-extension.test.tsx +32 -0
  88. package/src/field/extensions/heading/heading-extension.ts +27 -0
  89. package/src/field/extensions/quote/quote-extension.ts +23 -0
  90. package/src/field/hooks/markdown-source-mode.test.tsx +221 -0
  91. package/src/field/hooks/use-markdown-toggle.ts +39 -4
  92. package/src/field/hooks/use-platform-modifier.test.tsx +81 -0
  93. package/src/field/hooks/use-platform-modifier.ts +41 -0
  94. package/src/field/markdown/transformers.ts +18 -4
  95. package/src/field/nodes/index.ts +11 -1
  96. package/src/field/normalize/adapted-notice.css +52 -0
  97. package/src/field/normalize/adapted-notice.tsx +63 -0
  98. package/src/field/normalize/clipboard-equivalence.test.tsx +330 -0
  99. package/src/field/normalize/content-labels.ts +44 -0
  100. package/src/field/normalize/declared-conversions.test.node.ts +67 -0
  101. package/src/field/normalize/declared-conversions.ts +160 -0
  102. package/src/field/normalize/emission-gate.test.tsx +302 -0
  103. package/src/field/normalize/normalization-status.tsx +48 -0
  104. package/src/field/normalize/normalize-load.test.tsx +371 -0
  105. package/src/field/normalize/normalize-value.test.node.ts +530 -0
  106. package/src/field/normalize/normalize-value.ts +180 -0
  107. package/src/field/normalize/notice-i18n.test.tsx +128 -0
  108. package/src/field/normalize/use-notice-text.ts +82 -0
  109. package/src/field/normalize/wiring.test.tsx +348 -0
  110. package/src/field/plugins/toolbar-plugin/block-format-capabilities.test.tsx +57 -0
  111. package/src/field/plugins/toolbar-plugin/index.tsx +131 -68
  112. package/src/field/test-support/build-test-editor.test.tsx +17 -0
  113. package/src/field/test-support/build-test-editor.ts +99 -0
  114. package/src/field/test-support/capture-editor.tsx +20 -0
  115. package/src/field/test-support/frame-control.ts +91 -0
  116. package/src/field/test-support/jsdom-setup.ts +41 -0
  117. package/src/index.ts +12 -1
  118. package/src/scan/capability-manifest.test.tsx +82 -0
  119. package/src/scan/capability-manifest.ts +81 -0
  120. package/src/scan/scan-documents.test.node.ts +125 -0
  121. package/src/scan/scan-documents.ts +78 -0
  122. package/src/scan/types.ts +66 -0
  123. package/src/scan.ts +27 -0
@@ -0,0 +1,330 @@
1
+ import { $insertDataTransferForRichText } from '@lexical/clipboard'
2
+ import { registerRichText } from '@lexical/rich-text'
3
+ import {
4
+ $createParagraphNode,
5
+ $getRoot,
6
+ $getSelection,
7
+ type LexicalEditor,
8
+ type SerializedEditorState,
9
+ } from 'lexical'
10
+ import { describe, expect, it, vi } from 'vitest'
11
+
12
+ import { registeredNodeTypes } from '../capabilities/registered-node-types'
13
+ import { builtInExtensions } from '../config/built-in-extension-names'
14
+ import { buildRestrictedEditor } from '../test-support/build-test-editor'
15
+ import { normalizeValue } from './normalize-value'
16
+
17
+ const NAMESPACE = 'LexicalRichText'
18
+
19
+ const text = (value: string) => ({
20
+ detail: 0,
21
+ format: 0,
22
+ mode: 'normal',
23
+ style: '',
24
+ text: value,
25
+ type: 'text',
26
+ version: 1,
27
+ })
28
+ // biome-ignore lint/suspicious/noExplicitAny: serialized fixtures are structural
29
+ const doc = (...children: unknown[]): any => ({
30
+ root: { children, direction: null, format: '', indent: 0, type: 'root', version: 1 },
31
+ })
32
+
33
+ function snapshot(editor: LexicalEditor): { types: string[]; text: string } {
34
+ let types: string[] = []
35
+ let content = ''
36
+ editor.getEditorState().read(() => {
37
+ types = $getRoot()
38
+ .getChildren()
39
+ .map((node) => node.getType())
40
+ content = $getRoot().getTextContent()
41
+ })
42
+ return { types, text: content }
43
+ }
44
+
45
+ /** Load `saved` through the normalizer and commit it. */
46
+ function viaStorage(removals: string[] | undefined, saved: SerializedEditorState) {
47
+ const editor = buildRestrictedEditor(removals)
48
+ const result = normalizeValue(saved, registeredNodeTypes(editor))
49
+ if (result.status !== 'adapted') throw new Error(`expected adapted, got ${result.status}`)
50
+ editor.setEditorState(editor.parseEditorState(JSON.stringify(result.value)))
51
+ const out = snapshot(editor)
52
+ editor.dispose()
53
+ return out
54
+ }
55
+
56
+ const stubDataTransfer = (data: Record<string, string>) => ({
57
+ types: Object.keys(data),
58
+ getData: (type: string) => data[type] ?? '',
59
+ })
60
+
61
+ /** Paste a real clipboard payload through registerRichText's handler. */
62
+ function pasteClipboard(
63
+ removals: string[] | undefined,
64
+ data: Record<string, string>
65
+ ): { types: string[]; text: string; threw: string; consoleErrors: string[] } {
66
+ const editor = buildRestrictedEditor(removals)
67
+ const consoleErrors: string[] = []
68
+ const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
69
+ consoleErrors.push(String((args[0] as Error)?.message ?? args[0]))
70
+ })
71
+
72
+ registerRichText(editor)
73
+ editor.update(
74
+ () => {
75
+ $getRoot().clear().append($createParagraphNode())
76
+ $getRoot().selectEnd()
77
+ },
78
+ { discrete: true }
79
+ )
80
+
81
+ let threw = 'none'
82
+ try {
83
+ editor.update(
84
+ () => {
85
+ // biome-ignore lint/suspicious/noExplicitAny: stub data transfer
86
+ $insertDataTransferForRichText(stubDataTransfer(data) as any, $getSelection()!, editor)
87
+ },
88
+ { discrete: true }
89
+ )
90
+ } catch (error) {
91
+ threw = (error as Error).message
92
+ }
93
+ spy.mockRestore()
94
+
95
+ const out = snapshot(editor)
96
+ editor.dispose()
97
+ return { ...out, threw, consoleErrors }
98
+ }
99
+
100
+ /** Paste HTML the way a real paste does: through insertion, not parsing. */
101
+ function viaClipboardHtml(removals: string[] | undefined, html: string) {
102
+ return pasteClipboard(removals, { 'text/html': html, 'text/plain': '' })
103
+ }
104
+
105
+ const block = (type: string, children: unknown[], extra: Record<string, unknown> = {}) => ({
106
+ children,
107
+ direction: null,
108
+ format: '',
109
+ indent: 0,
110
+ type,
111
+ version: 1,
112
+ ...extra,
113
+ })
114
+
115
+ describe('clipboard and storage reach equivalent outcomes', () => {
116
+ // Every structure the specification lists as convertible, compared
117
+ // across both routes. Both go through real insertion — inspecting
118
+ // imported nodes before insertion would not evidence what a paste
119
+ // actually leaves in the document.
120
+ const cases: Array<{
121
+ name: string
122
+ saved: SerializedEditorState
123
+ html: string
124
+ /**
125
+ * The ordered block structure BOTH routes must produce. Pinned
126
+ * explicitly rather than compared route-to-route as sets: a set
127
+ * comparison treats one paragraph and three as equal, which is
128
+ * exactly the boundary loss this work fixed — two adjacent headings
129
+ * merging into a single paragraph would pass unnoticed.
130
+ */
131
+ expectedTypes: string[]
132
+ expectedText: string
133
+ }> = [
134
+ {
135
+ name: 'heading',
136
+ saved: doc(block('heading', [text('Title')], { tag: 'h1' })),
137
+ html: '<h1>Title</h1>',
138
+ expectedTypes: ['paragraph'],
139
+ expectedText: 'Title',
140
+ },
141
+ {
142
+ name: 'quote',
143
+ saved: doc(block('quote', [text('Quoted words')])),
144
+ html: '<blockquote>Quoted words</blockquote>',
145
+ expectedTypes: ['paragraph'],
146
+ expectedText: 'Quoted words',
147
+ },
148
+ {
149
+ name: 'code block',
150
+ saved: doc(block('code', [text('const x = 1')], { language: 'js' })),
151
+ html: '<pre>const x = 1</pre>',
152
+ expectedTypes: ['paragraph'],
153
+ expectedText: 'const x = 1',
154
+ },
155
+ {
156
+ name: 'bullet list',
157
+ saved: doc(
158
+ block(
159
+ 'list',
160
+ [
161
+ block('listitem', [text('item one')], { value: 1 }),
162
+ block('listitem', [text('item two')], { value: 2 }),
163
+ ],
164
+ { listType: 'bullet', start: 1, tag: 'ul' }
165
+ )
166
+ ),
167
+ html: '<ul><li>item one</li><li>item two</li></ul>',
168
+ // TWO paragraphs, not one: each item keeps its own boundary.
169
+ expectedTypes: ['paragraph', 'paragraph'],
170
+ expectedText: 'item one\n\nitem two',
171
+ },
172
+ {
173
+ name: 'table',
174
+ saved: doc(
175
+ block(
176
+ 'table',
177
+ [
178
+ block('tablerow', [
179
+ block('tablecell', [block('paragraph', [text('cell text')])], {
180
+ colSpan: 1,
181
+ rowSpan: 1,
182
+ headerState: 0,
183
+ }),
184
+ ]),
185
+ ],
186
+ {}
187
+ )
188
+ ),
189
+ html: '<table><tr><td>cell text</td></tr></table>',
190
+ expectedTypes: ['paragraph'],
191
+ expectedText: 'cell text',
192
+ },
193
+ ]
194
+
195
+ for (const { name, saved, html, expectedTypes, expectedText } of cases) {
196
+ it(`degrades a ${name} equivalently by either route`, () => {
197
+ const storage = viaStorage(undefined, saved)
198
+ const clipboard = viaClipboardHtml(undefined, html)
199
+
200
+ expect(clipboard.threw).toBe('none')
201
+ // Ordered structure, pinned — block count and order both matter.
202
+ expect(storage.types, `${name} via storage`).toEqual(expectedTypes)
203
+ expect(clipboard.types, `${name} via clipboard`).toEqual(expectedTypes)
204
+ expect(storage.text, `${name} via storage`).toBe(expectedText)
205
+ expect(clipboard.text, `${name} via clipboard`).toBe(expectedText)
206
+ })
207
+ }
208
+
209
+ it('drops a horizontal rule on both routes and keeps its neighbours', () => {
210
+ const storage = viaStorage(
211
+ undefined,
212
+ doc(
213
+ block('paragraph', [text('before')]),
214
+ { type: 'horizontalrule', version: 1 },
215
+ block('paragraph', [text('after')])
216
+ )
217
+ )
218
+ const clipboard = viaClipboardHtml(undefined, '<p>before</p><hr /><p>after</p>')
219
+
220
+ // Exactly the two neighbours, in order — the rule is gone and took
221
+ // nothing with it.
222
+ expect(storage.types).toEqual(['paragraph', 'paragraph'])
223
+ expect(clipboard.types).toEqual(['paragraph', 'paragraph'])
224
+ expect(storage.text).toBe('before\n\nafter')
225
+ expect(clipboard.text).toBe('before\n\nafter')
226
+ })
227
+
228
+ it('preserves the link URL on the storage route only, by design', () => {
229
+ const link = block('link', [text('link text')], {
230
+ version: 2,
231
+ attributes: { url: 'https://example.test/a' },
232
+ })
233
+ const storage = viaStorage([builtInExtensions.Link], doc(block('paragraph', [link])))
234
+ const clipboard = viaClipboardHtml(
235
+ [builtInExtensions.Link],
236
+ '<p><a href="https://example.test/a">link text</a></p>'
237
+ )
238
+
239
+ // Equivalent structure and text — NOT identical content.
240
+ expect(storage.types).toEqual(['paragraph'])
241
+ expect(clipboard.types).toEqual(['paragraph'])
242
+ expect(storage.text).toContain('link text')
243
+ expect(clipboard.text).toContain('link text')
244
+
245
+ // The intended difference, asserted from both sides so it cannot
246
+ // drift into either a "bug" or a silent regression.
247
+ expect(storage.text).toContain('https://example.test/a')
248
+ expect(clipboard.text).not.toContain('https://example.test/a')
249
+ })
250
+
251
+ it('preserves the admonition title on the storage route only', () => {
252
+ const storage = viaStorage(
253
+ [builtInExtensions.Admonition],
254
+ doc(block('admonition', [block('paragraph', [text('body copy')])], { title: 'Note title' }))
255
+ )
256
+ const clipboard = viaClipboardHtml(
257
+ [builtInExtensions.Admonition],
258
+ '<div data-type="note" data-title="Note title" class="admonition"><p>body copy</p></div>'
259
+ )
260
+
261
+ expect(clipboard.threw).toBe('none')
262
+ // Both keep the body...
263
+ expect(storage.text).toContain('body copy')
264
+ expect(clipboard.text).toContain('body copy')
265
+
266
+ // ...and only storage keeps the title, as its own leading line.
267
+ expect(storage.text).toContain('Note title')
268
+ expect(storage.text.indexOf('Note title')).toBeLessThan(storage.text.indexOf('body copy'))
269
+ expect(clipboard.text).not.toContain('Note title')
270
+ })
271
+ })
272
+
273
+ describe('Lexical-to-Lexical paste', () => {
274
+ const headingPayload = JSON.stringify({
275
+ namespace: NAMESPACE,
276
+ nodes: [
277
+ {
278
+ children: [text('Copied title')],
279
+ direction: null,
280
+ format: '',
281
+ indent: 0,
282
+ type: 'heading',
283
+ version: 1,
284
+ tag: 'h1',
285
+ },
286
+ ],
287
+ })
288
+
289
+ it('falls through to the HTML handler and degrades', () => {
290
+ // application/x-lexical-editor is tried FIRST (priority 0, ahead of
291
+ // text/html at 10) and does not use importDOM at all — it calls the
292
+ // same $parseSerializedNode that throws on an unregistered type.
293
+ // Byline gives every field the same namespace, so a copy between two
294
+ // Byline fields takes this branch.
295
+ const result = pasteClipboard(undefined, {
296
+ 'application/x-lexical-editor': headingPayload,
297
+ 'text/html': '<h1>Copied title</h1>',
298
+ 'text/plain': 'Copied title',
299
+ })
300
+
301
+ expect(result.threw).toBe('none')
302
+ expect(result.types).toEqual(['paragraph'])
303
+ expect(result.text).toContain('Copied title')
304
+ // The fallthrough is internal, not a fault — assert it rather than
305
+ // suppress it, so a future Lexical change that makes it fatal is
306
+ // caught here.
307
+ expect(result.consoleErrors.join(' ')).toContain('not found')
308
+ })
309
+
310
+ it('survives a clipboard with no text/html at all', () => {
311
+ // A source application may offer only Lexical JSON and plain text.
312
+ // The guarantee must not depend on HTML being present.
313
+ const result = pasteClipboard(undefined, {
314
+ 'application/x-lexical-editor': headingPayload,
315
+ 'text/plain': 'Copied title',
316
+ })
317
+
318
+ expect(result.threw).toBe('none')
319
+ expect(result.types).toEqual(['paragraph'])
320
+ // Text preservation is the guarantee on this route; inline formatting
321
+ // is not, because plain text carries none.
322
+ expect(result.text).toContain('Copied title')
323
+ })
324
+
325
+ it('pastes plain text normally when that is all the clipboard holds', () => {
326
+ const result = pasteClipboard(undefined, { 'text/plain': 'just words' })
327
+ expect(result.threw).toBe('none')
328
+ expect(result.text).toContain('just words')
329
+ })
330
+ })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Translation keys for the node types a reader may be told about.
11
+ *
12
+ * Only types with NO declared conversion can reach a notice, which is
13
+ * why this map is small: everything else is adapted silently.
14
+ *
15
+ * A type absent here falls back to its own id. That is deliberate — a
16
+ * site or plugin contributing its own node should not have to supply a
17
+ * display name to get a usable message, and reaching this path at all is
18
+ * rare. The id is developer vocabulary, but it is accurate and
19
+ * searchable, which beats inventing a label for a node Byline knows
20
+ * nothing about.
21
+ */
22
+ const CONTENT_LABEL_KEYS: Readonly<Record<string, string>> = {
23
+ 'inline-image': 'richtext.content.image',
24
+ youtube: 'richtext.content.youtube',
25
+ vimeo: 'richtext.content.vimeo',
26
+ }
27
+
28
+ export function contentLabelKey(type: string): string | undefined {
29
+ return CONTENT_LABEL_KEYS[type]
30
+ }
31
+
32
+ /**
33
+ * Join labels the way the active locale writes a list.
34
+ *
35
+ * `Intl.ListFormat` is not available in every runtime this may render
36
+ * in, so fall back to a comma join rather than throwing over a notice.
37
+ */
38
+ export function joinLabels(labels: string[], locale: string): string {
39
+ try {
40
+ return new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(labels)
41
+ } catch {
42
+ return labels.join(', ')
43
+ }
44
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { conversionFor, DECLARED_CONVERSIONS } from './declared-conversions'
4
+
5
+ describe('declared conversions', () => {
6
+ it('declares one for every structure the spec lists as supported', () => {
7
+ for (const type of [
8
+ 'heading',
9
+ 'quote',
10
+ 'code',
11
+ 'listitem',
12
+ 'table',
13
+ 'tablerow',
14
+ 'tablecell',
15
+ 'list',
16
+ 'layout-container',
17
+ 'layout-item',
18
+ 'admonition',
19
+ 'link',
20
+ 'autolink',
21
+ 'horizontalrule',
22
+ ]) {
23
+ expect(conversionFor(type), type).toBeDefined()
24
+ }
25
+ })
26
+
27
+ it('declares none for media, embeds or unknown types', () => {
28
+ for (const type of ['inline-image', 'youtube', 'vimeo', 'acme-custom-node']) {
29
+ expect(conversionFor(type), type).toBeUndefined()
30
+ }
31
+ })
32
+
33
+ it('classifies each structure by what it contains', () => {
34
+ expect(DECLARED_CONVERSIONS.heading.kind).toBe('to-paragraph')
35
+ expect(DECLARED_CONVERSIONS.listitem.kind).toBe('to-paragraph')
36
+ expect(DECLARED_CONVERSIONS.table.kind).toBe('lift-blocks')
37
+ expect(DECLARED_CONVERSIONS.list.kind).toBe('lift-blocks')
38
+ expect(DECLARED_CONVERSIONS.link.kind).toBe('unwrap-inline')
39
+ })
40
+
41
+ it('preserves the admonition title', () => {
42
+ expect(
43
+ DECLARED_CONVERSIONS.admonition.preserveText({ type: 'admonition', title: 'Note title' })
44
+ ).toBe('Note title')
45
+ expect(DECLARED_CONVERSIONS.admonition.preserveText({ type: 'admonition' })).toBeUndefined()
46
+ })
47
+
48
+ it('preserves the link URL from either serialized shape', () => {
49
+ // v1 stored `url` at the top level; v2 nests it under `attributes`.
50
+ expect(DECLARED_CONVERSIONS.link.preserveText({ type: 'link', url: 'https://a.test' })).toBe(
51
+ 'https://a.test'
52
+ )
53
+ expect(
54
+ DECLARED_CONVERSIONS.link.preserveText({
55
+ type: 'link',
56
+ attributes: { url: 'https://b.test' },
57
+ })
58
+ ).toBe('https://b.test')
59
+ expect(DECLARED_CONVERSIONS.link.preserveText({ type: 'link' })).toBeUndefined()
60
+ })
61
+
62
+ it('drops a horizontal rule without inventing text', () => {
63
+ const rule = conversionFor('horizontalrule')
64
+ expect(rule?.kind).toBe('drop')
65
+ expect(rule?.preserveText).toBeUndefined()
66
+ })
67
+ })
@@ -0,0 +1,160 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /** A serialized Lexical node, as stored. */
10
+ export interface SerializedNode {
11
+ type: string
12
+ children?: SerializedNode[]
13
+ [key: string]: unknown
14
+ }
15
+
16
+ /**
17
+ * What a convertible structure CONTAINS, which is what decides a
18
+ * structurally valid replacement for it.
19
+ *
20
+ * - `to-paragraph` — a block holding inline content. Becomes exactly one
21
+ * paragraph, so two adjacent headings stay two paragraphs and an empty
22
+ * heading stays an empty paragraph instead of vanishing.
23
+ * - `lift-blocks` — a block holding blocks. Its children are lifted in
24
+ * place, already block-level and already valid where the parent sat.
25
+ * - `unwrap-inline` — inline content. Its children are lifted inline and
26
+ * stay inside the paragraph that held them.
27
+ * - `to-text` — a text-carrying leaf whose class is unsupported, such as
28
+ * a syntax-highlighted code token. Becomes a plain text node with its
29
+ * text and inline formats intact.
30
+ * - `drop` — removed entirely; neighbours are untouched.
31
+ */
32
+ export type ConversionKind = 'to-paragraph' | 'lift-blocks' | 'unwrap-inline' | 'to-text' | 'drop'
33
+
34
+ /**
35
+ * Serialized types Byline knows to be block-level.
36
+ *
37
+ * Needed because a converted node's children are not always inline: a
38
+ * `listitem` may hold a nested `list`, so turning every item straight
39
+ * into a paragraph would nest a block inside a paragraph.
40
+ */
41
+ export const BLOCK_TYPES: ReadonlySet<string> = new Set([
42
+ 'paragraph',
43
+ 'heading',
44
+ 'quote',
45
+ 'code',
46
+ 'list',
47
+ 'listitem',
48
+ 'table',
49
+ 'tablerow',
50
+ 'tablecell',
51
+ 'layout-container',
52
+ 'layout-item',
53
+ 'admonition',
54
+ 'horizontalrule',
55
+ 'youtube',
56
+ 'vimeo',
57
+ ])
58
+
59
+ export function isBlockType(type: string): boolean {
60
+ return BLOCK_TYPES.has(type)
61
+ }
62
+
63
+ /**
64
+ * Serialized types Byline knows to be inline.
65
+ *
66
+ * The counterpart to {@link BLOCK_TYPES}, and the reason both lists
67
+ * exist rather than one: a node's structural role cannot be recovered
68
+ * from its serialized shape. An inline `link` and a block `quote` are
69
+ * both elements with `children`, `direction`, `format` and `indent`, so
70
+ * there is no reliable metadata to infer the role from.
71
+ *
72
+ * A SUPPORTED node can reach this test — it arrives as the child of an
73
+ * unsupported parent being converted — so "not a known block" cannot be
74
+ * read as "inline". A custom block from a downstream site would be
75
+ * wrapped into a paragraph and produce a tree Lexical rejects. When a
76
+ * type appears in neither list its role is unknown, and normalization
77
+ * refuses the document rather than guessing at it.
78
+ */
79
+ export const INLINE_TYPES: ReadonlySet<string> = new Set([
80
+ 'text',
81
+ 'linebreak',
82
+ 'tab',
83
+ 'link',
84
+ 'autolink',
85
+ 'mark',
86
+ 'overflow',
87
+ 'code-highlight',
88
+ 'inline-image',
89
+ ])
90
+
91
+ export function isInlineType(type: string): boolean {
92
+ return INLINE_TYPES.has(type)
93
+ }
94
+
95
+ export interface DeclaredConversion {
96
+ kind: ConversionKind
97
+ /**
98
+ * Information carried on the node's own properties rather than in its
99
+ * children, which would otherwise be lost silently. Emitted as a
100
+ * leading paragraph for `lift-blocks` (an admonition title becomes the
101
+ * first line) and appended to the text for `unwrap-inline` (a link URL
102
+ * follows its text).
103
+ */
104
+ preserveText?: (node: SerializedNode) => string | undefined
105
+ }
106
+
107
+ const urlText = (node: SerializedNode): string | undefined => {
108
+ const attributes = node.attributes as { url?: unknown } | undefined
109
+ const url = typeof node.url === 'string' ? node.url : attributes?.url
110
+ return typeof url === 'string' && url.length > 0 ? url : undefined
111
+ }
112
+
113
+ /**
114
+ * How each convertible structure degrades when a field does not support
115
+ * it. Type strings are the node classes' own `getType()` values.
116
+ *
117
+ * A type absent from this table has NO conversion and is never guessed
118
+ * at — normalization refuses the document instead. That covers custom
119
+ * nodes from downstream sites as well as inline images and embeds, whose
120
+ * media relations and nested-editor captions cannot survive any
121
+ * structural rewrite.
122
+ */
123
+ export const DECLARED_CONVERSIONS = {
124
+ // Blocks holding inline content.
125
+ heading: { kind: 'to-paragraph' },
126
+ quote: { kind: 'to-paragraph' },
127
+ code: { kind: 'to-paragraph' },
128
+ listitem: { kind: 'to-paragraph' },
129
+
130
+ // Blocks holding blocks.
131
+ table: { kind: 'lift-blocks' },
132
+ tablerow: { kind: 'lift-blocks' },
133
+ tablecell: { kind: 'lift-blocks' },
134
+ list: { kind: 'lift-blocks' },
135
+ 'layout-container': { kind: 'lift-blocks' },
136
+ 'layout-item': { kind: 'lift-blocks' },
137
+ admonition: {
138
+ kind: 'lift-blocks',
139
+ preserveText: (node) =>
140
+ typeof node.title === 'string' && node.title.length > 0 ? node.title : undefined,
141
+ },
142
+
143
+ // Text-carrying leaves. CodeHighlightNode is a TextNode subclass owned
144
+ // by the same extension as CodeNode, so removing code-highlight
145
+ // unregisters BOTH: without this entry a highlighted code block would
146
+ // be refused rather than adapted, contradicting the promise that code
147
+ // degrades to text.
148
+ 'code-highlight': { kind: 'to-text' },
149
+
150
+ // Inline content.
151
+ link: { kind: 'unwrap-inline', preserveText: urlText },
152
+ autolink: { kind: 'unwrap-inline', preserveText: urlText },
153
+
154
+ // Decorative, with nothing to preserve.
155
+ horizontalrule: { kind: 'drop' },
156
+ } as const satisfies Record<string, DeclaredConversion>
157
+
158
+ export function conversionFor(type: string): DeclaredConversion | undefined {
159
+ return (DECLARED_CONVERSIONS as Record<string, DeclaredConversion | undefined>)[type]
160
+ }