@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.
@@ -25,10 +25,6 @@ export const NitrogenComponentUsage = {
25
25
  type: 'text',
26
26
  required: true,
27
27
  },
28
- {
29
- name: 'depth',
30
- type: 'number',
31
- },
32
28
  {
33
29
  name: 'sourceCollection',
34
30
  type: 'text',
@@ -56,26 +52,12 @@ export const NitrogenComponentUsage = {
56
52
  name: 'sourceStatus',
57
53
  type: 'text',
58
54
  },
55
+ // Usage answers "which components appear in which documents". A module's
56
+ // position in the tree is deliberately not stored: nothing read it, and
57
+ // keeping it meant every reorder rewrote every row of the document.
59
58
  {
60
59
  name: 'moduleId',
61
60
  type: 'text',
62
61
  },
63
- {
64
- name: 'modulePath',
65
- type: 'text',
66
- required: true,
67
- },
68
- {
69
- name: 'parentComponentName',
70
- type: 'text',
71
- },
72
- {
73
- name: 'parentModuleId',
74
- type: 'text',
75
- },
76
- {
77
- name: 'parentModulePath',
78
- type: 'text',
79
- },
80
62
  ],
81
63
  };
@@ -93,5 +93,13 @@ export const NitrogenTemplates = {
93
93
  },
94
94
  ],
95
95
  },
96
+ {
97
+ name: 'nitrogenTranslationStatus',
98
+ type: 'json',
99
+ admin: {
100
+ hidden: true,
101
+ description: 'Per-language translation coverage, computed by Nitrogen on save',
102
+ },
103
+ },
96
104
  ],
97
105
  };
