multisite-cms-mcp 1.2.3 → 1.3.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.
@@ -14,36 +14,34 @@ async function validatePackage(fileList, manifestContent, templateContents) {
14
14
  const hasManifest = fileList.some(f => f === 'manifest.json' || f.endsWith('/manifest.json'));
15
15
  const hasPagesFolder = fileList.some(f => f.includes('pages/'));
16
16
  const hasPublicFolder = fileList.some(f => f.includes('public/'));
17
- // Note: Templates folder is optional - only needed for CMS pages
18
- // Check is performed but variable not stored since it's informational only
19
17
  if (!hasManifest) {
20
18
  errors.push('- Missing manifest.json at package root');
21
19
  }
22
20
  else {
23
- validations.push('manifest.json found');
21
+ validations.push('manifest.json found');
24
22
  }
25
23
  if (!hasPagesFolder) {
26
24
  warnings.push('- No pages/ folder found - static pages should be in pages/');
27
25
  }
28
26
  else {
29
- validations.push('pages/ folder found');
27
+ validations.push('pages/ folder found');
30
28
  }
31
29
  if (!hasPublicFolder) {
32
30
  warnings.push('- No public/ folder found - assets (CSS, JS, images) should be in public/');
33
31
  }
34
32
  else {
35
- validations.push('public/ folder found');
33
+ validations.push('public/ folder found');
36
34
  }
37
35
  // 2. Validate manifest
38
36
  const manifestResult = await (0, validate_manifest_1.validateManifest)(manifestContent);
39
- if (manifestResult.startsWith('')) {
37
+ if (manifestResult.includes('INVALID')) {
40
38
  errors.push('- Manifest validation failed (see details below)');
41
39
  }
42
- else if (manifestResult.startsWith('⚠️')) {
40
+ else if (manifestResult.includes('WARNINGS')) {
43
41
  warnings.push('- Manifest has warnings (see details below)');
44
42
  }
45
43
  else {
46
- validations.push('manifest.json is valid');
44
+ validations.push('manifest.json is valid');
47
45
  }
48
46
  // 3. Parse manifest for template checks
49
47
  let manifest = null;
@@ -65,19 +63,25 @@ async function validatePackage(fileList, manifestContent, templateContents) {
65
63
  }
66
64
  }
67
65
  }
68
- // 5. Check CMS template files exist
66
+ // 5. Check CMS template files exist (detect custom collections from manifest)
69
67
  if (manifest?.cmsTemplates) {
70
- const templateFields = ['blogIndex', 'blogPost', 'team', 'downloads', 'authorsIndex', 'authorDetail'];
71
- for (const field of templateFields) {
72
- const templateFile = manifest.cmsTemplates[field];
73
- if (templateFile) {
74
- const fileExists = fileList.some(f => f === templateFile || f.endsWith('/' + templateFile));
75
- if (!fileExists) {
76
- errors.push(`- Template file not found: ${templateFile} (cmsTemplates.${field})`);
77
- }
78
- else {
79
- validations.push(`✓ ${field} template exists`);
80
- }
68
+ const templates = manifest.cmsTemplates;
69
+ const checkedTemplates = new Set();
70
+ for (const [key, value] of Object.entries(templates)) {
71
+ // Skip path keys and non-string values
72
+ if (key.endsWith('Path') || typeof value !== 'string' || !value.endsWith('.html')) {
73
+ continue;
74
+ }
75
+ // Avoid duplicate checks
76
+ if (checkedTemplates.has(value))
77
+ continue;
78
+ checkedTemplates.add(value);
79
+ const fileExists = fileList.some(f => f === value || f.endsWith('/' + value));
80
+ if (!fileExists) {
81
+ errors.push(`- Template file not found: ${value} (cmsTemplates.${key})`);
82
+ }
83
+ else {
84
+ validations.push(`${key} template exists`);
81
85
  }
82
86
  }
83
87
  }
@@ -85,31 +89,32 @@ async function validatePackage(fileList, manifestContent, templateContents) {
85
89
  const templateValidations = [];
86
90
  if (templateContents) {
87
91
  for (const [path, content] of Object.entries(templateContents)) {
92
+ // Determine template type based on path
88
93
  let templateType = 'static_page';
89
- if (path.includes('blog_index') || path.includes('insights_index') || path.includes('news_index')) {
90
- templateType = 'blog_index';
94
+ // Check if it's an index template
95
+ if (path.includes('_index') || path.includes('-index')) {
96
+ templateType = 'custom_index';
91
97
  }
92
- else if (path.includes('blog_post') || path.includes('insights_detail') || path.includes('news_detail')) {
93
- templateType = 'blog_post';
98
+ // Check if it's a detail template
99
+ else if (path.includes('_detail') || path.includes('-detail') || path.includes('_post') || path.includes('-post')) {
100
+ templateType = 'custom_detail';
94
101
  }
95
- else if (path.includes('team')) {
96
- templateType = 'team';
97
- }
98
- else if (path.includes('download')) {
99
- templateType = 'downloads';
100
- }
101
- else if (path.includes('author') && path.includes('index')) {
102
- templateType = 'authors_index';
103
- }
104
- else if (path.includes('author') && path.includes('detail')) {
105
- templateType = 'author_detail';
102
+ // Check if it's in templates/ folder (likely a CMS template)
103
+ else if (path.includes('templates/')) {
104
+ // If it has collection-like patterns
105
+ if (content.includes('{{#each')) {
106
+ templateType = 'custom_index';
107
+ }
108
+ else if (content.includes('{{name}}') || content.includes('{{slug}}')) {
109
+ templateType = 'custom_detail';
110
+ }
106
111
  }
107
112
  const result = await (0, validate_template_1.validateTemplate)(content, templateType);
108
- if (result.startsWith('')) {
113
+ if (result.includes('ERRORS')) {
109
114
  errors.push(`- Template ${path} has errors`);
110
115
  templateValidations.push(`\n### ${path}\n${result}`);
111
116
  }
112
- else if (result.startsWith('⚠️')) {
117
+ else if (result.includes('WARNINGS')) {
113
118
  warnings.push(`- Template ${path} has warnings`);
114
119
  templateValidations.push(`\n### ${path}\n${result}`);
115
120
  }
@@ -141,9 +146,9 @@ async function validatePackage(fileList, manifestContent, templateContents) {
141
146
  // Build result
142
147
  let output = '';
143
148
  if (errors.length === 0) {
144
- output = `✅ PACKAGE STRUCTURE VALID
149
+ output = `PACKAGE STRUCTURE VALID
145
150
 
146
- ${validations.join('\n')}
151
+ ${validations.map(v => `- ${v}`).join('\n')}
147
152
 
148
153
  Summary:
149
154
  - ${htmlFiles.length} HTML file(s)
@@ -158,13 +163,13 @@ ${warnings.join('\n')}`;
158
163
  }
159
164
  }
160
165
  else {
161
- output = `❌ PACKAGE HAS ERRORS
166
+ output = `PACKAGE HAS ERRORS
162
167
 
163
168
  Errors (must fix):
164
169
  ${errors.join('\n')}
165
170
 
166
171
  What passed:
167
- ${validations.join('\n')}`;
172
+ ${validations.map(v => `- ${v}`).join('\n')}`;
168
173
  if (warnings.length > 0) {
169
174
  output += `
170
175
 
@@ -1,9 +1,9 @@
1
- type TemplateType = 'blog_index' | 'blog_post' | 'team' | 'downloads' | 'authors_index' | 'author_detail' | 'custom_index' | 'custom_detail' | 'static_page';
1
+ type TemplateType = 'custom_index' | 'custom_detail' | 'static_page';
2
2
  /**
3
3
  * Validates an HTML template for correct CMS token usage
4
4
  *
5
5
  * @param html - The HTML template content
6
- * @param templateType - The type of template
6
+ * @param templateType - The type of template (custom_index, custom_detail, static_page)
7
7
  * @param collectionSlug - For custom collections, the collection slug
8
8
  * @param projectId - Optional: Project ID or name to validate against actual schema
9
9
  */
@@ -1 +1 @@
1
- {"version":3,"file":"validate-template.d.ts","sourceRoot":"","sources":["../../src/tools/validate-template.ts"],"names":[],"mappings":"AAMA,KAAK,YAAY,GAAG,YAAY,GAAG,WAAW,GAAG,MAAM,GAAG,WAAW,GAAG,eAAe,GAAG,eAAe,GAAG,cAAc,GAAG,eAAe,GAAG,aAAa,CAAC;AA6O7J;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,YAAY,EAC1B,cAAc,CAAC,EAAE,MAAM,EACvB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC,CA4ZjB"}
1
+ {"version":3,"file":"validate-template.d.ts","sourceRoot":"","sources":["../../src/tools/validate-template.ts"],"names":[],"mappings":"AAMA,KAAK,YAAY,GAAG,cAAc,GAAG,eAAe,GAAG,aAAa,CAAC;AAmJrE;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,YAAY,EAC1B,cAAc,CAAC,EAAE,MAAM,EACvB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC,CAoXjB"}
@@ -2,73 +2,14 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateTemplate = validateTemplate;
4
4
  const api_client_1 = require("../lib/api-client");
5
- const BLOG_FIELDS = [
6
- { name: 'name', isRichText: false, isRequired: true },
7
- { name: 'slug', isRichText: false, isRequired: true },
8
- { name: 'mainImage', isRichText: false },
9
- { name: 'thumbnailImage', isRichText: false },
10
- { name: 'postSummary', isRichText: false },
11
- { name: 'postBody', isRichText: true },
12
- { name: 'featured', isRichText: false },
13
- { name: 'publishedAt', isRichText: false },
14
- { name: 'url', isRichText: false },
15
- ];
16
- const AUTHOR_FIELDS = [
17
- { name: 'name', isRichText: false, isRequired: true },
18
- { name: 'slug', isRichText: false, isRequired: true },
19
- { name: 'bio', isRichText: true },
20
- { name: 'picture', isRichText: false },
21
- { name: 'email', isRichText: false },
22
- { name: 'twitterProfileLink', isRichText: false },
23
- { name: 'linkedinProfileLink', isRichText: false },
24
- { name: 'url', isRichText: false },
25
- ];
26
- const TEAM_FIELDS = [
27
- { name: 'name', isRichText: false, isRequired: true },
28
- { name: 'slug', isRichText: false },
29
- { name: 'role', isRichText: false },
30
- { name: 'bio', isRichText: true },
31
- { name: 'photo', isRichText: false },
32
- { name: 'email', isRichText: false },
33
- { name: 'order', isRichText: false },
34
- ];
35
- const DOWNLOAD_FIELDS = [
36
- { name: 'title', isRichText: false, isRequired: true },
37
- { name: 'slug', isRichText: false },
38
- { name: 'description', isRichText: false },
39
- { name: 'fileUrl', isRichText: false },
40
- { name: 'category', isRichText: false },
41
- { name: 'order', isRichText: false },
42
- ];
43
- // Common wrong field names and their corrections
44
- const COMMON_MISTAKES = {
45
- 'title': 'name (for blog posts) or title (for downloads)',
46
- 'body': 'postBody',
47
- 'content': 'postBody',
48
- 'excerpt': 'postSummary',
49
- 'summary': 'postSummary',
50
- 'image': 'mainImage or thumbnailImage',
51
- 'coverImage': 'mainImage',
52
- 'coverImageUrl': 'mainImage',
53
- 'heroImage': 'mainImage',
54
- 'thumbnail': 'thumbnailImage',
55
- 'date': 'publishedAt',
56
- 'createdAt': 'publishedAt',
57
- 'author_name': 'author.name',
58
- 'authorName': 'author.name',
59
- 'link': 'url',
60
- 'href': 'url',
61
- 'headshot': 'photo (for team) or picture (for authors)',
62
- 'headshotUrl': 'photo (for team) or picture (for authors)',
63
- 'position': 'role',
64
- 'jobTitle': 'role',
65
- };
66
- // Built-in collection standard fields (not including custom fields)
67
- const BUILTIN_STANDARD_FIELDS = {
68
- blogs: ['name', 'slug', 'mainImage', 'thumbnailImage', 'postSummary', 'postBody', 'featured', 'publishedAt', 'author', 'url'],
69
- authors: ['name', 'slug', 'bio', 'picture', 'email', 'twitterProfileLink', 'linkedinProfileLink', 'url'],
70
- team: ['name', 'slug', 'role', 'bio', 'photo', 'email', 'order'],
71
- downloads: ['title', 'slug', 'description', 'fileUrl', 'category', 'order'],
5
+ // Common wrong field names and suggestions (generic)
6
+ const COMMON_SUGGESTIONS = {
7
+ 'body': 'Consider using a descriptive name like "content" or "description"',
8
+ 'content': 'Good field name - make sure it exists in your collection',
9
+ 'image': 'Consider using a descriptive name like "heroImage", "thumbnail", etc.',
10
+ 'link': 'Use "url" as the field name for links',
11
+ 'href': 'Use "url" as the field name for links',
12
+ 'date': 'Use "publishedAt" (auto-tracked) or define a custom date field',
72
13
  };
73
14
  /**
74
15
  * Resolve project identifier to tenant ID
@@ -95,17 +36,8 @@ async function fetchTenantSchema(tenantId) {
95
36
  if ((0, api_client_1.isApiError)(collectionsRes)) {
96
37
  return null;
97
38
  }
98
- // Fetch builtin collection fields
99
- const builtinRes = await (0, api_client_1.apiRequest)('/api/cms/builtin-collections', { tenantId });
100
- const builtinFields = {};
101
- if (!(0, api_client_1.isApiError)(builtinRes)) {
102
- for (const bc of builtinRes.data) {
103
- builtinFields[bc.type] = bc.fields;
104
- }
105
- }
106
39
  return {
107
40
  collections: collectionsRes.data,
108
- builtinFields,
109
41
  };
110
42
  }
111
43
  /**
@@ -135,42 +67,41 @@ function extractTokenFieldNames(html) {
135
67
  /**
136
68
  * Check which fields are missing from schema
137
69
  */
138
- function findMissingFields(usedFields, collectionType, isBuiltin, schema) {
70
+ function findMissingFields(usedFields, collectionType, schema) {
139
71
  const missing = [];
140
- if (isBuiltin) {
141
- // Check against builtin standard fields + custom fields
142
- const standardFields = BUILTIN_STANDARD_FIELDS[collectionType] || [];
143
- const customFields = schema.builtinFields[collectionType]?.map(f => f.slug) || [];
144
- const allFields = [...standardFields, ...customFields];
72
+ // Check against custom collection fields
73
+ const collection = schema.collections.find(c => c.slug === collectionType);
74
+ if (collection) {
75
+ // Built-in fields available on all items
76
+ const builtInFields = ['name', 'slug', 'url', 'publishedAt', 'createdAt', 'updatedAt'];
77
+ const collectionFields = [...builtInFields, ...collection.fields.map(f => f.slug)];
145
78
  for (const field of usedFields) {
146
- if (!allFields.includes(field)) {
79
+ if (!collectionFields.includes(field)) {
147
80
  missing.push(field);
148
81
  }
149
82
  }
150
83
  }
151
84
  else {
152
- // Check against custom collection fields
153
- const collection = schema.collections.find(c => c.slug === collectionType);
154
- if (collection) {
155
- const collectionFields = ['name', 'slug', 'publishedAt', ...collection.fields.map(f => f.slug)];
156
- for (const field of usedFields) {
157
- if (!collectionFields.includes(field)) {
158
- missing.push(field);
159
- }
160
- }
161
- }
162
- else {
163
- // Collection doesn't exist - all fields are "missing"
164
- return usedFields;
165
- }
85
+ // Collection doesn't exist - all fields except built-ins are "missing"
86
+ const builtInFields = ['name', 'slug', 'url', 'publishedAt', 'createdAt', 'updatedAt'];
87
+ return usedFields.filter(f => !builtInFields.includes(f));
166
88
  }
167
89
  return missing;
168
90
  }
91
+ /**
92
+ * Get richText fields from collection
93
+ */
94
+ function getRichTextFields(collectionSlug, schema) {
95
+ const collection = schema.collections.find(c => c.slug === collectionSlug);
96
+ if (!collection)
97
+ return [];
98
+ return collection.fields.filter(f => f.type === 'richText').map(f => f.slug);
99
+ }
169
100
  /**
170
101
  * Validates an HTML template for correct CMS token usage
171
102
  *
172
103
  * @param html - The HTML template content
173
- * @param templateType - The type of template
104
+ * @param templateType - The type of template (custom_index, custom_detail, static_page)
174
105
  * @param collectionSlug - For custom collections, the collection slug
175
106
  * @param projectId - Optional: Project ID or name to validate against actual schema
176
107
  */
@@ -178,36 +109,7 @@ async function validateTemplate(html, templateType, collectionSlug, projectId) {
178
109
  const errors = [];
179
110
  const warnings = [];
180
111
  const suggestions = [];
181
- // Get expected fields based on template type
182
- let expectedFields = [];
183
- let collectionName = '';
184
- switch (templateType) {
185
- case 'blog_index':
186
- case 'blog_post':
187
- expectedFields = BLOG_FIELDS;
188
- collectionName = 'blogs';
189
- break;
190
- case 'authors_index':
191
- case 'author_detail':
192
- expectedFields = AUTHOR_FIELDS;
193
- collectionName = 'authors';
194
- break;
195
- case 'team':
196
- expectedFields = TEAM_FIELDS;
197
- collectionName = 'team';
198
- break;
199
- case 'downloads':
200
- expectedFields = DOWNLOAD_FIELDS;
201
- collectionName = 'downloads';
202
- break;
203
- case 'custom_index':
204
- case 'custom_detail':
205
- collectionName = collectionSlug || 'collection';
206
- break;
207
- case 'static_page':
208
- // Static pages just use data-edit-key
209
- break;
210
- }
112
+ let richTextFields = [];
211
113
  // Extract all tokens from the HTML
212
114
  const doubleTokens = html.match(/\{\{([^{}#/]+)\}\}/g) || [];
213
115
  const tripleTokens = html.match(/\{\{\{([^{}]+)\}\}\}/g) || [];
@@ -219,38 +121,21 @@ async function validateTemplate(html, templateType, collectionSlug, projectId) {
219
121
  if (tokensWithSpaces.length > 0 || tokensWithSpaces2.length > 0) {
220
122
  warnings.push('- Some tokens have spaces inside braces (e.g., {{ name }} instead of {{name}}). While supported, {{name}} without spaces is preferred.');
221
123
  }
222
- // Check for common mistakes
124
+ // Check for common suggestions
223
125
  for (const token of doubleTokens) {
224
126
  const fieldName = token.replace(/\{\{|\}\}/g, '').trim();
225
127
  // Skip control structures
226
128
  if (fieldName.startsWith('#') || fieldName.startsWith('/') || fieldName === 'else' || fieldName.startsWith('@')) {
227
129
  continue;
228
130
  }
229
- // Check against common mistakes
131
+ // Check against common suggestions
230
132
  const baseName = fieldName.split('.')[0];
231
- if (COMMON_MISTAKES[baseName]) {
232
- errors.push(`- Wrong token: {{${fieldName}}} → Use ${COMMON_MISTAKES[baseName]}`);
133
+ if (COMMON_SUGGESTIONS[baseName]) {
134
+ suggestions.push(`- Token {{${baseName}}}: ${COMMON_SUGGESTIONS[baseName]}`);
233
135
  }
234
136
  }
235
- // Check richText fields are using triple braces
236
- for (const field of expectedFields) {
237
- if (field.isRichText) {
238
- const doublePattern = new RegExp(`\\{\\{${field.name}\\}\\}`, 'g');
239
- const triplePattern = new RegExp(`\\{\\{\\{${field.name}\\}\\}\\}`, 'g');
240
- const hasDouble = doublePattern.test(html);
241
- const hasTriple = triplePattern.test(html);
242
- if (hasDouble && !hasTriple) {
243
- errors.push(`- {{${field.name}}} must use triple braces {{{${field.name}}}} because it contains HTML`);
244
- }
245
- }
246
- }
247
- // Check for nested author fields in double braces (should be fine)
248
- const authorBioDouble = /\{\{author\.bio\}\}/g.test(html);
249
- if (authorBioDouble) {
250
- errors.push('- {{author.bio}} must use triple braces {{{author.bio}}} because it contains HTML');
251
- }
252
137
  // Check loop syntax for index templates
253
- if (templateType.endsWith('_index') || templateType === 'team' || templateType === 'downloads') {
138
+ if (templateType === 'custom_index') {
254
139
  if (eachLoops.length === 0) {
255
140
  warnings.push(`- No {{#each}} loop found - index templates usually need a loop to display items`);
256
141
  }
@@ -260,8 +145,8 @@ async function validateTemplate(html, templateType, collectionSlug, projectId) {
260
145
  const match = loop.match(/\{\{#each\s+(\w+)/);
261
146
  if (match) {
262
147
  const usedCollection = match[1];
263
- if (collectionName && usedCollection !== collectionName) {
264
- warnings.push(`- Loop uses '${usedCollection}' but expected '${collectionName}'`);
148
+ if (collectionSlug && usedCollection !== collectionSlug) {
149
+ warnings.push(`- Loop uses '${usedCollection}' but expected '${collectionSlug}'`);
265
150
  }
266
151
  }
267
152
  }
@@ -302,6 +187,23 @@ async function validateTemplate(html, templateType, collectionSlug, projectId) {
302
187
  if (wrongAssetPaths.length > 5) {
303
188
  warnings.push(`- ...and ${wrongAssetPaths.length - 5} more asset paths that may need /public/ prefix`);
304
189
  }
190
+ // Check YouTube iframes for required attributes
191
+ const allIframes = html.match(/<iframe[^>]*>/gi) || [];
192
+ for (const iframe of allIframes) {
193
+ const isYouTubeEmbed = /youtube\.com|youtu\.be/i.test(iframe);
194
+ const hasTemplateSrc = /src=["'][^"']*\{\{[^}]+\}\}[^"']*["']/i.test(iframe);
195
+ if (isYouTubeEmbed || hasTemplateSrc) {
196
+ if (!/referrerpolicy/i.test(iframe)) {
197
+ errors.push(`- YouTube iframe missing referrerpolicy attribute. Add: referrerpolicy="strict-origin-when-cross-origin" (without this, YouTube will show Error 153)`);
198
+ }
199
+ if (!/allowfullscreen/i.test(iframe)) {
200
+ warnings.push(`- iframe missing allowfullscreen attribute - fullscreen button won't work`);
201
+ }
202
+ if (!/title=/i.test(iframe)) {
203
+ suggestions.push(`- Consider adding a title attribute to iframe for accessibility`);
204
+ }
205
+ }
206
+ }
305
207
  // Validate site tokens
306
208
  const siteTokens = html.match(/\{\{site\.(\w+)\}\}/g) || [];
307
209
  for (const token of siteTokens) {
@@ -320,7 +222,6 @@ async function validateTemplate(html, templateType, collectionSlug, projectId) {
320
222
  // Validate parent context references (../)
321
223
  const parentRefs = html.match(/\{\{\.\.\/([\w.]+)\}\}/g) || [];
322
224
  if (parentRefs.length > 0) {
323
- // Check if they're used inside a loop
324
225
  if (eachLoops.length === 0) {
325
226
  warnings.push(`- Found ${parentRefs.length} parent reference(s) like {{../name}} but no {{#each}} loop - these only work inside loops`);
326
227
  }
@@ -332,7 +233,6 @@ async function validateTemplate(html, templateType, collectionSlug, projectId) {
332
233
  const eqHelpers = html.match(/\{\{#if\s+\(eq\s+[^)]+\)\s*\}\}/g) || [];
333
234
  if (eqHelpers.length > 0) {
334
235
  suggestions.push(`- Found ${eqHelpers.length} equality comparison(s) like {{#if (eq field1 field2)}} - compares two values`);
335
- // Check if closing {{/if}} exists for each
336
236
  for (const helper of eqHelpers) {
337
237
  if (!html.includes('{{/if}}')) {
338
238
  errors.push(`- Missing {{/if}} to close: ${helper}`);
@@ -353,49 +253,35 @@ async function validateTemplate(html, templateType, collectionSlug, projectId) {
353
253
  if (resolved) {
354
254
  const schema = await fetchTenantSchema(resolved.tenantId);
355
255
  if (schema) {
356
- // Determine which collection we're validating
357
- let targetCollection = collectionSlug || '';
358
- let isBuiltin = false;
359
- switch (templateType) {
360
- case 'blog_index':
361
- case 'blog_post':
362
- targetCollection = 'blogs';
363
- isBuiltin = true;
364
- break;
365
- case 'authors_index':
366
- case 'author_detail':
367
- targetCollection = 'authors';
368
- isBuiltin = true;
369
- break;
370
- case 'team':
371
- targetCollection = 'team';
372
- isBuiltin = true;
373
- break;
374
- case 'downloads':
375
- targetCollection = 'downloads';
376
- isBuiltin = true;
377
- break;
378
- case 'custom_index':
379
- case 'custom_detail':
380
- isBuiltin = false;
381
- break;
382
- }
256
+ const targetCollection = collectionSlug || '';
383
257
  if (targetCollection && templateType !== 'static_page') {
258
+ // Get richText fields for triple brace validation
259
+ richTextFields = getRichTextFields(targetCollection, schema);
260
+ // Check richText fields are using triple braces
261
+ for (const fieldSlug of richTextFields) {
262
+ const doublePattern = new RegExp(`\\{\\{${fieldSlug}\\}\\}`, 'g');
263
+ const triplePattern = new RegExp(`\\{\\{\\{${fieldSlug}\\}\\}\\}`, 'g');
264
+ const hasDouble = doublePattern.test(html);
265
+ const hasTriple = triplePattern.test(html);
266
+ if (hasDouble && !hasTriple) {
267
+ errors.push(`- {{${fieldSlug}}} must use triple braces {{{${fieldSlug}}}} because it contains HTML (richText field)`);
268
+ }
269
+ }
384
270
  // Extract fields used in template
385
271
  const usedFields = extractTokenFieldNames(html);
386
- // Check if custom collection exists
387
- const collectionExists = isBuiltin || schema.collections.some(c => c.slug === targetCollection);
388
- if (!isBuiltin && !collectionExists) {
272
+ // Check if collection exists
273
+ const collectionExists = schema.collections.some(c => c.slug === targetCollection);
274
+ if (!collectionExists) {
389
275
  // Collection doesn't exist - need to create it
390
276
  schemaValidation = `
391
277
 
392
- ## 🚨 ACTION REQUIRED: Collection Does Not Exist
278
+ ## ACTION REQUIRED: Collection Does Not Exist
393
279
 
394
280
  The collection "${targetCollection}" does **not exist** in this project.
395
281
 
396
282
  ---
397
283
 
398
- ### YOU MUST CREATE THIS COLLECTION
284
+ ### YOU MUST CREATE THIS COLLECTION
399
285
 
400
286
  Use the \`sync_schema\` tool to create the collection and its fields before deploying.
401
287
 
@@ -412,7 +298,7 @@ Use the \`sync_schema\` tool to create the collection and its fields before depl
412
298
  "name": "${targetCollection.charAt(0).toUpperCase() + targetCollection.slice(1)}",
413
299
  "nameSingular": "${targetCollection.endsWith('s') ? targetCollection.slice(0, -1).charAt(0).toUpperCase() + targetCollection.slice(0, -1).slice(1) : targetCollection.charAt(0).toUpperCase() + targetCollection.slice(1)}",
414
300
  "fields": [
415
- ${usedFields.filter(f => !['name', 'slug', 'publishedAt'].includes(f)).map(f => ` { "slug": "${f}", "name": "${f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').trim()}", "type": "YOUR_TYPE" }`).join(',\n')}
301
+ ${usedFields.filter(f => !['name', 'slug', 'url', 'publishedAt', 'createdAt', 'updatedAt'].includes(f)).map(f => ` { "slug": "${f}", "name": "${f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').trim()}", "type": "YOUR_TYPE" }`).join(',\n')}
416
302
  ]
417
303
  }
418
304
  ]
@@ -428,17 +314,18 @@ ${usedFields.filter(f => !['name', 'slug', 'publishedAt'].includes(f)).map(f =>
428
314
  - \`date\` - Date picker
429
315
  - \`number\` - Numeric values
430
316
  - \`select\` - Dropdown (requires \`options\` parameter)
317
+ - \`relation\` - Link to another collection (requires \`referenceCollection\` parameter)
431
318
 
432
319
  **DO NOT SKIP THIS STEP** - Templates will not render without the collection.
433
320
  `;
434
321
  }
435
322
  else {
436
323
  // Find missing fields
437
- const missingFields = findMissingFields(usedFields, targetCollection, isBuiltin, schema);
324
+ const missingFields = findMissingFields(usedFields, targetCollection, schema);
438
325
  if (missingFields.length > 0) {
439
326
  schemaValidation = `
440
327
 
441
- ## 🚨 ACTION REQUIRED: Missing Fields
328
+ ## ACTION REQUIRED: Missing Fields
442
329
 
443
330
  The following fields are used in the template but **do not exist** in the "${targetCollection}" collection:
444
331
 
@@ -446,7 +333,7 @@ ${missingFields.map(f => `- \`${f}\``).join('\n')}
446
333
 
447
334
  ---
448
335
 
449
- ### YOU MUST CREATE THESE FIELDS
336
+ ### YOU MUST CREATE THESE FIELDS
450
337
 
451
338
  Use the \`sync_schema\` tool to create the missing fields before deploying. This is required for your template to work correctly.
452
339
 
@@ -460,7 +347,7 @@ Use the \`sync_schema\` tool to create the missing fields before deploying. This
460
347
  "fieldsToAdd": [
461
348
  {
462
349
  "collectionSlug": "${targetCollection}",
463
- "isBuiltin": ${isBuiltin},
350
+ "isBuiltin": false,
464
351
  "fields": [
465
352
  ${missingFields.map(f => ` { "slug": "${f}", "name": "${f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').trim()}", "type": "YOUR_TYPE" }`).join(',\n')}
466
353
  ]
@@ -478,6 +365,7 @@ ${missingFields.map(f => ` { "slug": "${f}", "name": "${f.charAt(0).toUpp
478
365
  - \`date\` - Date picker
479
366
  - \`number\` - Numeric values
480
367
  - \`select\` - Dropdown (requires \`options\` parameter)
368
+ - \`relation\` - Link to another collection (requires \`referenceCollection\` parameter)
481
369
 
482
370
  **DO NOT SKIP THIS STEP** - Templates will not render correctly without these fields.
483
371
  `;
@@ -485,7 +373,7 @@ ${missingFields.map(f => ` { "slug": "${f}", "name": "${f.charAt(0).toUpp
485
373
  else {
486
374
  schemaValidation = `
487
375
 
488
- ## Schema Validation Passed
376
+ ## Schema Validation Passed
489
377
 
490
378
  All fields used in this template exist in the "${targetCollection}" collection.
491
379
  `;
@@ -499,7 +387,7 @@ All fields used in this template exist in the "${targetCollection}" collection.
499
387
  // projectId provided but not authenticated
500
388
  schemaValidation = `
501
389
 
502
- ## ℹ️ Schema Validation Skipped
390
+ ## Schema Validation Skipped
503
391
 
504
392
  A projectId was provided but you're not authenticated.
505
393
  To validate against your project's schema, authenticate first using the device flow or FASTMODE_AUTH_TOKEN.
@@ -510,7 +398,7 @@ Without authentication, only syntax validation is performed.
510
398
  // Build result
511
399
  let output = '';
512
400
  if (errors.length === 0 && warnings.length === 0) {
513
- output = `✅ TEMPLATE VALID
401
+ output = `TEMPLATE VALID
514
402
 
515
403
  The ${templateType} template structure looks correct.
516
404
 
@@ -527,7 +415,7 @@ ${suggestions.join('\n')}`;
527
415
  }
528
416
  }
529
417
  else if (errors.length === 0) {
530
- output = `⚠️ TEMPLATE VALID WITH WARNINGS
418
+ output = `TEMPLATE VALID WITH WARNINGS
531
419
 
532
420
  Warnings:
533
421
  ${warnings.join('\n')}`;
@@ -539,7 +427,7 @@ ${suggestions.join('\n')}`;
539
427
  }
540
428
  }
541
429
  else {
542
- output = `❌ TEMPLATE HAS ERRORS
430
+ output = `TEMPLATE HAS ERRORS
543
431
 
544
432
  Errors (must fix):
545
433
  ${errors.join('\n')}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multisite-cms-mcp",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "MCP server for Fast Mode CMS. Convert websites, validate packages, and deploy directly to Fast Mode. Includes authentication, project creation, schema sync, and one-click deployment.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {