@byline/richtext-lexical 2.4.4 → 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.
@@ -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: persistedRelation,
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: editorSettings.embedRelationsOnSave ? picked.document : void 0
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
- if ('string' == typeof path && path.length > 0) next.path = path;
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 an `afterRead` collection hook at read time.
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 (eventually) refreshed by an `afterRead`
29
- * hook at read time. Treat as advisory; never the source of truth.
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?: Record<string, any>;
38
+ document?: D;
32
39
  }
@@ -17,13 +17,7 @@ const RichTextField = ({ field, value, defaultValue, editorConfig, readonly = fa
17
17
  ...resolved,
18
18
  extensions: defaultExtensionsList()
19
19
  };
20
- const resolvedEditorConfig = void 0 === field.embedRelationsOnSave ? baseEditorConfig : {
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 point for the Lexical adapter — registered into
10
- * `ServerConfig.fields.richText.populate`. No React, no DOM, no Lexical
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 { lexicalEditorServer } from '@byline/richtext-lexical/server'
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: { populate: lexicalEditorServer({ getClient: getAdminBylineClient }) },
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
- * populate call so registration order doesn't matter.
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
- * lexicalEditorServer({
61
+ * lexicalEditorPopulateServer({
52
62
  * getClient,
53
- * visitors: [inlineImageVisitor, linkVisitor, myCustomEmbedVisitor],
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 lexicalEditorServer(options: LexicalServerOptions): RichTextPopulateFn;
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 lexicalEditorServer(options) {
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, lexicalEditorServer, linkVisitor };
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.4.4",
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/core": "2.4.4",
76
- "@byline/ui": "2.4.4",
77
- "@byline/client": "2.4.4"
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",
@@ -21,7 +21,6 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
21
21
  },
22
22
  inlineImageUploadCollection: 'media',
23
23
  placeholderText: 'Enter some rich text...',
24
- embedRelationsOnSave: true,
25
24
  }
26
25
 
27
26
  /**
@@ -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
- // The form-state `documentRelation` always carries `{ title, altText,
154
- // image, sizes }` embedded at picker-select time so the modal preview
155
- // can render. When `embedRelationsOnSave: false`, strip that envelope
156
- // at save so the persisted node only carries the relation primary keys.
157
- // Top-level `src` / `width` / `height` / `altText` are kept regardless —
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: persistedRelation,
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
- // Persist `picked.document` (the embedded `{ title, path }` envelope)
187
- // only when picker-time embedding is enabled. Otherwise the link node
188
- // carries the relation primary keys alone, and the registered server
189
- // populate adapter is expected to refresh the renderer-facing fields
190
- // on read.
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: editorSettings.embedRelationsOnSave ? picked.document : undefined,
202
+ document: picked.document,
205
203
  }
206
204
 
207
205
  onSubmit({
@@ -7,18 +7,44 @@
7
7
  */
8
8
 
9
9
  /**
10
- * Server-side populate visitor for the link plugin. Pure / framework-
11
- * agnostic — imported only from the package's `server` entry.
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'`. Tight projection — `{ title,
15
- * path }` only, matching what the link modal embeds at picker time.
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
- if (typeof title === 'string' && title.length > 0) next.title = title
43
- if (typeof path === 'string' && path.length > 0) next.path = path
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 extends BaseLinkAttributes, DocumentRelation {
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 (!target) continue
186
- p.apply(target)
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 an `afterRead` collection hook at read time.
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 (eventually) refreshed by an `afterRead`
30
- * hook at read time. Treat as advisory; never the source of truth.
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?: Record<string, any>
39
+ document?: D
33
40
  }
@@ -84,18 +84,12 @@ export const RichTextField = ({
84
84
  const baseEditorConfig: EditorConfig =
85
85
  resolved.extensions != null ? resolved : { ...resolved, extensions: defaultExtensionsList() }
86
86
 
87
- // Adapter-agnostic field-level leverwhen present, override the resolved
88
- // editor settings so the inline-image / link modals see this field's policy.
89
- const resolvedEditorConfig: EditorConfig =
90
- field.embedRelationsOnSave === undefined
91
- ? baseEditorConfig
92
- : {
93
- ...baseEditorConfig,
94
- settings: {
95
- ...baseEditorConfig.settings,
96
- embedRelationsOnSave: field.embedRelationsOnSave,
97
- },
98
- }
87
+ // `field.embedRelationsOnSave` is a server-side flagread 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 point for the Lexical adapter — registered into
11
- * `ServerConfig.fields.richText.populate`. No React, no DOM, no Lexical
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 { lexicalEditorServer } from '@byline/richtext-lexical/server'
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: { populate: lexicalEditorServer({ getClient: getAdminBylineClient }) },
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 { RichTextPopulateContext, RichTextPopulateFn } from '@byline/core'
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
- * populate call so registration order doesn't matter.
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
- * lexicalEditorServer({
83
+ * lexicalEditorPopulateServer({
69
84
  * getClient,
70
- * visitors: [inlineImageVisitor, linkVisitor, myCustomEmbedVisitor],
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 lexicalEditorServer(options: LexicalServerOptions): RichTextPopulateFn {
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
+ }