@byline/richtext-lexical 3.20.0 → 3.20.2

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.
@@ -0,0 +1,32 @@
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
+ import type { EditorConfig } from './types';
9
+ /**
10
+ * Merge a schema field's `editorConfig` over the registered editor's
11
+ * config (baked at registration via `lexicalEditor()`, or the package
12
+ * default).
13
+ *
14
+ * This is a MERGE, not a pick-one precedence — each layer can only express
15
+ * what its side is allowed to carry, so whole-object precedence would
16
+ * silently discard the rest:
17
+ *
18
+ * - The field layer (schema side) is JSON-safe: `settings` + `lexical`
19
+ * only, never `extensions`. It is the most specific layer, so its
20
+ * values win — but ONLY for the keys it can express. Settings merge
21
+ * per-key (including `settings.options`), so a field config produced
22
+ * by a partial override keeps the registered layer's remaining flags.
23
+ * - The registered layer is the only one that can carry an `extensions`
24
+ * graph. Before this merge existed, a schema-side `editorConfig`
25
+ * replaced the baked config wholesale, throwing away registration-time
26
+ * extension changes (an added AI extension, removed Insert-menu
27
+ * extensions, …) and silently falling back to the built-in list.
28
+ *
29
+ * Returns the registered config as-is when the field carries no
30
+ * `editorConfig`. Never mutates either input.
31
+ */
32
+ export declare function resolveEditorConfig(fieldConfig: EditorConfig | undefined, registeredConfig: EditorConfig): EditorConfig;
@@ -0,0 +1,16 @@
1
+ function resolveEditorConfig(fieldConfig, registeredConfig) {
2
+ if (null == fieldConfig) return registeredConfig;
3
+ return {
4
+ settings: {
5
+ ...registeredConfig.settings,
6
+ ...fieldConfig.settings,
7
+ options: {
8
+ ...registeredConfig.settings.options,
9
+ ...fieldConfig.settings?.options
10
+ }
11
+ },
12
+ lexical: fieldConfig.lexical ?? registeredConfig.lexical,
13
+ extensions: fieldConfig.extensions ?? registeredConfig.extensions
14
+ };
15
+ }
16
+ export { resolveEditorConfig };
@@ -16,9 +16,11 @@ function EditorContext(props) {
16
16
  const editable = true !== readOnly;
17
17
  const rootExtension = useMemo(()=>{
18
18
  const extensionsList = editorConfig.extensions ?? defaultExtensionsList();
19
- const dependencies = extensionsList.clone().configure(InlineImageExtension, {
19
+ const configured = extensionsList.clone();
20
+ if (configured.has(InlineImageExtension)) configured.configure(InlineImageExtension, {
20
21
  collection: editorConfig.settings.inlineImageUploadCollection
21
- }).toArray();
22
+ });
23
+ const dependencies = configured.toArray();
22
24
  return defineExtension({
23
25
  name: '[root]',
24
26
  namespace: editorConfig.lexical.namespace,
@@ -20,9 +20,11 @@ type ConfigureFn = (config: LexicalEditorConfigureInput) => LexicalEditorConfigu
20
20
  /**
21
21
  * Returns a `RichTextEditorComponent` with editor settings baked in. Use
22
22
  * this at the registration site in your admin config when you want to
23
- * customise the editor across the whole installation; per-field
24
- * overrides via `RichTextField.editorConfig` continue to take precedence
25
- * at render time.
23
+ * customise the editor across the whole installation. Per-field overrides
24
+ * via `RichTextField.editorConfig` are MERGED over the baked config at
25
+ * render time (settings win per-key, most specific first) — but the baked
26
+ * `extensions` graph survives, since schema-side configs cannot carry
27
+ * extension references. See `resolveEditorConfig`.
26
28
  *
27
29
  * The returned component is lazy: the editor module graph (RichTextField
28
30
  * + every built-in extension + the Lexical core) is dynamically imported
@@ -4,6 +4,7 @@ import { ErrorText, Label } from "@byline/ui/react";
4
4
  import classnames from "classnames";
5
5
  import { defaultEditorConfig } from "./field/config/default.js";
6
6
  import { defaultExtensionsList } from "./field/config/default-extensions.js";
7
+ import { resolveEditorConfig } from "./field/config/resolve-editor-config.js";
7
8
  import { EditorField } from "./field/editor-field.js";
8
9
  import richtext_field_module from "./richtext-field.module.js";
9
10
  const RichTextField = ({ field, value, defaultValue, editorConfig, readonly = false, instanceKey, onChange, path, locale, featureBeforeEditor, featureAfterEditor, featureChildren })=>{
@@ -13,7 +14,7 @@ const RichTextField = ({ field, value, defaultValue, editorConfig, readonly = fa
13
14
  const incomingValue = value ?? fieldValue;
14
15
  const incomingDefault = defaultValue;
15
16
  const fieldId = instanceKey ? `${field.name}-${instanceKey}` : field.name;
16
- const resolved = field.editorConfig ?? editorConfig ?? defaultEditorConfig;
17
+ const resolved = resolveEditorConfig(field.editorConfig, editorConfig ?? defaultEditorConfig);
17
18
  const baseEditorConfig = null != resolved.extensions ? resolved : {
18
19
  ...resolved,
19
20
  extensions: defaultExtensionsList()
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "3.20.0",
6
+ "version": "3.20.2",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -73,10 +73,10 @@
73
73
  "lexical": "0.46.0",
74
74
  "npm-run-all": "^4.1.5",
75
75
  "react-error-boundary": "^6.1.2",
76
- "@byline/admin": "3.20.0",
77
- "@byline/core": "3.20.0",
78
- "@byline/ui": "3.20.0",
79
- "@byline/client": "3.20.0"
76
+ "@byline/core": "3.20.2",
77
+ "@byline/admin": "3.20.2",
78
+ "@byline/client": "3.20.2",
79
+ "@byline/ui": "3.20.2"
80
80
  },
81
81
  "peerDependencies": {
82
82
  "react": "^19.0.0",
@@ -0,0 +1,61 @@
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 { AnyLexicalExtensionArgument } from '@lexical/extension'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import { ExtensionsList } from './extensions-list'
13
+
14
+ // Structural stand-ins — ExtensionsList compares by `.name`, so plain
15
+ // objects are sufficient and keep the heavy extension classes (React,
16
+ // Lexical nodes) out of this node-mode test.
17
+ const extA = { name: 'test/A' } as unknown as AnyLexicalExtensionArgument
18
+ const extB = { name: 'test/B' } as unknown as AnyLexicalExtensionArgument
19
+
20
+ describe('ExtensionsList', () => {
21
+ it('remove() by name string drops the entry; has() reflects it', () => {
22
+ const list = new ExtensionsList([extA, extB])
23
+ expect(list.has('test/A')).toBe(true)
24
+ list.remove('test/A')
25
+ expect(list.has('test/A')).toBe(false)
26
+ expect(list.has('test/B')).toBe(true)
27
+ })
28
+
29
+ it('configure() upserts — documented behaviour: absent extension is ADDED', () => {
30
+ const list = new ExtensionsList([extB])
31
+ // biome-ignore lint/suspicious/noExplicitAny: structural stand-in
32
+ list.configure(extA as any, {})
33
+ expect(list.has('test/A')).toBe(true)
34
+ })
35
+
36
+ it('REGRESSION: callers forwarding settings must gate configure() on has() — the clone survives a prior remove', () => {
37
+ // Mirrors editor-context.tsx: settings forwarding used to call
38
+ // `.configure(InlineImageExtension, …)` unconditionally, and the
39
+ // upsert resurrected an extension the registration had deliberately
40
+ // removed. The gated pattern must leave the removal intact.
41
+ const list = new ExtensionsList([extA, extB])
42
+ list.remove('test/A')
43
+
44
+ const configured = list.clone()
45
+ if (configured.has('test/A')) {
46
+ // biome-ignore lint/suspicious/noExplicitAny: structural stand-in
47
+ configured.configure(extA as any, {})
48
+ }
49
+
50
+ expect(configured.has('test/A')).toBe(false)
51
+ expect(configured.toArray().length).toBe(1)
52
+ })
53
+
54
+ it('clone() is independent — mutations do not leak back', () => {
55
+ const list = new ExtensionsList([extA, extB])
56
+ const copy = list.clone()
57
+ copy.remove('test/B')
58
+ expect(list.has('test/B')).toBe(true)
59
+ expect(copy.has('test/B')).toBe(false)
60
+ })
61
+ })
@@ -0,0 +1,101 @@
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 { describe, expect, it } from 'vitest'
10
+
11
+ import { defaultEditorConfig } from './default'
12
+ import { resolveEditorConfig } from './resolve-editor-config'
13
+ import type { EditorConfig } from './types'
14
+
15
+ /**
16
+ * Minimal stand-in for an ExtensionsList — resolveEditorConfig only passes
17
+ * the reference through, so identity is all these tests need.
18
+ */
19
+ const fakeExtensions = { items: ['ext-a', 'ext-b'] } as unknown as NonNullable<
20
+ EditorConfig['extensions']
21
+ >
22
+
23
+ function clone(config: EditorConfig): EditorConfig {
24
+ return structuredClone(config)
25
+ }
26
+
27
+ describe('resolveEditorConfig', () => {
28
+ it('returns the registered config as-is when the field carries none', () => {
29
+ const registered = { ...clone(defaultEditorConfig), extensions: fakeExtensions }
30
+ expect(resolveEditorConfig(undefined, registered)).toBe(registered)
31
+ })
32
+
33
+ it('field settings win per-key; unspecified registered settings survive', () => {
34
+ const registered = clone(defaultEditorConfig)
35
+ registered.settings.options.markdownToggle = true
36
+ registered.settings.placeholderText = 'registered placeholder'
37
+
38
+ const fieldConfig = clone(defaultEditorConfig)
39
+ fieldConfig.settings.options.textStyle = false
40
+ fieldConfig.settings.options.undoRedo = false
41
+
42
+ const resolved = resolveEditorConfig(fieldConfig, registered)
43
+ // Field's flags applied…
44
+ expect(resolved.settings.options.textStyle).toBe(false)
45
+ expect(resolved.settings.options.undoRedo).toBe(false)
46
+ // …and since schema-side configs are complete objects, its values win
47
+ // for every key it carries (markdownToggle false from the default seed).
48
+ expect(resolved.settings.options.markdownToggle).toBe(false)
49
+ expect(resolved.settings.placeholderText).toBe(fieldConfig.settings.placeholderText)
50
+ })
51
+
52
+ it('REGRESSION: a schema-side settings-only config must not discard the registered extensions graph', () => {
53
+ // Before the merge existed, `field.editorConfig ?? registered` replaced
54
+ // the whole object: a compact/minimal settings preset baked into the
55
+ // schema silently dropped registration-time extension changes (removed
56
+ // Insert-menu extensions, an added AI extension) and the editor fell
57
+ // back to the built-in list.
58
+ const registered = { ...clone(defaultEditorConfig), extensions: fakeExtensions }
59
+ const fieldConfig = clone(defaultEditorConfig) // settings-only, no extensions
60
+
61
+ const resolved = resolveEditorConfig(fieldConfig, registered)
62
+ expect(resolved.extensions).toBe(fakeExtensions)
63
+ })
64
+
65
+ it('a field-carried extensions graph (advanced/manual) still wins', () => {
66
+ const fieldExtensions = { items: ['field-ext'] } as unknown as NonNullable<
67
+ EditorConfig['extensions']
68
+ >
69
+ const registered = { ...clone(defaultEditorConfig), extensions: fakeExtensions }
70
+ const fieldConfig = { ...clone(defaultEditorConfig), extensions: fieldExtensions }
71
+
72
+ const resolved = resolveEditorConfig(fieldConfig, registered)
73
+ expect(resolved.extensions).toBe(fieldExtensions)
74
+ })
75
+
76
+ it('lexical config: field wins when present, registered otherwise', () => {
77
+ const registered = clone(defaultEditorConfig)
78
+ const fieldConfig = clone(defaultEditorConfig)
79
+
80
+ const withField = resolveEditorConfig(fieldConfig, registered)
81
+ expect(withField.lexical).toBe(fieldConfig.lexical)
82
+
83
+ const withoutFieldLexical = resolveEditorConfig(
84
+ { settings: fieldConfig.settings } as EditorConfig,
85
+ registered
86
+ )
87
+ expect(withoutFieldLexical.lexical).toBe(registered.lexical)
88
+ })
89
+
90
+ it('does not mutate either input', () => {
91
+ const registered = { ...clone(defaultEditorConfig), extensions: fakeExtensions }
92
+ const fieldConfig = clone(defaultEditorConfig)
93
+ const registeredSnapshot = JSON.stringify({ ...registered, extensions: undefined })
94
+ const fieldSnapshot = JSON.stringify(fieldConfig)
95
+
96
+ resolveEditorConfig(fieldConfig, registered)
97
+
98
+ expect(JSON.stringify({ ...registered, extensions: undefined })).toBe(registeredSnapshot)
99
+ expect(JSON.stringify(fieldConfig)).toBe(fieldSnapshot)
100
+ })
101
+ })
@@ -0,0 +1,53 @@
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 { EditorConfig } from './types'
10
+
11
+ /**
12
+ * Merge a schema field's `editorConfig` over the registered editor's
13
+ * config (baked at registration via `lexicalEditor()`, or the package
14
+ * default).
15
+ *
16
+ * This is a MERGE, not a pick-one precedence — each layer can only express
17
+ * what its side is allowed to carry, so whole-object precedence would
18
+ * silently discard the rest:
19
+ *
20
+ * - The field layer (schema side) is JSON-safe: `settings` + `lexical`
21
+ * only, never `extensions`. It is the most specific layer, so its
22
+ * values win — but ONLY for the keys it can express. Settings merge
23
+ * per-key (including `settings.options`), so a field config produced
24
+ * by a partial override keeps the registered layer's remaining flags.
25
+ * - The registered layer is the only one that can carry an `extensions`
26
+ * graph. Before this merge existed, a schema-side `editorConfig`
27
+ * replaced the baked config wholesale, throwing away registration-time
28
+ * extension changes (an added AI extension, removed Insert-menu
29
+ * extensions, …) and silently falling back to the built-in list.
30
+ *
31
+ * Returns the registered config as-is when the field carries no
32
+ * `editorConfig`. Never mutates either input.
33
+ */
34
+ export function resolveEditorConfig(
35
+ fieldConfig: EditorConfig | undefined,
36
+ registeredConfig: EditorConfig
37
+ ): EditorConfig {
38
+ if (fieldConfig == null) return registeredConfig
39
+ return {
40
+ settings: {
41
+ ...registeredConfig.settings,
42
+ ...fieldConfig.settings,
43
+ options: {
44
+ ...registeredConfig.settings.options,
45
+ ...fieldConfig.settings?.options,
46
+ },
47
+ },
48
+ lexical: fieldConfig.lexical ?? registeredConfig.lexical,
49
+ // Schema-side configs never carry extensions (not JSON-safe); the
50
+ // registered editor's graph survives a field-level settings override.
51
+ extensions: fieldConfig.extensions ?? registeredConfig.extensions,
52
+ }
53
+ }
@@ -80,14 +80,19 @@ export function EditorContext(props: {
80
80
  const extensionsList = editorConfig.extensions ?? defaultExtensionsList()
81
81
 
82
82
  // Forward the inline-image upload collection from the settings facade
83
- // onto the InlineImageExtension's typed config, but only when the
84
- // user hasn't already configured the extension explicitly.
85
- const dependencies = extensionsList
86
- .clone()
87
- .configure(InlineImageExtension, {
83
+ // onto the InlineImageExtension's typed config but ONLY when the
84
+ // extension is actually present. `configure()` has upsert semantics
85
+ // (absent added), so an ungated call here silently resurrected
86
+ // InlineImageExtension on every editor build for registrations that
87
+ // had deliberately removed it via
88
+ // `lexicalEditor((c) => c.extensions.remove(builtInExtensions.InlineImage))`.
89
+ const configured = extensionsList.clone()
90
+ if (configured.has(InlineImageExtension)) {
91
+ configured.configure(InlineImageExtension, {
88
92
  collection: editorConfig.settings.inlineImageUploadCollection,
89
93
  })
90
- .toArray()
94
+ }
95
+ const dependencies = configured.toArray()
91
96
 
92
97
  return defineExtension({
93
98
  name: '[root]',
@@ -71,9 +71,11 @@ function loadEditorBundle(): Promise<EditorBundle> {
71
71
  /**
72
72
  * Returns a `RichTextEditorComponent` with editor settings baked in. Use
73
73
  * this at the registration site in your admin config when you want to
74
- * customise the editor across the whole installation; per-field
75
- * overrides via `RichTextField.editorConfig` continue to take precedence
76
- * at render time.
74
+ * customise the editor across the whole installation. Per-field overrides
75
+ * via `RichTextField.editorConfig` are MERGED over the baked config at
76
+ * render time (settings win per-key, most specific first) — but the baked
77
+ * `extensions` graph survives, since schema-side configs cannot carry
78
+ * extension references. See `resolveEditorConfig`.
77
79
  *
78
80
  * The returned component is lazy: the editor module graph (RichTextField
79
81
  * + every built-in extension + the Lexical core) is dynamically imported
@@ -15,6 +15,7 @@ import cx from 'classnames'
15
15
 
16
16
  import { defaultEditorConfig } from './field/config/default'
17
17
  import { defaultExtensionsList } from './field/config/default-extensions'
18
+ import { resolveEditorConfig } from './field/config/resolve-editor-config'
18
19
  import { EditorField } from './field/editor-field'
19
20
  import styles from './richtext-field.module.css'
20
21
  import type { EditorConfig } from './field/config/types'
@@ -70,18 +71,20 @@ export const RichTextField = ({
70
71
 
71
72
  const fieldId = instanceKey ? `${field.name}-${instanceKey}` : field.name
72
73
 
73
- // Resolve the editor config with field-level priority:
74
- // 1. `field.editorConfig` set on the schema field itself (most specific).
75
- // 2. `editorConfig` prop — baked in at registration via `lexicalEditor()`.
76
- // 3. `defaultEditorConfig` — package default.
77
- // The schema-level value is typed as `unknown` at the `@byline/core` boundary,
78
- // so the cast lives here where the Lexical config shape is known.
79
- const resolved: EditorConfig =
80
- (field.editorConfig as EditorConfig | undefined) ?? editorConfig ?? defaultEditorConfig
81
- // Schema-side configs and the server-safe `defaultEditorConfig` carry no
82
- // `extensions` field — extension references aren't JSON-safe. Materialise
83
- // the package's client-only default list when one isn't already present so
84
- // every render has a complete graph to feed `EditorContext`.
74
+ // Resolve the editor config by MERGING the schema field's `editorConfig`
75
+ // (settings-only, most specific) over the registered editor's config
76
+ // (baked via `lexicalEditor()`, the only layer that can carry an
77
+ // `extensions` graph) see `resolveEditorConfig` for the full rationale.
78
+ // The schema-level value is typed as `unknown` at the `@byline/core`
79
+ // boundary, so the cast lives here where the Lexical config shape is known.
80
+ const resolved: EditorConfig = resolveEditorConfig(
81
+ field.editorConfig as EditorConfig | undefined,
82
+ editorConfig ?? defaultEditorConfig
83
+ )
84
+ // The server-safe `defaultEditorConfig` carries no `extensions` field
85
+ // extension references aren't JSON-safe. Materialise the package's
86
+ // client-only default list when one isn't already present so every render
87
+ // has a complete graph to feed `EditorContext`.
85
88
  const baseEditorConfig: EditorConfig =
86
89
  resolved.extensions != null ? resolved : { ...resolved, extensions: defaultExtensionsList() }
87
90