@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,371 @@
1
+ import { $getRoot } from 'lexical'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { registeredNodeTypes } from '../capabilities/registered-node-types'
5
+ import { builtInExtensions } from '../config/built-in-extension-names'
6
+ import { buildRestrictedEditor } from '../test-support/build-test-editor'
7
+ import { normalizeValue } from './normalize-value'
8
+
9
+ const text = (value: string, format = 0) => ({
10
+ detail: 0,
11
+ format,
12
+ mode: 'normal',
13
+ style: '',
14
+ text: value,
15
+ type: 'text',
16
+ version: 1,
17
+ })
18
+ const para = (...children: unknown[]) => ({
19
+ children,
20
+ direction: null,
21
+ format: '',
22
+ indent: 0,
23
+ type: 'paragraph',
24
+ version: 1,
25
+ })
26
+ const heading = (tag: string, ...children: unknown[]) => ({
27
+ children,
28
+ direction: null,
29
+ format: '',
30
+ indent: 0,
31
+ type: 'heading',
32
+ version: 1,
33
+ tag,
34
+ })
35
+ // biome-ignore lint/suspicious/noExplicitAny: serialized fixtures are structural
36
+ const doc = (...children: unknown[]): any => ({
37
+ root: { children, direction: null, format: '', indent: 0, type: 'root', version: 1 },
38
+ })
39
+
40
+ /**
41
+ * Normalize for this editor and actually load the result.
42
+ *
43
+ * Structural assertions on the JSON are not enough: only Lexical can say
44
+ * whether the tree it produced is one it will accept, and an invalid
45
+ * parent/child arrangement throws here rather than in a unit assertion.
46
+ */
47
+ function normalizeAndLoad(
48
+ /** Undefined means the default fully-stripped editor. */
49
+ removals: string[] | undefined,
50
+ // biome-ignore lint/suspicious/noExplicitAny: serialized fixtures are structural
51
+ value: any
52
+ ): { types: string[]; text: string; formats: number[] } {
53
+ const editor = buildRestrictedEditor(removals)
54
+ const result = normalizeValue(value, registeredNodeTypes(editor))
55
+ if (result.status !== 'adapted') throw new Error(`expected adapted, got ${result.status}`)
56
+
57
+ // Actually COMMIT the state. Parsing alone does not exercise
58
+ // setEditorState's own validation — an empty root parses happily and
59
+ // then throws "setEditorState: the editor state is empty" on commit.
60
+ const state = editor.parseEditorState(JSON.stringify(result.value))
61
+ editor.setEditorState(state)
62
+
63
+ let types: string[] = []
64
+ let content = ''
65
+ let formats: number[] = []
66
+ editor.getEditorState().read(() => {
67
+ types = $getRoot()
68
+ .getChildren()
69
+ .map((node) => node.getType())
70
+ content = $getRoot().getTextContent()
71
+ formats = $getRoot()
72
+ .getAllTextNodes()
73
+ .map((node) => node.getFormat())
74
+ })
75
+ editor.dispose()
76
+ return { types, text: content, formats }
77
+ }
78
+
79
+ describe('normalized output loads into a real editor', () => {
80
+ it('commits a document whose only node was dropped', () => {
81
+ const result = normalizeAndLoad(undefined, doc({ type: 'horizontalrule', version: 1 }))
82
+ expect(result.types).toEqual(['paragraph'])
83
+ expect(result.text).toBe('')
84
+ })
85
+
86
+ it('commits highlighted code as plain text with formats intact', () => {
87
+ const code = {
88
+ children: [
89
+ {
90
+ detail: 0,
91
+ format: 1,
92
+ mode: 'normal',
93
+ style: '',
94
+ text: 'const',
95
+ type: 'code-highlight',
96
+ version: 1,
97
+ highlightType: 'keyword',
98
+ },
99
+ {
100
+ detail: 0,
101
+ format: 0,
102
+ mode: 'normal',
103
+ style: '',
104
+ text: ' x = 1',
105
+ type: 'code-highlight',
106
+ version: 1,
107
+ },
108
+ ],
109
+ direction: null,
110
+ format: '',
111
+ indent: 0,
112
+ type: 'code',
113
+ version: 1,
114
+ language: 'js',
115
+ }
116
+ const result = normalizeAndLoad([builtInExtensions.CodeHighlight], doc(code))
117
+ expect(result.types).toEqual(['paragraph'])
118
+ expect(result.text).toBe('const x = 1')
119
+ expect(result.formats).toContain(1)
120
+ })
121
+
122
+ it('commits a nested list flattened to paragraphs', () => {
123
+ const inner = {
124
+ children: [
125
+ {
126
+ children: [text('inner')],
127
+ direction: null,
128
+ format: '',
129
+ indent: 1,
130
+ type: 'listitem',
131
+ version: 1,
132
+ value: 1,
133
+ },
134
+ ],
135
+ direction: null,
136
+ format: '',
137
+ indent: 0,
138
+ type: 'list',
139
+ version: 1,
140
+ listType: 'bullet',
141
+ start: 1,
142
+ tag: 'ul',
143
+ }
144
+ const outer = {
145
+ children: [
146
+ {
147
+ children: [text('outer')],
148
+ direction: null,
149
+ format: '',
150
+ indent: 0,
151
+ type: 'listitem',
152
+ version: 1,
153
+ value: 1,
154
+ },
155
+ {
156
+ children: [inner],
157
+ direction: null,
158
+ format: '',
159
+ indent: 0,
160
+ type: 'listitem',
161
+ version: 1,
162
+ value: 2,
163
+ },
164
+ ],
165
+ direction: null,
166
+ format: '',
167
+ indent: 0,
168
+ type: 'list',
169
+ version: 1,
170
+ listType: 'bullet',
171
+ start: 1,
172
+ tag: 'ul',
173
+ }
174
+ const result = normalizeAndLoad(undefined, doc(outer))
175
+ expect(new Set(result.types)).toEqual(new Set(['paragraph']))
176
+ expect(result.text).toContain('outer')
177
+ expect(result.text).toContain('inner')
178
+ })
179
+
180
+ it('loads mixed content — adjacent headings, an empty one, a table and a list', () => {
181
+ const cell = {
182
+ children: [para(text('cell text'))],
183
+ direction: null,
184
+ format: '',
185
+ indent: 0,
186
+ type: 'tablecell',
187
+ version: 1,
188
+ colSpan: 1,
189
+ rowSpan: 1,
190
+ headerState: 0,
191
+ }
192
+ const table = {
193
+ children: [
194
+ {
195
+ children: [cell],
196
+ direction: null,
197
+ format: '',
198
+ indent: 0,
199
+ type: 'tablerow',
200
+ version: 1,
201
+ },
202
+ ],
203
+ direction: null,
204
+ format: '',
205
+ indent: 0,
206
+ type: 'table',
207
+ version: 1,
208
+ }
209
+ const list = {
210
+ children: [
211
+ {
212
+ children: [text('item one')],
213
+ direction: null,
214
+ format: '',
215
+ indent: 0,
216
+ type: 'listitem',
217
+ version: 1,
218
+ value: 1,
219
+ },
220
+ ],
221
+ direction: null,
222
+ format: '',
223
+ indent: 0,
224
+ type: 'list',
225
+ version: 1,
226
+ listType: 'bullet',
227
+ start: 1,
228
+ tag: 'ul',
229
+ }
230
+
231
+ const result = normalizeAndLoad(
232
+ undefined,
233
+ doc(
234
+ heading('h1', text('First')),
235
+ heading('h2'),
236
+ heading('h2', text('Second')),
237
+ table,
238
+ list,
239
+ para(text('tail'))
240
+ )
241
+ )
242
+
243
+ // Every block came back as a paragraph and Lexical accepted the tree.
244
+ expect(new Set(result.types)).toEqual(new Set(['paragraph']))
245
+ for (const fragment of ['First', 'Second', 'cell text', 'item one', 'tail']) {
246
+ expect(result.text, fragment).toContain(fragment)
247
+ }
248
+ // Adjacent headings did not merge into one paragraph.
249
+ expect(result.text).not.toContain('FirstSecond')
250
+ })
251
+
252
+ it('preserves bold through the load, not just through the rewrite', () => {
253
+ const result = normalizeAndLoad(undefined, doc(heading('h1', text('Bold title', 1))))
254
+ expect(result.types).toEqual(['paragraph'])
255
+ expect(result.text).toBe('Bold title')
256
+ expect(result.formats).toContain(1)
257
+ })
258
+
259
+ it('loads a preserved link URL as readable text', () => {
260
+ const link = {
261
+ children: [text('link text')],
262
+ direction: null,
263
+ format: '',
264
+ indent: 0,
265
+ type: 'link',
266
+ version: 2,
267
+ attributes: { url: 'https://x.test/a' },
268
+ }
269
+ const result = normalizeAndLoad([builtInExtensions.Link], doc(para(link)))
270
+ expect(result.types).toEqual(['paragraph'])
271
+ expect(result.text).toContain('link text')
272
+ expect(result.text).toContain('https://x.test/a')
273
+ })
274
+
275
+ it('loads a preserved admonition title as the first line', () => {
276
+ const admonition = {
277
+ children: [para(text('body copy'))],
278
+ direction: null,
279
+ format: '',
280
+ indent: 0,
281
+ type: 'admonition',
282
+ version: 1,
283
+ title: 'Note title',
284
+ }
285
+ const result = normalizeAndLoad([builtInExtensions.Admonition], doc(admonition))
286
+ expect(result.types).toEqual(['paragraph', 'paragraph'])
287
+ expect(result.text.indexOf('Note title')).toBeLessThan(result.text.indexOf('body copy'))
288
+ })
289
+
290
+ it('loads a layout that still contains its supported image', () => {
291
+ // Shaped as InlineImageNode.exportJSON writes it: the relation is
292
+ // spread at the top level, and `caption` is a whole nested editor
293
+ // state — which is exactly why an inline image has no safe
294
+ // structural conversion and must take the refusal path instead.
295
+ const image = {
296
+ type: 'inline-image',
297
+ version: 1,
298
+ targetDocumentId: 'doc-1',
299
+ targetCollectionPath: 'media',
300
+ src: '/cat.png',
301
+ position: 'full',
302
+ altText: 'a cat',
303
+ width: 10,
304
+ height: 10,
305
+ showCaption: false,
306
+ caption: {
307
+ editorState: {
308
+ root: {
309
+ children: [para(text('caption words'))],
310
+ direction: null,
311
+ format: '',
312
+ indent: 0,
313
+ type: 'root',
314
+ version: 1,
315
+ },
316
+ },
317
+ },
318
+ }
319
+ const item = {
320
+ children: [para(image)],
321
+ direction: null,
322
+ format: '',
323
+ indent: 0,
324
+ type: 'layout-item',
325
+ version: 1,
326
+ }
327
+ const container = {
328
+ children: [item],
329
+ direction: null,
330
+ format: '',
331
+ indent: 0,
332
+ type: 'layout-container',
333
+ version: 1,
334
+ templateColumns: '1fr',
335
+ }
336
+ // Layout removed, inline image KEPT: the image must survive intact.
337
+ const editor = buildRestrictedEditor([builtInExtensions.Layout])
338
+ const result = normalizeValue(doc(container), registeredNodeTypes(editor))
339
+ if (result.status !== 'adapted') throw new Error('expected adapted')
340
+ const state = editor.parseEditorState(JSON.stringify(result.value))
341
+ const types: string[] = []
342
+ let sources = ''
343
+ state.read(() => {
344
+ const walk = (
345
+ nodes: ReturnType<typeof $getRoot>['getChildren'] extends () => infer R ? R : never
346
+ ) => {
347
+ for (const node of nodes as Array<{
348
+ getType: () => string
349
+ getChildren?: () => unknown[]
350
+ }>) {
351
+ types.push(node.getType())
352
+ if (typeof node.getChildren === 'function') {
353
+ // biome-ignore lint/suspicious/noExplicitAny: structural walk
354
+ walk(node.getChildren() as any)
355
+ }
356
+ }
357
+ }
358
+ // biome-ignore lint/suspicious/noExplicitAny: structural walk
359
+ walk($getRoot().getChildren() as any)
360
+ sources = JSON.stringify(result.value)
361
+ })
362
+ // The layout wrapper is gone, the image it held is still there.
363
+ expect(types).not.toContain('layout-container')
364
+ expect(types).toContain('inline-image')
365
+ expect(sources).toContain('/cat.png')
366
+ expect(sources).toContain('doc-1')
367
+ // The caption's nested editor state survived untouched.
368
+ expect(sources).toContain('caption words')
369
+ editor.dispose()
370
+ })
371
+ })