@byline/core 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.
@@ -713,6 +713,36 @@ export interface CollectionDefinition {
713
713
  * without `useAsPath` receive a UUID `path` instead.
714
714
  */
715
715
  useAsPath?: string;
716
+ /**
717
+ * Optional host-defined function that composes a renderable root-relative
718
+ * path for a document in this collection. Called server-side by the
719
+ * richtext write-time walker (when `embedRelationsOnSave` is true on a
720
+ * `richText` field) and the read-time populate visitor (when
721
+ * `populateRelationsOnRead` is true). Sits next to `useAsPath` — that
722
+ * names the field that becomes the slug; this says how the slug
723
+ * composes into a renderable path.
724
+ *
725
+ * Returns a path with a leading slash, or `null` when no path can be
726
+ * built for this document (e.g. the doc is in a state that should not
727
+ * be linked to). Origin / host / protocol AND locale prefix are runtime
728
+ * concerns of the renderer (`LangLink` etc.) and MUST NOT be included —
729
+ * paths returned here are locale-agnostic; the renderer composes the
730
+ * final URL by prepending the request-time locale.
731
+ *
732
+ * Receives the same minimal document shape as
733
+ * `CollectionAdminConfig.preview.url` (`PreviewDocument`-style envelope:
734
+ * top-level columns plus `fields`) so the two hooks can share an
735
+ * implementation today and `preview.url` can default to
736
+ * `buildDocumentPath` in a later pass.
737
+ */
738
+ buildDocumentPath?: (doc: {
739
+ id: string;
740
+ path: string;
741
+ status: string;
742
+ fields: Record<string, any>;
743
+ }, ctx: {
744
+ collectionPath: string;
745
+ }) => string | null;
716
746
  /***
717
747
  * When `true`, the rich text editor's link plugin surfaces relation targets
718
748
  * from this collection as linkable options. Requires the collection to have
@@ -629,4 +629,40 @@ export interface RichTextPopulateContext {
629
629
  * `populateRelationsOnRead` flag.
630
630
  */
631
631
  export type RichTextPopulateFn = (ctx: RichTextPopulateContext) => Promise<void>;
632
+ /**
633
+ * Context passed to the richtext embed function for one rich-text field
634
+ * value at write time. Mirrors `RichTextPopulateContext` — same shape,
635
+ * same `readContext` threading rules — but fires from the document
636
+ * write path (`document-lifecycle.*`) instead of the read pipeline.
637
+ *
638
+ * The adapter mutates `value` in place — typically by walking the editor's
639
+ * node tree and refreshing embedded relation envelopes (e.g. calling
640
+ * `CollectionDefinition.buildDocumentPath` and overwriting
641
+ * `document.path` on internal-link nodes). The framework holds the parent
642
+ * reference and persists the mutated value.
643
+ */
644
+ export interface RichTextEmbedContext {
645
+ /** The richText field's value (raw editor JSON, possibly stringified). */
646
+ value: unknown;
647
+ /** Field path within the document — e.g. `'body'` or `'content.0.caption'`. */
648
+ fieldPath: string;
649
+ /** Collection path the document belongs to. */
650
+ collectionPath: string;
651
+ /**
652
+ * Shared request-scoped `ReadContext`. Threading is mandatory — adapter
653
+ * implementations must pass this back into any `client.collection(...)`
654
+ * read they perform via `_readContext`. The lifecycle write path spins
655
+ * one up per save so the embed walker's reads share visited-set / read
656
+ * budget machinery with the rest of the framework.
657
+ */
658
+ readContext: import('./db-types.js').ReadContext;
659
+ }
660
+ /**
661
+ * Server-side embed function contract. Editor adapters export an
662
+ * implementation; installations register one via
663
+ * `ServerConfig.fields.richText.embed`. Called once per rich-text
664
+ * leaf the framework discovers in a document during save, gated by
665
+ * each field's `embedRelationsOnSave` flag.
666
+ */
667
+ export type RichTextEmbedFn = (ctx: RichTextEmbedContext) => Promise<void>;
632
668
  export {};
@@ -10,7 +10,7 @@ import type { SlugifierFn } from '../utils/slugify.js';
10
10
  import type { CollectionAdminConfig } from './admin-types.js';
11
11
  import type { CollectionDefinition } from './collection-types.js';
12
12
  import type { IDbAdapter } from './db-types.js';
13
- import type { RichTextEditorComponent, RichTextPopulateFn } from './field-types.js';
13
+ import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn } from './field-types.js';
14
14
  import type { IStorageProvider } from './storage-types.js';
