@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,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
|
+
}
|