@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.
- package/dist/config.d.ts +1 -1
- package/dist/field/apply-value-plugin.js +31 -6
- package/dist/field/capabilities/filter-transformers.d.ts +29 -0
- package/dist/field/capabilities/filter-transformers.js +8 -0
- package/dist/field/capabilities/registered-node-types.d.ts +29 -0
- package/dist/field/capabilities/registered-node-types.js +7 -0
- package/dist/field/config/built-in-extension-names.d.ts +42 -0
- package/dist/field/config/built-in-extension-names.js +12 -2
- package/dist/field/config/default-extensions.js +4 -0
- package/dist/field/config/default.js +8 -8
- package/dist/field/config/editor-config-context.d.ts +2 -2
- package/dist/field/config/editor-config-context.js +10 -13
- package/dist/field/config/resolve-editor-config.js +3 -3
- package/dist/field/config/root-dependencies.d.ts +26 -0
- package/dist/field/config/root-dependencies.js +8 -0
- package/dist/field/config/types.d.ts +61 -8
- package/dist/field/editor-context.js +44 -27
- package/dist/field/editor.js +15 -8
- package/dist/field/extensions/core-nodes/core-nodes-extension.d.ts +24 -0
- package/dist/field/extensions/core-nodes/core-nodes-extension.js +11 -0
- package/dist/field/extensions/heading/heading-extension.d.ts +20 -0
- package/dist/field/extensions/heading/heading-extension.js +9 -0
- package/dist/field/extensions/quote/quote-extension.d.ts +16 -0
- package/dist/field/extensions/quote/quote-extension.js +9 -0
- package/dist/field/hooks/use-markdown-toggle.d.ts +15 -0
- package/dist/field/hooks/use-markdown-toggle.js +12 -4
- package/dist/field/hooks/use-platform-modifier.d.ts +16 -0
- package/dist/field/hooks/use-platform-modifier.js +8 -0
- package/dist/field/markdown/transformers.d.ts +1 -0
- package/dist/field/markdown/transformers.js +5 -4
- package/dist/field/nodes/index.d.ts +11 -1
- package/dist/field/nodes/index.js +2 -2
- package/dist/field/normalize/adapted-notice.css +34 -0
- package/dist/field/normalize/adapted-notice.d.ts +29 -0
- package/dist/field/normalize/adapted-notice.js +35 -0
- package/dist/field/normalize/content-labels.d.ts +15 -0
- package/dist/field/normalize/content-labels.js +19 -0
- package/dist/field/normalize/declared-conversions.d.ts +131 -0
- package/dist/field/normalize/declared-conversions.js +93 -0
- package/dist/field/normalize/normalization-status.d.ts +33 -0
- package/dist/field/normalize/normalization-status.js +28 -0
- package/dist/field/normalize/normalize-value.d.ts +38 -0
- package/dist/field/normalize/normalize-value.js +132 -0
- package/dist/field/normalize/use-notice-text.d.ts +11 -0
- package/dist/field/normalize/use-notice-text.js +34 -0
- package/dist/field/plugins/toolbar-plugin/index.d.ts +23 -0
- package/dist/field/plugins/toolbar-plugin/index.js +104 -78
- package/dist/field/test-support/build-test-editor.js +49 -0
- package/dist/field/test-support/capture-editor.js +7 -0
- package/dist/field/test-support/frame-control.js +40 -0
- package/dist/field/test-support/jsdom-setup.js +21 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +6 -1
- package/dist/scan/capability-manifest.d.ts +30 -0
- package/dist/scan/capability-manifest.js +43 -0
- package/dist/scan/scan-documents.d.ts +29 -0
- package/dist/scan/scan-documents.js +39 -0
- package/dist/scan/types.d.ts +63 -0
- package/dist/scan/types.js +1 -0
- package/dist/scan.d.ts +25 -0
- package/dist/scan.js +1 -0
- package/package.json +14 -5
- package/src/config.ts +1 -1
- package/src/field/apply-value-plugin.tsx +71 -5
- package/src/field/capabilities/filter-transformers.test.tsx +71 -0
- package/src/field/capabilities/filter-transformers.ts +42 -0
- package/src/field/capabilities/markdown-behaviour.test.tsx +188 -0
- package/src/field/capabilities/no-blanket-registration.test.tsx +86 -0
- package/src/field/capabilities/registered-node-types.test.tsx +29 -0
- package/src/field/capabilities/registered-node-types.ts +36 -0
- package/src/field/config/built-in-extension-names.test.node.ts +38 -14
- package/src/field/config/built-in-extension-names.ts +29 -1
- package/src/field/config/default-extensions.ts +6 -0
- package/src/field/config/default.ts +8 -8
- package/src/field/config/editor-config-context.tsx +14 -9
- package/src/field/config/list-removal.test.tsx +44 -0
- package/src/field/config/resolve-editor-config.test.node.ts +27 -6
- package/src/field/config/resolve-editor-config.ts +3 -3
- package/src/field/config/root-dependencies.ts +32 -0
- package/src/field/config/types.ts +61 -17
- package/src/field/editor-component.test.tsx +18 -3
- package/src/field/editor-context.regression.test.tsx +124 -0
- package/src/field/editor-context.tsx +90 -22
- package/src/field/editor.tsx +22 -12
- package/src/field/extensions/core-nodes/core-nodes-extension.test.tsx +13 -0
- package/src/field/extensions/core-nodes/core-nodes-extension.ts +32 -0
- package/src/field/extensions/heading/heading-extension.test.tsx +32 -0
- package/src/field/extensions/heading/heading-extension.ts +27 -0
- package/src/field/extensions/quote/quote-extension.ts +23 -0
- package/src/field/hooks/markdown-source-mode.test.tsx +221 -0
- package/src/field/hooks/use-markdown-toggle.ts +39 -4
- package/src/field/hooks/use-platform-modifier.test.tsx +81 -0
- package/src/field/hooks/use-platform-modifier.ts +41 -0
- package/src/field/markdown/transformers.ts +18 -4
- package/src/field/nodes/index.ts +11 -1
- package/src/field/normalize/adapted-notice.css +52 -0
- package/src/field/normalize/adapted-notice.tsx +63 -0
- package/src/field/normalize/clipboard-equivalence.test.tsx +330 -0
- package/src/field/normalize/content-labels.ts +44 -0
- package/src/field/normalize/declared-conversions.test.node.ts +67 -0
- package/src/field/normalize/declared-conversions.ts +160 -0
- package/src/field/normalize/emission-gate.test.tsx +302 -0
- package/src/field/normalize/normalization-status.tsx +48 -0
- package/src/field/normalize/normalize-load.test.tsx +371 -0
- package/src/field/normalize/normalize-value.test.node.ts +530 -0
- package/src/field/normalize/normalize-value.ts +180 -0
- package/src/field/normalize/notice-i18n.test.tsx +128 -0
- package/src/field/normalize/use-notice-text.ts +82 -0
- package/src/field/normalize/wiring.test.tsx +348 -0
- package/src/field/plugins/toolbar-plugin/block-format-capabilities.test.tsx +57 -0
- package/src/field/plugins/toolbar-plugin/index.tsx +131 -68
- package/src/field/test-support/build-test-editor.test.tsx +17 -0
- package/src/field/test-support/build-test-editor.ts +99 -0
- package/src/field/test-support/capture-editor.tsx +20 -0
- package/src/field/test-support/frame-control.ts +91 -0
- package/src/field/test-support/jsdom-setup.ts +41 -0
- package/src/index.ts +12 -1
- package/src/scan/capability-manifest.test.tsx +82 -0
- package/src/scan/capability-manifest.ts +81 -0
- package/src/scan/scan-documents.test.node.ts +125 -0
- package/src/scan/scan-documents.ts +78 -0
- package/src/scan/types.ts +66 -0
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/field/nodes/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|