@nitrogenbuilder/connector-payload 1.1.1 → 1.2.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.
@@ -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
  };
@@ -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,
@@ -106,6 +106,35 @@ function resolveMediaUrl(url, settings) {
106
106
  const relativeUrl = url.startsWith('/') ? url : `/${url}`;
107
107
  return baseUrl ? `${baseUrl}${relativeUrl}` : relativeUrl;
108
108
  }
109
+ const MEDIA_PATH_MARKER = 'api/media/file/';
110
+ // The editor's wysiwyg (TinyMCE) rewrites same-origin media URLs to relative
111
+ // paths on save (e.g. `../../api/media/file/x.pdf`), which 404 on the frontend
112
+ // host. Re-anchor anything that is only `./`/`../` hops away from the media
113
+ // path back to the Payload origin; other relative URLs are left untouched.
114
+ function resolveInlineMediaUrl(url, settings) {
115
+ if (/^https?:\/\//i.test(url))
116
+ return url;
117
+ const index = url.indexOf(MEDIA_PATH_MARKER);
118
+ if (index === -1)
119
+ return url;
120
+ const prefix = url.slice(0, index);
121
+ if (!/^[./]*$/.test(prefix))
122
+ return url;
123
+ return resolveMediaUrl(`/${url.slice(index)}`, settings);
124
+ }
125
+ function resolveInlineMediaUrls(value, settings) {
126
+ if (!value.includes(MEDIA_PATH_MARKER))
127
+ return value;
128
+ if (!value.includes('<')) {
129
+ const trimmed = value.trim();
130
+ const resolved = resolveInlineMediaUrl(trimmed, settings);
131
+ return resolved === trimmed ? value : resolved;
132
+ }
133
+ return value.replace(/((?:href|src)=)(["'])([^"']*)\2/gi, (match, attr, quote, url) => {
134
+ const resolved = resolveInlineMediaUrl(url, settings);
135
+ return resolved === url ? match : `${attr}${quote}${resolved}${quote}`;
136
+ });
137
+ }
109
138
  async function findInternalTarget(context, link, internalRef) {
110
139
  const entryId = getId(internalRef.entryId);
111
140
  if (entryId === null)
@@ -205,13 +234,25 @@ async function resolveLinkRecord(context, link) {
205
234
  }
206
235
  async function walk(context, value) {
207
236
  if (Array.isArray(value)) {
208
- await Promise.all(value.map((item) => walk(context, item)));
237
+ await Promise.all(value.map(async (item, index) => {
238
+ if (typeof item === 'string') {
239
+ value[index] = resolveInlineMediaUrls(item, context.settings);
240
+ return;
241
+ }
242
+ await walk(context, item);
243
+ }));
209
244
  return;
210
245
  }
211
246
  if (!isRecord(value))
212
247
  return;
213
248
  await resolveLinkRecord(context, value);
214
- await Promise.all(Object.values(value).map((item) => walk(context, item)));
249
+ await Promise.all(Object.entries(value).map(async ([key, item]) => {
250
+ if (typeof item === 'string') {
251
+ value[key] = resolveInlineMediaUrls(item, context.settings);
252
+ return;
253
+ }
254
+ await walk(context, item);
255
+ }));
215
256
  }
216
257
  export async function resolveCtrlLinksInNitrogenData(payload, value, settings) {
217
258
  if (!value)
@@ -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
  }