@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,221 @@
1
+ import { act } from 'react'
2
+
3
+ import { defineAdminConfig } from '@byline/core'
4
+ import { CodeNode } from '@lexical/code'
5
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
6
+ import { $createParagraphNode, $createTextNode, $getRoot, type LexicalEditor } from 'lexical'
7
+ import { createRoot, type Root } from 'react-dom/client'
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+
10
+ import { builtInExtensions } from '../config/built-in-extension-names'
11
+ import { defaultClientEditorConfig } from '../config/default-extensions'
12
+ import { EditorContext } from '../editor-context'
13
+ import { useMarkdownToggle } from './use-markdown-toggle'
14
+
15
+ // biome-ignore lint/suspicious/noExplicitAny: React act environment flag
16
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
17
+
18
+ defineAdminConfig({
19
+ i18n: {
20
+ admin: { defaultLocale: 'en', locales: ['en'] },
21
+ content: { defaultLocale: 'en', locales: ['en'] },
22
+ },
23
+ collections: [
24
+ {
25
+ path: 'pages',
26
+ labels: { singular: 'Page', plural: 'Pages' },
27
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
28
+ },
29
+ ],
30
+ })
31
+
32
+ interface Harness {
33
+ editor: LexicalEditor
34
+ container: HTMLDivElement
35
+ toggle: () => Promise<void>
36
+ isMarkdown: () => boolean
37
+ /**
38
+ * Editor updates wrapped in an async act() so React state settles.
39
+ * Lexical flushes its listeners in a microtask, and ToolbarPlugin sets
40
+ * state from them, so a synchronous act() leaves those updates outside
41
+ * the scope and React warns.
42
+ */
43
+ update: (fn: () => void) => Promise<void>
44
+ changes: Array<{ text: string }>
45
+ }
46
+
47
+ const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
48
+
49
+ afterEach(async () => {
50
+ for (const { root, container } of mounted.splice(0)) {
51
+ await act(async () => {
52
+ root.unmount()
53
+ })
54
+ container.remove()
55
+ }
56
+ })
57
+
58
+ async function mount(
59
+ removals: string[],
60
+ options: { markdownToggleControl?: boolean } = {}
61
+ ): Promise<Harness> {
62
+ const extensions = defaultClientEditorConfig.extensions?.clone()
63
+ for (const name of removals) extensions.remove(name)
64
+
65
+ const settings = {
66
+ ...defaultClientEditorConfig.settings,
67
+ controls: {
68
+ ...defaultClientEditorConfig.settings.controls,
69
+ // Default the preference ON so a missing button proves the
70
+ // capability gate, not a preference left switched off.
71
+ markdownToggle: options.markdownToggleControl ?? true,
72
+ },
73
+ }
74
+
75
+ const changes: Array<{ text: string }> = []
76
+
77
+ let editor: LexicalEditor | undefined
78
+ let toggleMarkdown: (() => void) | undefined
79
+ let isMarkdown = false
80
+
81
+ function Probe(): null {
82
+ const [instance] = useLexicalComposerContext()
83
+ const markdown = useMarkdownToggle()
84
+ editor = instance
85
+ toggleMarkdown = markdown.toggleMarkdown
86
+ isMarkdown = markdown.isMarkdown
87
+ return null
88
+ }
89
+
90
+ const container = document.createElement('div')
91
+ document.body.appendChild(container)
92
+ const root = createRoot(container)
93
+ mounted.push({ root, container })
94
+
95
+ await act(async () => {
96
+ root.render(
97
+ <EditorContext
98
+ composerKey="markdown"
99
+ editorConfig={{ ...defaultClientEditorConfig, settings, extensions }}
100
+ onChange={(editorState) => {
101
+ editorState.read(() => {
102
+ changes.push({ text: $getRoot().getTextContent() })
103
+ })
104
+ }}
105
+ readOnly={false}
106
+ >
107
+ <Probe />
108
+ </EditorContext>
109
+ )
110
+ })
111
+
112
+ if (editor == null || toggleMarkdown == null) throw new Error('probe never ran')
113
+ const instance = editor
114
+ return {
115
+ editor: instance,
116
+ container,
117
+ changes,
118
+ toggle: async () => {
119
+ await act(async () => {
120
+ toggleMarkdown?.()
121
+ })
122
+ },
123
+ isMarkdown: () => isMarkdown,
124
+ update: async (fn: () => void) => {
125
+ await act(async () => {
126
+ instance.update(fn, { discrete: true })
127
+ })
128
+ },
129
+ }
130
+ }
131
+
132
+ function text(editor: LexicalEditor): string {
133
+ let value = ''
134
+ editor.read(() => {
135
+ value = $getRoot().getTextContent()
136
+ })
137
+ return value
138
+ }
139
+
140
+ function blockTypes(editor: LexicalEditor): string[] {
141
+ let types: string[] = []
142
+ editor.read(() => {
143
+ types = $getRoot()
144
+ .getChildren()
145
+ .map((node) => node.getType())
146
+ })
147
+ return types
148
+ }
149
+
150
+ async function seedParagraph(harness: Harness, value: string): Promise<void> {
151
+ await harness.update(() => {
152
+ $getRoot()
153
+ .clear()
154
+ .append($createParagraphNode().append($createTextNode(value)))
155
+ })
156
+ }
157
+
158
+ describe('Markdown source mode follows the editor capabilities', () => {
159
+ it('round-trips with headings removed, preserving text and producing no heading', async () => {
160
+ const harness = await mount([builtInExtensions.Heading])
161
+ await seedParagraph(harness, 'Body text')
162
+
163
+ await harness.toggle()
164
+ expect(harness.isMarkdown()).toBe(true)
165
+ // While in source mode the surface is a single CodeNode of raw text.
166
+ expect(blockTypes(harness.editor)).toEqual(['code'])
167
+
168
+ // Edit the source, introducing Markdown for a structure this field
169
+ // no longer supports.
170
+ await harness.update(() => {
171
+ const code = $getRoot().getFirstChild()
172
+ code?.selectEnd().insertRawText('\n\n# Smuggled heading')
173
+ })
174
+
175
+ await harness.toggle()
176
+ expect(harness.isMarkdown()).toBe(false)
177
+ expect(blockTypes(harness.editor)).not.toContain('heading')
178
+ expect(text(harness.editor)).toContain('Smuggled heading')
179
+ })
180
+
181
+ it('hides the toggle button when the code node is unregistered', async () => {
182
+ // The preference is ON in this fixture, so an absent button proves
183
+ // the capability gate rather than a switched-off preference.
184
+ const withCode = await mount([])
185
+ expect(withCode.container.querySelector('.markdown-toggle')).not.toBeNull()
186
+
187
+ const withoutCode = await mount([builtInExtensions.CodeHighlight])
188
+ expect(withoutCode.editor.hasNodes([CodeNode])).toBe(false)
189
+ expect(withoutCode.container.querySelector('.markdown-toggle')).toBeNull()
190
+ })
191
+
192
+ it('a rejected toggle leaves the field still able to persist', async () => {
193
+ // CodeHighlightExtension owns CodeNode, and source mode holds its
194
+ // text in one. Without it, $createCodeNode throws.
195
+ const harness = await mount([builtInExtensions.CodeHighlight])
196
+ expect(harness.editor.hasNodes([CodeNode])).toBe(false)
197
+
198
+ const before = blockTypes(harness.editor)
199
+ await expect(harness.toggle()).resolves.toBeUndefined()
200
+ expect(harness.isMarkdown()).toBe(false)
201
+ expect(blockTypes(harness.editor)).toEqual(before)
202
+
203
+ // `isMarkdown` and `markdownModeRef` are separate state, and it is
204
+ // the ref that suppresses persistence. Setting it before the throwing
205
+ // call would leave the field silently unable to save, which this
206
+ // catches: ordinary editing after a rejected toggle must still reach
207
+ // onChange.
208
+ harness.changes.length = 0
209
+ await seedParagraph(harness, 'typed after the rejected toggle')
210
+
211
+ expect(harness.changes.length).toBeGreaterThan(0)
212
+ expect(harness.changes.at(-1)?.text).toContain('typed after the rejected toggle')
213
+ })
214
+
215
+ it('offers source mode with the default configuration', async () => {
216
+ const harness = await mount([])
217
+ expect(harness.editor.hasNodes([CodeNode])).toBe(true)
218
+ await harness.toggle()
219
+ expect(harness.isMarkdown()).toBe(true)
220
+ })
221
+ })
@@ -10,11 +10,12 @@
10
10
 
