@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.
@@ -0,0 +1,297 @@
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 { buildDynamicData } from './helpers.js';
14
+ import { resolveCtrlLinksInNitrogenData } from './ctrlLinkResolver.js';
15
+ // Template types that are resolved separately (header/footer/404) and must be
16
+ // skipped by the document template resolver.
17
+ const RESERVED_TEMPLATE_TYPES = ['header', 'footer', 'not_found'];
18
+ // --- Built-in condition evaluators (php:225-309) -------------------------------
19
+ /**
20
+ * post_type — compare 'is'/'is_not' against context.postType.
21
+ * The condition value is a list (single values are coerced to a one-item list).
22
+ */
23
+ function evaluatePostType(condition, context) {
24
+ const value = toArray(condition.value).map((v) => String(v));
25
+ const postType = context.postType ?? '';
26
+ const compare = condition.compare ?? 'is';
27
+ const inList = value.includes(postType);
28
+ return compare === 'is' ? inList : !inList;
29
+ }
30
+ /**
31
+ * post_id — compare 'is'/'is_not' against context.postId.
32
+ * Payload ids may be string or number, so everything is coerced to string
33
+ * for comparison.
34
+ */
35
+ function evaluatePostId(condition, context) {
36
+ const value = toArray(condition.value).map((v) => String(v));
37
+ const postId = String(context.postId ?? '');
38
+ const compare = condition.compare ?? 'is';
39
+ const inList = value.includes(postId);
40
+ return compare === 'is' ? inList : !inList;
41
+ }
42
+ /**
43
+ * dynamic_data — resolve `field` (a dot-path, optionally wrapped in {{ }}) and
44
+ * compare it.
45
+ *
46
+ * NOTE: the exact dynamic-data path namespace is best-effort — the WP source
47
+ * resolved against post/acf_fields/taxonomies, whereas the Payload
48
+ * buildDynamicData() namespace is shaped differently (e.g. `Post.*`,
49
+ * `Global Variables.*`). To match editor-authored paths leniently we resolve
50
+ * against BOTH context.dynamic and the raw context.post (dynamic first).
51
+ *
52
+ * Compares: equals/not_equals/contains/not_contains/is_empty/is_not_empty.
53
+ */
54
+ function evaluateDynamicData(condition, context) {
55
+ let fieldPath = condition.field ?? '';
56
+ const compare = condition.compare ?? 'equals';
57
+ const expected = condition.value ?? '';
58
+ if (!fieldPath) {
59
+ return false;
60
+ }
61
+ // Strip surrounding {{ }} wrappers, e.g. "{{Post.id}}" -> "Post.id".
62
+ fieldPath = fieldPath.trim().replace(/^\{\{/, '').replace(/\}\}$/, '').trim();
63
+ if (!fieldPath) {
64
+ return false;
65
+ }
66
+ // Try the dynamic-data namespace first, then fall back to the raw post.
67
+ let actual = resolveDotPath(context.dynamic, fieldPath);
68
+ if (actual === null || actual === undefined) {
69
+ actual = resolveDotPath(context.post, fieldPath);
70
+ }
71
+ const actualStr = Array.isArray(actual)
72
+ ? actual.map((v) => String(v)).join(', ')
73
+ : String(actual ?? '');
74
+ const expectedStr = String(expected ?? '');
75
+ switch (compare) {
76
+ case 'equals':
77
+ return actualStr === expectedStr;
78
+ case 'not_equals':
79
+ return actualStr !== expectedStr;
80
+ case 'contains':
81
+ return actualStr.includes(expectedStr);
82
+ case 'not_contains':
83
+ return !actualStr.includes(expectedStr);
84
+ case 'is_empty':
85
+ return isEmpty(actual);
86
+ case 'is_not_empty':
87
+ return !isEmpty(actual);
88
+ default:
89
+ return false;
90
+ }
91
+ }
92
+ const CONDITION_EVALUATORS = {
93
+ post_type: evaluatePostType,
94
+ post_id: evaluatePostId,
95
+ dynamic_data: evaluateDynamicData,
96
+ };
97
+ // --- Helpers -------------------------------------------------------------------
98
+ function toArray(value) {
99
+ if (Array.isArray(value))
100
+ return value;
101
+ if (value === null || value === undefined)
102
+ return [];
103
+ return [value];
104
+ }
105
+ function isRecord(value) {
106
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
107
+ }
108
+ /**
109
+ * Mirrors PHP `empty()` closely enough for condition evaluation:
110
+ * null/undefined, '', '0', 0, false, and empty arrays are "empty".
111
+ */
112
+ function isEmpty(value) {
113
+ if (value === null || value === undefined)
114
+ return true;
115
+ if (value === false || value === 0)
116
+ return true;
117
+ if (value === '' || value === '0')
118
+ return true;
119
+ if (Array.isArray(value))
120
+ return value.length === 0;
121
+ if (isRecord(value))
122
+ return Object.keys(value).length === 0;
123
+ return false;
124
+ }
125
+ /**
126
+ * Resolve a dot-notation path against a nested object/array.
127
+ * Returns null if any segment is missing (matching the PHP behaviour).
128
+ */
129
+ function resolveDotPath(data, path) {
130
+ const keys = path.split('.');
131
+ let current = data;
132
+ for (const key of keys) {
133
+ if (isRecord(current) && Object.prototype.hasOwnProperty.call(current, key)) {
134
+ current = current[key];
135
+ }
136
+ else if (Array.isArray(current)) {
137
+ const index = Number(key);
138
+ if (Number.isInteger(index) && index >= 0 && index < current.length) {
139
+ current = current[index];
140
+ }
141
+ else {
142
+ return null;
143
+ }
144
+ }
145
+ else {
146
+ return null;
147
+ }
148
+ }
149
+ return current;
150
+ }
151
+ function parseConditions(templateConditions) {
152
+ let data = templateConditions;
153
+ if (typeof data === 'string') {
154
+ try {
155
+ data = JSON.parse(data);
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ if (!isRecord(data))
162
+ return null;
163
+ return data;
164
+ }
165
+ // --- Public API ----------------------------------------------------------------
166
+ /**
167
+ * Evaluate authored conditions against a context (php:45-100).
168
+ *
169
+ * Groups are OR'd; conditions within a group are AND'd. An `exclude` flag
170
+ * inverts a condition's result. An unknown condition type fails the whole group.
171
+ *
172
+ * Returns the matched-condition count (score) of the first matching group, or
173
+ * false. If there are no conditionGroups, returns false (the caller handles the
174
+ * legacy fallback).
175
+ */
176
+ export function evaluateConditions(templateConditions, context) {
177
+ const data = parseConditions(templateConditions);
178
+ if (!data || !Array.isArray(data.conditionGroups) || data.conditionGroups.length === 0) {
179
+ return false;
180
+ }
181
+ for (const group of data.conditionGroups) {
182
+ const conditions = group?.conditions;
183
+ if (!Array.isArray(conditions) || conditions.length === 0) {
184
+ continue;
185
+ }
186
+ let groupMatches = true;
187
+ let groupScore = 0;
188
+ for (const condition of conditions) {
189
+ const type = condition?.type ?? '';
190
+ const evaluator = CONDITION_EVALUATORS[type];
191
+ if (!evaluator) {
192
+ // Unknown condition type — treat the group as a non-match.
193
+ groupMatches = false;
194
+ break;
195
+ }
196
+ let result;
197
+ try {
198
+ result = evaluator(condition, context);
199
+ }
200
+ catch {
201
+ result = false;
202
+ }
203
+ // The exclude flag inverts the result.
204
+ if (condition?.exclude) {
205
+ result = !result;
206
+ }
207
+ if (!result) {
208
+ groupMatches = false;
209
+ break;
210
+ }
211
+ groupScore++;
212
+ }
213
+ if (groupMatches) {
214
+ return groupScore;
215
+ }
216
+ }
217
+ return false;
218
+ }
219
+ /**
220
+ * Build the evaluation context for a document (php:187-216, Payload-adapted).
221
+ */
222
+ export function buildTemplateContext(doc, collectionSlug, settings) {
223
+ return {
224
+ post: doc,
225
+ postType: collectionSlug,
226
+ postId: String(doc.id),
227
+ dynamic: buildDynamicData(doc, settings),
228
+ };
229
+ }
230
+ /**
231
+ * Resolve the best-matching template for a document
232
+ * (php:140-182 resolve_template + php:106-132 evaluate_legacy).
233
+ *
234
+ * Returns a { ID, content } ref with ctrl-links resolved and content
235
+ * JSON-stringified — the same shape getTemplateForType() returns — or null if
236
+ * no template matches.
237
+ */
238
+ export async function resolveTemplateForDocument(payload, doc, collectionSlug, settings) {
239
+ let templates;
240
+ try {
241
+ const result = await payload.find({
242
+ collection: 'nitrogen-templates',
243
+ where: {
244
+ status: { equals: 'published' },
245
+ },
246
+ limit: 10000,
247
+ depth: 0,
248
+ sort: '-updatedAt',
249
+ });
250
+ templates = result.docs;
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ if (!templates.length)
256
+ return null;
257
+ const context = buildTemplateContext(doc, collectionSlug, settings);
258
+ let bestMatch = null;
259
+ let bestScore = -1;
260
+ for (const template of templates) {
261
+ const associatedCollection = template.associatedCollection ?? '';
262
+ // Skip header/footer/not_found templates — resolved separately.
263
+ if (RESERVED_TEMPLATE_TYPES.includes(associatedCollection)) {
264
+ continue;
265
+ }
266
+ const conditionsData = parseConditions(template.templateConditions);
267
+ let score;
268
+ if (conditionsData && Array.isArray(conditionsData.conditionGroups)) {
269
+ score = evaluateConditions(template.templateConditions, context);
270
+ }
271
+ else {
272
+ // Legacy fallback (php:106-132): match by associatedCollection.
273
+ score = associatedCollection === collectionSlug ? 1 : false;
274
+ }
275
+ // Highest score wins; ties resolve to the first encountered template, which
276
+ // (thanks to the -updatedAt sort) is the most recently updated.
277
+ if (score !== false && score > bestScore) {
278
+ bestScore = score;
279
+ bestMatch = template;
280
+ }
281
+ }
282
+ if (!bestMatch)
283
+ return null;
284
+ let resolved = null;
285
+ if (bestMatch.nitrogenData) {
286
+ try {
287
+ resolved = await resolveCtrlLinksInNitrogenData(payload, bestMatch.nitrogenData, settings);
288
+ }
289
+ catch {
290
+ resolved = null;
291
+ }
292
+ }
293
+ return {
294
+ ID: bestMatch.id,
295
+ content: resolved ? JSON.stringify(resolved) : '[]',
296
+ };
297
+ }
@@ -1,4 +1,4 @@
1
- import { getNitrogenSettings, buildDynamicData, buildPageResponse, buildListItemResponse, getDocumentPermalink, getDocumentRelativePermalink, getTemplateForType, requireAuth, } from './helpers.js';
1
+ import { getNitrogenSettings, buildDynamicData, buildResolvedPageResponse, buildResolvedListItemResponse, getDocumentPermalink, getDocumentRelativePermalink, getTemplateForType, requireAuth, } from './helpers.js';
2
2
  function isContentTemplateCollection(collection) {
3
3
  return !!collection && collection !== 'header' && collection !== 'footer';
4
4
  }
@@ -60,7 +60,7 @@ export const templatesEndpoints = [
60
60
  // Collection may not exist — use template's own data
61
61
  }
62
62
  }
63
- const response = buildPageResponse(doc, settings, dynamicData);
63
+ const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData);
64
64
  const previewTarget = getTemplatePreviewTarget(doc, settings, associatedCollection, associatedDoc);
65
65
  return Response.json({
66
66
  ...response,
@@ -99,11 +99,14 @@ export const templatesEndpoints = [
99
99
  limit: 0,
100
100
  depth: 1,
101
101
  });
102
- const items = result.docs.map((doc) => ({
103
- ...buildListItemResponse(doc, settings),
104
- permalink: `${(settings.frontendUrl || '').replace(/\/$/, '')}/nitrogen-templates/${doc.slug}`,
105
- relative_permalink: `/nitrogen-templates/${doc.slug}`,
106
- postType: doc.associatedCollection || '',
102
+ const items = await Promise.all(result.docs.map(async (doc) => {
103
+ const response = await buildResolvedListItemResponse(payload, doc, settings);
104
+ return {
105
+ ...response,
106
+ permalink: `${(settings.frontendUrl || '').replace(/\/$/, '')}/nitrogen-templates/${doc.slug}`,
107
+ relative_permalink: `/nitrogen-templates/${doc.slug}`,
108
+ postType: doc.associatedCollection || '',
109
+ };
107
110
  }));
108
111
  return Response.json(items);
109
112
  },
@@ -147,7 +150,7 @@ export const templatesEndpoints = [
147
150
  // Collection may not exist — use template's own data
148
151
  }
149
152
  }
150
- const response = buildPageResponse(doc, settings, dynamicData);
153
+ const response = await buildResolvedPageResponse(payload, doc, settings, dynamicData);
151
154
  const previewTarget = getTemplatePreviewTarget(doc, settings, associatedCollection, associatedDoc);
152
155
  return Response.json({
153
156
  ...response,
@@ -27,6 +27,14 @@ export const NitrogenSettings = {
27
27
  type: 'text',
28
28
  admin: { description: 'Connector token for server communication' },
29
29
  },
30
+ {
31
+ name: 'nitrogenInstanceUrl',
32
+ label: 'Nitrogen Instance URL',
33
+ type: 'text',
34
+ admin: {
35
+ description: 'Base URL of the Nitrogen instance hosting the MCP server (the agent endpoint is this + "/mcp"). Leave blank to use the hosted app.',
36
+ },
37
+ },
30
38
  {
31
39
  type: 'row',
32
40
  fields: [
package/dist/index.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface NitrogenConnectorPluginOptions {
28
28
  * inventory. These do not need to be editor-enabled.
29
29
  */
30
30
  indexCollections?: string[];
31
+ /**
32
+ * Slug of the auth-enabled collection that owns MCP agent credentials. When
33
+ * omitted, the first collection with `auth` enabled is used (falling back to
34
+ * `users`).
35
+ */
36
+ userCollection?: string;
31
37
  }
32
38
  export declare const nitrogenConnectorPlugin: (options?: NitrogenConnectorPluginOptions) => any;
33
39
  export { NitrogenTemplates } from "./collections/NitrogenTemplates.js";
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ import { menuEndpoints } from "./endpoints/menu.js";
12
12
  import { createCollectionEndpoints } from "./endpoints/collection-endpoints.js";
13
13
  import { createComponentInventoryEndpoints } from './endpoints/component-inventory.js';
14
14
  import { batchEndpoints } from "./endpoints/batch.js";
15
+ import { sitemapEndpoints } from "./endpoints/sitemap.js";
16
+ import { createAgentAuthEndpoints } from "./endpoints/agent-auth.js";
15
17
  import { registerCollection } from "./collection-registry.js";
16
18
  import { deleteDocumentUsage, reindexDocumentUsage, syncComponentCatalog, } from './inventory/indexing.js';
17
19
  /** Fields required by Nitrogen that will be injected into collections if missing */
@@ -42,6 +44,11 @@ export const nitrogenConnectorPlugin = (options = {}) => (incomingConfig) => {
42
44
  return incomingConfig;
43
45
  }
44
46
  const config = { ...incomingConfig };
47
+ // The auth collection that owns MCP agent credentials. Prefer the explicit
48
+ // option, else the first auth-enabled collection, else `users`.
49
+ const userCollectionSlug = options.userCollection ||
50
+ (incomingConfig.collections || []).find((col) => col.auth)?.slug ||
51
+ "users";
45
52
  const inventoryCollections = Array.from(new Set([
46
53
  ...(options.collections || []),
47
54
  ...(options.indexCollections || []),
@@ -94,6 +101,8 @@ export const nitrogenConnectorPlugin = (options = {}) => (incomingConfig) => {
94
101
  ...collectionsEndpoints,
95
102
  ...menuEndpoints,
96
103
  ...batchEndpoints,
104
+ ...sitemapEndpoints,
105
+ ...createAgentAuthEndpoints(userCollectionSlug),
97
106
  ...createComponentInventoryEndpoints({
98
107
  collections: inventoryCollections,
99
108
  componentManifest: options.componentManifest,
@@ -224,6 +233,55 @@ export const nitrogenConnectorPlugin = (options = {}) => (incomingConfig) => {
224
233
  }
225
234
  config.collections[existingIndex] = withInventoryHooks(config.collections[existingIndex], slug);
226
235
  }
236
+ // Inject MCP agent-credential storage + management UI onto the auth users
237
+ // collection. The hashed secret is never exposed via the API.
238
+ const usersIndex = (config.collections || []).findIndex((col) => col.slug === userCollectionSlug);
239
+ if (usersIndex === -1) {
240
+ console.warn(`[@nitrogenbuilder/connector-payload] Users collection "${userCollectionSlug}" not found; ` +
241
+ `MCP agent credentials cannot be stored. Pass the "userCollection" option if your auth collection differs.`);
242
+ }
243
+ else {
244
+ const usersCol = config.collections[usersIndex];
245
+ const usersFieldNames = new Set((usersCol.fields || [])
246
+ .filter((f) => "name" in f && !!f.name)
247
+ .map((f) => f.name));
248
+ const agentFields = [];
249
+ if (!usersFieldNames.has("nitrogenAgentTokenHash")) {
250
+ agentFields.push({
251
+ name: "nitrogenAgentTokenHash",
252
+ type: "text",
253
+ access: { read: () => false },
254
+ admin: { hidden: true, readOnly: true },
255
+ });
256
+ }
257
+ if (!usersFieldNames.has("nitrogenAgentTokenCreated")) {
258
+ agentFields.push({
259
+ name: "nitrogenAgentTokenCreated",
260
+ type: "number",
261
+ admin: { hidden: true, readOnly: true },
262
+ });
263
+ }
264
+ if (!usersFieldNames.has("nitrogenAgentCredentialUI")) {
265
+ agentFields.push({
266
+ name: "nitrogenAgentCredentialUI",
267
+ type: "ui",
268
+ admin: {
269
+ components: {
270
+ Field: {
271
+ path: "@nitrogenbuilder/connector-payload/components/NitrogenAgentCredential#NitrogenAgentCredential",
272
+ clientProps: { apiUrl: "/api/nitrogen/v1" },
273
+ },
274
+ },
275
+ },
276
+ });
277
+ }
278
+ if (agentFields.length > 0) {
279
+ config.collections[usersIndex] = {
280
+ ...usersCol,
281
+ fields: [...usersCol.fields, ...agentFields],
282
+ };
283
+ }
284
+ }
227
285
  return config;
228
286
  };
229
287
  // Re-export for consumers who need direct access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitrogenbuilder/connector-payload",
3
- "version": "0.1.43",
3
+ "version": "0.1.50",
4
4
  "description": "Nitrogen page builder connector plugin for Payload CMS 3.x",
5
5
  "author": "Leonardo Dentzien <leo@torchmedia.ca>",
6
6
  "type": "module",
@@ -56,6 +56,9 @@
56
56
  "@nitrogenbuilder/types": "link:../monogen/packages/types"
57
57
  },
58
58
  "pnpm": {
59
- "onlyBuiltDependencies": ["esbuild", "sharp"]
59
+ "onlyBuiltDependencies": [
60
+ "esbuild",
61
+ "sharp"
62
+ ]
60
63
  }
61
- }
64
+ }