@byline/richtext-lexical 2.4.3 → 2.5.0
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/field/config/default-extensions.js +0 -2
- package/dist/field/config/default.js +1 -2
- package/dist/field/config/types.d.ts +0 -11
- package/dist/field/extensions/inline-image/inline-image-modal.js +1 -8
- package/dist/field/extensions/link/link-modal.js +1 -3
- package/dist/field/extensions/link/populate.d.ts +0 -12
- package/dist/field/extensions/link/populate.js +42 -3
- package/dist/field/extensions/link/types.d.ts +28 -1
- package/dist/field/lexical-populate-shared.d.ts +10 -1
- package/dist/field/lexical-populate-shared.js +1 -0
- package/dist/field/nodes/document-relation.d.ts +12 -5
- package/dist/richtext-field.js +1 -7
- package/dist/server.d.ts +34 -10
- package/dist/server.js +18 -2
- package/package.json +4 -4
- package/src/field/config/default-extensions.ts +0 -2
- package/src/field/config/default.ts +0 -1
- package/src/field/config/types.ts +0 -11
- package/src/field/extensions/inline-image/inline-image-modal.tsx +6 -16
- package/src/field/extensions/link/link-modal.tsx +6 -8
- package/src/field/extensions/link/populate.ts +92 -8
- package/src/field/extensions/link/types.ts +31 -1
- package/src/field/lexical-populate-shared.ts +15 -3
- package/src/field/nodes/document-relation.ts +12 -5
- package/src/richtext-field.tsx +6 -12
- package/src/server.ts +50 -10
|
@@ -9,7 +9,6 @@ import { FloatingTextFormatExtension } from "../extensions/floating-text-format/
|
|
|
9
9
|
import { HorizontalRuleExtension } from "../extensions/horizontal-rule/horizontal-rule-extension.js";
|
|
10
10
|
import { InlineImageExtension } from "../extensions/inline-image/inline-image-extension.js";
|
|
11
11
|
import { LayoutExtension } from "../extensions/layout/layout-extension.js";
|
|
12
|
-
import { AutoLinkExtension } from "../extensions/link/auto-link-extension.js";
|
|
13
12
|
import { LinkExtension } from "../extensions/link/link-extension.js";
|
|
14
13
|
import { TableExtension } from "../extensions/table/table-extension.js";
|
|
15
14
|
import { VimeoExtension } from "../extensions/vimeo/vimeo-extension.js";
|
|
@@ -30,7 +29,6 @@ function defaultExtensionsArray() {
|
|
|
30
29
|
CodeHighlightExtension,
|
|
31
30
|
TableExtension,
|
|
32
31
|
LinkExtension,
|
|
33
|
-
AutoLinkExtension,
|
|
34
32
|
InlineImageExtension,
|
|
35
33
|
YouTubeExtension,
|
|
36
34
|
VimeoExtension,
|
|
@@ -15,8 +15,7 @@ const DEFAULT_EDITOR_SETTINGS = {
|
|
|
15
15
|
debug: false
|
|
16
16
|
},
|
|
17
17
|
inlineImageUploadCollection: 'media',
|
|
18
|
-
placeholderText: 'Enter some rich text...'
|
|
19
|
-
embedRelationsOnSave: true
|
|
18
|
+
placeholderText: 'Enter some rich text...'
|
|
20
19
|
};
|
|
21
20
|
const defaultEditorConfig = {
|
|
22
21
|
settings: DEFAULT_EDITOR_SETTINGS,
|
|
@@ -18,22 +18,11 @@ export interface EditorSettings {
|
|
|
18
18
|
*/
|
|
19
19
|
inlineImageUploadCollection: string;
|
|
20
20
|
placeholderText: string;
|
|
21
|
-
/**
|
|
22
|
-
* Whether relation-bearing nodes (link, inline-image) embed the picker's
|
|
23
|
-
* resolved target fields (`title`, `path`, `altText`, `image`, `sizes`)
|
|
24
|
-
* into the persisted Lexical JSON at modal-save time. Defaults to `true`.
|
|
25
|
-
*
|
|
26
|
-
* Mirrors `RichTextField.embedRelationsOnSave`. The lexical wrapper
|
|
27
|
-
* (`richtext-field.tsx`) merges the field-level value over the resolved
|
|
28
|
-
* editor settings so each field gets the policy it asked for.
|
|
29
|
-
*/
|
|
30
|
-
embedRelationsOnSave: boolean;
|
|
31
21
|
}
|
|
32
22
|
export interface EditorSettingsOverride {
|
|
33
23
|
options?: Partial<Record<OptionName, boolean>>;
|
|
34
24
|
inlineImageUploadCollection?: string;
|
|
35
25
|
placeholderText?: string;
|
|
36
|
-
embedRelationsOnSave?: boolean;
|
|
37
26
|
}
|
|
38
27
|
export interface EditorConfig {
|
|
39
28
|
settings: EditorSettings;
|
|
@@ -3,7 +3,6 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useMemo, useState } from "react";
|
|
4
4
|
import { getCollectionDefinition } from "@byline/core";
|
|
5
5
|
import { Button, Checkbox, CloseIcon, ErrorText, IconButton, Input, Label, Modal, RadioGroup, RadioGroupItem, RelationPicker } from "@byline/ui/react";
|
|
6
|
-
import { useEditorConfig } from "../../config/editor-config-context.js";
|
|
7
6
|
import { useModalFormState } from "../../shared/useModalFormState.js";
|
|
8
7
|
import { isAltTextValid, positionOptions } from "./fields.js";
|
|
9
8
|
import { deriveImageSizes, getPreferredSize } from "./utils.js";
|
|
@@ -28,7 +27,6 @@ const InlineImageModal = ({ isOpen, collection, data: dataFromProps, onSubmit, o
|
|
|
28
27
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
29
28
|
const [altError, setAltError] = useState(null);
|
|
30
29
|
const [imageError, setImageError] = useState(null);
|
|
31
|
-
const { config: editorSettings } = useEditorConfig();
|
|
32
30
|
const [state, setState] = useModalFormState(isOpen, ()=>fromInlineImageData(dataFromProps), ()=>{
|
|
33
31
|
setAltError(null);
|
|
34
32
|
setImageError(null);
|
|
@@ -84,13 +82,8 @@ const InlineImageModal = ({ isOpen, collection, data: dataFromProps, onSubmit, o
|
|
|
84
82
|
if (!state.documentRelation || !pickedImage) return void setImageError('Pick an image');
|
|
85
83
|
if (!isAltTextValid(state.altText)) return void setAltError('Alt text is required');
|
|
86
84
|
const preferred = getPreferredSize(state.position, pickedImage);
|
|
87
|
-
const persistedRelation = editorSettings.embedRelationsOnSave ? state.documentRelation : {
|
|
88
|
-
targetDocumentId: state.documentRelation.targetDocumentId,
|
|
89
|
-
targetCollectionId: state.documentRelation.targetCollectionId,
|
|
90
|
-
targetCollectionPath: state.documentRelation.targetCollectionPath
|
|
91
|
-
};
|
|
92
85
|
const data = {
|
|
93
|
-
documentRelation:
|
|
86
|
+
documentRelation: state.documentRelation,
|
|
94
87
|
src: preferred?.url ?? pickedImage.storageUrl ?? '',
|
|
95
88
|
altText: state.altText.trim(),
|
|
96
89
|
position: state.position,
|
|
@@ -3,7 +3,6 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useMemo, useState } from "react";
|
|
4
4
|
import { getClientConfig, getCollectionDefinition } from "@byline/core";
|
|
5
5
|
import { Button, Checkbox, CloseIcon, IconButton, Input, Label, Modal, RadioGroup, RadioGroupItem, RelationPicker, Select } from "@byline/ui/react";
|
|
6
|
-
import { useEditorConfig } from "../../config/editor-config-context.js";
|
|
7
6
|
import { useModalFormState } from "../../shared/useModalFormState.js";
|
|
8
7
|
import { validateUrl } from "../../utils/url.js";
|
|
9
8
|
function emptyState(linkable) {
|
|
@@ -44,7 +43,6 @@ const LinkModal = ({ isOpen = false, onSubmit, onClose, data: dataFromProps })=>
|
|
|
44
43
|
const linkable = useMemo(()=>getClientConfig().collections.filter((c)=>true === c.linksInEditor), []);
|
|
45
44
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
46
45
|
const [urlError, setUrlError] = useState(null);
|
|
47
|
-
const { config: editorSettings } = useEditorConfig();
|
|
48
46
|
const [state, setState] = useModalFormState(isOpen, ()=>fromLinkData(dataFromProps, linkable), ()=>setUrlError(null));
|
|
49
47
|
const targetDef = state.targetCollection ? getCollectionDefinition(state.targetCollection) : null;
|
|
50
48
|
const collectionItems = useMemo(()=>linkable.map((c)=>({
|
|
@@ -104,7 +102,7 @@ const LinkModal = ({ isOpen = false, onSubmit, onClose, data: dataFromProps })=>
|
|
|
104
102
|
targetDocumentId: picked.targetDocumentId,
|
|
105
103
|
targetCollectionId: picked.targetCollectionId,
|
|
106
104
|
targetCollectionPath: picked.targetCollectionPath,
|
|
107
|
-
document:
|
|
105
|
+
document: picked.document
|
|
108
106
|
};
|
|
109
107
|
onSubmit({
|
|
110
108
|
text: state.text.length > 0 ? state.text : null,
|
|
@@ -5,17 +5,5 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
/**
|
|
9
|
-
* Server-side populate visitor for the link plugin. Pure / framework-
|
|
10
|
-
* agnostic — imported only from the package's `server` entry.
|
|
11
|
-
*
|
|
12
|
-
* Refreshes `attributes.document` on `link` nodes whose
|
|
13
|
-
* `attributes.linkType` is `'internal'`. Tight projection — `{ title,
|
|
14
|
-
* path }` only, matching what the link modal embeds at picker time.
|
|
15
|
-
*
|
|
16
|
-
* `linkType: 'custom'` links carry a literal URL and have no relation
|
|
17
|
-
* envelope; they're skipped. Auto-link nodes (`type: 'autolink'`) are
|
|
18
|
-
* also skipped — they're derived from URL patterns and never internal.
|
|
19
|
-
*/
|
|
20
8
|
import type { LexicalNodeVisitor } from '../../lexical-populate-shared';
|
|
21
9
|
export declare const linkVisitor: LexicalNodeVisitor;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getCollectionDefinition, getLogger } from "@byline/core";
|
|
1
2
|
const linkVisitor = {
|
|
2
3
|
match (node) {
|
|
3
4
|
if ('link' !== node.type) return null;
|
|
@@ -12,14 +13,52 @@ const linkVisitor = {
|
|
|
12
13
|
collectionPath,
|
|
13
14
|
documentId,
|
|
14
15
|
apply (target) {
|
|
16
|
+
const definition = getCollectionDefinition(collectionPath);
|
|
17
|
+
const useAsTitle = definition?.useAsTitle ?? 'title';
|
|
15
18
|
const targetFields = target.fields ?? {};
|
|
16
|
-
const path = target.path;
|
|
17
|
-
const title = targetFields.title;
|
|
18
19
|
const next = {
|
|
19
20
|
...attributes.document ?? {}
|
|
20
21
|
};
|
|
22
|
+
const title = targetFields[useAsTitle];
|
|
21
23
|
if ('string' == typeof title && title.length > 0) next.title = title;
|
|
22
|
-
|
|
24
|
+
let pathThrew = false;
|
|
25
|
+
let built;
|
|
26
|
+
if (definition?.buildDocumentPath != null) try {
|
|
27
|
+
built = definition.buildDocumentPath({
|
|
28
|
+
id: target.id,
|
|
29
|
+
path: target.path,
|
|
30
|
+
status: target.status,
|
|
31
|
+
fields: targetFields
|
|
32
|
+
}, {
|
|
33
|
+
collectionPath
|
|
34
|
+
});
|
|
35
|
+
} catch (err) {
|
|
36
|
+
pathThrew = true;
|
|
37
|
+
getLogger().info({
|
|
38
|
+
collectionPath,
|
|
39
|
+
documentId,
|
|
40
|
+
err
|
|
41
|
+
}, 'buildDocumentPath threw');
|
|
42
|
+
}
|
|
43
|
+
if (!pathThrew) if ('string' == typeof built) next.path = built;
|
|
44
|
+
else {
|
|
45
|
+
const targetPath = target.path;
|
|
46
|
+
if ('string' == typeof targetPath && targetPath.length > 0) next.path = `/${collectionPath}/${targetPath}`;
|
|
47
|
+
}
|
|
48
|
+
if ('_resolved' in next) delete next._resolved;
|
|
49
|
+
attributes.document = next;
|
|
50
|
+
},
|
|
51
|
+
applyMissing () {
|
|
52
|
+
getLogger().warn({
|
|
53
|
+
collectionPath,
|
|
54
|
+
documentId
|
|
55
|
+
}, 'internal link target not found');
|
|
56
|
+
const next = {
|
|
57
|
+
...attributes.document ?? {}
|
|
58
|
+
};
|
|
59
|
+
delete next.title;
|
|
60
|
+
delete next.path;
|
|
61
|
+
next._resolved = false;
|
|
23
62
|
attributes.document = next;
|
|
24
63
|
}
|
|
25
64
|
};
|
|
@@ -9,13 +9,40 @@ export interface CustomLinkAttributes extends BaseLinkAttributes {
|
|
|
9
9
|
linkType?: 'custom';
|
|
10
10
|
url?: string;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Denormalised document fields carried on an internal-link node.
|
|
14
|
+
*
|
|
15
|
+
* - `title` — the target document's `useAsTitle` value. Refreshed by the
|
|
16
|
+
* server-side link walker (both embed-on-save and populate-on-read
|
|
17
|
+
* pipelines) whenever the target resolves.
|
|
18
|
+
* - `path` — the canonical renderable path. Has dual meaning during
|
|
19
|
+
* migration:
|
|
20
|
+
* * with a leading `/` — composed by `CollectionDefinition.buildDocumentPath`
|
|
21
|
+
* (or the generic `/${collectionPath}/${slug}` fallback) and
|
|
22
|
+
* considered authoritative by the renderer.
|
|
23
|
+
* * without a leading `/` — bare slug from `byline_document_paths`,
|
|
24
|
+
* either legacy data or a picker-time write that has not yet been
|
|
25
|
+
* through the walker. The renderer applies the generic compose
|
|
26
|
+
* fallback in that case.
|
|
27
|
+
* - `_resolved` — explicitly set to `false` by the walker when the most
|
|
28
|
+
* recent pass could not find the target document. Absent (i.e. the
|
|
29
|
+
* property is omitted) when the target resolved on the last pass.
|
|
30
|
+
* Renderers strip the `<a>` wrapper and render children as plain text
|
|
31
|
+
* when this is `false`, preserving editor intent without producing
|
|
32
|
+
* a broken anchor on the public site.
|
|
33
|
+
*/
|
|
34
|
+
export interface InternalLinkDocument {
|
|
35
|
+
title?: string;
|
|
36
|
+
path?: string;
|
|
37
|
+
_resolved?: false;
|
|
38
|
+
}
|
|
12
39
|
/**
|
|
13
40
|
* Internal link to a Byline document. The relation envelope (`targetDocumentId`,
|
|
14
41
|
* `targetCollectionId`, `targetCollectionPath`, `document`) is flattened
|
|
15
42
|
* directly onto the attributes alongside `linkType` — same shape pattern as
|
|
16
43
|
* the `RelationField` value, no extra wrapper.
|
|
17
44
|
*/
|
|
18
|
-
export interface InternalLinkAttributes extends BaseLinkAttributes, DocumentRelation {
|
|
45
|
+
export interface InternalLinkAttributes extends BaseLinkAttributes, DocumentRelation<InternalLinkDocument> {
|
|
19
46
|
linkType: 'internal';
|
|
20
47
|
}
|
|
21
48
|
export type LinkAttributes = CustomLinkAttributes | InternalLinkAttributes;
|
|
@@ -60,9 +60,18 @@ export interface PendingHydration {
|
|
|
60
60
|
* Apply the freshly-fetched target document to the node. Mutates `node`
|
|
61
61
|
* in place; `target.fields` is the shaped collection record. Receives
|
|
62
62
|
* the full target so the visitor can pick whatever projection it cares
|
|
63
|
-
* about.
|
|
63
|
+
* about. Invoked by the driver only when the target was found.
|
|
64
64
|
*/
|
|
65
65
|
apply: (target: Record<string, any>) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Optional handler invoked by the driver when the target document could
|
|
68
|
+
* not be fetched — i.e. it was deleted between the picker's write and
|
|
69
|
+
* this walk. Lets the visitor mark the node with degraded state
|
|
70
|
+
* (e.g. `document._resolved = false` on internal-link nodes) so the
|
|
71
|
+
* renderer can react. Omit on visitors that prefer the older "silent
|
|
72
|
+
* skip" behaviour; the driver no-ops when absent.
|
|
73
|
+
*/
|
|
74
|
+
applyMissing?: () => void;
|
|
66
75
|
}
|
|
67
76
|
/**
|
|
68
77
|
* Visitor contract — one per node type that participates in populate.
|
|
@@ -55,6 +55,7 @@ async function runLexicalPopulate(options) {
|
|
|
55
55
|
for (const p of pending){
|
|
56
56
|
const target = fetched.get(p.collectionPath)?.get(p.documentId);
|
|
57
57
|
if (target) p.apply(target);
|
|
58
|
+
else p.applyMissing?.();
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
export { getLexicalRoot, iterAllNodes, runLexicalPopulate };
|
|
@@ -11,22 +11,29 @@
|
|
|
11
11
|
* picks). Mirrors the relation-field envelope (`targetDocumentId` /
|
|
12
12
|
* `targetCollectionId`) and adds the human-readable collection `path` plus
|
|
13
13
|
* an optional `document` bag for denormalised fields populated by the picker
|
|
14
|
-
* at write time or by
|
|
14
|
+
* at write time or by the server-side richtext walker at read / save time.
|
|
15
15
|
*
|
|
16
16
|
* The two ID fields are the source of truth; `targetCollectionPath` is
|
|
17
17
|
* carried alongside because the editor has no field-definition side-channel
|
|
18
18
|
* (unlike a `relation` field) to look up the path from the id at render time.
|
|
19
19
|
* `document` is best-effort — renderers must tolerate it being absent.
|
|
20
|
+
*
|
|
21
|
+
* The `document` slot is parameterised by `D` so each node type can pin its
|
|
22
|
+
* own shape: internal-link nodes pin `{ title?, path?, _resolved?: false }`
|
|
23
|
+
* (see `InternalLinkAttributes`); inline-image nodes carry image-specific
|
|
24
|
+
* fields (`title`, `altText`, `image`, `sizes`). Defaults to a loose
|
|
25
|
+
* `Record<string, any>` so existing call sites continue to compile.
|
|
20
26
|
*/
|
|
21
|
-
export interface DocumentRelation {
|
|
27
|
+
export interface DocumentRelation<D extends Record<string, any> = Record<string, any>> {
|
|
22
28
|
targetDocumentId: string;
|
|
23
29
|
targetCollectionId: string;
|
|
24
30
|
targetCollectionPath: string;
|
|
25
31
|
/**
|
|
26
32
|
* Denormalised fields from the target document — typically `path`, `title`,
|
|
27
33
|
* and any other fields a renderer needs without a round-trip. Populated by
|
|
28
|
-
* the picker at write time and
|
|
29
|
-
*
|
|
34
|
+
* the picker at write time and refreshed by the server-side richtext
|
|
35
|
+
* walker (write-time embed and / or read-time populate). Treat as
|
|
36
|
+
* advisory; the ID fields are the source of truth.
|
|
30
37
|
*/
|
|
31
|
-
document?:
|
|
38
|
+
document?: D;
|
|
32
39
|
}
|
package/dist/richtext-field.js
CHANGED
|
@@ -17,13 +17,7 @@ const RichTextField = ({ field, value, defaultValue, editorConfig, readonly = fa
|
|
|
17
17
|
...resolved,
|
|
18
18
|
extensions: defaultExtensionsList()
|
|
19
19
|
};
|
|
20
|
-
const resolvedEditorConfig =
|
|
21
|
-
...baseEditorConfig,
|
|
22
|
-
settings: {
|
|
23
|
-
...baseEditorConfig.settings,
|
|
24
|
-
embedRelationsOnSave: field.embedRelationsOnSave
|
|
25
|
-
}
|
|
26
|
-
};
|
|
20
|
+
const resolvedEditorConfig = baseEditorConfig;
|
|
27
21
|
const labelNode = locale && field.label ? /*#__PURE__*/ jsxs("div", {
|
|
28
22
|
className: classnames('byline-field-richtext-label', richtext_field_module["label-row"]),
|
|
29
23
|
children: [
|
package/dist/server.d.ts
CHANGED
|
@@ -6,27 +6,37 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
|
-
* Server-side entry
|
|
10
|
-
* `ServerConfig.fields.richText.populate`. No React, no DOM,
|
|
11
|
-
* runtime — safe to import from server-only modules.
|
|
9
|
+
* Server-side entry points for the Lexical adapter — registered into
|
|
10
|
+
* `ServerConfig.fields.richText.{ populate, embed }`. No React, no DOM,
|
|
11
|
+
* no Lexical runtime — safe to import from server-only modules.
|
|
12
|
+
*
|
|
13
|
+
* Two factories, one shared visitor pipeline. The visitors themselves
|
|
14
|
+
* (link, inline-image) don't care whether they're firing on read or
|
|
15
|
+
* save; what differs is *when* the framework runs them.
|
|
12
16
|
*
|
|
13
17
|
* @example
|
|
14
18
|
* ```ts
|
|
15
19
|
* // apps/webapp/byline/server.config.ts
|
|
16
|
-
* import {
|
|
20
|
+
* import {
|
|
21
|
+
* lexicalEditorEmbedServer,
|
|
22
|
+
* lexicalEditorPopulateServer,
|
|
23
|
+
* } from '@byline/richtext-lexical/server'
|
|
17
24
|
* import { defineServerConfig } from '@byline/core'
|
|
18
25
|
* import { getAdminBylineClient } from '@/lib/byline-client'
|
|
19
26
|
*
|
|
20
27
|
* defineServerConfig({
|
|
21
28
|
* // …
|
|
22
29
|
* fields: {
|
|
23
|
-
* richText: {
|
|
30
|
+
* richText: {
|
|
31
|
+
* embed: lexicalEditorEmbedServer({ getClient: getAdminBylineClient }),
|
|
32
|
+
* populate: lexicalEditorPopulateServer({ getClient: getAdminBylineClient }),
|
|
33
|
+
* },
|
|
24
34
|
* },
|
|
25
35
|
* })
|
|
26
36
|
* ```
|
|
27
37
|
*/
|
|
28
38
|
import type { BylineClient } from '@byline/client';
|
|
29
|
-
import type { RichTextPopulateFn } from '@byline/core';
|
|
39
|
+
import type { RichTextEmbedFn, RichTextPopulateFn } from '@byline/core';
|
|
30
40
|
import { type LexicalNodeVisitor } from './field/lexical-populate-shared';
|
|
31
41
|
export { defaultEditorConfig } from './field/config/default';
|
|
32
42
|
export { inlineImageVisitor } from './field/extensions/inline-image/populate';
|
|
@@ -38,7 +48,7 @@ export interface LexicalServerOptions {
|
|
|
38
48
|
* Returns the server-side `BylineClient` used to batch-fetch target
|
|
39
49
|
* documents. Typically the host application's cached singleton (e.g.
|
|
40
50
|
* `getAdminBylineClient` in the webapp). Resolved lazily on every
|
|
41
|
-
*
|
|
51
|
+
* call so registration order doesn't matter.
|
|
42
52
|
*/
|
|
43
53
|
getClient: () => BylineClient;
|
|
44
54
|
/**
|
|
@@ -48,9 +58,9 @@ export interface LexicalServerOptions {
|
|
|
48
58
|
* built-ins, or temporarily disable a built-in:
|
|
49
59
|
*
|
|
50
60
|
* ```ts
|
|
51
|
-
*
|
|
61
|
+
* lexicalEditorPopulateServer({
|
|
52
62
|
* getClient,
|
|
53
|
-
* visitors: [inlineImageVisitor, linkVisitor,
|
|
63
|
+
* visitors: [inlineImageVisitor, linkVisitor, myCustomVisitor],
|
|
54
64
|
* })
|
|
55
65
|
* ```
|
|
56
66
|
*/
|
|
@@ -62,4 +72,18 @@ export interface LexicalServerOptions {
|
|
|
62
72
|
* invokes this function once per rich-text leaf it discovers in a
|
|
63
73
|
* document tree, gated by each leaf field's `populateRelationsOnRead`.
|
|
64
74
|
*/
|
|
65
|
-
export declare function
|
|
75
|
+
export declare function lexicalEditorPopulateServer(options: LexicalServerOptions): RichTextPopulateFn;
|
|
76
|
+
/**
|
|
77
|
+
* Build the registered `RichTextEmbedFn`. Mirror of
|
|
78
|
+
* `lexicalEditorPopulateServer` — same visitor pipeline, fires from the
|
|
79
|
+
* write path instead of the read path. The framework invokes this
|
|
80
|
+
* function once per rich-text leaf in the outgoing document data,
|
|
81
|
+
* gated by each leaf field's `embedRelationsOnSave` (default: `true`).
|
|
82
|
+
*
|
|
83
|
+
* The visitors mutate `ctx.value` in place (refreshing `document.path`,
|
|
84
|
+
* `document.title`, and `_resolved` on internal-link nodes; the inline-
|
|
85
|
+
* image bag on inline-image nodes). The lifecycle write path catches
|
|
86
|
+
* per-leaf errors and leaves the leaf untouched on hard failure (branch
|
|
87
|
+
* C of docs/RICHTEXT-LINK-REFACTOR-STRATEGY.md § 3.3).
|
|
88
|
+
*/
|
|
89
|
+
export declare function lexicalEditorEmbedServer(options: LexicalServerOptions): RichTextEmbedFn;
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { inlineImageVisitor } from "./field/extensions/inline-image/populate.js";
|
|
2
2
|
import { linkVisitor } from "./field/extensions/link/populate.js";
|
|
3
3
|
import { runLexicalPopulate } from "./field/lexical-populate-shared.js";
|
|
4
|
-
function
|
|
4
|
+
function lexicalEditorPopulateServer(options) {
|
|
5
|
+
const visitors = options.visitors ?? [
|
|
6
|
+
inlineImageVisitor,
|
|
7
|
+
linkVisitor
|
|
8
|
+
];
|
|
9
|
+
return async (ctx)=>{
|
|
10
|
+
await runLexicalPopulate({
|
|
11
|
+
client: options.getClient(),
|
|
12
|
+
readContext: ctx.readContext,
|
|
13
|
+
visitors,
|
|
14
|
+
values: [
|
|
15
|
+
ctx.value
|
|
16
|
+
]
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function lexicalEditorEmbedServer(options) {
|
|
5
21
|
const visitors = options.visitors ?? [
|
|
6
22
|
inlineImageVisitor,
|
|
7
23
|
linkVisitor
|
|
@@ -18,4 +34,4 @@ function lexicalEditorServer(options) {
|
|
|
18
34
|
};
|
|
19
35
|
}
|
|
20
36
|
export { defaultEditorConfig } from "./field/config/default.js";
|
|
21
|
-
export { inlineImageVisitor,
|
|
37
|
+
export { inlineImageVisitor, lexicalEditorEmbedServer, lexicalEditorPopulateServer, linkVisitor };
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.5.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -72,9 +72,9 @@
|
|
|
72
72
|
"npm-run-all": "^4.1.5",
|
|
73
73
|
"prism-react-renderer": "^2.4.1",
|
|
74
74
|
"react-error-boundary": "^6.1.1",
|
|
75
|
-
"@byline/
|
|
76
|
-
"@byline/
|
|
77
|
-
"@byline/
|
|
75
|
+
"@byline/client": "2.5.0",
|
|
76
|
+
"@byline/core": "2.5.0",
|
|
77
|
+
"@byline/ui": "2.5.0"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
80
|
"react": "^19.0.0",
|
|
@@ -25,7 +25,6 @@ import { FloatingTextFormatExtension } from '../extensions/floating-text-format/
|
|
|
25
25
|
import { HorizontalRuleExtension } from '../extensions/horizontal-rule/horizontal-rule-extension'
|
|
26
26
|
import { InlineImageExtension } from '../extensions/inline-image/inline-image-extension'
|
|
27
27
|
import { LayoutExtension } from '../extensions/layout/layout-extension'
|
|
28
|
-
import { AutoLinkExtension } from '../extensions/link/auto-link-extension'
|
|
29
28
|
import { LinkExtension } from '../extensions/link/link-extension'
|
|
30
29
|
import { TableExtension } from '../extensions/table/table-extension'
|
|
31
30
|
import { VimeoExtension } from '../extensions/vimeo/vimeo-extension'
|
|
@@ -73,7 +72,6 @@ export function defaultExtensionsArray(): AnyLexicalExtensionArgument[] {
|
|
|
73
72
|
|
|
74
73
|
// Link & link-related.
|
|
75
74
|
LinkExtension,
|
|
76
|
-
AutoLinkExtension,
|
|
77
75
|
|
|
78
76
|
// Embeds & inline images.
|
|
79
77
|
InlineImageExtension,
|
|
@@ -29,23 +29,12 @@ export interface EditorSettings {
|
|
|
29
29
|
*/
|
|
30
30
|
inlineImageUploadCollection: string
|
|
31
31
|
placeholderText: string
|
|
32
|
-
/**
|
|
33
|
-
* Whether relation-bearing nodes (link, inline-image) embed the picker's
|
|
34
|
-
* resolved target fields (`title`, `path`, `altText`, `image`, `sizes`)
|
|
35
|
-
* into the persisted Lexical JSON at modal-save time. Defaults to `true`.
|
|
36
|
-
*
|
|
37
|
-
* Mirrors `RichTextField.embedRelationsOnSave`. The lexical wrapper
|
|
38
|
-
* (`richtext-field.tsx`) merges the field-level value over the resolved
|
|
39
|
-
* editor settings so each field gets the policy it asked for.
|
|
40
|
-
*/
|
|
41
|
-
embedRelationsOnSave: boolean
|
|
42
32
|
}
|
|
43
33
|
|
|
44
34
|
export interface EditorSettingsOverride {
|
|
45
35
|
options?: Partial<Record<OptionName, boolean>>
|
|
46
36
|
inlineImageUploadCollection?: string
|
|
47
37
|
placeholderText?: string
|
|
48
|
-
embedRelationsOnSave?: boolean
|
|
49
38
|
}
|
|
50
39
|
|
|
51
40
|
export interface EditorConfig {
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
RelationPicker,
|
|
28
28
|
} from '@byline/ui/react'
|
|
29
29
|
|
|
30
|
-
import { useEditorConfig } from '../../config/editor-config-context'
|
|
31
30
|
import { useModalFormState } from '../../shared/useModalFormState'
|
|
32
31
|
import { isAltTextValid, positionOptions } from './fields'
|
|
33
32
|
import { deriveImageSizes, getPreferredSize } from './utils'
|
|
@@ -71,7 +70,6 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
|
|
|
71
70
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
72
71
|
const [altError, setAltError] = useState<string | null>(null)
|
|
73
72
|
const [imageError, setImageError] = useState<string | null>(null)
|
|
74
|
-
const { config: editorSettings } = useEditorConfig()
|
|
75
73
|
|
|
76
74
|
const [state, setState] = useModalFormState<FormState>(
|
|
77
75
|
isOpen,
|
|
@@ -150,21 +148,13 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
|
|
|
150
148
|
}
|
|
151
149
|
|
|
152
150
|
const preferred = getPreferredSize(state.position, pickedImage)
|
|
153
|
-
//
|
|
154
|
-
// image, sizes }` embedded at picker-select time
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// Lexical needs them to render the inline image in the admin editor.
|
|
159
|
-
const persistedRelation = editorSettings.embedRelationsOnSave
|
|
160
|
-
? state.documentRelation
|
|
161
|
-
: {
|
|
162
|
-
targetDocumentId: state.documentRelation.targetDocumentId,
|
|
163
|
-
targetCollectionId: state.documentRelation.targetCollectionId,
|
|
164
|
-
targetCollectionPath: state.documentRelation.targetCollectionPath,
|
|
165
|
-
}
|
|
151
|
+
// Always persist the full `documentRelation` envelope (`{ title,
|
|
152
|
+
// altText, image, sizes }` embedded at picker-select time). The
|
|
153
|
+
// server-side write-time walker refreshes this on save; the
|
|
154
|
+
// editor reads it back to render the inline image without a
|
|
155
|
+
// round-trip.
|
|
166
156
|
const data: InlineImageData = {
|
|
167
|
-
documentRelation:
|
|
157
|
+
documentRelation: state.documentRelation,
|
|
168
158
|
src: preferred?.url ?? pickedImage.storageUrl ?? '',
|
|
169
159
|
altText: state.altText.trim(),
|
|
170
160
|
position: state.position,
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
type SelectValue,
|
|
29
29
|
} from '@byline/ui/react'
|
|
30
30
|
|
|
31
|
-
import { useEditorConfig } from '../../config/editor-config-context'
|
|
32
31
|
import { useModalFormState } from '../../shared/useModalFormState'
|
|
33
32
|
import { validateUrl } from '../../utils/url'
|
|
34
33
|
import type { DocumentRelation } from '../../nodes/document-relation'
|
|
@@ -109,7 +108,6 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|
|
109
108
|
|
|
110
109
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
111
110
|
const [urlError, setUrlError] = useState<string | null>(null)
|
|
112
|
-
const { config: editorSettings } = useEditorConfig()
|
|
113
111
|
|
|
114
112
|
const [state, setState] = useModalFormState<FormState>(
|
|
115
113
|
isOpen,
|
|
@@ -183,11 +181,11 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|
|
183
181
|
}
|
|
184
182
|
|
|
185
183
|
const picked = state.picked as DocumentRelation
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// on
|
|
184
|
+
// Always embed the picker's `{ title, path }` envelope on save —
|
|
185
|
+
// the in-editor display reads it back to render a label without a
|
|
186
|
+
// round-trip, and the server-side write-time walker
|
|
187
|
+
// (`embedRichTextFields` in `@byline/core` + `lexicalEditorEmbedServer`)
|
|
188
|
+
// refreshes / canonicalises it on persistence.
|
|
191
189
|
const fields: LinkAttributes =
|
|
192
190
|
state.linkType === 'custom'
|
|
193
191
|
? {
|
|
@@ -201,7 +199,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|
|
201
199
|
targetDocumentId: picked.targetDocumentId,
|
|
202
200
|
targetCollectionId: picked.targetCollectionId,
|
|
203
201
|
targetCollectionPath: picked.targetCollectionPath,
|
|
204
|
-
document:
|
|
202
|
+
document: picked.document,
|
|
205
203
|
}
|
|
206
204
|
|
|
207
205
|
onSubmit({
|
|
@@ -7,18 +7,44 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Server-side
|
|
11
|
-
*
|
|
10
|
+
* Server-side visitor for the link plugin. Pure / framework-agnostic —
|
|
11
|
+
* imported from the package's `server` entry by both the read-time
|
|
12
|
+
* populate adapter and the write-time embed adapter. The two modes share
|
|
13
|
+
* the same visitor; only the trigger point differs.
|
|
12
14
|
*
|
|
13
15
|
* Refreshes `attributes.document` on `link` nodes whose
|
|
14
|
-
* `attributes.linkType` is `'internal'`.
|
|
15
|
-
*
|
|
16
|
+
* `attributes.linkType` is `'internal'`. Three branches:
|
|
17
|
+
*
|
|
18
|
+
* - **Found** — sets `document.title` to the target's `useAsTitle`
|
|
19
|
+
* field value (falling back to `title` when `useAsTitle` is not
|
|
20
|
+
* defined), composes `document.path` via the collection's
|
|
21
|
+
* `buildDocumentPath` hook (with `/${collectionPath}/${target.path}`
|
|
22
|
+
* as the generic fallback when the hook is absent or returns
|
|
23
|
+
* `null`), and clears any prior `document._resolved` flag.
|
|
24
|
+
*
|
|
25
|
+
* - **Hook threw** (branch A) — logs at `info` level and leaves
|
|
26
|
+
* `document.path` and `document._resolved` untouched. The picker-
|
|
27
|
+
* time embedded value (if any) stays in place; the renderer's
|
|
28
|
+
* fallback chain copes.
|
|
29
|
+
*
|
|
30
|
+
* - **Target not found** (branch B) — logs at `warn` level, deletes
|
|
31
|
+
* `document.title` and `document.path`, and sets
|
|
32
|
+
* `document._resolved = false` so the renderer strips the `<a>`
|
|
33
|
+
* wrapper and renders the link's children as plain text. Persisted
|
|
34
|
+
* state remains a complete record — re-linking is possible whenever
|
|
35
|
+
* the editor returns.
|
|
36
|
+
*
|
|
37
|
+
* Hard errors (DB unreachable, transport-level failures) propagate to
|
|
38
|
+
* the caller — `document-lifecycle` / the read pipeline — which catch
|
|
39
|
+
* per-field and leave the persisted state untouched (branch C).
|
|
16
40
|
*
|
|
17
41
|
* `linkType: 'custom'` links carry a literal URL and have no relation
|
|
18
42
|
* envelope; they're skipped. Auto-link nodes (`type: 'autolink'`) are
|
|
19
43
|
* also skipped — they're derived from URL patterns and never internal.
|
|
20
44
|
*/
|
|
21
45
|
|
|
46
|
+
import { getCollectionDefinition, getLogger } from '@byline/core'
|
|
47
|
+
|
|
22
48
|
import type { LexicalNodeLike, LexicalNodeVisitor } from '../../lexical-populate-shared'
|
|
23
49
|
|
|
24
50
|
export const linkVisitor: LexicalNodeVisitor = {
|
|
@@ -30,17 +56,75 @@ export const linkVisitor: LexicalNodeVisitor = {
|
|
|
30
56
|
const collectionPath = attributes.targetCollectionPath as string | undefined
|
|
31
57
|
const documentId = attributes.targetDocumentId as string | undefined
|
|
32
58
|
if (!collectionPath || !documentId) return null
|
|
59
|
+
|
|
33
60
|
return {
|
|
34
61
|
node,
|
|
35
62
|
collectionPath,
|
|
36
63
|
documentId,
|
|
37
64
|
apply(target: Record<string, any>) {
|
|
65
|
+
const definition = getCollectionDefinition(collectionPath)
|
|
66
|
+
const useAsTitle = definition?.useAsTitle ?? 'title'
|
|
38
67
|
const targetFields = (target.fields ?? {}) as Record<string, any>
|
|
39
|
-
const path = target.path as string | undefined
|
|
40
|
-
const title = targetFields.title as string | undefined
|
|
41
68
|
const next: Record<string, any> = { ...(attributes.document ?? {}) }
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
|
|
70
|
+
// Title — `useAsTitle` lookup with `title` fallback.
|
|
71
|
+
const title = targetFields[useAsTitle]
|
|
72
|
+
if (typeof title === 'string' && title.length > 0) {
|
|
73
|
+
next.title = title
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Path — buildDocumentPath, then generic compose fallback.
|
|
77
|
+
// Branch A: hook threw — leave any existing `document.path`
|
|
78
|
+
// untouched and surface a log line so operators can find the
|
|
79
|
+
// bug without it taking the save / read down with it.
|
|
80
|
+
let pathThrew = false
|
|
81
|
+
let built: string | null | undefined
|
|
82
|
+
if (definition?.buildDocumentPath != null) {
|
|
83
|
+
try {
|
|
84
|
+
built = definition.buildDocumentPath(
|
|
85
|
+
{
|
|
86
|
+
id: target.id as string,
|
|
87
|
+
path: target.path as string,
|
|
88
|
+
status: target.status as string,
|
|
89
|
+
fields: targetFields,
|
|
90
|
+
},
|
|
91
|
+
{ collectionPath }
|
|
92
|
+
)
|
|
93
|
+
} catch (err) {
|
|
94
|
+
pathThrew = true
|
|
95
|
+
getLogger().info({ collectionPath, documentId, err }, 'buildDocumentPath threw')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!pathThrew) {
|
|
100
|
+
if (typeof built === 'string') {
|
|
101
|
+
next.path = built
|
|
102
|
+
} else {
|
|
103
|
+
// Generic compose fallback. Only fires when the target has a
|
|
104
|
+
// non-empty `path` — otherwise we'd produce `/${collectionPath}/`
|
|
105
|
+
// or `/${collectionPath}/undefined`, both of which are worse
|
|
106
|
+
// than leaving the previous value alone.
|
|
107
|
+
const targetPath = target.path as string | undefined
|
|
108
|
+
if (typeof targetPath === 'string' && targetPath.length > 0) {
|
|
109
|
+
next.path = `/${collectionPath}/${targetPath}`
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Found-and-resolved: clear any stale miss flag from a prior pass.
|
|
115
|
+
if ('_resolved' in next) {
|
|
116
|
+
delete next._resolved
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
attributes.document = next
|
|
120
|
+
},
|
|
121
|
+
applyMissing() {
|
|
122
|
+
// Branch B — target deleted between picker and walker.
|
|
123
|
+
getLogger().warn({ collectionPath, documentId }, 'internal link target not found')
|
|
124
|
+
const next: Record<string, any> = { ...(attributes.document ?? {}) }
|
|
125
|
+
delete next.title
|
|
126
|
+
delete next.path
|
|
127
|
+
next._resolved = false
|
|
44
128
|
attributes.document = next
|
|
45
129
|
},
|
|
46
130
|
}
|
|
@@ -13,13 +13,43 @@ export interface CustomLinkAttributes extends BaseLinkAttributes {
|
|
|
13
13
|
url?: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Denormalised document fields carried on an internal-link node.
|
|
18
|
+
*
|
|
19
|
+
* - `title` — the target document's `useAsTitle` value. Refreshed by the
|
|
20
|
+
* server-side link walker (both embed-on-save and populate-on-read
|
|
21
|
+
* pipelines) whenever the target resolves.
|
|
22
|
+
* - `path` — the canonical renderable path. Has dual meaning during
|
|
23
|
+
* migration:
|
|
24
|
+
* * with a leading `/` — composed by `CollectionDefinition.buildDocumentPath`
|
|
25
|
+
* (or the generic `/${collectionPath}/${slug}` fallback) and
|
|
26
|
+
* considered authoritative by the renderer.
|
|
27
|
+
* * without a leading `/` — bare slug from `byline_document_paths`,
|
|
28
|
+
* either legacy data or a picker-time write that has not yet been
|
|
29
|
+
* through the walker. The renderer applies the generic compose
|
|
30
|
+
* fallback in that case.
|
|
31
|
+
* - `_resolved` — explicitly set to `false` by the walker when the most
|
|
32
|
+
* recent pass could not find the target document. Absent (i.e. the
|
|
33
|
+
* property is omitted) when the target resolved on the last pass.
|
|
34
|
+
* Renderers strip the `<a>` wrapper and render children as plain text
|
|
35
|
+
* when this is `false`, preserving editor intent without producing
|
|
36
|
+
* a broken anchor on the public site.
|
|
37
|
+
*/
|
|
38
|
+
export interface InternalLinkDocument {
|
|
39
|
+
title?: string
|
|
40
|
+
path?: string
|
|
41
|
+
_resolved?: false
|
|
42
|
+
}
|
|
43
|
+
|
|
16
44
|
/**
|
|
17
45
|
* Internal link to a Byline document. The relation envelope (`targetDocumentId`,
|
|
18
46
|
* `targetCollectionId`, `targetCollectionPath`, `document`) is flattened
|
|
19
47
|
* directly onto the attributes alongside `linkType` — same shape pattern as
|
|
20
48
|
* the `RelationField` value, no extra wrapper.
|
|
21
49
|
*/
|
|
22
|
-
export interface InternalLinkAttributes
|
|
50
|
+
export interface InternalLinkAttributes
|
|
51
|
+
extends BaseLinkAttributes,
|
|
52
|
+
DocumentRelation<InternalLinkDocument> {
|
|
23
53
|
linkType: 'internal'
|
|
24
54
|
}
|
|
25
55
|
|
|
@@ -100,9 +100,18 @@ export interface PendingHydration {
|
|
|
100
100
|
* Apply the freshly-fetched target document to the node. Mutates `node`
|
|
101
101
|
* in place; `target.fields` is the shaped collection record. Receives
|
|
102
102
|
* the full target so the visitor can pick whatever projection it cares
|
|
103
|
-
* about.
|
|
103
|
+
* about. Invoked by the driver only when the target was found.
|
|
104
104
|
*/
|
|
105
105
|
apply: (target: Record<string, any>) => void
|
|
106
|
+
/**
|
|
107
|
+
* Optional handler invoked by the driver when the target document could
|
|
108
|
+
* not be fetched — i.e. it was deleted between the picker's write and
|
|
109
|
+
* this walk. Lets the visitor mark the node with degraded state
|
|
110
|
+
* (e.g. `document._resolved = false` on internal-link nodes) so the
|
|
111
|
+
* renderer can react. Omit on visitors that prefer the older "silent
|
|
112
|
+
* skip" behaviour; the driver no-ops when absent.
|
|
113
|
+
*/
|
|
114
|
+
applyMissing?: () => void
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
/**
|
|
@@ -182,7 +191,10 @@ export async function runLexicalPopulate(options: RunPopulateOptions): Promise<v
|
|
|
182
191
|
|
|
183
192
|
for (const p of pending) {
|
|
184
193
|
const target = fetched.get(p.collectionPath)?.get(p.documentId)
|
|
185
|
-
if (
|
|
186
|
-
|
|
194
|
+
if (target) {
|
|
195
|
+
p.apply(target)
|
|
196
|
+
} else {
|
|
197
|
+
p.applyMissing?.()
|
|
198
|
+
}
|
|
187
199
|
}
|
|
188
200
|
}
|
|
@@ -12,22 +12,29 @@
|
|
|
12
12
|
* picks). Mirrors the relation-field envelope (`targetDocumentId` /
|
|
13
13
|
* `targetCollectionId`) and adds the human-readable collection `path` plus
|
|
14
14
|
* an optional `document` bag for denormalised fields populated by the picker
|
|
15
|
-
* at write time or by
|
|
15
|
+
* at write time or by the server-side richtext walker at read / save time.
|
|
16
16
|
*
|
|
17
17
|
* The two ID fields are the source of truth; `targetCollectionPath` is
|
|
18
18
|
* carried alongside because the editor has no field-definition side-channel
|
|
19
19
|
* (unlike a `relation` field) to look up the path from the id at render time.
|
|
20
20
|
* `document` is best-effort — renderers must tolerate it being absent.
|
|
21
|
+
*
|
|
22
|
+
* The `document` slot is parameterised by `D` so each node type can pin its
|
|
23
|
+
* own shape: internal-link nodes pin `{ title?, path?, _resolved?: false }`
|
|
24
|
+
* (see `InternalLinkAttributes`); inline-image nodes carry image-specific
|
|
25
|
+
* fields (`title`, `altText`, `image`, `sizes`). Defaults to a loose
|
|
26
|
+
* `Record<string, any>` so existing call sites continue to compile.
|
|
21
27
|
*/
|
|
22
|
-
export interface DocumentRelation {
|
|
28
|
+
export interface DocumentRelation<D extends Record<string, any> = Record<string, any>> {
|
|
23
29
|
targetDocumentId: string
|
|
24
30
|
targetCollectionId: string
|
|
25
31
|
targetCollectionPath: string
|
|
26
32
|
/**
|
|
27
33
|
* Denormalised fields from the target document — typically `path`, `title`,
|
|
28
34
|
* and any other fields a renderer needs without a round-trip. Populated by
|
|
29
|
-
* the picker at write time and
|
|
30
|
-
*
|
|
35
|
+
* the picker at write time and refreshed by the server-side richtext
|
|
36
|
+
* walker (write-time embed and / or read-time populate). Treat as
|
|
37
|
+
* advisory; the ID fields are the source of truth.
|
|
31
38
|
*/
|
|
32
|
-
document?:
|
|
39
|
+
document?: D
|
|
33
40
|
}
|
package/src/richtext-field.tsx
CHANGED
|
@@ -84,18 +84,12 @@ export const RichTextField = ({
|
|
|
84
84
|
const baseEditorConfig: EditorConfig =
|
|
85
85
|
resolved.extensions != null ? resolved : { ...resolved, extensions: defaultExtensionsList() }
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
...baseEditorConfig,
|
|
94
|
-
settings: {
|
|
95
|
-
...baseEditorConfig.settings,
|
|
96
|
-
embedRelationsOnSave: field.embedRelationsOnSave,
|
|
97
|
-
},
|
|
98
|
-
}
|
|
87
|
+
// `field.embedRelationsOnSave` is a server-side flag — read by the
|
|
88
|
+
// document-lifecycle write path's richtext embed walker. The client
|
|
89
|
+
// editor no longer reads it (the modals always embed picker-time
|
|
90
|
+
// envelopes; the server walker refreshes them on save), so nothing
|
|
91
|
+
// here needs to propagate the field-level value into `EditorSettings`.
|
|
92
|
+
const resolvedEditorConfig: EditorConfig = baseEditorConfig
|
|
99
93
|
|
|
100
94
|
// Assemble the label node here (a Byline-level concern) so that the editor
|
|
101
95
|
// component itself stays free of any Byline-specific dependencies.
|
package/src/server.ts
CHANGED
|
@@ -7,28 +7,43 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Server-side entry
|
|
11
|
-
* `ServerConfig.fields.richText.populate`. No React, no DOM,
|
|
12
|
-
* runtime — safe to import from server-only modules.
|
|
10
|
+
* Server-side entry points for the Lexical adapter — registered into
|
|
11
|
+
* `ServerConfig.fields.richText.{ populate, embed }`. No React, no DOM,
|
|
12
|
+
* no Lexical runtime — safe to import from server-only modules.
|
|
13
|
+
*
|
|
14
|
+
* Two factories, one shared visitor pipeline. The visitors themselves
|
|
15
|
+
* (link, inline-image) don't care whether they're firing on read or
|
|
16
|
+
* save; what differs is *when* the framework runs them.
|
|
13
17
|
*
|
|
14
18
|
* @example
|
|
15
19
|
* ```ts
|
|
16
20
|
* // apps/webapp/byline/server.config.ts
|
|
17
|
-
* import {
|
|
21
|
+
* import {
|
|
22
|
+
* lexicalEditorEmbedServer,
|
|
23
|
+
* lexicalEditorPopulateServer,
|
|
24
|
+
* } from '@byline/richtext-lexical/server'
|
|
18
25
|
* import { defineServerConfig } from '@byline/core'
|
|
19
26
|
* import { getAdminBylineClient } from '@/lib/byline-client'
|
|
20
27
|
*
|
|
21
28
|
* defineServerConfig({
|
|
22
29
|
* // …
|
|
23
30
|
* fields: {
|
|
24
|
-
* richText: {
|
|
31
|
+
* richText: {
|
|
32
|
+
* embed: lexicalEditorEmbedServer({ getClient: getAdminBylineClient }),
|
|
33
|
+
* populate: lexicalEditorPopulateServer({ getClient: getAdminBylineClient }),
|
|
34
|
+
* },
|
|
25
35
|
* },
|
|
26
36
|
* })
|
|
27
37
|
* ```
|
|
28
38
|
*/
|
|
29
39
|
|
|
30
40
|
import type { BylineClient } from '@byline/client'
|
|
31
|
-
import type {
|
|
41
|
+
import type {
|
|
42
|
+
RichTextEmbedContext,
|
|
43
|
+
RichTextEmbedFn,
|
|
44
|
+
RichTextPopulateContext,
|
|
45
|
+
RichTextPopulateFn,
|
|
46
|
+
} from '@byline/core'
|
|
32
47
|
|
|
33
48
|
import { inlineImageVisitor } from './field/extensions/inline-image/populate'
|
|
34
49
|
import { linkVisitor } from './field/extensions/link/populate'
|
|
@@ -55,7 +70,7 @@ export interface LexicalServerOptions {
|
|
|
55
70
|
* Returns the server-side `BylineClient` used to batch-fetch target
|
|
56
71
|
* documents. Typically the host application's cached singleton (e.g.
|
|
57
72
|
* `getAdminBylineClient` in the webapp). Resolved lazily on every
|
|
58
|
-
*
|
|
73
|
+
* call so registration order doesn't matter.
|
|
59
74
|
*/
|
|
60
75
|
getClient: () => BylineClient
|
|
61
76
|
/**
|
|
@@ -65,9 +80,9 @@ export interface LexicalServerOptions {
|
|
|
65
80
|
* built-ins, or temporarily disable a built-in:
|
|
66
81
|
*
|
|
67
82
|
* ```ts
|
|
68
|
-
*
|
|
83
|
+
* lexicalEditorPopulateServer({
|
|
69
84
|
* getClient,
|
|
70
|
-
* visitors: [inlineImageVisitor, linkVisitor,
|
|
85
|
+
* visitors: [inlineImageVisitor, linkVisitor, myCustomVisitor],
|
|
71
86
|
* })
|
|
72
87
|
* ```
|
|
73
88
|
*/
|
|
@@ -80,7 +95,7 @@ export interface LexicalServerOptions {
|
|
|
80
95
|
* invokes this function once per rich-text leaf it discovers in a
|
|
81
96
|
* document tree, gated by each leaf field's `populateRelationsOnRead`.
|
|
82
97
|
*/
|
|
83
|
-
export function
|
|
98
|
+
export function lexicalEditorPopulateServer(options: LexicalServerOptions): RichTextPopulateFn {
|
|
84
99
|
const visitors = options.visitors ?? [inlineImageVisitor, linkVisitor]
|
|
85
100
|
return async (ctx: RichTextPopulateContext): Promise<void> => {
|
|
86
101
|
await runLexicalPopulate({
|
|
@@ -91,3 +106,28 @@ export function lexicalEditorServer(options: LexicalServerOptions): RichTextPopu
|
|
|
91
106
|
})
|
|
92
107
|
}
|
|
93
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build the registered `RichTextEmbedFn`. Mirror of
|
|
112
|
+
* `lexicalEditorPopulateServer` — same visitor pipeline, fires from the
|
|
113
|
+
* write path instead of the read path. The framework invokes this
|
|
114
|
+
* function once per rich-text leaf in the outgoing document data,
|
|
115
|
+
* gated by each leaf field's `embedRelationsOnSave` (default: `true`).
|
|
116
|
+
*
|
|
117
|
+
* The visitors mutate `ctx.value` in place (refreshing `document.path`,
|
|
118
|
+
* `document.title`, and `_resolved` on internal-link nodes; the inline-
|
|
119
|
+
* image bag on inline-image nodes). The lifecycle write path catches
|
|
120
|
+
* per-leaf errors and leaves the leaf untouched on hard failure (branch
|
|
121
|
+
* C of docs/RICHTEXT-LINK-REFACTOR-STRATEGY.md § 3.3).
|
|
122
|
+
*/
|
|
123
|
+
export function lexicalEditorEmbedServer(options: LexicalServerOptions): RichTextEmbedFn {
|
|
124
|
+
const visitors = options.visitors ?? [inlineImageVisitor, linkVisitor]
|
|
125
|
+
return async (ctx: RichTextEmbedContext): Promise<void> => {
|
|
126
|
+
await runLexicalPopulate({
|
|
127
|
+
client: options.getClient(),
|
|
128
|
+
readContext: ctx.readContext,
|
|
129
|
+
visitors,
|
|
130
|
+
values: [ctx.value],
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|