@nitrogenbuilder/connector-payload 1.1.0 → 1.2.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.
@@ -10,50 +10,41 @@ function moduleNameFromNode(node) {
10
10
  }
11
11
  return null;
12
12
  }
13
- function collectUsageFromTree(modules, pathPrefix, results, parentContext, depth = 0) {
13
+ function collectUsageFromTree(modules, pathPrefix, results) {
14
14
  if (!Array.isArray(modules))
15
15
  return;
16
16
  modules.forEach((maybeNode, index) => {
17
17
  if (!maybeNode || typeof maybeNode !== 'object')
18
18
  return;
19
19
  const node = maybeNode;
20
- const modulePath = `${pathPrefix}[${index}]`;
21
20
  const componentName = moduleNameFromNode(node);
21
+ // The tree path is no longer stored, but it is still the identity of last
22
+ // resort for a node that carries no id of its own. Such a node reverts to
23
+ // the old positional behaviour and churns when the tree is reordered.
24
+ const modulePath = `${pathPrefix}[${index}]`;
22
25
  const moduleId = typeof node.id === 'string' || typeof node.id === 'number'
23
26
  ? String(node.id)
24
27
  : modulePath;
25
- const currentContext = componentName
26
- ? {
27
- componentName,
28
- moduleId,
29
- modulePath,
30
- }
31
- : parentContext;
32
28
  if (componentName) {
33
29
  results.push({
34
30
  componentName,
35
- depth,
36
31
  moduleId,
37
- modulePath,
38
- parentComponentName: parentContext?.componentName,
39
- parentModuleId: parentContext?.moduleId,
40
- parentModulePath: parentContext?.modulePath,
41
32
  });
42
33
  }
43
34
  const props = node.props;
44
35
  if (props && typeof props === 'object') {
45
36
  const children = props.children;
46
37
  if (Array.isArray(children)) {
47
- collectUsageFromTree(children, `${modulePath}.props.children`, results, currentContext, depth + 1);
38
+ collectUsageFromTree(children, `${modulePath}.props.children`, results);
48
39
  }
49
40
  else if (children && typeof children === 'object') {
50
41
  for (const [slotName, slotChildren] of Object.entries(children)) {
51
- collectUsageFromTree(slotChildren, `${modulePath}.props.children.${slotName}`, results, currentContext, depth + 1);
42
+ collectUsageFromTree(slotChildren, `${modulePath}.props.children.${slotName}`, results);
52
43
  }
53
44
  }
54
45
  }
55
46
  if (Array.isArray(node.children)) {
56
- collectUsageFromTree(node.children, `${modulePath}.children`, results, currentContext, depth + 1);
47
+ collectUsageFromTree(node.children, `${modulePath}.children`, results);
57
48
  }
58
49
  });
59
50
  }
@@ -71,6 +62,7 @@ export async function findAllDocs(payload, collection, options, req) {
71
62
  collection: collection,
72
63
  depth: 0,
73
64
  limit: options?.limit ?? 200,
65
+ locale: options?.locale,
74
66
  page,
75
67
  overrideAccess: true,
76
68
  pagination: true,
@@ -84,62 +76,77 @@ export async function findAllDocs(payload, collection, options, req) {
84
76
  }
85
77
  return docs;
86
78
  }
87
- async function deleteUsageDocs(payload, docs, req) {
88
- for (const doc of docs) {
89
- await payload.delete({
90
- collection: NITROGEN_COMPONENT_USAGE_COLLECTION,
91
- id: doc.id,
92
- overrideAccess: true,
93
- req,
94
- });
95
- }
79
+ /**
80
+ * Delete usage rows in one bulk statement rather than paginating the ids and
81
+ * issuing a `payload.delete` per row. Usage is rebuilt wholesale on every save,
82
+ * so a document with N modules previously cost N deletes (plus the finds to
83
+ * discover them) on the critical path of the save transaction.
84
+ */
85
+ async function deleteUsageDocsWhere(payload, where, req) {
86
+ await payload.delete({
87
+ collection: NITROGEN_COMPONENT_USAGE_COLLECTION,
88
+ overrideAccess: true,
89
+ req,
90
+ where,
91
+ });
96
92
  }
97
- async function findUsageDocsForSource(payload, sourceCollection, sourceDocumentId, req) {
98
- return findAllDocs(payload, NITROGEN_COMPONENT_USAGE_COLLECTION, {
99
- limit: 100,
100
- select: {
101
- id: true,
102
- },
103
- where: {
104
- and: [
105
- {
106
- sourceCollection: {
107
- equals: sourceCollection,
108
- },
93
+ function usageDocsForSourceWhere(sourceCollection, sourceDocumentId) {
94
+ return {
95
+ and: [
96
+ {
97
+ sourceCollection: {
98
+ equals: sourceCollection,
109
99
  },
110
- {
111
- sourceDocumentId: {
112
- equals: sourceDocumentId,
113
- },
100
+ },
101
+ {
102
+ sourceDocumentId: {
103
+ equals: sourceDocumentId,
114
104
  },
115
- ],
116
- },
117
- }, req);
105
+ },
106
+ ],
107
+ };
108
+ }
109
+ function usageKeyFor(sourceCollection, sourceDocumentId, record) {
110
+ return `${sourceCollection}:${sourceDocumentId}:${record.moduleId}:${record.componentName}`;
111
+ }
112
+ function usageSourceFields(doc) {
113
+ return {
114
+ sourceTitle: doc.title || '',
115
+ sourceSlug: doc.slug || '',
116
+ sourceStatus: doc.status || doc._status || '',
117
+ };
118
+ }
119
+ /**
120
+ * `componentName`, `moduleId`, `sourceCollection` and `sourceDocumentId` are all
121
+ * encoded in the usage key, so a key match already implies they agree; the
122
+ * source fields are reconciled separately by a single bulk update. That leaves
123
+ * only `sourceType`, which is checked defensively so a row written with the
124
+ * wrong value can still heal.
125
+ */
126
+ function usageRowIsCurrent(row, sourceType) {
127
+ return row.sourceType === sourceType;
128
+ }
129
+ function usageRowData(sourceCollection, sourceType, sourceDocumentId, sourceFields, record) {
130
+ return {
131
+ usageKey: usageKeyFor(sourceCollection, sourceDocumentId, record),
132
+ componentName: record.componentName,
133
+ sourceCollection,
134
+ sourceType,
135
+ sourceDocumentId,
136
+ ...sourceFields,
137
+ moduleId: record.moduleId,
138
+ };
118
139
  }
119
140
  async function createUsageDocs(payload, sourceCollection, sourceType, doc, req) {
120
141
  const sourceDocumentId = String(doc.id);
142
+ const sourceFields = usageSourceFields(doc);
121
143
  const usageRecords = extractComponentUsageRecords(doc.nitrogenData);
122
144
  for (const record of usageRecords) {
123
145
  await payload.create({
124
146
  collection: NITROGEN_COMPONENT_USAGE_COLLECTION,
125
147
  overrideAccess: true,
126
148
  req,
127
- data: {
128
- usageKey: `${sourceCollection}:${sourceDocumentId}:${record.moduleId}:${record.modulePath}:${record.componentName}`,
129
- componentName: record.componentName,
130
- depth: record.depth,
131
- sourceCollection,
132
- sourceType,
133
- sourceDocumentId,
134
- sourceTitle: doc.title || '',
135
- sourceSlug: doc.slug || '',
136
- sourceStatus: doc.status || doc._status || '',
137
- moduleId: record.moduleId,
138
- modulePath: record.modulePath,
139
- parentComponentName: record.parentComponentName || '',
140
- parentModuleId: record.parentModuleId || '',
141
- parentModulePath: record.parentModulePath || '',
142
- },
149
+ data: usageRowData(sourceCollection, sourceType, sourceDocumentId, sourceFields, record),
143
150
  });
144
151
  }
145
152
  return usageRecords.length;
@@ -193,23 +200,72 @@ export async function syncComponentCatalog(payload, manifestInput, req) {
193
200
  }
194
201
  export async function reindexDocumentUsage(payload, sourceCollection, doc, sourceType = 'document', req) {
195
202
  const sourceDocumentId = String(doc.id);
196
- const docsForSource = await findUsageDocsForSource(payload, sourceCollection, sourceDocumentId, req);
197
- await deleteUsageDocs(payload, docsForSource, req);
198
- return createUsageDocs(payload, sourceCollection, sourceType, doc, req);
203
+ const sourceWhere = usageDocsForSourceWhere(sourceCollection, sourceDocumentId);
204
+ const sourceFields = usageSourceFields(doc);
205
+ const usageRecords = extractComponentUsageRecords(doc.nitrogenData);
206
+ // Usage rows describe tree *structure* only — nothing derived from module
207
+ // props or content. Editing copy therefore leaves every row identical, so
208
+ // diffing turns the common autosave case into zero writes instead of a full
209
+ // delete-and-recreate of every row on the save's critical path.
210
+ const existingRows = await findAllDocs(payload, NITROGEN_COMPONENT_USAGE_COLLECTION, { limit: 200, where: sourceWhere }, req);
211
+ const desiredByKey = new Map();
212
+ for (const record of usageRecords) {
213
+ desiredByKey.set(usageKeyFor(sourceCollection, sourceDocumentId, record), record);
214
+ }
215
+ const existingByKey = new Map();
216
+ const staleKeys = [];
217
+ for (const row of existingRows) {
218
+ if (desiredByKey.has(row.usageKey))
219
+ existingByKey.set(row.usageKey, row);
220
+ else
221
+ staleKeys.push(row.usageKey);
222
+ }
223
+ if (staleKeys.length > 0) {
224
+ await deleteUsageDocsWhere(payload, { usageKey: { in: staleKeys } }, req);
225
+ }
226
+ // Title/slug/status are identical across every row of the document, so when
227
+ // they change one bulk update fixes the whole set rather than N row updates.
228
+ const sourceFieldsStale = existingRows.some((row) => (row.sourceTitle || '') !== sourceFields.sourceTitle ||
229
+ (row.sourceSlug || '') !== sourceFields.sourceSlug ||
230
+ (row.sourceStatus || '') !== sourceFields.sourceStatus);
231
+ if (sourceFieldsStale && existingByKey.size > 0) {
232
+ await payload.update({
233
+ collection: NITROGEN_COMPONENT_USAGE_COLLECTION,
234
+ overrideAccess: true,
235
+ req,
236
+ where: sourceWhere,
237
+ data: sourceFields,
238
+ });
239
+ }
240
+ for (const [usageKey, record] of desiredByKey) {
241
+ const row = existingByKey.get(usageKey);
242
+ const data = usageRowData(sourceCollection, sourceType, sourceDocumentId, sourceFields, record);
243
+ if (!row) {
244
+ await payload.create({
245
+ collection: NITROGEN_COMPONENT_USAGE_COLLECTION,
246
+ overrideAccess: true,
247
+ req,
248
+ data,
249
+ });
250
+ }
251
+ else if (!usageRowIsCurrent(row, sourceType)) {
252
+ await payload.update({
253
+ collection: NITROGEN_COMPONENT_USAGE_COLLECTION,
254
+ id: row.id,
255
+ overrideAccess: true,
256
+ req,
257
+ data,
258
+ });
259
+ }
260
+ }
261
+ return usageRecords.length;
199
262
  }
200
263
  export async function deleteDocumentUsage(payload, sourceCollection, sourceDocumentId, req) {
201
- const docsForSource = await findUsageDocsForSource(payload, sourceCollection, String(sourceDocumentId), req);
202
- await deleteUsageDocs(payload, docsForSource, req);
264
+ await deleteUsageDocsWhere(payload, usageDocsForSourceWhere(sourceCollection, String(sourceDocumentId)), req);
203
265
  }
204
266
  export async function reindexAllComponentInventory(payload, collections, manifestInput, req) {
205
267
  const entries = await syncComponentCatalog(payload, manifestInput, req);
206
- const usageDocs = await findAllDocs(payload, NITROGEN_COMPONENT_USAGE_COLLECTION, {
207
- limit: 200,
208
- select: {
209
- id: true,
210
- },
211
- }, req);
212
- await deleteUsageDocs(payload, usageDocs, req);
268
+ await deleteUsageDocsWhere(payload, { id: { exists: true } }, req);
213
269
  let documentCount = 0;
214
270
  let usageCount = 0;
215
271
  for (const collection of collections) {
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Localization support — locale threading for read endpoints, dynamic-data
3
+ * translation metadata, and per-language translation status for builder
4
+ * content.
5
+ *
6
+ * Two independent layers cooperate here:
7
+ *
8
+ * 1. **Payload-native localization** (field data). When the host project's
9
+ * Payload config has `localization` configured (either directly or injected
10
+ * from the plugin's `localization` option), eligible text fields are
11
+ * localized and read endpoints thread `?lang=` through to
12
+ * `payload.find`/`findByID` as `locale` + `fallbackLocale`.
13
+ * 2. **Nitrogen settings localization** (builder content). Site languages live
14
+ * in the `nitrogen-settings` global under `nitrogenConfig.localization`.
15
+ * Translatable prop values inside `nitrogenData` are stored language-keyed
16
+ * with the language as the OUTER key (`{ en: ..., es: ... }`); legacy bare
17
+ * values read as the default language.
18
+ *
19
+ * Everything is gated: with no localization configured anywhere, every helper
20
+ * returns null/empty and callers behave exactly as before.
21
+ */
22
+ import type { Field, Payload, PayloadRequest, SanitizedCollectionConfig } from 'payload';
23
+ import type { ComponentManifestProp, DynamicDataMeta, LanguageStatus, LocalizationSettings } from '@nitrogenbuilder/types';
24
+ import type { NitrogenSettingsGlobal } from './types.js';
25
+ export interface PayloadLocaleConfig {
26
+ locales: string[];
27
+ defaultLocale: string;
28
+ }
29
+ /**
30
+ * Reads the sanitized Payload localization config at runtime. Returns null
31
+ * when the project has no Payload-level localization configured.
32
+ */
33
+ export declare function getPayloadLocalization(payload: Payload): PayloadLocaleConfig | null;
34
+ /** Reads `?lang=` (alias `?locale=`) off a request URL. */
35
+ export declare function getRequestLanguage(req: PayloadRequest): string | null;
36
+ export interface LocaleQueryOptions {
37
+ locale?: string;
38
+ fallbackLocale?: string;
39
+ }
40
+ /**
41
+ * Turns a requested language into `locale`/`fallbackLocale` options for
42
+ * `payload.find`/`findByID`. Returns `{}` (default behavior) when no language
43
+ * was requested, the project has no Payload localization, or the language is
44
+ * not a configured locale.
45
+ */
46
+ export declare function resolveLocaleOptions(payload: Payload, lang?: string | null): LocaleQueryOptions;
47
+ export declare function getCollectionConfig(payload: Payload, collectionSlug: string): SanitizedCollectionConfig | undefined;
48
+ /**
49
+ * Dot-paths of localized text-typed fields of a collection config. Walks
50
+ * groups, rows, collapsibles, and tabs; named groups/tabs contribute a path
51
+ * segment.
52
+ */
53
+ export declare function getLocalizedTextFieldPaths(fields: Field[] | undefined, prefix?: string): string[];
54
+ /**
55
+ * The site language config from the `nitrogen-settings` global. Null when
56
+ * localization is absent or disabled — callers must treat null as
57
+ * "no localization behavior at all".
58
+ */
59
+ export declare function getLocalizationSettings(settings: NitrogenSettingsGlobal): LocalizationSettings | null;
60
+ /**
61
+ * Fetches a doc with every locale's value for localized fields (one
62
+ * `locale: 'all'` query). Returns null when the project has no Payload
63
+ * localization configured or the doc can't be read — advisory callers keep
64
+ * their default behavior in that case.
65
+ */
66
+ export declare function fetchLocaleAllDoc(payload: Payload, collectionSlug: string, docId: string | number): Promise<Record<string, unknown> | null>;
67
+ /**
68
+ * The default-locale title from a `locale: 'all'` doc, for editor-facing
69
+ * single-doc responses. The editor edits default-language field data only
70
+ * (the CMS admin owns field translations), so the `title` it displays and
71
+ * PATCHes back on save must never be a locale-resolved one. Returns null when
72
+ * there is nothing to override (no localization, title not localized, or no
73
+ * stored title) — callers then keep the response title as-is.
74
+ */
75
+ export declare function getDefaultLocaleTitle(payload: Payload, localeAllDoc: Record<string, unknown> | null): string | null;
76
+ /**
77
+ * Builds the connector-provided metadata the editor uses to badge
78
+ * untranslated CMS fields. Eligible keys are the localized text-typed fields
79
+ * of the collection config; translated keys per language are the fields whose
80
+ * locale-specific value exists (checked via one `locale: 'all'` fetch, or a
81
+ * caller-preloaded `localeAllDoc` to avoid a duplicate query).
82
+ *
83
+ * Returns null when the project has no Payload localization configured, so
84
+ * callers can attach `dynamic_data_meta` conditionally with zero change to
85
+ * the default response shape.
86
+ */
87
+ export declare function buildDynamicDataMeta(payload: Payload, collectionSlug: string, docId?: string | number, localeAllDoc?: Record<string, unknown> | null): Promise<DynamicDataMeta | null>;
88
+ type CatalogPropDef = ComponentManifestProp & {
89
+ translatable?: boolean;
90
+ };
91
+ /**
92
+ * componentName → group key → propKey → prop definition. Module prop values
93
+ * are stored group-keyed (`props[groupKey][propKey]`, mirroring the renderer's
94
+ * `spreadProps`), so the group level is preserved for lookups during the walk.
95
+ */
96
+ export type TranslatablePropDefs = Map<string, Record<string, Record<string, CatalogPropDef>>>;
97
+ /**
98
+ * Loads component prop definitions from the nitrogen-component-catalog
99
+ * collection so translatable props can be identified precisely. Returns an
100
+ * empty map when the catalog is unavailable — the walk then falls back to the
101
+ * lang-map heuristic.
102
+ */
103
+ export declare function loadTranslatablePropDefs(payload: Payload): Promise<TranslatablePropDefs>;
104
+ export interface ComputeTranslationStatusArgs {
105
+ payload: Payload;
106
+ collectionSlug: string;
107
+ docId: string | number;
108
+ nitrogenData: unknown;
109
+ localization: LocalizationSettings;
110
+ /** Preloaded catalog defs (avoids a re-fetch when batching). */
111
+ propDefs?: TranslatablePropDefs;
112
+ /** Preloaded `locale: 'all'` doc (avoids a re-fetch when batching). */
113
+ localeAllDoc?: Record<string, unknown> | null;
114
+ }
115
+ /**
116
+ * Computes per-language translation status for one page/template. Returns a
117
+ * `{ [langCode]: LanguageStatus }` map covering every configured non-default
118
+ * language, or null when there is nothing to compute.
119
+ */
120
+ export declare function computeDocTranslationStatus(args: ComputeTranslationStatusArgs): Promise<Record<string, LanguageStatus> | null>;
121
+ export {};