@nitrogenbuilder/connector-payload 0.1.43 → 0.1.50

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.
@@ -1,4 +1,5 @@
1
- import { getNitrogenSettings, buildDynamicData, buildPageResponse, buildListItemResponse, getTemplateForType, requireAuth, } from './helpers.js';
1
+ import { getNitrogenSettings, buildDynamicData, buildResolvedPageResponse, buildResolvedListItemResponse, getTemplateForType, requireAuth, canServeDocument, } from './helpers.js';
2
+ import { resolveTemplateForDocument } from './templateConditions.js';
2
3
  /**
3
4
  * Creates a complete set of CRUD endpoints for a given Payload collection.
4
5
  *
@@ -28,7 +29,7 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
28
29
  limit: 0,
29
30
  depth: 1,
30
31
  });
31
- const items = result.docs.map((doc) => buildListItemResponse(doc, settings, collectionSlug));
32
+ const items = await Promise.all(result.docs.map((doc) => buildResolvedListItemResponse(payload, doc, settings, collectionSlug)));
32
33
  return Response.json(items);
33
34
  },
34
35
  },
@@ -67,16 +68,20 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
67
68
  const { payload, routeParams } = req;
68
69
  const id = routeParams?.id;
69
70
  try {
70
- const [doc, settings, headerTemplate, footerTemplate, pageTemplate] = await Promise.all([
71
+ const [doc, settings, headerTemplate, footerTemplate] = await Promise.all([
71
72
  payload.findByID({ collection, id, depth: 1 }),
72
73
  getNitrogenSettings(payload),
73
74
  getTemplateForType(payload, 'header'),
74
75
  getTemplateForType(payload, 'footer'),
75
- getTemplateForType(payload, collectionSlug),
76
76
  ]);
77
+ if (!canServeDocument(req, doc, settings)) {
78
+ return Response.json({ error: 'Not found' }, { status: 404 });
79
+ }
80
+ const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
77
81
  const dynamicData = buildDynamicData(doc, settings);
82
+ const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
78
83
  return Response.json({
79
- ...buildPageResponse(doc, settings, dynamicData, collectionSlug),
84
+ ...response,
80
85
  template: pageTemplate,
81
86
  headerTemplate,
82
87
  footerTemplate,
@@ -178,20 +183,24 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
178
183
  if (!slug) {
179
184
  return Response.json({ error: 'Slug is required' }, { status: 400 });
180
185
  }
181
- const [result, settings, headerTemplate, footerTemplate, pageTemplate] = await Promise.all([
186
+ const [result, settings, headerTemplate, footerTemplate] = await Promise.all([
182
187
  payload.find({ collection, where: { slug: { equals: slug } }, limit: 1, depth: 1 }),
183
188
  getNitrogenSettings(payload),
184
189
  getTemplateForType(payload, 'header'),
185
190
  getTemplateForType(payload, 'footer'),
186
- getTemplateForType(payload, collectionSlug),
187
191
  ]);
188
192
  if (!result.docs.length) {
189
193
  return Response.json({ error: 'Not found' }, { status: 404 });
190
194
  }
191
195
  const doc = result.docs[0];
196
+ if (!canServeDocument(req, doc, settings)) {
197
+ return Response.json({ error: 'Not found' }, { status: 404 });
198
+ }
199
+ const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
192
200
  const dynamicData = buildDynamicData(doc, settings);
201
+ const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
193
202
  return Response.json({
194
- ...buildPageResponse(doc, settings, dynamicData, collectionSlug),
203
+ ...response,
195
204
  template: pageTemplate,
196
205
  headerTemplate,
197
206
  footerTemplate,
@@ -205,20 +214,24 @@ export function createCollectionEndpoints(collectionSlug, endpointPrefix) {
205
214
  handler: async (req) => {
206
215
  const { payload, routeParams } = req;
207
216
  const slug = routeParams?.slug;
208
- const [result, settings, headerTemplate, footerTemplate, pageTemplate] = await Promise.all([
217
+ const [result, settings, headerTemplate, footerTemplate] = await Promise.all([
209
218
  payload.find({ collection, where: { slug: { equals: slug } }, limit: 1, depth: 1 }),
210
219
  getNitrogenSettings(payload),
211
220
  getTemplateForType(payload, 'header'),
212
221
  getTemplateForType(payload, 'footer'),
213
- getTemplateForType(payload, collectionSlug),
214
222
  ]);
215
223
  if (!result.docs.length) {
216
224
  return Response.json({ error: 'Not found' }, { status: 404 });
217
225
  }
218
226
  const doc = result.docs[0];
227
+ if (!canServeDocument(req, doc, settings)) {
228
+ return Response.json({ error: 'Not found' }, { status: 404 });
229
+ }
230
+ const pageTemplate = await resolveTemplateForDocument(payload, doc, collectionSlug, settings);
219
231
  const dynamicData = buildDynamicData(doc, settings);
232
+ const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug);
220
233
  return Response.json({
221
- ...buildPageResponse(doc, settings, dynamicData, collectionSlug),
234
+ ...response,
222
235
  template: pageTemplate,
223
236
  headerTemplate,
224
237
  footerTemplate,
@@ -0,0 +1,3 @@
1
+ import type { Payload } from 'payload';
2
+ import type { NitrogenSettingsGlobal } from '../types.js';
3
+ export declare function resolveCtrlLinksInNitrogenData<T>(payload: Payload, value: T, settings: NitrogenSettingsGlobal): Promise<T>;
@@ -0,0 +1,228 @@
1
+ import { buildRelativePermalink, getRegisteredCollections, } from '../collection-registry.js';
2
+ function isRecord(value) {
3
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
4
+ }
5
+ function getString(value) {
6
+ return typeof value === 'string' ? value : '';
7
+ }
8
+ function getId(value) {
9
+ if (typeof value === 'string' || typeof value === 'number')
10
+ return value;
11
+ return null;
12
+ }
13
+ function cloneJson(value) {
14
+ if (value === undefined)
15
+ return value;
16
+ return JSON.parse(JSON.stringify(value));
17
+ }
18
+ function normalizeRelativePath(value) {
19
+ const trimmed = value.trim();
20
+ if (!trimmed || trimmed === '/home')
21
+ return '/';
22
+ try {
23
+ const parsed = new URL(trimmed);
24
+ return normalizeRelativePath(`${parsed.pathname}${parsed.search}${parsed.hash}`);
25
+ }
26
+ catch {
27
+ // Treat as already relative.
28
+ }
29
+ const [path = '', suffix = ''] = trimmed.split(/([?#].*)/, 2);
30
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
31
+ const withoutTrailingSlash = normalizedPath.length > 1 ? normalizedPath.replace(/\/+$/, '') : normalizedPath;
32
+ return `${withoutTrailingSlash}${suffix}`;
33
+ }
34
+ function getPathname(value) {
35
+ const normalized = normalizeRelativePath(value);
36
+ return normalized.split(/[?#]/)[0] || '/';
37
+ }
38
+ function matchRoutePattern(pathname, routePattern) {
39
+ if (!routePattern)
40
+ return 1;
41
+ const pattern = getPathname(routePattern);
42
+ const placeholderMatch = pattern.match(/\[(?:\.\.\.)?slug\]/);
43
+ if (placeholderMatch) {
44
+ const base = pattern.slice(0, placeholderMatch.index).replace(/\/+$/, '');
45
+ if (!base) {
46
+ return pathname === '/' ? 0 : 20;
47
+ }
48
+ return pathname.startsWith(`${base}/`) ? 100 + base.length : 0;
49
+ }
50
+ if (pathname === pattern)
51
+ return 90 + pattern.length;
52
+ return pathname.startsWith(`${pattern}/`) ? 50 + pattern.length : 0;
53
+ }
54
+ function getCollectionCandidates(link, internalRef) {
55
+ const explicitCollection = getString(internalRef.collection) ||
56
+ getString(internalRef.collectionSlug) ||
57
+ getString(link.collection) ||
58
+ getString(link.collectionSlug);
59
+ const savedPath = getString(internalRef.relativePermalink) ||
60
+ getString(link.url) ||
61
+ getString(internalRef.slug);
62
+ const pathname = getPathname(savedPath);
63
+ const candidates = new Map();
64
+ if (explicitCollection) {
65
+ candidates.set(explicitCollection, Number.MAX_SAFE_INTEGER);
66
+ }
67
+ for (const collection of getRegisteredCollections()) {
68
+ const score = matchRoutePattern(pathname, collection.routePattern);
69
+ if (score <= 0)
70
+ continue;
71
+ candidates.set(collection.collectionSlug, Math.max(candidates.get(collection.collectionSlug) || 0, score));
72
+ }
73
+ if (!candidates.has('pages')) {
74
+ candidates.set('pages', 1);
75
+ }
76
+ return Array.from(candidates.entries())
77
+ .sort((a, b) => b[1] - a[1])
78
+ .map(([collection]) => collection);
79
+ }
80
+ function getDocumentRelativePermalink(doc, collectionSlug) {
81
+ const breadcrumbs = doc.breadcrumbs;
82
+ const lastUrl = Array.isArray(breadcrumbs)
83
+ ? breadcrumbs.at(-1)?.url
84
+ : undefined;
85
+ if (typeof lastUrl === 'string' && lastUrl.trim()) {
86
+ return normalizeRelativePath(lastUrl);
87
+ }
88
+ return buildRelativePermalink(collectionSlug, String(doc.slug || ''));
89
+ }
90
+ function getDocumentPermalink(doc, settings, collectionSlug) {
91
+ const relativePermalink = getDocumentRelativePermalink(doc, collectionSlug);
92
+ const baseUrl = (settings.frontendUrl || '').replace(/\/$/, '');
93
+ return baseUrl ? `${baseUrl}${relativePermalink}` : relativePermalink;
94
+ }
95
+ function getPayloadBaseUrl(settings) {
96
+ return (process.env.NEXT_PUBLIC_PAYLOAD_API_URL ||
97
+ process.env.NEXT_PUBLIC_SERVER_URL ||
98
+ settings.nitrogenEditorUrl ||
99
+ settings.instanceUrl ||
100
+ '').replace(/\/$/, '');
101
+ }
102
+ function resolveMediaUrl(url, settings) {
103
+ if (!url || /^https?:\/\//i.test(url))
104
+ return url;
105
+ const baseUrl = getPayloadBaseUrl(settings);
106
+ const relativeUrl = url.startsWith('/') ? url : `/${url}`;
107
+ return baseUrl ? `${baseUrl}${relativeUrl}` : relativeUrl;
108
+ }
109
+ async function findInternalTarget(context, link, internalRef) {
110
+ const entryId = getId(internalRef.entryId);
111
+ if (entryId === null)
112
+ return null;
113
+ const candidates = getCollectionCandidates(link, internalRef);
114
+ for (const collection of candidates) {
115
+ try {
116
+ const doc = (await context.payload.findByID({
117
+ collection: collection,
118
+ id: entryId,
119
+ depth: 1,
120
+ disableErrors: true,
121
+ }));
122
+ if (!doc)
123
+ continue;
124
+ return {
125
+ slug: String(doc.slug || internalRef.slug || ''),
126
+ relativePermalink: getDocumentRelativePermalink(doc, collection),
127
+ url: getDocumentPermalink(doc, context.settings, collection),
128
+ };
129
+ }
130
+ catch {
131
+ // Try the next candidate collection.
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ async function resolveInternalLink(context, link, internalRef) {
137
+ const entryId = getId(internalRef.entryId);
138
+ if (entryId === null)
139
+ return null;
140
+ const key = [
141
+ entryId,
142
+ getString(internalRef.relativePermalink),
143
+ getString(link.url),
144
+ getString(internalRef.collection),
145
+ getString(link.collection),
146
+ ].join(':');
147
+ if (!context.internalLinks.has(key)) {
148
+ context.internalLinks.set(key, findInternalTarget(context, link, internalRef));
149
+ }
150
+ return context.internalLinks.get(key);
151
+ }
152
+ async function findMediaTarget(context, mediaRef) {
153
+ const mediaId = getId(mediaRef.mediaId);
154
+ if (mediaId === null)
155
+ return null;
156
+ try {
157
+ const doc = (await context.payload.findByID({
158
+ collection: 'media',
159
+ id: mediaId,
160
+ depth: 0,
161
+ disableErrors: true,
162
+ }));
163
+ const url = doc?.url || '';
164
+ if (!doc || !url)
165
+ return null;
166
+ return {
167
+ filename: doc.filename || getString(mediaRef.filename),
168
+ mimeType: doc.mimeType || getString(mediaRef.mimeType),
169
+ url: resolveMediaUrl(url, context.settings),
170
+ };
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ async function resolveMediaLink(context, mediaRef) {
177
+ const mediaId = getId(mediaRef.mediaId);
178
+ if (mediaId === null)
179
+ return null;
180
+ const key = String(mediaId);
181
+ if (!context.mediaLinks.has(key)) {
182
+ context.mediaLinks.set(key, findMediaTarget(context, mediaRef));
183
+ }
184
+ return context.mediaLinks.get(key);
185
+ }
186
+ async function resolveLinkRecord(context, link) {
187
+ if (link.type === 'internal' && isRecord(link.internalRef)) {
188
+ const resolved = await resolveInternalLink(context, link, link.internalRef);
189
+ if (!resolved)
190
+ return;
191
+ link.url = resolved.url;
192
+ link.internalRef.slug = resolved.slug;
193
+ link.internalRef.relativePermalink = resolved.relativePermalink;
194
+ return;
195
+ }
196
+ if (link.type === 'media' && isRecord(link.mediaRef)) {
197
+ const resolved = await resolveMediaLink(context, link.mediaRef);
198
+ if (!resolved)
199
+ return;
200
+ link.url = resolved.url;
201
+ link.mediaRef.url = resolved.url;
202
+ link.mediaRef.filename = resolved.filename;
203
+ link.mediaRef.mimeType = resolved.mimeType;
204
+ }
205
+ }
206
+ async function walk(context, value) {
207
+ if (Array.isArray(value)) {
208
+ await Promise.all(value.map((item) => walk(context, item)));
209
+ return;
210
+ }
211
+ if (!isRecord(value))
212
+ return;
213
+ await resolveLinkRecord(context, value);
214
+ await Promise.all(Object.values(value).map((item) => walk(context, item)));
215
+ }
216
+ export async function resolveCtrlLinksInNitrogenData(payload, value, settings) {
217
+ if (!value)
218
+ return value;
219
+ const cloned = cloneJson(value);
220
+ const context = {
221
+ internalLinks: new Map(),
222
+ mediaLinks: new Map(),
223
+ payload,
224
+ settings,
225
+ };
226
+ await walk(context, cloned);
227
+ return cloned;
228
+ }
@@ -31,6 +31,29 @@ export declare function buildPageResponse(doc: NitrogenPageDoc | NitrogenTemplat
31
31
  wysiwygColors: {};
32
32
  };
33
33
  };
34
+ export declare function buildResolvedPageResponse(payload: Payload, doc: NitrogenPageDoc | NitrogenTemplateDoc, settings: NitrogenSettingsGlobal, dynamicData: JsonObject, collectionSlug?: string): Promise<{
35
+ id: string | number;
36
+ token: string;
37
+ title: string;
38
+ author: string | number;
39
+ slug: string;
40
+ permalink: string;
41
+ relative_permalink: string;
42
+ content: string;
43
+ dynamic_data: JsonObject;
44
+ settings: JsonObject;
45
+ nitrogen_settings: {
46
+ variables?: Record<string, {
47
+ type?: string;
48
+ value?: string | boolean;
49
+ }> | Array<{
50
+ name: string;
51
+ value?: string;
52
+ }>;
53
+ urlMaps: never[];
54
+ wysiwygColors: {};
55
+ };
56
+ }>;
34
57
  export declare function buildListItemResponse(doc: NitrogenPageDoc | NitrogenTemplateDoc, settings: NitrogenSettingsGlobal, collectionSlug?: string): {
35
58
  id: string | number;
36
59
  title: string;
@@ -51,6 +74,26 @@ export declare function buildListItemResponse(doc: NitrogenPageDoc | NitrogenTem
51
74
  wysiwygColors: {};
52
75
  };
53
76
  };
77
+ export declare function buildResolvedListItemResponse(payload: Payload, doc: NitrogenPageDoc | NitrogenTemplateDoc, settings: NitrogenSettingsGlobal, collectionSlug?: string): Promise<{
78
+ id: string | number;
79
+ title: string;
80
+ slug: string;
81
+ permalink: string;
82
+ relative_permalink: string;
83
+ content: string;
84
+ settings: JsonObject;
85
+ nitrogen_settings: {
86
+ variables?: Record<string, {
87
+ type?: string;
88
+ value?: string | boolean;
89
+ }> | Array<{
90
+ name: string;
91
+ value?: string;
92
+ }>;
93
+ urlMaps: never[];
94
+ wysiwygColors: {};
95
+ };
96
+ }>;
54
97
  export declare function buildMediaItemResponse(doc: MediaDoc): {
55
98
  id: string | number;
56
99
  filename: string;
@@ -98,3 +141,16 @@ export declare function getNitrogenSettings(payload: Payload): Promise<NitrogenS
98
141
  */
99
142
  export declare function getTemplateForType(payload: Payload, type: string): Promise<TemplateRef | null>;
100
143
  export declare function requireAuth(req: PayloadRequest): Response | null;
144
+ /**
145
+ * True when the request carries the connector token (the `x-nitrogen-token`
146
+ * header, or `nitrogen-token` query param for iframe URLs) matching the stored
147
+ * `connectorToken` setting. Lets the staging frontend / editor preview draft &
148
+ * private docs without a logged-in session. Mirrors WP `preview_token_valid`.
149
+ */
150
+ export declare function previewTokenValid(req: PayloadRequest, settings: NitrogenSettingsGlobal): boolean;
151
+ /**
152
+ * Whether a document may be served for a given request. Published docs are
153
+ * public; non-published (draft/pending/private) docs require either a logged-in
154
+ * user or a valid preview token. Mirrors the WP status gate in `get_item`.
155
+ */
156
+ export declare function canServeDocument(req: PayloadRequest, doc: Record<string, unknown> | null | undefined, settings: NitrogenSettingsGlobal): boolean;
@@ -1,4 +1,5 @@
1
1
  import { buildRelativePermalink } from '../collection-registry.js';
2
+ import { resolveCtrlLinksInNitrogenData } from './ctrlLinkResolver.js';
2
3
  function normalizeRelativePath(value) {
3
4
  const trimmed = value.trim();
4
5
  if (!trimmed || trimmed === '/home')
@@ -95,6 +96,13 @@ export function buildPageResponse(doc, settings, dynamicData, collectionSlug = '
95
96
  },
96
97
  };