11
11
  import { useCallback, useEffect, useRef } from 'react'
12
12
 
13
- import { $createCodeNode, $isCodeNode } from '@lexical/code'
13
+ import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code'
14
14
  import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'
15
15
  import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
16
- import { $getRoot, type EditorState, RootNode } from 'lexical'
16
+ import { $getRoot, type EditorState, type LexicalEditor, RootNode } from 'lexical'
17
17
 
18
+ import { transformersFor } from '../capabilities/filter-transformers'
18
19
  import { APPLY_VALUE_TAG } from '../constants'
19
20
  import { useMarkdownMode } from '../context/markdown-mode-context'
20
21
  import { BYLINE_TRANSFORMERS } from '../markdown/transformers'
@@ -42,6 +43,23 @@ function normalize(markdown: string): string {
42
43
  * - Edits made in markdown produce a single conversion back to rich
43
44
  * nodes on exit, emitting one field value change → **one patch**.
44
45
  */
46
+ /**
47
+ * Whether this editor can offer Markdown source mode.
48
+ *
49
+ * Source mode holds the Markdown text in a `CodeNode`, so it needs that
50
+ * class registered — `$createCodeNode()` throws
51
+ * "Attempted to create node CodeNode that was not configured to be used
52
+ * on the editor" otherwise. A field with the code-highlight extension
53
+ * removed therefore cannot offer the toggle.
54
+ *
55
+ * The fix is to withhold the control, NOT to register `CodeNode` for the
56
+ * toggle's benefit: registering it would let code blocks back in through
57
+ * paste and storage, which is the acceptance hole this work closed.
58
+ */
59
+ export function canUseMarkdownSourceMode(editor: LexicalEditor): boolean {
60
+ return editor.hasNodes([CodeNode])
61
+ }
62
+
45
63
  export function useMarkdownToggle(): {
46
64
  isMarkdown: boolean
47
65
  toggleMarkdown: () => void
@@ -58,6 +76,7 @@ export function useMarkdownToggle(): {
58
76
  const unregisterTransformRef = useRef<(() => void) | null>(null)
59
77
 
60
78
  const registerRootGuard = useCallback(() => {
79
+ if (!canUseMarkdownSourceMode(editor)) return
61
80
  unregisterTransformRef.current?.()
62
81
  // Safety net: keep the root as exactly one markdown CodeNode so the user
63
82
  // can't split it into sibling root nodes while editing source.
@@ -82,12 +101,23 @@ export function useMarkdownToggle(): {
82
101
  }, [])
83
102
 
84
103
  const enterMarkdown = useCallback(() => {
104
+ // Guard before touching any state. `markdownModeRef` suppresses
105
+ // persistence, so setting it first and then throwing inside the
106
+ // update would leave the editor silently unable to save.
107
+ if (!canUseMarkdownSourceMode(editor)) return
108
+
85
109
  originalEditorStateRef.current = editor.getEditorState()
86
110
  // Suppress persistence *before* mutating so the code-block snapshot is
87
111
  // never emitted to the form.
88
112
  markdownModeRef.current = true
89
113
  editor.update(() => {
90
- const markdown = $convertToMarkdownString(BYLINE_TRANSFORMERS, undefined, true)
114
+ // Filtered in both directions so source mode never offers syntax
115
+ // this field cannot import back.
116
+ const markdown = $convertToMarkdownString(
117
+ transformersFor(editor, BYLINE_TRANSFORMERS),
118
+ undefined,
119
+ true
120
+ )
91
121
  originalMarkdownRef.current = markdown
92
122
  const codeNode = $createCodeNode(MARKDOWN_LANGUAGE)
93
123
  $getRoot().clear().append(codeNode)
@@ -118,7 +148,12 @@ export function useMarkdownToggle(): {
118
148
  // emitted to the form (one field change → one patch).
119
149
  markdownModeRef.current = false
120
150
  editor.update(() => {
121
- $convertFromMarkdownString(currentMarkdown, BYLINE_TRANSFORMERS, undefined, true)
151
+ $convertFromMarkdownString(
152
+ currentMarkdown,
153
+ transformersFor(editor, BYLINE_TRANSFORMERS),
154
+ undefined,
155
+ true
156
+ )
122
157
  })
123
158
  }
124
159
 
@@ -0,0 +1,81 @@
1
+ import { act } from 'react'
2
+
3
+ import { hydrateRoot } from 'react-dom/client'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ // The client believes it is on a Mac. The SERVER never does — it has no
7
+ // `navigator` — so this mock reproduces the split that caused the
8
+ // mismatch: markup rendered without the platform, hydrated with it.
9
+ vi.mock('../shared/environment', () => ({ IS_APPLE: true }))
10
+
11
+ import { IS_APPLE } from '../shared/environment'
12
+ import { useIsApplePlatform } from './use-platform-modifier'
13
+
14
+ // biome-ignore lint/suspicious/noExplicitAny: React act environment flag
15
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
16
+
17
+ /** The label a server renders: platform unknown, so the non-Apple form. */
18
+ const SERVER_MARKUP = '<button type="button" title="Bold (Ctrl+B)"></button>'
19
+
20
+ function ViaHook(): React.JSX.Element {
21
+ const isApple = useIsApplePlatform()
22
+ return <button type="button" title={isApple ? 'Bold (⌘B)' : 'Bold (Ctrl+B)'} />
23
+ }
24
+
25
+ /** How the toolbar read the platform before: straight from the constant. */
26
+ function ViaConstant(): React.JSX.Element {
27
+ return <button type="button" title={IS_APPLE ? 'Bold (⌘B)' : 'Bold (Ctrl+B)'} />
28
+ }
29
+
30
+ const mounted: Array<{ root: { unmount: () => void }; container: HTMLDivElement }> = []
31
+
32
+ afterEach(async () => {
33
+ for (const { root, container } of mounted.splice(0)) {
34
+ await act(async () => {
35
+ root.unmount()
36
+ })
37
+ container.remove()
38
+ }
39
+ })
40
+
41
+ async function hydrate(element: React.JSX.Element): Promise<{
42
+ container: HTMLDivElement
43
+ errors: string[]
44
+ }> {
45
+ const errors: string[] = []
46
+ const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
47
+ errors.push(String(args[0]))
48
+ })
49
+
50
+ const container = document.createElement('div')
51
+ container.innerHTML = SERVER_MARKUP
52
+ document.body.appendChild(container)
53
+
54
+ let root: ReturnType<typeof hydrateRoot>
55
+ await act(async () => {
56
+ root = hydrateRoot(container, element)
57
+ })
58
+ // biome-ignore lint/style/noNonNullAssertion: assigned inside act
59
+ mounted.push({ root: root!, container })
60
+ spy.mockRestore()
61
+ return { container, errors }
62
+ }
63
+
64
+ describe('useIsApplePlatform', () => {
65
+ it('hydrates server markup without a mismatch, then adopts the platform', async () => {
66
+ const { container, errors } = await hydrate(<ViaHook />)
67
+
68
+ expect(errors.join(' ')).not.toMatch(/did not match|hydrat/i)
69
+ // Adopted after hydration, which is the whole point: the label is
70
+ // right for the reader without the first render disagreeing.
71
+ expect(container.querySelector('button')?.title).toBe('Bold (⌘B)')
72
+ })
73
+
74
+ it('CONTROL: reading the constant during render does mismatch', async () => {
75
+ // Pins why the hook exists. React reports this and, as it warns,
76
+ // does not patch the attribute up — leaving a Mac user looking at
77
+ // `Ctrl+B`, and a screen reader announcing it.
78
+ const { errors } = await hydrate(<ViaConstant />)
79
+ expect(errors.join(' ')).toMatch(/did not match|hydrat/i)
80
+ })
81
+ })
@@ -0,0 +1,41 @@
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 { useSyncExternalStore } from 'react'
12
+
13
+ import { IS_APPLE } from '../shared/environment'
14
+
15
+ /** Never changes after load, so nothing to subscribe to. */
16
+ const subscribe = () => () => {}
17
+
18
+ /**
19
+ * Whether to label keyboard shortcuts the Apple way.
20
+ *
21
+ * `IS_APPLE` is resolved at module scope from `navigator.platform`, which
22
+ * is false on the server and true on a Mac client. Reading it directly
23
+ * during render therefore produced markup the server and client
24
+ * disagreed about — React reported a hydration mismatch on the toolbar's
25
+ * `title` and `aria-label` attributes and, as it warns, did not patch
26
+ * them up: a Mac user could be shown `Ctrl+B`, and a screen reader could
27
+ * announce it.
28
+ *
29
+ * `useSyncExternalStore` uses its server snapshot for the initial client
30
+ * render as well as for SSR, so the first client render matches the
31
+ * server exactly and the real platform is adopted immediately after.
32
+ */
33
+ export function useIsApplePlatform(): boolean {
34
+ return useSyncExternalStore(
35
+ subscribe,
36
+ () => IS_APPLE,
37
+ // Server, and the hydrating client render: the platform is unknown
38
+ // to the server, so both must agree on the same answer.
39
+ () => false
40
+ )
41
+ }
@@ -42,8 +42,9 @@ import {
42
42
  TableNode,
43
43
  TableRowNode,
44
44
  } from '@lexical/table'
45
- import { $createParagraphNode, $isParagraphNode, $isTextNode } from 'lexical'
45
+ import { $createParagraphNode, $getEditor, $isParagraphNode, $isTextNode } from 'lexical'
46
46
 
47
+ import { transformersFor } from '../capabilities/filter-transformers'
47
48
  import {
48
49
  $createAdmonitionNode,
49
50
  $isAdmonitionNode,
@@ -82,6 +83,9 @@ export const TABLE: ElementTransformer = {
82
83
  // Cell content is itself markdown; escape literal newlines so the
83
84
  // row stays on one line.
84
85
  rowOutput.push(
86
+ // Unfiltered on purpose: export only ever meets nodes that
87
+ // are already in the tree, so an unregistered dependency
88
+ // simply never matches. Only the import direction can throw.
85
89
  $convertToMarkdownString(BYLINE_TRANSFORMERS, cell).replace(/\n/g, '\\n').trim()
86
90
  )
87
91
  if (cell.hasHeaderState(TableCellHeaderStates.ROW)) {
@@ -188,7 +192,10 @@ function getTableColumnsSize(table: TableNode): number {
188
192
  const $createTableCell = (textContent: string): TableCellNode => {
189
193
  const content = textContent.replace(/\\n/g, '\n')
190
194
  const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS)
191
- $convertFromMarkdownString(content, BYLINE_TRANSFORMERS, cell)
195
+ // Filter to what this editor registered: a nested cell import creates
196
+ // nodes just as the top-level one does, so an unregistered dependency
197
+ // throws here too.
198
+ $convertFromMarkdownString(content, transformersFor($getEditor(), BYLINE_TRANSFORMERS), cell)
192
199
  return cell
193
200
  }
194
201
 
@@ -228,7 +235,7 @@ const ADMONITION_END_REG_EXP = /^:::\s*$/
228
235
  // paragraph bodies round-trip without any element transformer. Excluding the
229
236
  // block transformers here is what keeps headings / lists / tables / nested
230
237
  // admonitions out of an admonition body at parse time.
231
- const ADMONITION_BODY_TRANSFORMERS: Array<Transformer> = [...TEXT_FORMAT_TRANSFORMERS, LINK]
238
+ export const ADMONITION_BODY_TRANSFORMERS: Array<Transformer> = [...TEXT_FORMAT_TRANSFORMERS, LINK]
232
239
 
233
240
  export const ADMONITION: MultilineElementTransformer = {
234
241
  dependencies: [AdmonitionNode],
@@ -241,6 +248,7 @@ export const ADMONITION: MultilineElementTransformer = {
241
248
 
242
249
  // The body is real children of `node` — export its subtree directly with
243
250
  // the same engine.
251
+ // Unfiltered on purpose — see the note on the table-cell export above.
244
252
  const body = $convertToMarkdownString(ADMONITION_BODY_TRANSFORMERS, node).trim()
245
253
 
246
254
  const heading = title ? `:::${type}[${title}]` : `:::${type}`
@@ -272,7 +280,13 @@ export const ADMONITION: MultilineElementTransformer = {
272
280
  // Import / toggle path: body arrives as raw text between the fences.
273
281
  const body = linesInBetween.join('\n').trim()
274
282
  if (body) {
275
- $convertFromMarkdownString(body, ADMONITION_BODY_TRANSFORMERS, node)
283
+ // ADMONITION_BODY_TRANSFORMERS carries LINK, so this breaks when
284
+ // the link extension is removed unless it is filtered.
285
+ $convertFromMarkdownString(
286
+ body,
287
+ transformersFor($getEditor(), ADMONITION_BODY_TRANSFORMERS),
288
+ node
289
+ )
276
290
  }
277
291
  }
278
292
 
@@ -23,7 +23,17 @@ import { AutoLinkNode, LinkNode } from '../extensions/link'
23
23
  import { VimeoNode } from '../extensions/vimeo/vimeo-node'
24
24
  import { YouTubeNode } from '../extensions/youtube/youtube-node'
25
25
 
26
- export const Nodes: Array<Klass<LexicalNode>> = [
26
+ /**
27
+ * Every node class Byline knows how to READ.
28
+ *
29
+ * This is not a registration list — registration belongs to the
30
+ * extension that owns each node, so that removing an extension removes
31
+ * what the field accepts as well as what it offers. This array is the
32
+ * vocabulary the normalizer and the capability manifest work from, so a
33
+ * stored document can still be inspected by a field that supports far
34
+ * less than the document contains.
35
+ */
36
+ export const READABLE_NODES: Array<Klass<LexicalNode>> = [
27
37
  HeadingNode,
28
38
  ListNode,
29
39
  ListItemNode,
@@ -0,0 +1,52 @@
1
+ @layer base,
2
+ byline-base,
3
+ theme,
4
+ byline-theme,
5
+ byline-functional,
6
+ byline-typography,
7
+ components,
8
+ byline-components,
9
+ utilities,
10
+ byline-utilities;
11
+
12
+ /**
13
+ * Content-adaptation notices.
14
+ *
15
+ * Colours come from the UI kit's semantic tokens rather than literals so
16
+ * both notices follow the admin's theme. Hardcoded light values render
17
+ * as dark text on a pale block against the dark theme, which is the
18
+ * state these notices are least able to afford: one of them is the only
19
+ * thing a reader sees when a field will not open.
20
+ *
21
+ * Declared in `byline-components` so the layer order the admin
22
+ * establishes governs them, rather than Tailwind's preflight.
23
+ */
24
+ @layer byline-components {
25
+ .byline-richtext-notice {
26
+ border-radius: var(--border-radius-sm, 4px);
27
+ font-size: var(--font-size-sm, 0.8125rem);
28
+ line-height: 1.4;
29
+ margin-bottom: 8px;
30
+ padding: 8px 10px;
31
+ }
32
+
33
+ .byline-richtext-notice p {
34
+ margin: 0;
35
+ }
36
+
37
+ .byline-richtext-notice p + p {
38
+ margin-top: 4px;
39
+ }
40
+
41
+ .byline-richtext-notice--adapted {
42
+ background-color: var(--fill-warning-weak);
43
+ border: 1px solid var(--stroke-warning);
44
+ color: var(--text-on-warning-weak);
45
+ }
46
+
47
+ .byline-richtext-notice--refused {
48
+ background-color: var(--fill-danger-weak);
49
+ border: 1px solid var(--stroke-danger);
50
+ color: var(--text-on-danger-weak);
51
+ }
52
+ }
@@ -0,0 +1,63 @@
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 type * as React from 'react'
12
+
13
+ import { contentLabelKey, joinLabels } from './content-labels'
14
+ import { useNoticeText } from './use-notice-text'
15
+
16
+ import './adapted-notice.css'
17
+
18
+ /**
19
+ * Shown when stored content held a structure this field no longer
20
+ * supports and it was adapted for editing.
21
+ *
22
+ * Inline and non-blocking: the content is editable, and the adaptation
23
+ * only reaches storage if the reader actually saves an edit.
24
+ */
25
+ export function AdaptedNotice(): React.JSX.Element {
26
+ const { t } = useNoticeText()
27
+ return (
28
+ <div className="byline-richtext-notice byline-richtext-notice--adapted" role="status">
29
+ {t('richtext.adapted.notice')}
30
+ </div>
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Shown when stored content holds something with no safe conversion —
36
+ * an image, an embed, or a node type this build does not know.
37
+ *
38
+ * Those carry information no structural rewrite can preserve: an inline
39
+ * image has a media relation and a caption held in a nested editor.
40
+ * Discarding them silently would be worse than declining to edit, so the
41
+ * field opens read-only rather than adapting.
42
+ */
43
+ export function UnsupportedContentNotice({
44
+ unsupportedTypes,
45
+ }: {
46
+ unsupportedTypes: string[]
47
+ }): React.JSX.Element {
48
+ const { t, locale } = useNoticeText()
49
+
50
+ // Known types get a readable name; anything else keeps its id, so a
51
+ // node from a site's own extension still produces a usable message.
52
+ const labels = unsupportedTypes.map((type) => {
53
+ const key = contentLabelKey(type)
54
+ return key != null ? t(key) : type
55
+ })
56
+
57
+ return (
58
+ <div className="byline-richtext-notice byline-richtext-notice--refused" role="alert">
59
+ <p>{t('richtext.unsupported.notice', { items: joinLabels(labels, locale) })}</p>
60
+ <p>{t('richtext.unsupported.action')}</p>
61
+ </div>
62
+ )
63
+ }