@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,63 @@
|
|
|
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
|
+
/** What one richtext field accepts, as its resolved editor reports it. */
|
|
9
|
+
export interface FieldCapabilities {
|
|
10
|
+
collectionPath: string;
|
|
11
|
+
/**
|
|
12
|
+
* Declaration path of the field, so one nested in a block or array is
|
|
13
|
+
* addressable — `content.photoBlock.caption`, not just `caption`.
|
|
14
|
+
* Stored values carry instance paths (`content.1.photoBlock.caption`);
|
|
15
|
+
* eliding the selectors yields this.
|
|
16
|
+
*/
|
|
17
|
+
fieldPath: string;
|
|
18
|
+
/**
|
|
19
|
+
* Node types the field accepts, or `null` when they could not be
|
|
20
|
+
* measured.
|
|
21
|
+
*
|
|
22
|
+
* A field whose capabilities are unknown is NOT the same as a field
|
|
23
|
+
* with none. The scanner reports it as unmapped rather than treating
|
|
24
|
+
* its documents as clean, because reporting a document safe when it
|
|
25
|
+
* was never examined is the one failure an upgrade check must not
|
|
26
|
+
* have.
|
|
27
|
+
*/
|
|
28
|
+
supportedTypes: string[] | null;
|
|
29
|
+
/** Why the capabilities could not be measured, when they could not. */
|
|
30
|
+
unresolvedReason?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Field capabilities for a whole installation, produced where the client
|
|
34
|
+
* config can be loaded and consumed where the database can.
|
|
35
|
+
*/
|
|
36
|
+
export interface CapabilityManifest {
|
|
37
|
+
generatedAt: string;
|
|
38
|
+
fields: FieldCapabilities[];
|
|
39
|
+
}
|
|
40
|
+
/** One stored value that this field's current configuration cannot take as-is. */
|
|
41
|
+
export interface ScanFinding {
|
|
42
|
+
collectionPath: string;
|
|
43
|
+
/** Declaration path — the manifest key this value was matched against. */
|
|
44
|
+
fieldPath: string;
|
|
45
|
+
/**
|
|
46
|
+
* Instance path as stored, when the caller knows it —
|
|
47
|
+
* `content.1.photoBlock.caption`. Two affected captions in one version
|
|
48
|
+
* share a declaration path and are told apart only by this.
|
|
49
|
+
*/
|
|
50
|
+
instancePath?: string;
|
|
51
|
+
documentId: string;
|
|
52
|
+
versionId: string;
|
|
53
|
+
locale?: string;
|
|
54
|
+
/** Structures that will be converted when the field opens this value. */
|
|
55
|
+
adaptedTypes: string[];
|
|
56
|
+
/** Structures with no conversion — the field will open read-only. */
|
|
57
|
+
refusedTypes: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Set when the field's capabilities were never measured, so this value
|
|
60
|
+
* was not examined. Neither adapted nor refused: unknown.
|
|
61
|
+
*/
|
|
62
|
+
unmapped?: true;
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
* Pre-upgrade scanning, without the editor.
|
|
10
|
+
*
|
|
11
|
+
* This entry is deliberately **runtime-free**: it carries the pure
|
|
12
|
+
* scanning logic and its types, and imports no React, no Lexical
|
|
13
|
+
* extension and no CSS. A Node script can therefore read it, which the
|
|
14
|
+
* root barrel makes impossible — importing that pulls the editor graph
|
|
15
|
+
* and fails on the first `.css` import.
|
|
16
|
+
*
|
|
17
|
+
* The other half, measuring what a field accepts, needs a DOM and lives
|
|
18
|
+
* on the root entry as `capabilitiesFor` / `capabilitiesFromEditor`.
|
|
19
|
+
* That split is the same boundary the manifest crosses: capabilities are
|
|
20
|
+
* measured in a browser, written to a file, and consumed here.
|
|
21
|
+
*
|
|
22
|
+
* See docs/09-admin-ui/04-richtext-capabilities.md.
|
|
23
|
+
*/
|
|
24
|
+
export { scanDocument, summarise } from './scan/scan-documents.js';
|
|
25
|
+
export type { CapabilityManifest, FieldCapabilities, ScanFinding } from './scan/types.js';
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { scanDocument, summarise } from "./scan/scan-documents.js";
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "
|
|
6
|
+
"version": "6.0.1",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -53,6 +53,12 @@
|
|
|
53
53
|
"import": "./dist/server.js",
|
|
54
54
|
"main": "./dist/server.js",
|
|
55
55
|
"default": "./dist/server.js"
|
|
56
|
+
},
|
|
57
|
+
"./scan": {
|
|
58
|
+
"types": "./dist/scan.d.ts",
|
|
59
|
+
"import": "./dist/scan.js",
|
|
60
|
+
"main": "./dist/scan.js",
|
|
61
|
+
"default": "./dist/scan.js"
|
|
56
62
|
}
|
|
57
63
|
},
|
|
58
64
|
"dependencies": {
|
|
@@ -72,10 +78,11 @@
|
|
|
72
78
|
"clsx": "^2.1.1",
|
|
73
79
|
"lexical": "0.50.0",
|
|
74
80
|
"react-error-boundary": "^6.1.5",
|
|
75
|
-
"@byline/
|
|
76
|
-
"@byline/
|
|
77
|
-
"@byline/
|
|
78
|
-
"@byline/
|
|
81
|
+
"@byline/client": "6.0.1",
|
|
82
|
+
"@byline/i18n": "6.0.1",
|
|
83
|
+
"@byline/admin": "6.0.1",
|
|
84
|
+
"@byline/ui": "6.0.1",
|
|
85
|
+
"@byline/core": "6.0.1"
|
|
79
86
|
},
|
|
80
87
|
"peerDependencies": {
|
|
81
88
|
"react": "^19.0.0",
|
|
@@ -83,6 +90,8 @@
|
|
|
83
90
|
},
|
|
84
91
|
"devDependencies": {
|
|
85
92
|
"@biomejs/biome": "2.5.13",
|
|
93
|
+
"@lexical/clipboard": "0.50.0",
|
|
94
|
+
"@lexical/html": "0.50.0",
|
|
86
95
|
"@rsbuild/plugin-react": "^2.1.0",
|
|
87
96
|
"@rslib/core": "^1.0.0",
|
|
88
97
|
"@types/node": "^26.5.1",
|
package/src/config.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* registration factory (which dynamic-imports the editor runtime on first
|
|
14
14
|
* mount), the config types, the built-in extension **names**, and the
|
|
15
15
|
* light toolbar-authoring primitives — but NOT `RichTextField` /
|
|
16
|
-
* `EditorField` / `
|
|
16
|
+
* `EditorField` / `READABLE_NODES` or the heavy *content* extension classes
|
|
17
17
|
* (Table, InlineImage, Admonition, …), all of which statically pull React
|
|
18
18
|
* plugins, nodes, and the Lexical core. Import from here at registration
|
|
19
19
|
* sites (e.g. an admin/admin config that you want to evaluate eagerly)
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import type * as React from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { useLayoutEffect, useRef } from 'react'
|
|
5
5
|
|
|
6
6
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
7
7
|
import type { SerializedEditorState } from 'lexical'
|
|
8
8
|
|
|
9
|
+
import { registeredNodeTypes } from './capabilities/registered-node-types'
|
|
9
10
|
import { APPLY_VALUE_TAG } from './constants'
|
|
11
|
+
import { useNormalizationStatus } from './normalize/normalization-status'
|
|
12
|
+
import { normalizeValue } from './normalize/normalize-value'
|
|
10
13
|
import { hashSerializedState } from './utils/hashSerializedState'
|
|
11
14
|
|
|
12
15
|
export function ApplyValuePlugin({
|
|
@@ -23,16 +26,31 @@ export function ApplyValuePlugin({
|
|
|
23
26
|
hasNormalizedBaselineRef: React.RefObject<boolean>
|
|
24
27
|
}): React.JSX.Element | null {
|
|
25
28
|
const [editor] = useLexicalComposerContext()
|
|
29
|
+
const { setStatus } = useNormalizationStatus()
|
|
26
30
|
const lastAppliedHashRef = useRef<string | undefined>(undefined)
|
|
31
|
+
// True while the surface is showing the read-only refusal notice.
|
|
32
|
+
const isRefusedRef = useRef(false)
|
|
27
33
|
|
|
28
34
|
const cancelWaiterRef = useRef<() => void>(undefined)
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
// A layout effect, not a passive one: this is the ONLY path by which a
|
|
37
|
+
// stored value reaches the editor — the root extension deliberately
|
|
38
|
+
// carries no `$initialEditorState` — so applying before paint avoids
|
|
39
|
+
// showing an empty field first.
|
|
40
|
+
useLayoutEffect(() => {
|
|
31
41
|
if (value == null) return
|
|
32
42
|
|
|
33
43
|
const nextRawHash = incomingHash
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
// Both shortcuts below assume the editor already holds this value and
|
|
46
|
+
// that there is therefore nothing to do. Neither holds while refused:
|
|
47
|
+
// a refusal applies nothing, so the editor holds whatever preceded it
|
|
48
|
+
// and the notice is still up. Taking either shortcut would leave the
|
|
49
|
+
// field stranded — including via the echo path, where the value being
|
|
50
|
+
// returned to is one the editor itself last emitted.
|
|
51
|
+
const refused = isRefusedRef.current
|
|
52
|
+
|
|
53
|
+
if (!refused && nextRawHash === lastEmittedHashRef.current) {
|
|
36
54
|
if (hasNormalizedBaselineRef.current !== true) {
|
|
37
55
|
hasNormalizedBaselineRef.current = true
|
|
38
56
|
if (cancelWaiterRef.current) {
|
|
@@ -43,7 +61,7 @@ export function ApplyValuePlugin({
|
|
|
43
61
|
return
|
|
44
62
|
}
|
|
45
63
|
|
|
46
|
-
if (nextRawHash === lastAppliedHashRef.current) {
|
|
64
|
+
if (!refused && nextRawHash === lastAppliedHashRef.current) {
|
|
47
65
|
// If the incoming value matches what we last applied, we assume the previous
|
|
48
66
|
// waiter (if any) is still running or has completed.
|
|
49
67
|
// We do NOT want to cancel it just because of a re-render.
|
|
@@ -59,7 +77,52 @@ export function ApplyValuePlugin({
|
|
|
59
77
|
|
|
60
78
|
hasNormalizedBaselineRef.current = false
|
|
61
79
|
|
|
62
|
-
|
|
80
|
+
// Adapt the stored value to what this editor actually accepts.
|
|
81
|
+
// Lexical throws on an unregistered type and loses the text, so a
|
|
82
|
+
// field whose configuration narrowed since the document was written
|
|
83
|
+
// would otherwise open blank and erroring.
|
|
84
|
+
const normalized = normalizeValue(value, registeredNodeTypes(editor))
|
|
85
|
+
|
|
86
|
+
if (normalized.status === 'refused') {
|
|
87
|
+
// No safe conversion exists — an image's media relation and its
|
|
88
|
+
// nested-editor caption survive no structural rewrite. Leave the
|
|
89
|
+
// editor as it stands and let the surface render read-only rather
|
|
90
|
+
// than silently discarding what the document holds.
|
|
91
|
+
setStatus({ kind: 'refused', unsupportedTypes: normalized.unsupportedTypes })
|
|
92
|
+
|
|
93
|
+
// Forget the last applied value and remember that we are refused,
|
|
94
|
+
// so neither shortcut above can swallow the return to it. Toggling
|
|
95
|
+
// between a refused value and a valid one is the first thing a
|
|
96
|
+
// reader does, and the value returned to may be one the editor
|
|
97
|
+
// itself emitted.
|
|
98
|
+
lastAppliedHashRef.current = undefined
|
|
99
|
+
isRefusedRef.current = true
|
|
100
|
+
|
|
101
|
+
// Leave the baseline UNESTABLISHED. It is the only thing
|
|
102
|
+
// suppressing emission in `editor-component.tsx` — the gate there
|
|
103
|
+
// is `value != null && baseline !== true` — and while refused the
|
|
104
|
+
// editor does NOT hold the stored value. Setting it true would
|
|
105
|
+
// open that gate on a field whose content is not the document's:
|
|
106
|
+
// an emission deferred through `requestIdleCallback` before the
|
|
107
|
+
// refusal would then land after it and write the PREVIOUS
|
|
108
|
+
// document's text over the refused one, which is exactly the loss
|
|
109
|
+
// refusal exists to prevent. Recovery does not need it — a later
|
|
110
|
+
// valid value re-enters the apply path and its waiter sets the
|
|
111
|
+
// baseline itself.
|
|
112
|
+
hasNormalizedBaselineRef.current = false
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
isRefusedRef.current = false
|
|
117
|
+
setStatus(
|
|
118
|
+
normalized.status === 'adapted'
|
|
119
|
+
? { kind: 'adapted', convertedTypes: normalized.convertedTypes }
|
|
120
|
+
: { kind: 'ok' }
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const nextState = editor.parseEditorState(
|
|
124
|
+
normalized.status === 'adapted' ? JSON.stringify(normalized.value) : value
|
|
125
|
+
)
|
|
63
126
|
|
|
64
127
|
// Must NOT be wrapped in editor.update — setEditorState defers its commit
|
|
65
128
|
// when called inside an active update, leaving selection/node references
|
|
@@ -67,6 +130,8 @@ export function ApplyValuePlugin({
|
|
|
67
130
|
// errors ("Expected node with key N to exist") on the very next selection
|
|
68
131
|
// change. setEditorState applies the tag itself.
|
|
69
132
|
editor.setEditorState(nextState, { tag: APPLY_VALUE_TAG })
|
|
133
|
+
// Keyed to the ORIGINAL value's hash, never the adapted one, so an
|
|
134
|
+
// adapted document does not read as a change on every mount.
|
|
70
135
|
lastAppliedHashRef.current = nextRawHash
|
|
71
136
|
|
|
72
137
|
let cancelled = false
|
|
@@ -98,6 +163,7 @@ export function ApplyValuePlugin({
|
|
|
98
163
|
lastEmittedHashRef,
|
|
99
164
|
normalizedIncomingHashRef,
|
|
100
165
|
hasNormalizedBaselineRef,
|
|
166
|
+
setStatus,
|
|
101
167
|
])
|
|
102
168
|
|
|
103
169
|
return null
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { $convertFromMarkdownString, registerMarkdownShortcuts } from '@lexical/markdown'
|
|
2
|
+
import { $getRoot } from 'lexical'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { ADMONITION_BODY_TRANSFORMERS, BYLINE_TRANSFORMERS } from '../markdown/transformers'
|
|
6
|
+
import { buildFullEditor, buildRestrictedEditor } from '../test-support/build-test-editor'
|
|
7
|
+
import { transformersFor } from './filter-transformers'
|
|
8
|
+
|
|
9
|
+
describe('transformersFor', () => {
|
|
10
|
+
it('lets Markdown shortcuts register in a restricted editor', () => {
|
|
11
|
+
const editor = buildRestrictedEditor()
|
|
12
|
+
expect(() => {
|
|
13
|
+
registerMarkdownShortcuts(editor, transformersFor(editor, BYLINE_TRANSFORMERS))
|
|
14
|
+
}).not.toThrow()
|
|
15
|
+
editor.dispose()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('throws without the filter, which is why it exists', () => {
|
|
19
|
+
const editor = buildRestrictedEditor()
|
|
20
|
+
expect(() => {
|
|
21
|
+
registerMarkdownShortcuts(editor, [...BYLINE_TRANSFORMERS])
|
|
22
|
+
}).toThrow(/missing dependency/)
|
|
23
|
+
editor.dispose()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('keeps every transformer when nothing is removed', () => {
|
|
27
|
+
const editor = buildFullEditor()
|
|
28
|
+
expect(transformersFor(editor, BYLINE_TRANSFORMERS)).toHaveLength(BYLINE_TRANSFORMERS.length)
|
|
29
|
+
editor.dispose()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('drops a transformer whose node is unregistered', () => {
|
|
33
|
+
const editor = buildRestrictedEditor()
|
|
34
|
+
expect(transformersFor(editor, BYLINE_TRANSFORMERS).length).toBeLessThan(
|
|
35
|
+
BYLINE_TRANSFORMERS.length
|
|
36
|
+
)
|
|
37
|
+
editor.dispose()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('survives a Markdown import in a restricted editor', () => {
|
|
41
|
+
const editor = buildRestrictedEditor()
|
|
42
|
+
const active = transformersFor(editor, BYLINE_TRANSFORMERS)
|
|
43
|
+
expect(() => {
|
|
44
|
+
editor.update(
|
|
45
|
+
() => {
|
|
46
|
+
$convertFromMarkdownString(
|
|
47
|
+
'# heading\n\n| a | b |\n| - | - |\n\nplain text',
|
|
48
|
+
active,
|
|
49
|
+
undefined,
|
|
50
|
+
true
|
|
51
|
+
)
|
|
52
|
+
},
|
|
53
|
+
{ discrete: true }
|
|
54
|
+
)
|
|
55
|
+
}).not.toThrow()
|
|
56
|
+
let text = ''
|
|
57
|
+
editor.read(() => {
|
|
58
|
+
text = $getRoot().getTextContent()
|
|
59
|
+
})
|
|
60
|
+
expect(text).toContain('plain text')
|
|
61
|
+
editor.dispose()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('filters the admonition body list, which carries LINK', () => {
|
|
65
|
+
const editor = buildRestrictedEditor()
|
|
66
|
+
expect(transformersFor(editor, ADMONITION_BODY_TRANSFORMERS).length).toBeLessThan(
|
|
67
|
+
ADMONITION_BODY_TRANSFORMERS.length
|
|
68
|
+
)
|
|
69
|
+
editor.dispose()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { Transformer } from '@lexical/markdown'
|
|
10
|
+
import type { LexicalEditor } from 'lexical'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The transformers that can safely be used with this editor.
|
|
14
|
+
*
|
|
15
|
+
* Markdown is a second route into node creation, and it fails harder
|
|
16
|
+
* than paste does. `registerMarkdownShortcuts` throws when a transformer
|
|
17
|
+
* names a node class the editor has not registered, and it throws for
|
|
18
|
+
* the whole pipeline rather than skipping that one transformer — so a
|
|
19
|
+
* field with the table extension removed would lose Markdown entirely,
|
|
20
|
+
* not just tables. The same applies to every `$convertFromMarkdownString`
|
|
21
|
+
* call, which creates nodes directly.
|
|
22
|
+
*
|
|
23
|
+
* The membership test is `editor.hasNode`, which is exactly what
|
|
24
|
+
* `registerMarkdownShortcuts` uses for its own guard, so this filter
|
|
25
|
+
* cannot disagree with the check it exists to satisfy.
|
|
26
|
+
*
|
|
27
|
+
* Takes the list as a parameter rather than importing it: the Markdown
|
|
28
|
+
* module imports this one, so reaching back for `BYLINE_TRANSFORMERS`
|
|
29
|
+
* here would be a cycle.
|
|
30
|
+
*/
|
|
31
|
+
export function transformersFor(
|
|
32
|
+
editor: LexicalEditor,
|
|
33
|
+
transformers: ReadonlyArray<Transformer>
|
|
34
|
+
): Array<Transformer> {
|
|
35
|
+
return transformers.filter((transformer) => {
|
|
36
|
+
// `text-format` transformers act on TextNode formats and declare no
|
|
37
|
+
// dependencies; they are always safe.
|
|
38
|
+
const dependencies = 'dependencies' in transformer ? transformer.dependencies : undefined
|
|
39
|
+
if (dependencies == null) return true
|
|
40
|
+
return dependencies.every((klass) => editor.hasNode(klass))
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { $convertFromMarkdownString, registerMarkdownShortcuts } from '@lexical/markdown'
|
|
2
|
+
import {
|
|
3
|
+
$createParagraphNode,
|
|
4
|
+
$getRoot,
|
|
5
|
+
$getSelection,
|
|
6
|
+
$isRangeSelection,
|
|
7
|
+
type LexicalEditor,
|
|
8
|
+
} from 'lexical'
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { builtInExtensions } from '../config/built-in-extension-names'
|
|
12
|
+
import { ADMONITION_BODY_TRANSFORMERS, BYLINE_TRANSFORMERS } from '../markdown/transformers'
|
|
13
|
+
import { buildFullEditor, buildRestrictedEditor } from '../test-support/build-test-editor'
|
|
14
|
+
import { transformersFor } from './filter-transformers'
|
|
15
|
+
|
|
16
|
+
/** Block types and full text of the editor's current state. */
|
|
17
|
+
function snapshot(editor: LexicalEditor): { types: string[]; text: string } {
|
|
18
|
+
let types: string[] = []
|
|
19
|
+
let text = ''
|
|
20
|
+
editor.read(() => {
|
|
21
|
+
types = $getRoot()
|
|
22
|
+
.getChildren()
|
|
23
|
+
.map((node) => node.getType())
|
|
24
|
+
text = $getRoot().getTextContent()
|
|
25
|
+
})
|
|
26
|
+
return { types, text }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function importMarkdown(editor: LexicalEditor, markdown: string): void {
|
|
30
|
+
editor.update(
|
|
31
|
+
() => {
|
|
32
|
+
$convertFromMarkdownString(
|
|
33
|
+
markdown,
|
|
34
|
+
transformersFor(editor, BYLINE_TRANSFORMERS),
|
|
35
|
+
undefined,
|
|
36
|
+
true
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
{ discrete: true }
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('Markdown import produces no disabled structures', () => {
|
|
44
|
+
it('keeps heading text as a paragraph when headings are removed', () => {
|
|
45
|
+
const editor = buildRestrictedEditor([builtInExtensions.Heading])
|
|
46
|
+
importMarkdown(editor, '# Heading text\n\nBody text')
|
|
47
|
+
const result = snapshot(editor)
|
|
48
|
+
expect(result.types).not.toContain('heading')
|
|
49
|
+
expect(result.text).toContain('Heading text')
|
|
50
|
+
expect(result.text).toContain('Body text')
|
|
51
|
+
editor.dispose()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('still produces headings with the default configuration', () => {
|
|
55
|
+
const editor = buildFullEditor()
|
|
56
|
+
importMarkdown(editor, '# Heading text')
|
|
57
|
+
expect(snapshot(editor).types).toContain('heading')
|
|
58
|
+
editor.dispose()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('keeps table cell text as paragraphs when tables are removed', () => {
|
|
62
|
+
const editor = buildRestrictedEditor([builtInExtensions.Table])
|
|
63
|
+
importMarkdown(editor, '| a | b |\n| - | - |\n| one | two |')
|
|
64
|
+
const result = snapshot(editor)
|
|
65
|
+
expect(result.types).not.toContain('table')
|
|
66
|
+
expect(result.text).toContain('one')
|
|
67
|
+
expect(result.text).toContain('two')
|
|
68
|
+
editor.dispose()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('keeps list item text when lists are removed', () => {
|
|
72
|
+
const editor = buildRestrictedEditor([builtInExtensions.List, builtInExtensions.CheckList])
|
|
73
|
+
importMarkdown(editor, '- item one\n- item two')
|
|
74
|
+
const result = snapshot(editor)
|
|
75
|
+
expect(result.types).not.toContain('list')
|
|
76
|
+
expect(result.text).toContain('item one')
|
|
77
|
+
expect(result.text).toContain('item two')
|
|
78
|
+
editor.dispose()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps link text when links are removed', () => {
|
|
82
|
+
const editor = buildRestrictedEditor([builtInExtensions.Link])
|
|
83
|
+
importMarkdown(editor, 'see [link text](https://example.com) here')
|
|
84
|
+
const result = snapshot(editor)
|
|
85
|
+
expect(JSON.stringify(editor.getEditorState().toJSON())).not.toContain('"type":"link"')
|
|
86
|
+
expect(result.text).toContain('link text')
|
|
87
|
+
editor.dispose()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('nested Markdown imports follow the same rule', () => {
|
|
92
|
+
it('imports a table cell with headings and links removed', () => {
|
|
93
|
+
// Tables are KEPT here: the point is that the cell's own nested
|
|
94
|
+
// $convertFromMarkdownString must be filtered too, so the table
|
|
95
|
+
// survives while the unsupported link inside it degrades.
|
|
96
|
+
const editor = buildRestrictedEditor([builtInExtensions.Heading, builtInExtensions.Link])
|
|
97
|
+
expect(() => {
|
|
98
|
+
importMarkdown(editor, '| a | b |\n| - | - |\n| [link text](https://e.com) | plain |')
|
|
99
|
+
}).not.toThrow()
|
|
100
|
+
const result = snapshot(editor)
|
|
101
|
+
const json = JSON.stringify(editor.getEditorState().toJSON())
|
|
102
|
+
|
|
103
|
+
expect(result.types).toContain('table')
|
|
104
|
+
expect(json).not.toContain('"type":"link"')
|
|
105
|
+
// The degraded cell keeps its text, not just its untouched neighbour.
|
|
106
|
+
expect(result.text).toContain('link text')
|
|
107
|
+
expect(result.text).toContain('plain')
|
|
108
|
+
editor.dispose()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('imports an admonition body with links removed', () => {
|
|
112
|
+
// ADMONITION_BODY_TRANSFORMERS carries LINK, so this is the case that
|
|
113
|
+
// breaks without filtering.
|
|
114
|
+
//
|
|
115
|
+
// The directive syntax is `:::type[Title]` — the start regex is
|
|
116
|
+
// /^:::(note|tip|warning|danger)(?:\[([^\]]*)\])?\s*$/, so
|
|
117
|
+
// `:::note Title` silently fails to match and no admonition is ever
|
|
118
|
+
// created. Assert the node exists so this fixture cannot rot back
|
|
119
|
+
// into testing nothing.
|
|
120
|
+
const editor = buildRestrictedEditor([builtInExtensions.Link])
|
|
121
|
+
const active = transformersFor(editor, ADMONITION_BODY_TRANSFORMERS)
|
|
122
|
+
expect(active.length).toBeLessThan(ADMONITION_BODY_TRANSFORMERS.length)
|
|
123
|
+
|
|
124
|
+
expect(() => {
|
|
125
|
+
importMarkdown(editor, ':::note[Note title]\nbody with [a link](https://e.com)\n:::')
|
|
126
|
+
}).not.toThrow()
|
|
127
|
+
|
|
128
|
+
const result = snapshot(editor)
|
|
129
|
+
const json = JSON.stringify(editor.getEditorState().toJSON())
|
|
130
|
+
expect(result.types).toContain('admonition')
|
|
131
|
+
expect(json).not.toContain('"type":"link"')
|
|
132
|
+
expect(result.text).toContain('body with')
|
|
133
|
+
expect(result.text).toContain('a link')
|
|
134
|
+
editor.dispose()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
/** Type `input` at the caret, the way the shortcut listener sees it. */
|
|
139
|
+
function typeText(editor: LexicalEditor, input: string): void {
|
|
140
|
+
editor.update(
|
|
141
|
+
() => {
|
|
142
|
+
$getRoot().clear().append($createParagraphNode())
|
|
143
|
+
$getRoot().selectEnd()
|
|
144
|
+
},
|
|
145
|
+
{ discrete: true }
|
|
146
|
+
)
|
|
147
|
+
for (const char of input) {
|
|
148
|
+
editor.update(
|
|
149
|
+
() => {
|
|
150
|
+
const selection = $getSelection()
|
|
151
|
+
if ($isRangeSelection(selection)) selection.insertText(char)
|
|
152
|
+
},
|
|
153
|
+
{ discrete: true }
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe('keyboard Markdown shortcuts', () => {
|
|
159
|
+
it('POSITIVE CONTROL: "# " creates a heading in the default editor', () => {
|
|
160
|
+
// Without this the negative case below could pass simply because the
|
|
161
|
+
// typing simulation never triggers a shortcut at all.
|
|
162
|
+
const editor = buildFullEditor()
|
|
163
|
+
registerMarkdownShortcuts(editor, transformersFor(editor, BYLINE_TRANSFORMERS))
|
|
164
|
+
typeText(editor, '# Title')
|
|
165
|
+
expect(snapshot(editor).types).toEqual(['heading'])
|
|
166
|
+
editor.dispose()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('"# " creates no heading when headings are removed, and keeps the text', () => {
|
|
170
|
+
const editor = buildRestrictedEditor([builtInExtensions.Heading])
|
|
171
|
+
registerMarkdownShortcuts(editor, transformersFor(editor, BYLINE_TRANSFORMERS))
|
|
172
|
+
typeText(editor, '# Title')
|
|
173
|
+
const result = snapshot(editor)
|
|
174
|
+
expect(result.types).toEqual(['paragraph'])
|
|
175
|
+
expect(result.text).toContain('Title')
|
|
176
|
+
editor.dispose()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('"- " creates no list when lists are removed', () => {
|
|
180
|
+
const editor = buildRestrictedEditor([builtInExtensions.List, builtInExtensions.CheckList])
|
|
181
|
+
registerMarkdownShortcuts(editor, transformersFor(editor, BYLINE_TRANSFORMERS))
|
|
182
|
+
typeText(editor, '- item')
|
|
183
|
+
const result = snapshot(editor)
|
|
184
|
+
expect(result.types).toEqual(['paragraph'])
|
|
185
|
+
expect(result.text).toContain('item')
|
|
186
|
+
editor.dispose()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { $generateNodesFromDOM } from '@lexical/html'
|
|
2
|
+
import { $isElementNode, type LexicalEditor, type LexicalNode } from 'lexical'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { $isLinkNode } from '../extensions/link'
|
|
6
|
+
import {
|
|
7
|
+
buildFullEditor,
|
|
8
|
+
buildRestrictedEditor,
|
|
9
|
+
parseHtmlToTypes,
|
|
10
|
+
} from '../test-support/build-test-editor'
|
|
11
|
+
import { supportsNodeType } from './registered-node-types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Import `html` and report what the tree contains. Node methods only
|
|
15
|
+
* work inside an editor context, so everything is extracted in here
|
|
16
|
+
* rather than returning live nodes to the caller.
|
|
17
|
+
*/
|
|
18
|
+
function importedTree(editor: LexicalEditor, html: string): { linkUrls: string[]; text: string } {
|
|
19
|
+
const dom = new DOMParser().parseFromString(html, 'text/html')
|
|
20
|
+
const linkUrls: string[] = []
|
|
21
|
+
let text = ''
|
|
22
|
+
editor.update(
|
|
23
|
+
() => {
|
|
24
|
+
const walk = (nodes: LexicalNode[]) => {
|
|
25
|
+
for (const node of nodes) {
|
|
26
|
+
if ($isLinkNode(node)) linkUrls.push(node.getAttributes().url ?? '')
|
|
27
|
+
if ($isElementNode(node)) walk(node.getChildren())
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const roots = $generateNodesFromDOM(editor, dom)
|
|
31
|
+
walk(roots)
|
|
32
|
+
text = roots.map((node) => node.getTextContent()).join('')
|
|
33
|
+
},
|
|
34
|
+
{ discrete: true }
|
|
35
|
+
)
|
|
36
|
+
return { linkUrls, text }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('node registration follows the extensions list', () => {
|
|
40
|
+
it('registers nothing structural in a fully stripped editor', () => {
|
|
41
|
+
const editor = buildRestrictedEditor()
|
|
42
|
+
for (const type of ['heading', 'quote', 'table', 'link', 'list', 'code']) {
|
|
43
|
+
expect(supportsNodeType(editor, type), `${type} must not be registered`).toBe(false)
|
|
44
|
+
}
|
|
45
|
+
editor.dispose()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('degrades every structural paste and keeps the text', () => {
|
|
49
|
+
const editor = buildRestrictedEditor()
|
|
50
|
+
const cases: Array<[string, string, string]> = [
|
|
51
|
+
['<h1>Pasted title</h1>', 'paragraph', 'Pasted title'],
|
|
52
|
+
['<blockquote>Quoted words</blockquote>', 'paragraph', 'Quoted words'],
|
|
53
|
+
['<ul><li>one</li></ul>', 'paragraph', 'one'],
|
|
54
|
+
['<pre>const x = 1</pre>', 'paragraph', 'const x = 1'],
|
|
55
|
+
]
|
|
56
|
+
for (const [html, type, text] of cases) {
|
|
57
|
+
const result = parseHtmlToTypes(editor, html)
|
|
58
|
+
expect(result.types, html).toEqual([type])
|
|
59
|
+
expect(result.text, html).toContain(text)
|
|
60
|
+
}
|
|
61
|
+
editor.dispose()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not regress the full default set', () => {
|
|
65
|
+
const editor = buildFullEditor()
|
|
66
|
+
expect(parseHtmlToTypes(editor, '<h1>Title</h1>').types).toEqual(['heading'])
|
|
67
|
+
expect(parseHtmlToTypes(editor, '<ul><li>one</li></ul>').types).toEqual(['list'])
|
|
68
|
+
expect(parseHtmlToTypes(editor, '<table><tr><td>cell</td></tr></table>').types).toEqual([
|
|
69
|
+
'table',
|
|
70
|
+
])
|
|
71
|
+
// Inspect the link node itself, not the surrounding text: text
|
|
72
|
+
// survives even when the link is dropped, so a text assertion would
|
|
73
|
+
// pass in exactly the case this is meant to catch.
|
|
74
|
+
const tree = importedTree(editor, '<p>see <a href="https://x.com/a">link</a></p>')
|
|
75
|
+
expect(tree.linkUrls).toEqual(['https://x.com/a'])
|
|
76
|
+
editor.dispose()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('drops the link node, but keeps its text, when the link extension is removed', () => {
|
|
80
|
+
const editor = buildRestrictedEditor()
|
|
81
|
+
const tree = importedTree(editor, '<p>see <a href="https://x.com/a">link</a></p>')
|
|
82
|
+
expect(tree.linkUrls).toEqual([])
|
|
83
|
+
expect(tree.text).toContain('link')
|
|
84
|
+
editor.dispose()
|
|
85
|
+
})
|
|
86
|
+
})
|