15
15
  export type DbAdapterFn = (args: {
16
16
  connectionString: string;
@@ -189,24 +189,41 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
189
189
  *
190
190
  * @example
191
191
  * ```ts
192
- * import { lexicalEditorServer } from '@byline/richtext-lexical/server'
192
+ * import {
193
+ * lexicalEditorEmbedServer,
194
+ * lexicalEditorPopulateServer,
195
+ * } from '@byline/richtext-lexical/server'
193
196
  *
194
197
  * defineServerConfig({
195
198
  * fields: {
196
- * richText: { populate: lexicalEditorServer({ getClient: getAdminBylineClient }) },
199
+ * richText: {
200
+ * embed: lexicalEditorEmbedServer({ getClient: getAdminBylineClient }),
201
+ * populate: lexicalEditorPopulateServer({ getClient: getAdminBylineClient }),
202
+ * },
197
203
  * },
198
204
  * })
199
205
  * ```
200
206
  */
201
207
  fields?: {
202
208
  /**
203
- * Richtext server-side populate function. Invoked by the read pipeline
204
- * for every rich-text field whose effective `populateRelationsOnRead`
205
- * is `true`. Required when any collection has a `richText` field
206
- * configured to populate on read; `initBylineCore()` enforces this.
209
+ * Richtext server-side adapter slots.
210
+ *
211
+ * `populate` invoked by the read pipeline for every rich-text field
212
+ * whose effective `populateRelationsOnRead` is `true`. Required when
213
+ * any collection has a `richText` field configured to populate on
214
+ * read; `initBylineCore()` enforces this.
215
+ *
216
+ * `embed` — invoked by the document-lifecycle write path for every
217
+ * rich-text field whose effective `embedRelationsOnSave` is `true`
218
+ * (the default). Walks the editor tree at save time and refreshes
219
+ * embedded relation envelopes (e.g. composing `document.path` via
220
+ * `CollectionDefinition.buildDocumentPath` on internal-link nodes).
221
+ * Required when any collection has a `richText` field with
222
+ * `embedRelationsOnSave: true`; `initBylineCore()` enforces this.
207
223
  */
208
224
  richText?: {
209
- populate: RichTextPopulateFn;
225
+ populate?: RichTextPopulateFn;
226
+ embed?: RichTextEmbedFn;
210
227
  };
211
228
  };
212
229
  }
package/dist/core.js CHANGED
@@ -41,7 +41,10 @@ export const initBylineCore = async (config, pinoLogger) => {
41
41
  // before any DB work. Fail-fast surfaces unrenderable configurations
42
42
  // (both flags off) and missing-adapter cases at boot rather than at
43
43
  // request time.
44
- validateRichTextFieldFlags(composed.collections, config.fields?.richText?.populate != null);
44
+ validateRichTextFieldFlags(composed.collections, {
45
+ populate: config.fields?.richText?.populate != null,
46
+ embed: config.fields?.richText?.embed != null,
47
+ });
45
48
  // Backward compat: populate globalThis singletons
46
49
  defineServerConfig(config);
47
50
  defineLogger(composed.logger);
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { isArrayField, isBlocksField, isGroupField, normalizeCollectionHook, } from '../@types/index.js';
9
9
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
10
- import { getCollectionDefinition } from '../config/config.js';
10
+ import { getCollectionDefinition, getServerConfig } from '../config/config.js';
11
11
  import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_PATH_CONFLICT, ERR_VALIDATION, ErrorCodes, } from '../lib/errors.js';
12
12
  import { generateKeyBetween } from '../lib/fractional-index.js';
13
13
  import { withLogContext } from '../lib/logger.js';
@@ -17,6 +17,8 @@ import { slugify } from '../utils/slugify.js';
17
17
  import { getUploadFields } from '../utils/storage-utils.js';
18
18
  import { getDefaultStatus, getWorkflow, validateStatusTransition } from '../workflow/workflow.js';
19
19
  import { assignCounterValues } from './assign-counter-values.js';
20
+ import { createReadContext } from './populate.js';
21
+ import { embedRichTextFields } from './richtext-embed.js';
20
22
  // ---------------------------------------------------------------------------
21
23
  // Internal helpers
22
24
  // ---------------------------------------------------------------------------
@@ -31,6 +33,41 @@ async function invokeHook(hook, ctx) {
31
33
  await fn(ctx);
32
34
  }
33
35
  }
