@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.
Files changed (123) hide show
  1. package/dist/config.d.ts +1 -1
  2. package/dist/field/apply-value-plugin.js +31 -6
  3. package/dist/field/capabilities/filter-transformers.d.ts +29 -0
  4. package/dist/field/capabilities/filter-transformers.js +8 -0
  5. package/dist/field/capabilities/registered-node-types.d.ts +29 -0
  6. package/dist/field/capabilities/registered-node-types.js +7 -0
  7. package/dist/field/config/built-in-extension-names.d.ts +42 -0
  8. package/dist/field/config/built-in-extension-names.js +12 -2
  9. package/dist/field/config/default-extensions.js +4 -0
  10. package/dist/field/config/default.js +8 -8
  11. package/dist/field/config/editor-config-context.d.ts +2 -2
  12. package/dist/field/config/editor-config-context.js +10 -13
  13. package/dist/field/config/resolve-editor-config.js +3 -3
  14. package/dist/field/config/root-dependencies.d.ts +26 -0
  15. package/dist/field/config/root-dependencies.js +8 -0
  16. package/dist/field/config/types.d.ts +61 -8
  17. package/dist/field/editor-context.js +44 -27
  18. package/dist/field/editor.js +15 -8
  19. package/dist/field/extensions/core-nodes/core-nodes-extension.d.ts +24 -0
  20. package/dist/field/extensions/core-nodes/core-nodes-extension.js +11 -0
  21. package/dist/field/extensions/heading/heading-extension.d.ts +20 -0
  22. package/dist/field/extensions/heading/heading-extension.js +9 -0
  23. package/dist/field/extensions/quote/quote-extension.d.ts +16 -0
  24. package/dist/field/extensions/quote/quote-extension.js +9 -0
  25. package/dist/field/hooks/use-markdown-toggle.d.ts +15 -0
  26. package/dist/field/hooks/use-markdown-toggle.js +12 -4
  27. package/dist/field/hooks/use-platform-modifier.d.ts +16 -0
  28. package/dist/field/hooks/use-platform-modifier.js +8 -0
  29. package/dist/field/markdown/transformers.d.ts +1 -0
  30. package/dist/field/markdown/transformers.js +5 -4
  31. package/dist/field/nodes/index.d.ts +11 -1
  32. package/dist/field/nodes/index.js +2 -2
  33. package/dist/field/normalize/adapted-notice.css +34 -0
  34. package/dist/field/normalize/adapted-notice.d.ts +29 -0
  35. package/dist/field/normalize/adapted-notice.js +35 -0
  36. package/dist/field/normalize/content-labels.d.ts +15 -0
  37. package/dist/field/normalize/content-labels.js +19 -0
  38. package/dist/field/normalize/declared-conversions.d.ts +131 -0
  39. package/dist/field/normalize/declared-conversions.js +93 -0
  40. package/dist/field/normalize/normalization-status.d.ts +33 -0
  41. package/dist/field/normalize/normalization-status.js +28 -0
  42. package/dist/field/normalize/normalize-value.d.ts +38 -0
  43. package/dist/field/normalize/normalize-value.js +132 -0
  44. package/dist/field/normalize/use-notice-text.d.ts +11 -0
  45. package/dist/field/normalize/use-notice-text.js +34 -0
  46. package/dist/field/plugins/toolbar-plugin/index.d.ts +23 -0
  47. package/dist/field/plugins/toolbar-plugin/index.js +104 -78
  48. package/dist/field/test-support/build-test-editor.js +49 -0
  49. package/dist/field/test-support/capture-editor.js +7 -0
  50. package/dist/field/test-support/frame-control.js +40 -0
  51. package/dist/field/test-support/jsdom-setup.js +21 -0
  52. package/dist/index.d.ts +7 -1
  53. package/dist/index.js +6 -1
  54. package/dist/scan/capability-manifest.d.ts +30 -0
  55. package/dist/scan/capability-manifest.js +43 -0
  56. package/dist/scan/scan-documents.d.ts +29 -0
  57. package/dist/scan/scan-documents.js +39 -0
  58. package/dist/scan/types.d.ts +63 -0
  59. package/dist/scan/types.js +1 -0
  60. package/dist/scan.d.ts +25 -0
  61. package/dist/scan.js +1 -0
  62. package/package.json +14 -5
  63. package/src/config.ts +1 -1
  64. package/src/field/apply-value-plugin.tsx +71 -5
  65. package/src/field/capabilities/filter-transformers.test.tsx +71 -0
  66. package/src/field/capabilities/filter-transformers.ts +42 -0
  67. package/src/field/capabilities/markdown-behaviour.test.tsx +188 -0
  68. package/src/field/capabilities/no-blanket-registration.test.tsx +86 -0
  69. package/src/field/capabilities/registered-node-types.test.tsx +29 -0
  70. package/src/field/capabilities/registered-node-types.ts +36 -0
  71. package/src/field/config/built-in-extension-names.test.node.ts +38 -14
  72. package/src/field/config/built-in-extension-names.ts +29 -1
  73. package/src/field/config/default-extensions.ts +6 -0
  74. package/src/field/config/default.ts +8 -8
  75. package/src/field/config/editor-config-context.tsx +14 -9
  76. package/src/field/config/list-removal.test.tsx +44 -0
  77. package/src/field/config/resolve-editor-config.test.node.ts +27 -6
  78. package/src/field/config/resolve-editor-config.ts +3 -3
  79. package/src/field/config/root-dependencies.ts +32 -0
  80. package/src/field/config/types.ts +61 -17
  81. package/src/field/editor-component.test.tsx +18 -3
  82. package/src/field/editor-context.regression.test.tsx +124 -0
  83. package/src/field/editor-context.tsx +90 -22
  84. package/src/field/editor.tsx +22 -12
  85. package/src/field/extensions/core-nodes/core-nodes-extension.test.tsx +13 -0
  86. package/src/field/extensions/core-nodes/core-nodes-extension.ts +32 -0
  87. package/src/field/extensions/heading/heading-extension.test.tsx +32 -0
  88. package/src/field/extensions/heading/heading-extension.ts +27 -0
  89. package/src/field/extensions/quote/quote-extension.ts +23 -0
  90. package/src/field/hooks/markdown-source-mode.test.tsx +221 -0
  91. package/src/field/hooks/use-markdown-toggle.ts +39 -4
  92. package/src/field/hooks/use-platform-modifier.test.tsx +81 -0
  93. package/src/field/hooks/use-platform-modifier.ts +41 -0
  94. package/src/field/markdown/transformers.ts +18 -4
  95. package/src/field/nodes/index.ts +11 -1
  96. package/src/field/normalize/adapted-notice.css +52 -0
  97. package/src/field/normalize/adapted-notice.tsx +63 -0
  98. package/src/field/normalize/clipboard-equivalence.test.tsx +330 -0
  99. package/src/field/normalize/content-labels.ts +44 -0
  100. package/src/field/normalize/declared-conversions.test.node.ts +67 -0
  101. package/src/field/normalize/declared-conversions.ts +160 -0
  102. package/src/field/normalize/emission-gate.test.tsx +302 -0
  103. package/src/field/normalize/normalization-status.tsx +48 -0
  104. package/src/field/normalize/normalize-load.test.tsx +371 -0
  105. package/src/field/normalize/normalize-value.test.node.ts +530 -0
  106. package/src/field/normalize/normalize-value.ts +180 -0
  107. package/src/field/normalize/notice-i18n.test.tsx +128 -0
  108. package/src/field/normalize/use-notice-text.ts +82 -0
  109. package/src/field/normalize/wiring.test.tsx +348 -0
  110. package/src/field/plugins/toolbar-plugin/block-format-capabilities.test.tsx +57 -0
  111. package/src/field/plugins/toolbar-plugin/index.tsx +131 -68
  112. package/src/field/test-support/build-test-editor.test.tsx +17 -0
  113. package/src/field/test-support/build-test-editor.ts +99 -0
  114. package/src/field/test-support/capture-editor.tsx +20 -0
  115. package/src/field/test-support/frame-control.ts +91 -0
  116. package/src/field/test-support/jsdom-setup.ts +41 -0
  117. package/src/index.ts +12 -1
  118. package/src/scan/capability-manifest.test.tsx +82 -0
  119. package/src/scan/capability-manifest.ts +81 -0
  120. package/src/scan/scan-documents.test.node.ts +125 -0
  121. package/src/scan/scan-documents.ts +78 -0
  122. package/src/scan/types.ts +66 -0
  123. package/src/scan.ts +27 -0
@@ -0,0 +1,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": "5.2.0",
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/admin": "5.2.0",
76
- "@byline/ui": "5.2.0",
77
- "@byline/client": "5.2.0",
78
- "@byline/core": "5.2.0"
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` / `Nodes` or the heavy *content* extension classes
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 { useEffect, useRef } from 'react'
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
- useEffect(() => {
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
- if (nextRawHash === lastEmittedHashRef.current) {
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
- const nextState = editor.parseEditorState(value)
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
+ })