97
98
  }
99
+ export async function buildResolvedPageResponse(payload, doc, settings, dynamicData, collectionSlug = 'pages') {
100
+ const response = buildPageResponse(doc, settings, dynamicData, collectionSlug);
101
+ if (doc.nitrogenData) {
102
+ response.content = JSON.stringify(await resolveCtrlLinksInNitrogenData(payload, doc.nitrogenData, settings));
103
+ }
104
+ return response;
105
+ }
98
106
  export function buildListItemResponse(doc, settings, collectionSlug = 'pages') {
99
107
  const pageSettings = 'pageSettings' in doc ? doc.pageSettings : undefined;
100
108
  const templateSettings = 'templateSettings' in doc ? doc.templateSettings : undefined;
@@ -114,6 +122,13 @@ export function buildListItemResponse(doc, settings, collectionSlug = 'pages') {
114
122
  },
115
123
  };
116
124
  }
125
+ export async function buildResolvedListItemResponse(payload, doc, settings, collectionSlug = 'pages') {
126
+ const response = buildListItemResponse(doc, settings, collectionSlug);
127
+ if (doc.nitrogenData) {
128
+ response.content = JSON.stringify(await resolveCtrlLinksInNitrogenData(payload, doc.nitrogenData, settings));
129
+ }
130
+ return response;
131
+ }
117
132
  export function buildMediaItemResponse(doc) {
118
133
  const filename = doc.filename || '';
119
134
  const ext = filename.includes('.') ? `.${filename.split('.').pop()}` : '';
@@ -197,9 +212,13 @@ export async function getTemplateForType(payload, type) {
197
212
  const doc = result.docs[0];
198
213
  if (!doc)
199
214
  return null;
215
+ const settings = await getNitrogenSettings(payload);
216
+ const resolvedNitrogenData = doc.nitrogenData
217
+ ? await resolveCtrlLinksInNitrogenData(payload, doc.nitrogenData, settings)
218
+ : null;
200
219
  return {
201
220
  ID: doc.id,
202
- content: doc.nitrogenData ? JSON.stringify(doc.nitrogenData) : '[]',
221
+ content: resolvedNitrogenData ? JSON.stringify(resolvedNitrogenData) : '[]',
203
222
  };
204
223
  }
205
224
  catch {
@@ -212,3 +231,49 @@ export function requireAuth(req) {
212
231
  }
213
232
  return null;
214
233
  }
234
+ // Constant-time string compare in pure JS — deliberately avoids importing a
235
+ // node builtin. Payload's tsx-based `migrate` bin transpiles the config graph
236
+ // and chokes when esbuild normalizes a builtin import to `node:crypto`
237
+ // (ENOENT on `node:crypto?tsx-namespace=…`), so nothing reachable from the
238
+ // config at load time may statically import `crypto`.
239
+ function timingSafeStringEqual(a, b) {
240
+ if (a.length !== b.length)
241
+ return false;
242
+ let mismatch = 0;
243
+ for (let i = 0; i < a.length; i++) {
244
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
245
+ }
246
+ return mismatch === 0;
247
+ }
248
+ /**
249
+ * True when the request carries the connector token (the `x-nitrogen-token`
250
+ * header, or `nitrogen-token` query param for iframe URLs) matching the stored
251
+ * `connectorToken` setting. Lets the staging frontend / editor preview draft &
252
+ * private docs without a logged-in session. Mirrors WP `preview_token_valid`.
253
+ */
254
+ export function previewTokenValid(req, settings) {
255
+ const expected = settings.connectorToken;
256
+ if (!expected)
257
+ return false;
258
+ let token = req.headers?.get?.('x-nitrogen-token') || '';
259
+ if (!token) {
260
+ const url = new URL(req.url || '', 'http://localhost');
261
+ token = url.searchParams.get('nitrogen-token') || '';
262
+ }
263
+ if (!token)
264
+ return false;
265
+ return timingSafeStringEqual(String(expected), token);
266
+ }
267
+ /**
268
+ * Whether a document may be served for a given request. Published docs are
269
+ * public; non-published (draft/pending/private) docs require either a logged-in
270
+ * user or a valid preview token. Mirrors the WP status gate in `get_item`.
271
+ */
272
+ export function canServeDocument(req, doc, settings) {
273
+ if (!doc)
274
+ return false;
275
+ const status = String(doc._status ?? doc.status ?? 'published');
276
+ if (status === 'published')
277
+ return true;
278
+ return !!req.user || previewTokenValid(req, settings);
279
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Sitemap endpoints — ported from the WordPress connector
3
+ * (rest-routes.php: get_sitemap_index, get_sitemap_for_post_type,
4
+ * build_sitemap_urls_for_post_type, resolve_sitemap_locs_for_post).
5
+ *
6
+ * PORTING NOTE: in WP a single post could emit MULTIPLE `loc`s — one per
7
+ * nitrogen_template matching the post, each contributing its own `url_prefix`
8
+ * (so a post served at both canonical and prefixed URLs appeared multiple
9
+ * times). The TARGET (Payload) model has exactly one `routePattern` per
10
+ * registered collection, so we emit ONE canonical URL per document via
11
+ * `buildRelativePermalink`. Per-template prefix multiplexing is deferred.
12
+ */
13
+ import type { Endpoint } from 'payload';
14
+ export declare const sitemapEndpoints: Endpoint[];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Sitemap endpoints — ported from the WordPress connector
3
+ * (rest-routes.php: get_sitemap_index, get_sitemap_for_post_type,
4
+ * build_sitemap_urls_for_post_type, resolve_sitemap_locs_for_post).
5
+ *
6
+ * PORTING NOTE: in WP a single post could emit MULTIPLE `loc`s — one per
7
+ * nitrogen_template matching the post, each contributing its own `url_prefix`
8
+ * (so a post served at both canonical and prefixed URLs appeared multiple
9
+ * times). The TARGET (Payload) model has exactly one `routePattern` per
10
+ * registered collection, so we emit ONE canonical URL per document via
11
+ * `buildRelativePermalink`. Per-template prefix multiplexing is deferred.
12
+ */
13
+ import { buildRelativePermalink, getRegisteredCollections, resolveCollection, } from '../collection-registry.js';
14
+ import { findAllDocs } from '../inventory/indexing.js';
15
+ /** Internal Nitrogen collections that must never appear in the sitemap. */
16
+ const EXCLUDED_COLLECTIONS = new Set([
17
+ 'nitrogen-templates',
18
+ 'nitrogen-component-catalog',
19
+ 'nitrogen-component-usage',
20
+ ]);
21
+ /**
22
+ * Fetch published docs for a collection selecting only `slug` and `updatedAt`.
23
+ *
24
+ * Payload collections may or may not have drafts enabled (the `_status`
25
+ * field). We first try filtering by `_status: 'published'`; if that throws
26
+ * (collection has no `_status`), we fall back to fetching all docs.
27
+ */
28
+ async function fetchPublishedSitemapDocs(payload, collection) {
29
+ const select = { slug: true, updatedAt: true };
30
+ try {
31
+ return await findAllDocs(payload, collection, {
32
+ where: { _status: { equals: 'published' } },
33
+ select,
34
+ });
35
+ }
36
+ catch {
37
+ return findAllDocs(payload, collection, { select });
38
+ }
39
+ }
40
+ /** Coerce a doc's updatedAt into an ISO-8601 string, or null when absent. */
41
+ function toIsoString(value) {
42
+ if (!value)
43
+ return null;
44
+ const date = new Date(value);
45
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
46
+ }
47
+ export const sitemapEndpoints = [
48
+ // GET /nitrogen/v1/sitemap — sitemap index (mirror get_sitemap_index)
49
+ {
50
+ path: '/nitrogen/v1/sitemap',
51
+ method: 'get',
52
+ handler: async (req) => {
53
+ const { payload } = req;
54
+ const postTypes = [];
55
+ for (const { collectionSlug } of getRegisteredCollections()) {
56
+ if (EXCLUDED_COLLECTIONS.has(collectionSlug))
57
+ continue;
58
+ let docs;
59
+ try {
60
+ docs = await fetchPublishedSitemapDocs(payload, collectionSlug);
61
+ }
62
+ catch {
63
+ // One bad collection shouldn't 500 the whole index — skip it.
64
+ continue;
65
+ }
66
+ if (docs.length === 0)
67
+ continue;
68
+ let lastmod = null;
69
+ for (const doc of docs) {
70
+ const iso = toIsoString(doc.updatedAt);
71
+ if (iso !== null && (lastmod === null || iso > lastmod)) {
72
+ lastmod = iso;
73
+ }
74
+ }
75
+ postTypes.push({ post_type: collectionSlug, lastmod });
76
+ }
77
+ return Response.json({ post_types: postTypes });
78
+ },
79
+ },
80
+ // GET /nitrogen/v1/sitemap/:post_type — URLs for one post type
81
+ // (mirror get_sitemap_for_post_type)
82
+ {
83
+ path: '/nitrogen/v1/sitemap/:post_type',
84
+ method: 'get',
85
+ handler: async (req) => {
86
+ const { payload, routeParams } = req;
87
+ const postType = String(routeParams?.post_type || '');
88
+ const collection = resolveCollection(postType);
89
+ if (EXCLUDED_COLLECTIONS.has(collection)) {
90
+ return Response.json({ error: 'Post type not eligible for sitemap' }, { status: 404 });
91
+ }
92
+ let docs;
93
+ try {
94
+ docs = await fetchPublishedSitemapDocs(payload, collection);
95
+ }
96
+ catch {
97
+ return Response.json({ error: 'No URLs for this post type' }, { status: 404 });
98
+ }
99
+ const urls = docs.map((doc) => ({
100
+ loc: buildRelativePermalink(postType, String(doc.slug || '')),
101
+ lastmod: toIsoString(doc.updatedAt),
102
+ }));
103
+ if (urls.length === 0) {
104
+ return Response.json({ error: 'No URLs for this post type' }, { status: 404 });
105
+ }
106
+ return Response.json({ urls });
107
+ },
108
+ },
109
+ ];
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Template Conditions — TypeScript port of the WordPress
3
+ * `Nitrogen\TemplateConditions` scoring engine
4
+ * (nitrogen-connector/includes/template-conditions.php).
5
+ *
6
+ * Resolves the best-matching nitrogen-template for a document by scoring
7
+ * editor-authored condition groups (OR'd groups, AND'd conditions within a
8
+ * group) and falling back to a legacy associatedCollection match.
9
+ *
10
+ * Everything here is pure/typed and defensive: the evaluators never throw —
11
+ * malformed input simply yields a non-match (false).
12
+ */
13
+ import type { Payload } from 'payload';
14
+ import type { JsonObject, NitrogenPageDoc, NitrogenTemplateDoc, NitrogenSettingsGlobal } from '../types.js';
15
+ import { type TemplateRef } from './helpers.js';
16
+ /**
17
+ * Evaluation context for a single document.
18
+ *
19
+ * `dynamic` holds the buildDynamicData() namespace; `post` is the raw document.
20
+ * The dynamic_data evaluator resolves its dot-path against both (dynamic first,
21
+ * then post) so editor-authored paths match leniently.
22
+ */
23
+ export interface TemplateContext {
24
+ post: NitrogenPageDoc | NitrogenTemplateDoc | Record<string, unknown>;
25
+ postType: string;
26
+ postId: string;
27
+ dynamic: JsonObject;
28
+ }
29
+ /**
30
+ * Evaluate authored conditions against a context (php:45-100).
31
+ *
32
+ * Groups are OR'd; conditions within a group are AND'd. An `exclude` flag
33
+ * inverts a condition's result. An unknown condition type fails the whole group.
34
+ *
35
+ * Returns the matched-condition count (score) of the first matching group, or
36
+ * false. If there are no conditionGroups, returns false (the caller handles the
37
+ * legacy fallback).
38
+ */
39
+ export declare function evaluateConditions(templateConditions: unknown, context: TemplateContext): number | false;
40
+ /**
41
+ * Build the evaluation context for a document (php:187-216, Payload-adapted).
42
+ */
43
+ export declare function buildTemplateContext(doc: NitrogenPageDoc | NitrogenTemplateDoc, collectionSlug: string, settings: NitrogenSettingsGlobal): TemplateContext;
44
+ /**
45
+ * Resolve the best-matching template for a document
46
+ * (php:140-182 resolve_template + php:106-132 evaluate_legacy).
47
+ *
48
+ * Returns a { ID, content } ref with ctrl-links resolved and content
49
+ * JSON-stringified — the same shape getTemplateForType() returns — or null if
50
+ * no template matches.
51
+ */
52
+ export declare function resolveTemplateForDocument(payload: Payload, doc: NitrogenPageDoc | NitrogenTemplateDoc, collectionSlug: string, settings: NitrogenSettingsGlobal): Promise<TemplateRef | null>;