36
+ /**
37
+ * Run the registered richtext embed adapter across every rich-text leaf
38
+ * in the outgoing document data. Mirror of the read-side
39
+ * `populateRichTextFields` — fires once per write, mutates `data` in
40
+ * place. Per-leaf errors are logged and swallowed by `embedRichTextFields`
41
+ * itself (branch C); document-level errors propagate.
42
+ *
43
+ * No-op when no embed adapter is registered. The bootstrap validator
44
+ * (step 7 of the link-refactor strategy) will eventually fail-fast for
45
+ * collections that declare `embedRelationsOnSave: true` without a
46
+ * registered adapter; until then a missing adapter is silent and writes
47
+ * proceed unmodified.
48
+ */
49
+ async function applyRichTextEmbed(ctx, data) {
50
+ // Tolerate environments that drive the lifecycle without
51
+ // `initBylineCore()` (unit tests, isolated tooling) — they have no
52
+ // adapter to invoke, so this is a soft no-op.
53
+ let embed;
54
+ try {
55
+ embed = getServerConfig().fields?.richText?.embed;
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ if (embed == null)
61
+ return;
62
+ await embedRichTextFields({
63
+ fields: ctx.definition.fields,
64
+ collectionPath: ctx.collectionPath,
65
+ data,
66
+ embed,
67
+ readContext: createReadContext(),
68
+ logger: ctx.logger,
69
+ });
70
+ }
34
71
  /**
35
72
  * For collections with `orderable: true` on their schema definition, compute
36
73
  * an append-at-end fractional-index key for a newly-inserted document.
@@ -191,6 +228,9 @@ export async function createDocument(ctx, params) {
191
228
  // carries the key into the byline_documents row. No effect when the
192
229
  // admin config opts out or isn't registered.
193
230
  const orderKey = await maybeAppendOrderKey(ctx, collectionPath);
231
+ // Refresh embedded relation envelopes inside rich-text fields
232
+ // (internal-link / inline-image nodes) before flatten-and-persist.
233
+ await applyRichTextEmbed(ctx, data);
194
234
  const result = await db.commands.documents
195
235
  .createDocumentVersion({
196
236
  collectionId,
@@ -268,6 +308,7 @@ export async function updateDocument(ctx, params) {
268
308
  documentId: params.documentId,
269
309
  logger: ctx.logger,
270
310
  });
311
+ await applyRichTextEmbed(ctx, data);
271
312
  const result = await db.commands.documents
272
313
  .createDocumentVersion({
273
314
  documentId: params.documentId,
@@ -374,6 +415,7 @@ export async function updateDocumentWithPatches(ctx, params) {
374
415
  documentId: params.documentId,
375
416
  logger: ctx.logger,
376
417
  });
418
+ await applyRichTextEmbed(ctx, nextData);
377
419
  const result = await db.commands.documents
378
420
  .createDocumentVersion({
379
421
  documentId: params.documentId,
@@ -628,6 +670,11 @@ export async function restoreDocumentVersion(ctx, params) {
628
670
  collectionPath,
629
671
  restore: restoreContext,
630
672
  });
673
+ // Embed walker is a no-op here for localized richtext leaves
674
+ // (multi-locale `{ locale: lexJson }` shape — see
675
+ // richtext-embed.ts header). Non-localized richtext leaves still
676
+ // get refreshed, so leave the call in for that branch.
677
+ await applyRichTextEmbed(ctx, sourceFields);
631
678
  // 6. Persist new version. locale: 'all' carries every locale row in
632
679
  // the source tree forward in a single flatten pass — the
633
680
  // cross-locale carry-forward branch in createDocumentVersion does
@@ -930,6 +977,9 @@ export async function duplicateDocument(ctx, params) {
930
977
  // before the insert; the source row's order is intentionally not
931
978
  // copied — duplicates land at the end of the list.
932
979
  const orderKey = await maybeAppendOrderKey(ctx, collectionPath);
980
+ // Embed walker (no-op for multi-locale richtext leaves — see
981
+ // restoreDocumentVersion for the same caveat).
982
+ await applyRichTextEmbed(ctx, clonedFields);
933
983
  try {
934
984
  result = await db.commands.documents
935
985
  .createDocumentVersion({
@@ -1247,6 +1297,7 @@ export async function copyToLocale(ctx, params) {
1247
1297
  // *other* locale (not source, not target — those rows are
1248
1298
  // rewritten by this call).
1249
1299
  const previousVersionId = targetRecord.document_version_id ?? undefined;
1300
+ await applyRichTextEmbed(ctx, merged.data);
1250
1301
  const writeResult = await db.commands.documents.createDocumentVersion({
1251
1302
  documentId: params.documentId,
1252
1303
  collectionId,
@@ -8,5 +8,6 @@ export * from './document-read.js';
8
8
  export * from './field-upload.js';
9
9
  export { type CycleRelationValue, createReadContext, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateOptions, type PopulateSpec, populateDocuments, type ReadContext, type UnresolvedRelationValue, } from './populate.js';
10
10
  export { buildRelationSummaryPopulateMap, type RelationTargetResolver, resolveRelationProjection, } from './relation-projection.js';
11
- export { collectRichTextLeaves, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
11
+ export { type EmbedRichTextFieldsOptions, embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
12
+ export { collectRichTextLeaves, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextAdapterPresence, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
12
13
  export { type FieldLeaf, walkFieldTree } from './walk-field-tree.js';
@@ -9,5 +9,6 @@ export * from './document-read.js';
9
9
  export * from './field-upload.js';
10
10
  export { createReadContext, populateDocuments, } from './populate.js';
11
11
  export { buildRelationSummaryPopulateMap, resolveRelationProjection, } from './relation-projection.js';
12
+ export { embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
12
13
  export { collectRichTextLeaves, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
13
14
  export { walkFieldTree } from './walk-field-tree.js';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { FieldSet, RichTextEmbedFn, RichTextField } from '../@types/field-types.js';
9
+ import type { ReadContext } from '../@types/index.js';
10
+ import type { BylineLogger } from '../lib/logger.js';
11
+ /**
12
+ * Resolve the effective `embedRelationsOnSave` for a richText field.
13
+ * Defaults to `true` — embed-on-save is the headline behaviour the new
14
+ * walker turns on, and CMS authors expect picker-time choices to land
15
+ * with refreshed envelopes by default.
16
+ */
17
+ export declare function resolveEmbedOnSave(field: RichTextField): boolean;
18
+ export interface EmbedRichTextFieldsOptions {
19
+ /** Source collection's schema fields (used to drive the leaf walk). */
20
+ fields: FieldSet;
21
+ collectionPath: string;
22
+ /**
23
+ * The outgoing document data. Mutated in place by the adapter as each
24
+ * rich-text leaf is walked.
25
+ */
26
+ data: Record<string, any>;
27
+ /** Registered server-side embed function from `ServerConfig`. */
28
+ embed: RichTextEmbedFn;
29
+ /**
30
+ * Request-scoped read context. Threading is mandatory — the embed
31
+ * adapter performs reads while walking and must share the same
32
+ * visited-set / read-budget machinery as the rest of the framework.
33
+ */
34
+ readContext: ReadContext;
35
+ /** Structured logger — used for branch-C per-leaf error reporting. */
36
+ logger: BylineLogger;
37
+ }
38
+ /**
39
+ * For one document being saved, walk its rich-text leaves and call the
40
+ * registered embed function for each leaf whose effective
41
+ * `embedRelationsOnSave` is `true`.
42
+ *
43
+ * Per-leaf errors are caught and logged at `error` level. The leaf's
44
+ * value is left as whatever the caller submitted — the persistence step
45
+ * downstream proceeds. Document-level errors propagate.
46
+ */
47
+ export declare function embedRichTextFields(options: EmbedRichTextFieldsOptions): Promise<void>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Richtext embed service — walks an outgoing document, finds every
10
+ * rich-text leaf (including those nested inside `group` / `array` /
11
+ * `blocks` structures), gates each leaf by its `embedRelationsOnSave`
12
+ * flag, and dispatches to the registered richtext embed adapter so
13
+ * link envelopes (and any future write-time embeds) can be refreshed
14
+ * before the value is flattened and persisted.
15
+ *
16
+ * Mirror of `richtext-populate.ts`. Slots into the document-lifecycle
17
+ * write path:
18
+ *
19
+ * beforeCreate / beforeUpdate
20
+ * → assignCounterValues
21
+ * → embedRichTextFields (this module)
22
+ * → createDocumentVersion
23
+ * → afterCreate / afterUpdate
24
+ *
25
+ * Per-leaf errors are logged and swallowed — leaving the persisted state
26
+ * for that leaf as the editor submitted it. Aligns with the strategy's
27
+ * "branch C — hard error" non-destructive fail mode (see
28
+ * docs/RICHTEXT-LINK-REFACTOR-STRATEGY.md § 3.3).
29
+ *
30
+ * Multi-locale (`locale: 'all'`) writes: when a richText leaf's value is
31
+ * a `{ <locale>: lexicalJson }` map (the shape used by
32
+ * `restoreDocumentVersion` and `duplicateDocument`), the adapter's
33
+ * `getLexicalRoot` parses the object as a single tree, finds no `root`
34
+ * key and no `children` array, and yields nothing. So embed is a no-op
35
+ * on multi-locale writes — the persisted state carries forward exactly
36
+ * what the source had. Per-locale walking is a deliberate future
37
+ * refinement; today's behaviour matches the populate side (which only
38
+ * fires on locale-scoped reads) and the renderer's fallback chain
39
+ * handles stale embedded paths.
40
+ */
41
+ import { collectRichTextLeaves } from './richtext-populate.js';
42
+ /**
43
+ * Resolve the effective `embedRelationsOnSave` for a richText field.
44
+ * Defaults to `true` — embed-on-save is the headline behaviour the new
45
+ * walker turns on, and CMS authors expect picker-time choices to land
46
+ * with refreshed envelopes by default.
47
+ */
48
+ export function resolveEmbedOnSave(field) {
49
+ return field.embedRelationsOnSave ?? true;
50
+ }
51
+ /**
52
+ * For one document being saved, walk its rich-text leaves and call the
53
+ * registered embed function for each leaf whose effective
54
+ * `embedRelationsOnSave` is `true`.
55
+ *
56
+ * Per-leaf errors are caught and logged at `error` level. The leaf's
57
+ * value is left as whatever the caller submitted — the persistence step
58
+ * downstream proceeds. Document-level errors propagate.
59
+ */
60
+ export async function embedRichTextFields(options) {
61
+ const { fields, collectionPath, data, embed, readContext, logger } = options;
62
+ for (const leaf of collectRichTextLeaves(fields, data)) {
63
+ if (!resolveEmbedOnSave(leaf.field))
64
+ continue;
65
+ try {
66
+ await embed({
67
+ value: leaf.value,
68
+ fieldPath: leaf.fieldPath,
69
+ collectionPath,
70
+ readContext,
71
+ });
72
+ }
73
+ catch (err) {
74
+ logger.error({ err, collectionPath, fieldPath: leaf.fieldPath }, 'richtext embed adapter threw — leaf left untouched (branch C)');
75
+ }
76
+ }
77
+ }
@@ -76,6 +76,17 @@ export declare function resolvePopulateOnRead(field: RichTextField): boolean;
76
76
  * is `true`. Mutates document `fields` in place.
