@byline/richtext-lexical 2.4.4 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +19 -4
- package/dist/field/lexical-populate-shared.js +13 -10
- 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.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 +47 -13
- package/src/field/nodes/document-relation.ts +12 -5
- package/src/richtext-field.tsx +6 -12
- package/src/server.ts +50 -10
|
@@ -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.
|
|
@@ -88,9 +97,15 @@ interface RunPopulateOptions {
|
|
|
88
97
|
/**
|
|
89
98
|
* Walk every supplied rich-text value, collect the union of pending
|
|
90
99
|
* hydrations across all visitors, batch-fetch by source collection, and
|
|
91
|
-
* apply.
|
|
92
|
-
*
|
|
93
|
-
* `
|
|
100
|
+
* apply.
|
|
101
|
+
*
|
|
102
|
+
* `readContext` is accepted (factories thread it through from
|
|
103
|
+
* `RichTextPopulateContext` / `RichTextEmbedContext`) but currently unused —
|
|
104
|
+
* the batch fetch goes straight to `getDocumentsByDocumentIds` and doesn't
|
|
105
|
+
* recurse into populate or `afterRead`, so there's no visited-set or
|
|
106
|
+
* read-budget state to share. Retained on the options shape so a future
|
|
107
|
+
* visitor that performs nested populate can opt back in without another
|
|
108
|
+
* contract churn.
|
|
94
109
|
*/
|
|
95
110
|
export declare function runLexicalPopulate(options: RunPopulateOptions): Promise<void>;
|
|
96
111
|
export {};
|
|
@@ -17,7 +17,7 @@ function* iterAllNodes(node) {
|
|
|
17
17
|
if (Array.isArray(node.children)) for (const child of node.children)yield* iterAllNodes(child);
|
|
18
18
|
}
|
|
19
19
|
async function runLexicalPopulate(options) {
|
|
20
|
-
const { client,
|
|
20
|
+
const { client, visitors, values } = options;
|
|
21
21
|
const pending = [];
|
|
22
22
|
for (const value of values){
|
|
23
23
|
const root = getLexicalRoot(value);
|
|
@@ -39,22 +39,25 @@ async function runLexicalPopulate(options) {
|
|
|
39
39
|
const fetched = new Map();
|
|
40
40
|
await Promise.all(Array.from(idsByCollection.entries()).map(async ([collectionPath, idSet])=>{
|
|
41
41
|
const ids = Array.from(idSet);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
},
|
|
48
|
-
pageSize: ids.length,
|
|
49
|
-
_readContext: readContext
|
|
42
|
+
const collectionId = await client.resolveCollectionId(collectionPath);
|
|
43
|
+
const rawDocs = await client.db.queries.documents.getDocumentsByDocumentIds({
|
|
44
|
+
collection_id: collectionId,
|
|
45
|
+
document_ids: ids,
|
|
46
|
+
readMode: 'published'
|
|
50
47
|
});
|
|
51
48
|
const byId = new Map();
|
|
52
|
-
for (const
|
|
49
|
+
for (const raw of rawDocs)if ('string' == typeof raw.document_id) byId.set(raw.document_id, {
|
|
50
|
+
id: raw.document_id,
|
|
51
|
+
path: raw.path,
|
|
52
|
+
status: raw.status,
|
|
53
|
+
fields: raw.fields
|
|
54
|
+
});
|
|
53
55
|
fetched.set(collectionPath, byId);
|
|
54
56
|
}));
|
|
55
57
|
for (const p of pending){
|
|
56
58
|
const target = fetched.get(p.collectionPath)?.get(p.documentId);
|
|
57
59
|
if (target) p.apply(target);
|
|
60
|
+
else p.applyMissing?.();
|
|
58
61
|
}
|
|
59
62
|
}
|
|
60
63
|
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.1",
|
|
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/ui": "2.
|
|
77
|
-
"@byline/
|
|
75
|
+
"@byline/client": "2.5.1",
|
|
76
|
+
"@byline/ui": "2.5.1",
|
|
77
|
+
"@byline/core": "2.5.1"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
80
|
"react": "^19.0.0",
|
|
@@ -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
|
/**
|
|
@@ -131,12 +140,18 @@ interface RunPopulateOptions {
|
|
|
131
140
|
/**
|
|
132
141
|
* Walk every supplied rich-text value, collect the union of pending
|
|
133
142
|
* hydrations across all visitors, batch-fetch by source collection, and
|
|
134
|
-
* apply.
|
|
135
|
-
*
|
|
136
|
-
* `
|
|
143
|
+
* apply.
|
|
144
|
+
*
|
|
145
|
+
* `readContext` is accepted (factories thread it through from
|
|
146
|
+
* `RichTextPopulateContext` / `RichTextEmbedContext`) but currently unused —
|
|
147
|
+
* the batch fetch goes straight to `getDocumentsByDocumentIds` and doesn't
|
|
148
|
+
* recurse into populate or `afterRead`, so there's no visited-set or
|
|
149
|
+
* read-budget state to share. Retained on the options shape so a future
|
|
150
|
+
* visitor that performs nested populate can opt back in without another
|
|
151
|
+
* contract churn.
|
|
137
152
|
*/
|
|
138
153
|
export async function runLexicalPopulate(options: RunPopulateOptions): Promise<void> {
|
|
139
|
-
const { client,
|
|
154
|
+
const { client, visitors, values } = options
|
|
140
155
|
|
|
141
156
|
const pending: PendingHydration[] = []
|
|
142
157
|
for (const value of values) {
|
|
@@ -163,18 +178,34 @@ export async function runLexicalPopulate(options: RunPopulateOptions): Promise<v
|
|
|
163
178
|
bucket.add(p.documentId)
|
|
164
179
|
}
|
|
165
180
|
|
|
181
|
+
// Fetch directly through the adapter rather than the client's `find()` —
|
|
182
|
+
// `parseWhere` has no handler for `id`, so `find({ where: { id: { $in } } })`
|
|
183
|
+
// silently dropped the filter and returned arbitrary docs ordered by
|
|
184
|
+
// `created_at desc`. This is the same primitive relation populate uses
|
|
185
|
+
// (`packages/core/src/services/populate.ts`) when it batches by document id.
|
|
166
186
|
const fetched = new Map<string, Map<string, Record<string, any>>>()
|
|
167
187
|
await Promise.all(
|
|
168
188
|
Array.from(idsByCollection.entries()).map(async ([collectionPath, idSet]) => {
|
|
169
189
|
const ids = Array.from(idSet)
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
190
|
+
const collectionId = await client.resolveCollectionId(collectionPath)
|
|
191
|
+
const rawDocs = await client.db.queries.documents.getDocumentsByDocumentIds({
|
|
192
|
+
collection_id: collectionId,
|
|
193
|
+
document_ids: ids,
|
|
194
|
+
readMode: 'published',
|
|
174
195
|
})
|
|
175
196
|
const byId = new Map<string, Record<string, any>>()
|
|
176
|
-
for (const
|
|
177
|
-
if (typeof
|
|
197
|
+
for (const raw of rawDocs as Array<Record<string, any>>) {
|
|
198
|
+
if (typeof raw.document_id !== 'string') continue
|
|
199
|
+
// Normalise the raw storage shape (`document_id` / `path` / `status` /
|
|
200
|
+
// `fields`) to the `{ id, path, status, fields }` shape the visitors
|
|
201
|
+
// expect — matches the shaped `ClientDocument` the previous `find()`
|
|
202
|
+
// path returned.
|
|
203
|
+
byId.set(raw.document_id, {
|
|
204
|
+
id: raw.document_id,
|
|
205
|
+
path: raw.path,
|
|
206
|
+
status: raw.status,
|
|
207
|
+
fields: raw.fields,
|
|
208
|
+
})
|
|
178
209
|
}
|
|
179
210
|
fetched.set(collectionPath, byId)
|
|
180
211
|
})
|
|
@@ -182,7 +213,10 @@ export async function runLexicalPopulate(options: RunPopulateOptions): Promise<v
|
|
|
182
213
|
|
|
183
214
|
for (const p of pending) {
|
|
184
215
|
const target = fetched.get(p.collectionPath)?.get(p.documentId)
|
|
185
|
-
if (
|
|
186
|
-
|
|
216
|
+
if (target) {
|
|
217
|
+
p.apply(target)
|
|
218
|
+
} else {
|
|
219
|
+
p.applyMissing?.()
|
|
220
|
+
}
|
|
187
221
|
}
|
|
188
222
|
}
|
|
@@ -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
|
+
}
|