@byline/richtext-lexical 5.2.0 → 6.0.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 (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,180 @@
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
+ import type { SerializedEditorState } from 'lexical'
10
+
11
+ import {
12
+ conversionFor,
13
+ isBlockType,
14
+ isInlineType,
15
+ type SerializedNode,
16
+ } from './declared-conversions'
17
+
18
+ export type NormalizeResult =
19
+ | { status: 'unchanged' }
20
+ | { status: 'adapted'; value: SerializedEditorState; convertedTypes: string[] }
21
+ | { status: 'refused'; unsupportedTypes: string[] }
22
+
23
+ const textNode = (text: string): SerializedNode => ({
24
+ detail: 0,
25
+ format: 0,
26
+ mode: 'normal',
27
+ style: '',
28
+ text,
29
+ type: 'text',
30
+ version: 1,
31
+ })
32
+
33
+ /** A plain text node carrying another node's own text and inline formats. */
34
+ const textFromNode = (node: SerializedNode): SerializedNode => ({
35
+ detail: typeof node.detail === 'number' ? node.detail : 0,
36
+ format: typeof node.format === 'number' ? node.format : 0,
37
+ mode: typeof node.mode === 'string' ? node.mode : 'normal',
38
+ style: typeof node.style === 'string' ? node.style : '',
39
+ text: typeof node.text === 'string' ? node.text : '',
40
+ type: 'text',
41
+ version: 1,
42
+ })
43
+
44
+ const paragraphNode = (children: SerializedNode[]): SerializedNode => ({
45
+ children,
46
+ direction: null,
47
+ format: '',
48
+ indent: 0,
49
+ type: 'paragraph',
50
+ version: 1,
51
+ })
52
+
53
+ /**
54
+ * Adapt a stored value to what an editor can actually accept.
55
+ *
56
+ * Lexical throws `parseEditorState: type "X" + not found` on an
57
+ * unregistered type and the text is unrecoverable, so a field that no
58
+ * longer supports a structure would otherwise open blank and erroring.
59
+ *
60
+ * Only the unsupported nodes are converted. A supported node is copied
61
+ * with its own properties intact — it was already in a valid position in
62
+ * the saved document, so its position is never changed — while its
63
+ * children are still inspected, because a supported parent may hold an
64
+ * unsupported descendant. Re-encoding supported content to fix an
65
+ * unrelated structure is exactly what this must not do: an inline image
66
+ * sitting beside a heading would lose its media relation and its
67
+ * nested-editor caption as collateral.
68
+ *
69
+ * The input is never mutated. Refusal wins over any conversion, so the
70
+ * whole tree is walked before a decision is returned.
71
+ */
72
+ export function normalizeValue(
73
+ value: SerializedEditorState,
74
+ supportedTypes: ReadonlySet<string>
75
+ ): NormalizeResult {
76
+ const converted = new Set<string>()
77
+ const refused = new Set<string>()
78
+
79
+ /**
80
+ * Wrap runs of inline nodes in paragraphs, leaving blocks where they
81
+ * are. A converted block's children are not guaranteed to be inline —
82
+ * a `listitem` may hold a nested `list` — so turning the whole lot
83
+ * into one paragraph would nest a block inside a paragraph.
84
+ *
85
+ * A node whose role is in neither list is refused rather than assumed
86
+ * inline: it may be a supported custom block from a downstream site,
87
+ * and wrapping one in a paragraph produces a tree Lexical rejects.
88
+ */
89
+ function groupIntoBlocks(nodes: SerializedNode[]): SerializedNode[] {
90
+ const out: SerializedNode[] = []
91
+ let run: SerializedNode[] = []
92
+ const flush = () => {
93
+ if (run.length > 0) {
94
+ out.push(paragraphNode(run))
95
+ run = []
96
+ }
97
+ }
98
+ for (const node of nodes) {
99
+ if (isInlineType(node.type)) {
100
+ run.push(node)
101
+ continue
102
+ }
103
+ if (!isBlockType(node.type)) {
104
+ // Unknown structural role — do not guess.
105
+ refused.add(node.type)
106
+ }
107
+ flush()
108
+ out.push(node)
109
+ }
110
+ flush()
111
+ return out
112
+ }
113
+
114
+ function visit(node: SerializedNode): SerializedNode[] {
115
+ const children = Array.isArray(node.children) ? (node.children as SerializedNode[]) : undefined
116
+
117
+ if (node.type === 'root' || supportedTypes.has(node.type)) {
118
+ return children == null ? [node] : [{ ...node, children: children.flatMap(visit) }]
119
+ }
120
+
121
+ // Unsupported with no declared conversion: never guessed at.
122
+ const conversion = conversionFor(node.type)
123
+ if (conversion == null) {
124
+ refused.add(node.type)
125
+ return [node]
126
+ }
127
+
128
+ converted.add(node.type)
129
+ const preserved = conversion.preserveText?.(node)
130
+ const inner = (children ?? []).flatMap(visit)
131
+
132
+ switch (conversion.kind) {
133
+ case 'drop':
134
+ return []
135
+
136
+ // One block in, at least one block out — so adjacent blocks stay
137
+ // separate and an empty one stays an empty line. Children are
138
+ // partitioned rather than assumed inline, because a list item may
139
+ // hold a nested list.
140
+ case 'to-paragraph': {
141
+ const children = preserved != null ? [...inner, textNode(` ${preserved}`)] : inner
142
+ const grouped = groupIntoBlocks(children)
143
+ return grouped.length > 0 ? grouped : [paragraphNode([])]
144
+ }
145
+
146
+ // A text-carrying leaf keeps its text and inline formats.
147
+ case 'to-text':
148
+ return [textFromNode(node)]
149
+
150
+ // Children are already blocks. Preserved text leads as its own
151
+ // paragraph, so an admonition title becomes the first line.
152
+ case 'lift-blocks':
153
+ return preserved != null ? [paragraphNode([textNode(preserved)]), ...inner] : inner
154
+
155
+ // Inline content stays inline, inside the paragraph that held it.
156
+ case 'unwrap-inline':
157
+ return preserved != null ? [...inner, textNode(` ${preserved}`)] : inner
158
+ }
159
+ }
160
+
161
+ const visited = visit(value.root as unknown as SerializedNode)[0]
162
+
163
+ if (refused.size > 0) return { status: 'refused', unsupportedTypes: [...refused] }
164
+ if (converted.size === 0) return { status: 'unchanged' }
165
+
166
+ // Lexical rejects an empty root — "setEditorState: the editor state is
167
+ // empty" — and conversion can empty one, for instance a document whose
168
+ // only node was a horizontal rule that this field drops.
169
+ const children = Array.isArray(visited.children) ? visited.children : []
170
+ const nextRoot: SerializedNode = {
171
+ ...visited,
172
+ children: children.length > 0 ? children : [paragraphNode([])],
173
+ }
174
+
175
+ return {
176
+ status: 'adapted',
177
+ value: { ...value, root: nextRoot } as unknown as SerializedEditorState,
178
+ convertedTypes: [...converted],
179
+ }
180
+ }
@@ -0,0 +1,128 @@
1
+ import { act } from 'react'
2
+
3
+ import { adminTranslations } from '@byline/i18n/admin'
4
+ import { I18nProvider } from '@byline/i18n/react'
5
+ import { createRoot, type Root } from 'react-dom/client'
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+
8
+ import { AdaptedNotice, UnsupportedContentNotice } from './adapted-notice'
9
+
10
+ // biome-ignore lint/suspicious/noExplicitAny: React act environment flag
11
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
12
+
13
+ const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
14
+
15
+ afterEach(async () => {
16
+ for (const { root, container } of mounted.splice(0)) {
17
+ await act(async () => {
18
+ root.unmount()
19
+ })
20
+ container.remove()
21
+ }
22
+ })
23
+
24
+ async function renderWithBundle(
25
+ node: React.ReactNode,
26
+ // biome-ignore lint/suspicious/noExplicitAny: hand-built partial bundle
27
+ bundle: any,
28
+ locale: string
29
+ ): Promise<HTMLDivElement> {
30
+ const container = document.createElement('div')
31
+ document.body.appendChild(container)
32
+ const root = createRoot(container)
33
+ mounted.push({ root, container })
34
+ await act(async () => {
35
+ root.render(
36
+ <I18nProvider
37
+ bundle={bundle}
38
+ activeLocale={locale}
39
+ defaultLocale="en"
40
+ localeDefinitions={[{ code: 'en', name: 'English' }]}
41
+ onMissing={() => {}}
42
+ >
43
+ {node}
44
+ </I18nProvider>
45
+ )
46
+ })
47
+ return container
48
+ }
49
+
50
+ async function render(node: React.ReactNode, locale?: string): Promise<HTMLDivElement> {
51
+ const container = document.createElement('div')
52
+ document.body.appendChild(container)
53
+ const root = createRoot(container)
54
+ mounted.push({ root, container })
55
+
56
+ await act(async () => {
57
+ root.render(
58
+ locale == null ? (
59
+ node
60
+ ) : (
61
+ <I18nProvider
62
+ bundle={adminTranslations({ locales: ['en', 'fr'] })}
63
+ activeLocale={locale}
64
+ defaultLocale="en"
65
+ localeDefinitions={[
66
+ { code: 'en', name: 'English' },
67
+ { code: 'fr', name: 'Français' },
68
+ ]}
69
+ >
70
+ {node}
71
+ </I18nProvider>
72
+ )
73
+ )
74
+ })
75
+ return container
76
+ }
77
+
78
+ describe('notice translation', () => {
79
+ it('renders English without any provider', async () => {
80
+ // The editor can mount outside the admin shell, and `useTranslation`
81
+ // throws when no provider is present — a notice must never be the
82
+ // thing that breaks a field.
83
+ const container = await render(<AdaptedNotice />)
84
+ expect(container.textContent).toContain('no longer supports')
85
+ expect(container.textContent).not.toContain('richtext.adapted.notice')
86
+ })
87
+
88
+ it('renders English through the provider', async () => {
89
+ const container = await render(<AdaptedNotice />, 'en')
90
+ expect(container.textContent).toContain('no longer supports')
91
+ })
92
+
93
+ it('renders French through the provider', async () => {
94
+ const container = await render(<AdaptedNotice />, 'fr')
95
+ expect(container.textContent).toContain('ne prend plus en charge')
96
+ expect(container.textContent).not.toContain('no longer supports')
97
+ })
98
+
99
+ it('translates content names and joins them for the locale', async () => {
100
+ const container = await render(
101
+ <UnsupportedContentNotice unsupportedTypes={['inline-image', 'youtube']} />,
102
+ 'fr'
103
+ )
104
+ expect(container.textContent).toContain('une image')
105
+ expect(container.textContent).toContain('YouTube')
106
+ // Joined the way the locale writes a list, not with a bare comma.
107
+ expect(container.textContent).toContain(' et ')
108
+ })
109
+
110
+ it('falls back to English when the provider bundle predates these keys', async () => {
111
+ // A site pinned to an older @byline/i18n has a provider but no such
112
+ // key. The formatter's last resort is the raw key, which would put
113
+ // `richtext.adapted.notice` in front of a reader.
114
+ const stale = { en: { 'byline-admin': { 'common.actions.save': 'Save' } } }
115
+ const container = await renderWithBundle(<AdaptedNotice />, stale, 'en')
116
+ expect(container.textContent).toContain('no longer supports')
117
+ expect(container.textContent).not.toContain('richtext.adapted.notice')
118
+ })
119
+
120
+ it('keeps an unknown type as its id in any locale', async () => {
121
+ const container = await render(
122
+ <UnsupportedContentNotice unsupportedTypes={['acme-callout']} />,
123
+ 'fr'
124
+ )
125
+ expect(container.textContent).toContain('acme-callout')
126
+ expect(container.textContent).toContain('lecture seule')
127
+ })
128
+ })
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useContext, useMemo } from 'react'
12
+
13
+ import { I18nContext } from '@byline/i18n/react'
14
+
15
+ /**
16
+ * English text for every key this package renders.
17
+ *
18
+ * These duplicate the `byline-admin` bundle on purpose, and cover two
19
+ * different failures:
20
+ *
21
+ * - No provider at all. The editor can be mounted outside the admin
22
+ * shell — a host embedding a field, an error boundary rendering
23
+ * without its route's providers — and `useTranslation` throws when
24
+ * none is present. A notice must never be the thing that breaks a
25
+ * field.
26
+ * - A provider whose bundle predates these keys, on a site pinned to an
27
+ * older `@byline/i18n`. The formatter's last resort is the raw key,
28
+ * so without this the reader would see `richtext.unsupported.notice`.
29
+ */
30
+ const FALLBACKS: Readonly<Record<string, string>> = {
31
+ 'richtext.adapted.notice':
32
+ 'This content contains formatting this editor no longer supports. Saving edits will use the supported formatting.',
33
+ 'richtext.unsupported.notice':
34
+ 'This content contains {items}, which this field no longer supports. The field is read-only so the content is not lost.',
35
+ 'richtext.unsupported.action':
36
+ 'Ask an administrator to restore support for it, or to migrate the content.',
37
+ 'richtext.content.image': 'an image',
38
+ 'richtext.content.youtube': 'a YouTube embed',
39
+ 'richtext.content.vimeo': 'a Vimeo embed',
40
+ }
41
+
42
+ export interface NoticeText {
43
+ t: (key: string, values?: Record<string, string>) => string
44
+ locale: string
45
+ }
46
+
47
+ /**
48
+ * Translate a notice, tolerating the absence of an I18nProvider.
49
+ *
50
+ * Reads `I18nContext` directly rather than calling `useTranslation`,
51
+ * which throws without a provider.
52
+ */
53
+ export function useNoticeText(): NoticeText {
54
+ const context = useContext(I18nContext)
55
+
56
+ return useMemo(() => {
57
+ if (context == null) {
58
+ return {
59
+ locale: 'en',
60
+ t: (key, values) => interpolate(FALLBACKS[key] ?? key, values),
61
+ }
62
+ }
63
+ return {
64
+ locale: context.activeLocale,
65
+ t: (key, values) => {
66
+ const translated = context.formatter.t('byline-admin', key, values)
67
+ // The formatter's last resort is the raw key, which happens when
68
+ // a provider IS mounted but its bundle predates these keys — a
69
+ // site pinned to an older @byline/i18n. Falling back only on a
70
+ // missing provider would leave that reader looking at
71
+ // `richtext.unsupported.notice`, so treat a key echo as a miss.
72
+ return translated === key ? interpolate(FALLBACKS[key] ?? key, values) : translated
73
+ },
74
+ }
75
+ }, [context])
76
+ }
77
+
78
+ /** Minimal `{name}` substitution for the provider-less fallback path. */
79
+ function interpolate(template: string, values?: Record<string, string>): string {
80
+ if (values == null) return template
81
+ return template.replace(/\{(\w+)\}/g, (match, name) => values[name] ?? match)
82
+ }