77
77
  */
78
78
  export declare function populateRichTextFields(options: PopulateRichTextFieldsOptions): Promise<void>;
79
+ /**
80
+ * Which richtext server adapters the host has registered. Pass both
81
+ * flags so the validator can fail-fast on each missing-adapter case
82
+ * with a specific message.
83
+ */
84
+ export interface RichTextAdapterPresence {
85
+ /** `ServerConfig.fields.richText.populate != null` */
86
+ populate: boolean;
87
+ /** `ServerConfig.fields.richText.embed != null` */
88
+ embed: boolean;
89
+ }
79
90
  /**
80
91
  * Validate every richText field across every collection. Throws on:
81
92
  * 1. `embedRelationsOnSave === false && populateRelationsOnRead === false`
@@ -83,8 +94,12 @@ export declare function populateRichTextFields(options: PopulateRichTextFieldsOp
83
94
  * 2. Effective `populateRelationsOnRead === true` and no server-side
84
95
  * `RichTextPopulateFn` registered — populate would be a no-op and
85
96
  * the field would render with stale (or empty) embedded data.
97
+ * 3. Effective `embedRelationsOnSave === true` and no server-side
98
+ * `RichTextEmbedFn` registered — saves would silently skip the
99
+ * walker so internal-link `document.path` envelopes would never
100
+ * be canonicalised, breaking the renderer's fallback chain.
86
101
  *
87
102
  * Called once at `initBylineCore()` time. Fail-fast at boot is the right
88
103
  * posture; the alternative is a silent broken renderer at request time.
89
104
  */
