@byline/core 2.4.3 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/collection-types.d.ts +30 -0
- package/dist/@types/field-types.d.ts +36 -0
- package/dist/@types/site-config.d.ts +25 -8
- package/dist/core.js +4 -1
- package/dist/services/document-lifecycle.js +52 -1
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/richtext-embed.d.ts +47 -0
- package/dist/services/richtext-embed.js +77 -0
- package/dist/services/richtext-populate.d.ts +16 -1
- package/dist/services/richtext-populate.js +14 -4
- package/dist/services/richtext-populate.test.node.js +24 -10
- package/package.json +2 -2
|
@@ -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 {
|
|
192
|
+
* import {
|
|
193
|
+
* lexicalEditorEmbedServer,
|
|
194
|
+
* lexicalEditorPopulateServer,
|
|
195
|
+
* } from '@byline/richtext-lexical/server'
|
|
193
196
|
*
|
|
194
197
|
* defineServerConfig({
|
|
195
198
|
* fields: {
|
|
196
|
-
* richText: {
|
|
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
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
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,
|
|
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,
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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 {
|
|
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';
|
package/dist/services/index.js
CHANGED
|
@@ -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[],
|
|
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,
|
|
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 && !
|
|
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
|
|
144
|
+
`richtext populate adapter is registered. Wire one via ` +
|
|
141
145
|
`ServerConfig.fields.richText.populate — see ` +
|
|
142
|
-
`\`@byline/richtext-lexical/server\` → \`
|
|
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
|
|
154
|
-
expect(() => validateRichTextFieldFlags([makeCollection({})],
|
|
156
|
+
it('passes when default flags are used and both adapters are registered', () => {
|
|
157
|
+
expect(() => validateRichTextFieldFlags([makeCollection({})], BOTH)).not.toThrow();
|
|
155
158
|
});
|
|
156
|
-
it('
|
|
157
|
-
|
|
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 })],
|
|
170
|
+
expect(() => validateRichTextFieldFlags([makeCollection({ embedRelationsOnSave: false, populateRelationsOnRead: false })], BOTH)).toThrow(/both .* set to false/i);
|
|
161
171
|
});
|
|
162
|
-
it('throws when
|
|
163
|
-
|
|
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
|
|
166
|
-
expect(() => validateRichTextFieldFlags([makeCollection({ embedRelationsOnSave: true, populateRelationsOnRead: true })],
|
|
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],
|
|
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.
|
|
5
|
+
"version": "2.5.0",
|
|
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.
|
|
82
|
+
"@byline/auth": "2.5.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|