@@ -548,7 +548,7 @@ export default function NitrogenComponentInventoryClient({ adminRoute, component
548
548
  display: 'block',
549
549
  padding: 12,
550
550
  textDecoration: 'none',
551
- }, children: [_jsx("div", { style: { fontWeight: 600 }, children: usage.sourceTitle || usage.sourceSlug || usage.sourceDocumentId }), _jsxs("div", { style: { color: payloadTheme.mutedText, fontSize: 12, marginTop: 2 }, children: [usage.sourceCollection, " - ", usage.modulePath] })] }, usage.usageKey))) })) : (_jsx("p", { style: { color: payloadTheme.mutedText, margin: 0 }, children: "No indexed usages yet." }))] }), _jsxs("div", { children: [_jsx("h3", { style: { color: payloadTheme.text, margin: '0 0 10px', fontSize: 15 }, children: "Props" }), definition ? (Object.values(definition.categories).map((category) => renderCategory(category))) : (_jsx("p", { style: { color: payloadTheme.mutedText, margin: 0 }, children: "No manifest definition available." }))] }), _jsxs("div", { children: [_jsx("h3", { style: { color: payloadTheme.text, margin: '0 0 10px', fontSize: 15 }, children: "Update Prop Across Site" }), editableProps.length ? (_jsxs("div", { style: {
551
+ }, children: [_jsx("div", { style: { fontWeight: 600 }, children: usage.sourceTitle || usage.sourceSlug || usage.sourceDocumentId }), _jsxs("div", { style: { color: payloadTheme.mutedText, fontSize: 12, marginTop: 2 }, children: [usage.sourceCollection, usage.sourceSlug ? ` - ${usage.sourceSlug}` : ''] })] }, usage.usageKey))) })) : (_jsx("p", { style: { color: payloadTheme.mutedText, margin: 0 }, children: "No indexed usages yet." }))] }), _jsxs("div", { children: [_jsx("h3", { style: { color: payloadTheme.text, margin: '0 0 10px', fontSize: 15 }, children: "Props" }), definition ? (Object.values(definition.categories).map((category) => renderCategory(category))) : (_jsx("p", { style: { color: payloadTheme.mutedText, margin: 0 }, children: "No manifest definition available." }))] }), _jsxs("div", { children: [_jsx("h3", { style: { color: payloadTheme.text, margin: '0 0 10px', fontSize: 15 }, children: "Update Prop Across Site" }), editableProps.length ? (_jsxs("div", { style: {
552
552
  border: `1px solid ${payloadTheme.border}`,
553
553
  borderRadius: 8,
554
554
  display: 'grid',
@@ -1,5 +1,6 @@
1
1
  import { getDocumentPermalink, getDocumentRelativePermalink, getNitrogenSettings, } from './helpers.js';
2
2
  import { resolveCollection } from '../collection-registry.js';
3
+ import { getRequestLanguage, resolveLocaleOptions } from '../localization.js';
3
4
  export const allEndpoints = [
4
5
  // GET /api/nitrogen/v1/all — List all items with filtering
5
6
  {
@@ -17,6 +18,7 @@ export const allEndpoints = [
17
18
  const paged = parseInt(url.searchParams.get('paged') || '1', 10);
18
19
  const perPage = parseInt(url.searchParams.get('per_page') || '20', 10);
19
20
  const statuses = statusParam.split(',').map((s) => s.trim());
21
+ const localeOptions = resolveLocaleOptions(payload, getRequestLanguage(req));
20
22
  const collection = resolveCollection(postType);
21
23
  const where = {};
22
24
  if (!statuses.includes('any')) {
@@ -29,6 +31,7 @@ export const allEndpoints = [
29
31
  page: paged,
30
32
  limit: perPage,
31
33
  depth: 0,
34
+ ...localeOptions,
32
35
  });
33
36
  const items = result.docs.map((doc) => {
34
37
  const document = doc;
@@ -1,5 +1,6 @@
1
1
  import { resolveCollection } from "../collection-registry.js";
2
2
  import { getNitrogenSettings, buildDynamicData, buildPageResponse, getDocumentPermalink, getDocumentRelativePermalink, } from "./helpers.js";
3
+ import { getRequestLanguage, resolveLocaleOptions } from "../localization.js";
3
4
  function toArray(value) {
4
5
  if (Array.isArray(value)) {
5
6
  return value.map((item) => String(item).trim()).filter(Boolean);
@@ -98,6 +99,9 @@ export const batchEndpoints = [
98
99
  }
99
100
  const settings = await getNitrogenSettings(payload);
100
101
  const results = {};
102
+ // Request-level language (?lang=/?locale=) applies to every batched
103
+ // query; a per-request `lang`/`locale` param overrides it.
104
+ const requestLang = getRequestLanguage(req);
101
105
  // Dedup: group identical (endpoint, params) requests and run each group's
102
106
  // query once via its first ("representative") request; fan the result out
103
107
  // to the duplicate keys afterward.
@@ -285,6 +289,10 @@ export const batchEndpoints = [
285
289
  };
286
290
  }
287
291
  }
292
+ const lang = (typeof params.lang === "string" && params.lang) ||
293
+ (typeof params.locale === "string" && params.locale) ||
294
+ requestLang;
295
+ const localeOptions = resolveLocaleOptions(payload, lang);
288
296
  const result = await payload.find({
289
297
  collection: collectionSlug,
290
298
  where,
@@ -292,6 +300,7 @@ export const batchEndpoints = [
292
300
  limit,
293
301
  sort,
294
302
  depth,
303
+ ...localeOptions,
295
304
  });
296
305
  let data;
297
306
  if (raw) {
@@ -1,5 +1,6 @@
1
1
  import { getNitrogenSettings, buildDynamicData, buildResolvedPageResponse, buildResolvedListItemResponse, getTemplateForType, requireAuth, canServeDocument, } from './helpers.js';
2
2
  import { resolveTemplateForDocument } from './templateConditions.js';
3
+ import { buildDynamicDataMeta, computeDocTranslationStatus, fetchLocaleAllDoc, getDefaultLocaleTitle, getLocalizationSettings, getPayloadLocalization, getRequestLanguage, resolveLocaleOptions, } from '../localization.js';
3
4
  /**
4
5
  * Creates a complete set of CRUD endpoints for a given Payload collection.
5
6
  *
@@ -19,6 +20,7 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
19
20
  const url = new URL(req.url || '', 'http://localhost');
20
21
  const statusParam = url.searchParams.get('post_status') || 'published';
21
22
  const statuses = statusParam.split(',').map((s) => s.trim());
23
+ const localeOptions = resolveLocaleOptions(payload, getRequestLanguage(req));
22
24
  const where = {};
23
25
  if (!statuses.includes('any')) {
24
26
  where._status = { in: statuses };
@@ -28,6 +30,7 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
28
30
  where,
29
31
  limit: 0,
30
32
  depth: 1,
33
+ ...localeOptions,
31
34
  });
32
35
  const items = await Promise.all(result.docs.map((doc) => buildResolvedListItemResponse(payload, doc, settings, collectionSlug)));
33
36
  return Response.json(items);
@@ -67,9 +70,10 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
67
70
  handler: async (req) => {
68
71
  const { payload, routeParams } = req;
69
72
  const id = routeParams?.id;
73
+ const localeOptions = resolveLocaleOptions(payload, getRequestLanguage(req));
70
74
  try {
71
75
  const [doc, settings, headerTemplate, footerTemplate] = await Promise.all([
72
- payload.findByID({ collection, id, depth: 1 }),
76
+ payload.findByID({ collection, id, depth: 1, ...localeOptions }),
73
77
  getNitrogenSettings(payload),
74
78
  getTemplateForType(payload, 'header'),
75
79
  getTemplateForType(payload, 'footer'),
@@ -79,9 +83,22 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
79
83
  }
80
84
  const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
81
85
  const dynamicData = buildDynamicData(doc, settings);
86
+ // When a language was requested, the top-level `title` must stay
87
+ // the default-locale one — the editor edits default-language field
88
+ // data only and PATCHes `title` back on save. Only dynamic_data is
89
+ // locale-resolved. One locale:'all' fetch serves title + meta.
90
+ const localeAllDoc = localeOptions.locale
91
+ ? await fetchLocaleAllDoc(payload, collectionSlug, id)
92
+ : undefined;
93
+ const dynamicDataMeta = await buildDynamicDataMeta(payload, collectionSlug, id, localeAllDoc);
94
+ const titleOverride = localeOptions.locale
95
+ ? getDefaultLocaleTitle(payload, localeAllDoc ?? null)
96
+ : null;
82
97
  const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
83
98
  return Response.json({
84
99
  ...response,
100
+ ...(titleOverride !== null ? { title: titleOverride } : {}),
101
+ ...(dynamicDataMeta ? { dynamic_data_meta: dynamicDataMeta } : {}),
85
102
  template: pageTemplate,
86
103
  headerTemplate,
87
104
  footerTemplate,
@@ -92,6 +109,36 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
92
109
  }
93
110
  },
94
111
  },
112
+ // GET /api/nitrogen/v1/{prefix}/:id/dynamic-data — Dynamic data (+ meta)
113
+ // for one item, optionally in a specific language (?lang=es). Used by the
114
+ // editor on language switch, without reloading the whole page.
115
+ {
116
+ path: `/nitrogen/v1/${endpointPrefix}/:id/dynamic-data`,
117
+ method: 'get',
118
+ handler: async (req) => {
119
+ const { payload, routeParams } = req;
120
+ const id = routeParams?.id;
121
+ const localeOptions = resolveLocaleOptions(payload, getRequestLanguage(req));
122
+ try {
123
+ const [doc, settings] = await Promise.all([
124
+ payload.findByID({ collection, id, depth: 1, ...localeOptions }),
125
+ getNitrogenSettings(payload),
126
+ ]);
127
+ if (!canServeDocument(req, doc, settings)) {
128
+ return Response.json({ error: 'Not found' }, { status: 404 });
129
+ }
130
+ const dynamicData = buildDynamicData(doc, settings);
131
+ const dynamicDataMeta = await buildDynamicDataMeta(payload, collectionSlug, id);
132
+ return Response.json({
133
+ dynamic_data: dynamicData,
134
+ ...(dynamicDataMeta ? { dynamic_data_meta: dynamicDataMeta } : {}),
135
+ });
136
+ }
137
+ catch {
138
+ return Response.json({ error: 'Not found' }, { status: 404 });
139
+ }
140
+ },
141
+ },
95
142
  // PATCH /api/nitrogen/v1/{prefix}/:id — Update an item
96
143
  {
97
144
  path: `/nitrogen/v1/${endpointPrefix}/:id`,
@@ -145,6 +192,34 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
145
192
  if (data.settings !== undefined) {
146
193
  updateData.pageSettings = data.settings;
147
194
  }
195
+ // Localization enabled → recompute per-language translation status
196
+ // from the saved module tree. Advisory only — never blocks the save.
197
+ if (updateData.nitrogenData !== undefined) {
198
+ try {
199
+ const settings = await getNitrogenSettings(payload);
200
+ const localization = getLocalizationSettings(settings);
201
+ if (localization) {
202
+ const translationStatus = await computeDocTranslationStatus({
203
+ payload,
204
+ collectionSlug,
205
+ docId: id,
206
+ nitrogenData: updateData.nitrogenData,
207
+ localization,
208
+ });
209
+ if (translationStatus) {
210
+ updateData.nitrogenTranslationStatus = translationStatus;
211
+ }
212
+ }
213
+ }
214
+ catch {
215
+ // Status is advisory — never block the save.
216
+ }
217
+ }
218
+ // The editor edits default-language field data only — pin the write
219
+ // to the default locale. Without this, Payload's createLocalReq falls
220
+ // back to req.locale / req.query.locale, so a stray `?locale=` on the
221
+ // request would silently retarget localized fields (title).
222
+ const payloadLocalization = getPayloadLocalization(payload);
148
223
  try {
149
224
  await payload.update({
150
225
  collection,
@@ -152,6 +227,9 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
152
227
  data: updateData,
153
228
  depth: 0,
154
229
  overrideAccess: true,
230
+ ...(payloadLocalization
231
+ ? { locale: payloadLocalization.defaultLocale }
232
+ : {}),
155
233
  req,
156
234
  });
157
235
  }
@@ -183,8 +261,16 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
183
261
  if (!slug) {
184
262
  return Response.json({ error: 'Slug is required' }, { status: 400 });
185
263
  }
264
+ const lang = body?.lang || body?.data?.lang || getRequestLanguage(req);
265
+ const localeOptions = resolveLocaleOptions(payload, lang);
186
266
  const [result, settings, headerTemplate, footerTemplate] = await Promise.all([
187
- payload.find({ collection, where: { slug: { equals: slug } }, limit: 1, depth: 1 }),
267
+ payload.find({
268
+ collection,
269
+ where: { slug: { equals: slug } },
270
+ limit: 1,
271
+ depth: 1,
272
+ ...localeOptions,
273
+ }),
188
274
  getNitrogenSettings(payload),
189
275
  getTemplateForType(payload, 'header'),
190
276
  getTemplateForType(payload, 'footer'),
@@ -198,9 +284,19 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
198
284
  }
199
285
  const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
200
286
  const dynamicData = buildDynamicData(doc, settings);
287
+ // See GET /:id — keep the round-tripped `title` at the default locale.
288
+ const localeAllDoc = localeOptions.locale
289
+ ? await fetchLocaleAllDoc(payload, collectionSlug, doc.id)
290
+ : undefined;
291
+ const dynamicDataMeta = await buildDynamicDataMeta(payload, collectionSlug, doc.id, localeAllDoc);
292
+ const titleOverride = localeOptions.locale
293
+ ? getDefaultLocaleTitle(payload, localeAllDoc ?? null)
294
+ : null;
201
295
  const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
202
296
  return Response.json({
203
297
  ...response,
298
+ ...(titleOverride !== null ? { title: titleOverride } : {}),
299
+ ...(dynamicDataMeta ? { dynamic_data_meta: dynamicDataMeta } : {}),
204
300
  template: pageTemplate,
205
301
  headerTemplate,
206
302
  footerTemplate,
@@ -214,8 +310,18 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
214
310
  handler: async (req) => {
215
311
  const { payload, routeParams } = req;
216
312
  const slug = routeParams?.slug;
313
+ // When the host collection's slug field is localized, the locale-aware
314
+ // query matches the localized slug (falling back to the default
315
+ // locale's slug via fallbackLocale).
316
+ const localeOptions = resolveLocaleOptions(payload, getRequestLanguage(req));
217
317
  const [result, settings, headerTemplate, footerTemplate] = await Promise.all([
218
- payload.find({ collection, where: { slug: { equals: slug } }, limit: 1, depth: 1 }),
318
+ payload.find({
319
+ collection,
320
+ where: { slug: { equals: slug } },
321
+ limit: 1,
322
+ depth: 1,
323
+ ...localeOptions,
324
+ }),
219
325
  getNitrogenSettings(payload),
220
326
  getTemplateForType(payload, 'header'),
221
327
  getTemplateForType(payload, 'footer'),
@@ -229,9 +335,19 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
229
335
  }
230
336
  const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
231
337
  const dynamicData = buildDynamicData(doc, settings);
338
+ // See GET /:id — keep the round-tripped `title` at the default locale.
339
+ const localeAllDoc = localeOptions.locale
340
+ ? await fetchLocaleAllDoc(payload, collectionSlug, doc.id)
341
+ : undefined;
342
+ const dynamicDataMeta = await buildDynamicDataMeta(payload, collectionSlug, doc.id, localeAllDoc);
343
+ const titleOverride = localeOptions.locale
344
+ ? getDefaultLocaleTitle(payload, localeAllDoc ?? null)
345
+ : null;
232
346
  const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
233
347
  return Response.json({
234
348
  ...response,
349
+ ...(titleOverride !== null ? { title: titleOverride } : {}),
350
+ ...(dynamicDataMeta ? { dynamic_data_meta: dynamicDataMeta } : {}),
235
351
  template: pageTemplate,
236
352
  headerTemplate,
237
353
  footerTemplate,
@@ -30,7 +30,8 @@ export function buildDynamicData(doc, globalSettings) {
30
30
  const rawDoc = doc;
31
31
  const docData = Object.fromEntries(Object.entries(rawDoc).filter(([key]) => key !== 'nitrogenData' &&
32
32
  key !== 'pageSettings' &&
33
- key !== 'templateSettings'));
33
+ key !== 'templateSettings' &&
34
+ key !== 'nitrogenTranslationStatus'));
34
35
  const rawContent = typeof rawDoc.content === 'string'
35
36
  ? rawDoc.content
36
37
  : '';
@@ -2,6 +2,8 @@ export const menuEndpoints = [
2
2
  // POST /api/nitrogen/v1/menu — Get menu items (stub)
3
3
  // WordPress nav menus are WP-specific. Returns empty array.
4
4
  // Implement with a NavigationMenus collection if menus are needed.
5
+ // `?lang=` is accepted (and trivially honored) — thread it through
6
+ // resolveLocaleOptions if this stub gains a real menu query.
5
7
  {
6
8
  path: '/nitrogen/v1/menu',
7
9
  method: 'post',
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { buildRelativePermalink, getRegisteredCollections, resolveCollection, } from '../collection-registry.js';
14
14
  import { findAllDocs } from '../inventory/indexing.js';
15
+ import { getPayloadLocalization, } from '../localization.js';
15
16
  /** Internal Nitrogen collections that must never appear in the sitemap. */
16
17
  const EXCLUDED_COLLECTIONS = new Set([
17
18
  'nitrogen-templates',
@@ -24,18 +25,30 @@ const EXCLUDED_COLLECTIONS = new Set([
24
25
  * Payload collections may or may not have drafts enabled (the `_status`
25
26
  * field). We first try filtering by `_status: 'published'`; if that throws
26
27
  * (collection has no `_status`), we fall back to fetching all docs.
28
+ *
29
+ * With localization configured, docs are fetched at `locale: 'all'` so a
30
+ * localized slug field yields every locale's slug in one query.
27
31
  */
28
- async function fetchPublishedSitemapDocs(payload, collection) {
32
+ async function fetchPublishedSitemapDocs(payload, collection, localization) {
29
33
  const select = { slug: true, updatedAt: true };
34
+ const locale = localization ? 'all' : undefined;
30
35
  try {
31
36
  return await findAllDocs(payload, collection, {
32
37
  where: { _status: { equals: 'published' } },
33
38
  select,
39
+ locale,
34
40
  });
35
41
  }
36
42
  catch {
37
- return findAllDocs(payload, collection, { select });
43
+ return findAllDocs(payload, collection, { select, locale });
44
+ }
45
+ }
46
+ /** Resolve a (possibly locale-keyed) slug value for one locale. */
47
+ function slugForLocale(slug, locale, defaultLocale) {
48
+ if (slug && typeof slug === 'object' && !Array.isArray(slug)) {
49
+ return String(slug[locale] ?? slug[defaultLocale] ?? '');
38
50
  }
51
+ return String(slug ?? '');
39
52
  }
40
53
  /** Coerce a doc's updatedAt into an ISO-8601 string, or null when absent. */
41
54
  function toIsoString(value) {
@@ -89,17 +102,44 @@ export const sitemapEndpoints = [
89
102
  if (EXCLUDED_COLLECTIONS.has(collection)) {
90
103
  return Response.json({ error: 'Post type not eligible for sitemap' }, { status: 404 });
91
104
  }
105
+ const localization = getPayloadLocalization(payload);
92
106
  let docs;
93
107
  try {
94
- docs = await fetchPublishedSitemapDocs(payload, collection);
108
+ docs = await fetchPublishedSitemapDocs(payload, collection, localization);
95
109
  }
96
110
  catch {
97
111
  return Response.json({ error: 'No URLs for this post type' }, { status: 404 });
98
112
  }
99
- const urls = docs.map((doc) => ({
100
- loc: buildRelativePermalink(postType, String(doc.slug || '')),
101
- lastmod: toIsoString(doc.updatedAt),
102
- }));
113
+ // With localization configured, each doc emits its canonical
114
+ // default-locale URL plus one `/{lang}/`-prefixed entry per non-default
115
+ // locale, using the localized slug when present (falling back to the
116
+ // default locale's slug otherwise).
117
+ const urls = docs.flatMap((doc) => {
118
+ const lastmod = toIsoString(doc.updatedAt);
119
+ if (!localization) {
120
+ return [
121
+ {
122
+ loc: buildRelativePermalink(postType, String(doc.slug || '')),
123
+ lastmod,
124
+ },
125
+ ];
126
+ }
127
+ const { locales, defaultLocale } = localization;
128
+ const defaultSlug = slugForLocale(doc.slug, defaultLocale, defaultLocale);
129
+ const entries = [
130
+ { loc: buildRelativePermalink(postType, defaultSlug), lastmod },
131
+ ];
132
+ for (const locale of locales) {
133
+ if (locale === defaultLocale)
134
+ continue;
135
+ const localizedSlug = slugForLocale(doc.slug, locale, defaultLocale);
136
+ entries.push({
137
+ loc: `/${locale}${buildRelativePermalink(postType, localizedSlug)}`,
138
+ lastmod,
139
+ });
140
+ }
141
+ return entries;
142
+ });
103
143
  if (urls.length === 0) {
104
144
  return Response.json({ error: 'No URLs for this post type' }, { status: 404 });
105
145
  }