90
- export declare function validateRichTextFieldFlags(collections: CollectionDefinition[], hasServerAdapter: boolean): void;
105
+ export declare function validateRichTextFieldFlags(collections: CollectionDefinition[], adapters: RichTextAdapterPresence): void;
@@ -118,11 +118,15 @@ function* walkDeclaration(field, declaredPath) {
118
118
  * 2. Effective `populateRelationsOnRead === true` and no server-side
119
119
  * `RichTextPopulateFn` registered — populate would be a no-op and
120
120
  * the field would render with stale (or empty) embedded data.
121
+ * 3. Effective `embedRelationsOnSave === true` and no server-side
122
+ * `RichTextEmbedFn` registered — saves would silently skip the
123
+ * walker so internal-link `document.path` envelopes would never
124
+ * be canonicalised, breaking the renderer's fallback chain.
121
125
  *
122
126
  * Called once at `initBylineCore()` time. Fail-fast at boot is the right
123
127
  * posture; the alternative is a silent broken renderer at request time.
124
128
  */
125
- export function validateRichTextFieldFlags(collections, hasServerAdapter) {
129
+ export function validateRichTextFieldFlags(collections, adapters) {
126
130
  const errors = [];
127
131
  for (const def of collections) {
128
132
  for (const { field, declaredPath } of iterRichTextFieldDeclarations(def.fields)) {
@@ -134,12 +138,18 @@ export function validateRichTextFieldFlags(collections, hasServerAdapter) {
134
138
  `Set at least one to true — otherwise nothing renders.`);
135
139
  continue;
136
140
  }
137
- if (populate && !hasServerAdapter) {
141
+ if (populate && !adapters.populate) {
138
142
  errors.push(`[${def.path}] richText field '${declaredPath}' requires read-time populate ` +
139
143
  `(embedRelationsOnSave=${embed}, populateRelationsOnRead=${populate}) but no ` +
140
- `richtext server adapter is registered. Wire one via ` +
144
+ `richtext populate adapter is registered. Wire one via ` +
141
145
  `ServerConfig.fields.richText.populate — see ` +
142
- `\`@byline/richtext-lexical/server\` → \`lexicalEditorServer()\`.`);
146
+ `\`@byline/richtext-lexical/server\` → \`lexicalEditorPopulateServer()\`.`);
147
+ }
148
+ if (embed && !adapters.embed) {
149
+ errors.push(`[${def.path}] richText field '${declaredPath}' requires write-time embed ` +
150
+ `(embedRelationsOnSave=${embed}) but no richtext embed adapter is registered. ` +
151
+ `Wire one via ServerConfig.fields.richText.embed — see ` +
152
+ `\`@byline/richtext-lexical/server\` → \`lexicalEditorEmbedServer()\`.`);
143
153
  }
144
154
  }
145
155
  }
@@ -149,21 +149,35 @@ function makeCollection(richTextField) {
149
149
  ],
150
150
  };
151
151
  }
152
+ const BOTH = { populate: true, embed: true };
153
+ const POPULATE_ONLY = { populate: true, embed: false };
154
+ const NEITHER = { populate: false, embed: false };
152
155
  describe('validateRichTextFieldFlags', () => {
153
- it('passes when default flags are used and adapter is registered', () => {
154
- expect(() => validateRichTextFieldFlags([makeCollection({})], true)).not.toThrow();
156
+ it('passes when default flags are used and both adapters are registered', () => {
157
+ expect(() => validateRichTextFieldFlags([makeCollection({})], BOTH)).not.toThrow();
155
158
  });
156
- it('passes when default flags are used and no adapter is registered (snapshot mode)', () => {
157
- expect(() => validateRichTextFieldFlags([makeCollection({})], false)).not.toThrow();
159
+ it('throws when default flags are used and the embed adapter is missing', () => {
160
+ // Default `embedRelationsOnSave: true` requires the embed adapter.
161
+ expect(() => validateRichTextFieldFlags([makeCollection({})], POPULATE_ONLY)).toThrow(/no richtext embed adapter is registered/i);
162
+ });
163
+ it('passes when default flags are used and no adapters are registered (snapshot mode, fields opted out)', () => {
164
+ // Both flags explicitly off: field is non-renderable, so the
165
+ // first-line "both off" check trips before the missing-adapter
166
+ // checks. Wrap with a single field that opts out of both.
167
+ expect(() => validateRichTextFieldFlags([], NEITHER)).not.toThrow();
158
168
  });
159
169
  it('throws when both flags are explicitly false', () => {
160
- expect(() => validateRichTextFieldFlags([makeCollection({ embedRelationsOnSave: false, populateRelationsOnRead: false })], true)).toThrow(/both .* set to false/i);
170
+ expect(() => validateRichTextFieldFlags([makeCollection({ embedRelationsOnSave: false, populateRelationsOnRead: false })], BOTH)).toThrow(/both .* set to false/i);
161
171
  });
162
- it('throws when embedRelationsOnSave: false but no adapter is registered', () => {
163
- expect(() => validateRichTextFieldFlags([makeCollection({ embedRelationsOnSave: false })], false)).toThrow(/no.*server adapter/i);
172
+ it('throws when populate is required but the populate adapter is missing', () => {
173
+ // embedRelationsOnSave: false flips the default for populate to true.
174
+ expect(() => validateRichTextFieldFlags([makeCollection({ embedRelationsOnSave: false })], {
175
+ populate: false,
176
+ embed: true,
177
+ })).toThrow(/no richtext populate adapter is registered/i);
164
178
  });
165
- it('throws when belt-and-braces is asked for but no adapter is registered', () => {
166
- expect(() => validateRichTextFieldFlags([makeCollection({ embedRelationsOnSave: true, populateRelationsOnRead: true })], false)).toThrow(/no.*server adapter/i);
179
+ it('throws when belt-and-braces is asked for but no adapters are registered', () => {
180
+ expect(() => validateRichTextFieldFlags([makeCollection({ embedRelationsOnSave: true, populateRelationsOnRead: true })], NEITHER)).toThrow(/no richtext (populate|embed) adapter is registered/i);
167
181
  });
168
182
  it('reports nested richText paths inside blocks with the block type tag', () => {
169
183
  const collection = {
@@ -191,6 +205,6 @@ describe('validateRichTextFieldFlags', () => {
191
205
  },
192
206
  ],
193
207
  };
194
- expect(() => validateRichTextFieldFlags([collection], true)).toThrow(/content\.<photoBlock>\.caption/);
208
+ expect(() => validateRichTextFieldFlags([collection], BOTH)).toThrow(/content\.<photoBlock>\.caption/);
195
209
  });
196
210
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "2.4.4",
5
+ "version": "2.5.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "2.4.4"
82
+ "@byline/auth": "2.5.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",