fastmode-mcp 1.0.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.
Files changed (67) hide show
  1. package/README.md +561 -0
  2. package/bin/run.js +50 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +802 -0
  6. package/dist/lib/api-client.d.ts +81 -0
  7. package/dist/lib/api-client.d.ts.map +1 -0
  8. package/dist/lib/api-client.js +237 -0
  9. package/dist/lib/auth-state.d.ts +13 -0
  10. package/dist/lib/auth-state.d.ts.map +1 -0
  11. package/dist/lib/auth-state.js +24 -0
  12. package/dist/lib/context-fetcher.d.ts +67 -0
  13. package/dist/lib/context-fetcher.d.ts.map +1 -0
  14. package/dist/lib/context-fetcher.js +190 -0
  15. package/dist/lib/credentials.d.ts +52 -0
  16. package/dist/lib/credentials.d.ts.map +1 -0
  17. package/dist/lib/credentials.js +196 -0
  18. package/dist/lib/device-flow.d.ts +14 -0
  19. package/dist/lib/device-flow.d.ts.map +1 -0
  20. package/dist/lib/device-flow.js +244 -0
  21. package/dist/tools/cms-items.d.ts +56 -0
  22. package/dist/tools/cms-items.d.ts.map +1 -0
  23. package/dist/tools/cms-items.js +376 -0
  24. package/dist/tools/create-site.d.ts +9 -0
  25. package/dist/tools/create-site.d.ts.map +1 -0
  26. package/dist/tools/create-site.js +202 -0
  27. package/dist/tools/deploy-package.d.ts +9 -0
  28. package/dist/tools/deploy-package.d.ts.map +1 -0
  29. package/dist/tools/deploy-package.js +434 -0
  30. package/dist/tools/generate-samples.d.ts +19 -0
  31. package/dist/tools/generate-samples.d.ts.map +1 -0
  32. package/dist/tools/generate-samples.js +272 -0
  33. package/dist/tools/get-conversion-guide.d.ts +7 -0
  34. package/dist/tools/get-conversion-guide.d.ts.map +1 -0
  35. package/dist/tools/get-conversion-guide.js +1323 -0
  36. package/dist/tools/get-example.d.ts +7 -0
  37. package/dist/tools/get-example.d.ts.map +1 -0
  38. package/dist/tools/get-example.js +1568 -0
  39. package/dist/tools/get-field-types.d.ts +30 -0
  40. package/dist/tools/get-field-types.d.ts.map +1 -0
  41. package/dist/tools/get-field-types.js +154 -0
  42. package/dist/tools/get-schema.d.ts +5 -0
  43. package/dist/tools/get-schema.d.ts.map +1 -0
  44. package/dist/tools/get-schema.js +320 -0
  45. package/dist/tools/get-started.d.ts +21 -0
  46. package/dist/tools/get-started.d.ts.map +1 -0
  47. package/dist/tools/get-started.js +624 -0
  48. package/dist/tools/get-tenant-schema.d.ts +18 -0
  49. package/dist/tools/get-tenant-schema.d.ts.map +1 -0
  50. package/dist/tools/get-tenant-schema.js +158 -0
  51. package/dist/tools/list-projects.d.ts +5 -0
  52. package/dist/tools/list-projects.d.ts.map +1 -0
  53. package/dist/tools/list-projects.js +101 -0
  54. package/dist/tools/sync-schema.d.ts +41 -0
  55. package/dist/tools/sync-schema.d.ts.map +1 -0
  56. package/dist/tools/sync-schema.js +483 -0
  57. package/dist/tools/validate-manifest.d.ts +5 -0
  58. package/dist/tools/validate-manifest.d.ts.map +1 -0
  59. package/dist/tools/validate-manifest.js +311 -0
  60. package/dist/tools/validate-package.d.ts +5 -0
  61. package/dist/tools/validate-package.d.ts.map +1 -0
  62. package/dist/tools/validate-package.js +337 -0
  63. package/dist/tools/validate-template.d.ts +12 -0
  64. package/dist/tools/validate-template.d.ts.map +1 -0
  65. package/dist/tools/validate-template.js +790 -0
  66. package/package.json +54 -0
  67. package/scripts/postinstall.js +129 -0
@@ -0,0 +1,790 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateTemplate = validateTemplate;
4
+ const api_client_1 = require("../lib/api-client");
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',
13
+ };
14
+ /**
15
+ * Resolve project identifier to tenant ID
16
+ */
17
+ async function resolveProjectId(projectIdentifier) {
18
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
+ if (uuidPattern.test(projectIdentifier)) {
20
+ return { tenantId: projectIdentifier };
21
+ }
22
+ const response = await (0, api_client_1.apiRequest)('/api/tenants');
23
+ if ((0, api_client_1.isApiError)(response)) {
24
+ return null;
25
+ }
26
+ const match = response.data.find(p => p.name.toLowerCase() === projectIdentifier.toLowerCase() ||
27
+ p.name.toLowerCase().includes(projectIdentifier.toLowerCase()));
28
+ return match ? { tenantId: match.id } : null;
29
+ }
30
+ /**
31
+ * Fetch tenant schema for validation
32
+ */
33
+ async function fetchTenantSchema(tenantId) {
34
+ // Fetch custom collections
35
+ const collectionsRes = await (0, api_client_1.apiRequest)('/api/collections', { tenantId });
36
+ if ((0, api_client_1.isApiError)(collectionsRes)) {
37
+ return null;
38
+ }
39
+ return {
40
+ collections: collectionsRes.data,
41
+ };
42
+ }
43
+ /**
44
+ * Extract field names used in template tokens
45
+ */
46
+ function extractTokenFieldNames(html) {
47
+ const fields = new Set();
48
+ // Match {{fieldName}} and {{{fieldName}}}
49
+ const doubleTokens = html.match(/\{\{([^{}#/][^{}]*)\}\}/g) || [];
50
+ const tripleTokens = html.match(/\{\{\{([^{}]+)\}\}\}/g) || [];
51
+ for (const token of [...doubleTokens, ...tripleTokens]) {
52
+ const fieldName = token.replace(/\{|\}/g, '').trim();
53
+ // Skip special tokens
54
+ if (fieldName.startsWith('#') || fieldName.startsWith('/') ||
55
+ fieldName === 'else' || fieldName.startsWith('@') ||
56
+ fieldName.startsWith('this') || fieldName.startsWith('../')) {
57
+ continue;
58
+ }
59
+ // Get the base field name (before any dots)
60
+ const baseName = fieldName.split('.')[0];
61
+ if (baseName && baseName !== 'site') {
62
+ fields.add(baseName);
63
+ }
64
+ }
65
+ return Array.from(fields);
66
+ }
67
+ /**
68
+ * Check which fields are missing from schema
69
+ */
70
+ function findMissingFields(usedFields, collectionType, schema) {
71
+ const missing = [];
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)];
78
+ for (const field of usedFields) {
79
+ if (!collectionFields.includes(field)) {
80
+ missing.push(field);
81
+ }
82
+ }
83
+ }
84
+ else {
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));
88
+ }
89
+ return missing;
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
+ }
100
+ /**
101
+ * Validate forms in HTML templates
102
+ * Checks for proper data-form attribute, input names, and submit buttons
103
+ */
104
+ function validateForms(html) {
105
+ const errors = [];
106
+ const warnings = [];
107
+ const suggestions = [];
108
+ // Find all form elements
109
+ const formPattern = /<form[^>]*>/gi;
110
+ const forms = html.match(formPattern) || [];
111
+ if (forms.length === 0) {
112
+ return { errors, warnings, suggestions };
113
+ }
114
+ // Track forms for validation
115
+ let formsWithDataForm = 0;
116
+ let formsWithLegacyAttr = 0;
117
+ for (const formTag of forms) {
118
+ // Check for data-form attribute (correct format)
119
+ const hasDataForm = /data-form=["'][^"']+["']/i.test(formTag);
120
+ // Check for legacy data-form-name attribute
121
+ const hasLegacyDataFormName = /data-form-name=["'][^"']+["']/i.test(formTag);
122
+ if (hasDataForm) {
123
+ formsWithDataForm++;
124
+ // Check if form has proper action attribute pointing to /_forms/
125
+ if (!formTag.includes('action=')) {
126
+ errors.push(`- Form has data-form but no action attribute. Add action="/_forms/formname" for the form to work.`);
127
+ }
128
+ else if (!formTag.includes('/_forms/')) {
129
+ warnings.push(`- Form has action but it doesn't point to /_forms/. Fast Mode forms must submit to /_forms/{formName}.`);
130
+ }
131
+ }
132
+ else if (hasLegacyDataFormName) {
133
+ formsWithLegacyAttr++;
134
+ warnings.push(`- Form uses deprecated data-form-name attribute. Migrate to data-form="formname" for consistency.`);
135
+ // Also check action for legacy forms
136
+ if (!formTag.includes('action=')) {
137
+ errors.push(`- Form has data-form-name but no action attribute. Add action="/_forms/formname" for the form to work.`);
138
+ }
139
+ else if (!formTag.includes('/_forms/')) {
140
+ warnings.push(`- Form has action but it doesn't point to /_forms/. Fast Mode forms must submit to /_forms/{formName}.`);
141
+ }
142
+ }
143
+ else {
144
+ // Form without any data-form attribute - check if it looks like a CMS form
145
+ // Don't error on forms that might be external (like search forms, login forms)
146
+ if (!formTag.includes('action=')) {
147
+ errors.push(`- Form is missing data-form attribute. Add data-form="formname" and action="/_forms/formname" to identify the form for CMS submission.`);
148
+ }
149
+ }
150
+ }
151
+ // Extract form content for deeper validation
152
+ // Find each form block
153
+ const formBlocks = html.match(/<form[^>]*data-form[^>]*>[\s\S]*?<\/form>/gi) || [];
154
+ for (const formBlock of formBlocks) {
155
+ // Check for inputs with name attributes
156
+ const inputs = formBlock.match(/<input[^>]*>/gi) || [];
157
+ const textareas = formBlock.match(/<textarea[^>]*>/gi) || [];
158
+ const selects = formBlock.match(/<select[^>]*>/gi) || [];
159
+ const allInputElements = [...inputs, ...textareas, ...selects];
160
+ let inputsWithName = 0;
161
+ let inputsWithoutName = 0;
162
+ for (const input of allInputElements) {
163
+ // Skip hidden inputs and submit buttons
164
+ if (/type=["'](?:submit|button|hidden)["']/i.test(input)) {
165
+ continue;
166
+ }
167
+ if (/name=["'][^"']+["']/i.test(input)) {
168
+ inputsWithName++;
169
+ }
170
+ else {
171
+ inputsWithoutName++;
172
+ }
173
+ }
174
+ if (inputsWithoutName > 0) {
175
+ errors.push(`- Found ${inputsWithoutName} form input(s) without name attribute. All inputs must have name="fieldname" to be captured.`);
176
+ }
177
+ if (inputsWithName === 0 && allInputElements.length > 0) {
178
+ errors.push(`- Form has no inputs with name attributes - no data will be captured.`);
179
+ }
180
+ // Check for submit button
181
+ const hasSubmitButton = /<button[^>]*type=["']submit["'][^>]*>/i.test(formBlock) ||
182
+ /<input[^>]*type=["']submit["'][^>]*>/i.test(formBlock) ||
183
+ /<button[^>]*>(?!.*type=["']button["'])/i.test(formBlock); // button without type defaults to submit
184
+ if (!hasSubmitButton) {
185
+ warnings.push(`- Form may be missing a submit button. Add <button type="submit">Submit</button> for the form to work.`);
186
+ }
187
+ // Check for form handler script
188
+ const hasFormHandlerScript = html.includes("form[data-form]") || html.includes("form[data-form-name]");
189
+ if (!hasFormHandlerScript) {
190
+ suggestions.push(`- No form handler script detected. Make sure to include JavaScript that handles form submission to /_forms/{formName}`);
191
+ }
192
+ }
193
+ // Summary suggestion
194
+ if (formsWithDataForm > 0) {
195
+ suggestions.push(`- Found ${formsWithDataForm} form(s) with data-form attribute - forms will submit to /_forms/{formName}`);
196
+ }
197
+ // Check for thank-you page redirect pattern
198
+ const hasThankYouRedirect = /window\.location\.href\s*=\s*['"]\/thank-you['"]/i.test(html) ||
199
+ /window\.location\s*=\s*['"]\/thank-you['"]/i.test(html);
200
+ if (formsWithDataForm > 0 && !hasThankYouRedirect) {
201
+ suggestions.push(`- Consider adding a /thank-you page and redirecting there on successful submission for better UX`);
202
+ }
203
+ return { errors, warnings, suggestions };
204
+ }
205
+ /**
206
+ * Extract collection slugs referenced in {{#each}} loops on static pages
207
+ * Supports both {{#each collection}} and {{#each @root.collection}} syntax
208
+ */
209
+ function extractCollectionReferences(html) {
210
+ const collections = [];
211
+ // Match both {{#each collection}} and {{#each @root.collection}}
212
+ const eachLoops = html.match(/\{\{#each\s+(?:@root\.)?([\w]+)[^}]*\}\}/g) || [];
213
+ for (const loop of eachLoops) {
214
+ // Extract collection name, handling @root. prefix
215
+ const match = loop.match(/\{\{#each\s+(?:@root\.)?([\w]+)/);
216
+ if (match) {
217
+ collections.push(match[1]);
218
+ }
219
+ }
220
+ return [...new Set(collections)]; // Unique collections
221
+ }
222
+ /**
223
+ * Validate that loop variables (@first, @last, @index) are only used inside {{#each}} blocks
224
+ * Returns warnings for any usage found outside loops
225
+ */
226
+ function validateLoopVariables(html) {
227
+ const warnings = [];
228
+ // Find all @first, @last, @index, @length usage (standalone or in conditionals)
229
+ const loopVarPattern = /\{\{[#/]?(?:if|unless)?\s*@(first|last|index|length)[^}]*\}\}/g;
230
+ // Find all {{#each}}...{{/each}} blocks
231
+ // Use a simple approach: remove all loop content and check what's left
232
+ let outsideLoops = html;
233
+ // Repeatedly remove innermost loops until none left
234
+ // This handles nested loops correctly
235
+ let previousLength = 0;
236
+ while (outsideLoops.length !== previousLength) {
237
+ previousLength = outsideLoops.length;
238
+ // Match non-greedy: find {{#each...}} followed by content without nested {{#each}}, then {{/each}}
239
+ outsideLoops = outsideLoops.replace(/\{\{#each[^}]*\}\}(?:(?!\{\{#each)[\s\S])*?\{\{\/each\}\}/g, '');
240
+ }
241
+ // Now check if any loop variables remain in the content outside loops
242
+ const matches = outsideLoops.match(loopVarPattern);
243
+ if (matches && matches.length > 0) {
244
+ const uniqueVars = [...new Set(matches.map(m => {
245
+ const varMatch = m.match(/@(first|last|index|length)/);
246
+ return varMatch ? `@${varMatch[1]}` : m;
247
+ }))];
248
+ warnings.push(`- Loop variables (${uniqueVars.join(', ')}) found outside {{#each}} blocks. These only work inside loops.`);
249
+ }
250
+ return warnings;
251
+ }
252
+ /**
253
+ * Validates an HTML template for correct CMS token usage
254
+ *
255
+ * @param html - The HTML template content
256
+ * @param templateType - The type of template (custom_index, custom_detail, static_page)
257
+ * @param collectionSlug - For custom collections, the collection slug
258
+ * @param projectId - Optional: Project ID or name to validate against actual schema
259
+ */
260
+ async function validateTemplate(html, templateType, collectionSlug, projectId) {
261
+ const errors = [];
262
+ const warnings = [];
263
+ const suggestions = [];
264
+ let richTextFields = [];
265
+ // Extract all tokens from the HTML
266
+ const doubleTokens = html.match(/\{\{([^{}#/]+)\}\}/g) || [];
267
+ const tripleTokens = html.match(/\{\{\{([^{}]+)\}\}\}/g) || [];
268
+ // Match both {{#each collection}} and {{#each @root.collection}}
269
+ const eachLoops = html.match(/\{\{#each\s+(?:@root\.)?([\w]+)[^}]*\}\}/g) || [];
270
+ const conditionals = html.match(/\{\{#if\s+([^}]+)\}\}/g) || [];
271
+ // Check for tokens with spaces (like {{ name }} instead of {{name}})
272
+ const tokensWithSpaces = html.match(/\{\{\s+[^{}]+\s*\}\}/g) || [];
273
+ const tokensWithSpaces2 = html.match(/\{\{[^{}]+\s+\}\}/g) || [];
274
+ if (tokensWithSpaces.length > 0 || tokensWithSpaces2.length > 0) {
275
+ warnings.push('- Some tokens have spaces inside braces (e.g., {{ name }} instead of {{name}}). While supported, {{name}} without spaces is preferred.');
276
+ }
277
+ // Check for common suggestions
278
+ for (const token of doubleTokens) {
279
+ const fieldName = token.replace(/\{\{|\}\}/g, '').trim();
280
+ // Skip control structures
281
+ if (fieldName.startsWith('#') || fieldName.startsWith('/') || fieldName === 'else' || fieldName.startsWith('@')) {
282
+ continue;
283
+ }
284
+ // Check against common suggestions
285
+ const baseName = fieldName.split('.')[0];
286
+ if (COMMON_SUGGESTIONS[baseName]) {
287
+ suggestions.push(`- Token {{${baseName}}}: ${COMMON_SUGGESTIONS[baseName]}`);
288
+ }
289
+ }
290
+ // Check loop syntax for index templates
291
+ if (templateType === 'custom_index') {
292
+ if (eachLoops.length === 0) {
293
+ errors.push(`MISSING LOOP: Index templates MUST have an {{#each}} loop.
294
+
295
+ An index template without a loop will show an empty page.
296
+
297
+ REQUIRED pattern:
298
+ {{#each ${collectionSlug || 'collection'}}}
299
+ <div>
300
+ <h2>{{name}}</h2>
301
+ <a href="{{url}}">Read more</a>
302
+ </div>
303
+ {{/each}}
304
+
305
+ Add an {{#each}} loop to iterate over collection items.`);
306
+ }
307
+ else {
308
+ // Check if loop uses correct collection name (handles @root. prefix)
309
+ for (const loop of eachLoops) {
310
+ const match = loop.match(/\{\{#each\s+(?:@root\.)?([\w]+)/);
311
+ if (match) {
312
+ const usedCollection = match[1];
313
+ if (collectionSlug && usedCollection !== collectionSlug) {
314
+ errors.push(`WRONG COLLECTION: Loop uses '${usedCollection}' but template is for '${collectionSlug}'.
315
+
316
+ Change {{#each ${usedCollection}}} to {{#each ${collectionSlug}}}`);
317
+ }
318
+ }
319
+ }
320
+ }
321
+ }
322
+ // Check detail templates have CMS tokens (otherwise all item pages will be identical)
323
+ if (templateType === 'custom_detail') {
324
+ const hasItemTokens = /\{\{(name|slug|title|content|body|description)\}\}/i.test(html) ||
325
+ /\{\{\{[^}]+\}\}\}/i.test(html) ||
326
+ doubleTokens.length > 0;
327
+ if (!hasItemTokens) {
328
+ errors.push(`MISSING ITEM DATA: Detail template has no CMS tokens.
329
+
330
+ A detail template should display item data like:
331
+ - {{name}} - Item name
332
+ - {{{content}}} - Rich text content (triple braces!)
333
+ - {{image}} - Image URL
334
+
335
+ Without tokens, every item page will show the same static content.`);
336
+ }
337
+ }
338
+ // Check for {{#each}} without closing {{/each}}
339
+ const eachOpens = (html.match(/\{\{#each/g) || []).length;
340
+ const eachCloses = (html.match(/\{\{\/each\}\}/g) || []).length;
341
+ if (eachOpens !== eachCloses) {
342
+ errors.push(`- Unbalanced {{#each}} loops: ${eachOpens} opens, ${eachCloses} closes`);
343
+ }
344
+ // Check for {{#if}} without closing {{/if}}
345
+ const ifOpens = (html.match(/\{\{#if/g) || []).length;
346
+ const ifCloses = (html.match(/\{\{\/if\}\}/g) || []).length;
347
+ if (ifOpens !== ifCloses) {
348
+ errors.push(`- Unbalanced {{#if}} conditionals: ${ifOpens} opens, ${ifCloses} closes`);
349
+ }
350
+ // Check for {{#unless}} without closing
351
+ const unlessOpens = (html.match(/\{\{#unless/g) || []).length;
352
+ const unlessCloses = (html.match(/\{\{\/unless\}\}/g) || []).length;
353
+ if (unlessOpens !== unlessCloses) {
354
+ errors.push(`- Unbalanced {{#unless}}: ${unlessOpens} opens, ${unlessCloses} closes`);
355
+ }
356
+ // Check for loop variables (@first, @last, @index) used outside {{#each}} blocks
357
+ const loopVarWarnings = validateLoopVariables(html);
358
+ for (const warning of loopVarWarnings) {
359
+ warnings.push(warning);
360
+ }
361
+ // Check for data-edit-key in static pages (IMPORTANT for inline editing)
362
+ if (templateType === 'static_page') {
363
+ const editKeys = html.match(/data-edit-key="[^"]+"/g) || [];
364
+ // Check if page has meaningful text content but no edit keys
365
+ const hasHeadings = /<h[1-6][^>]*>[^<]+<\/h[1-6]>/gi.test(html);
366
+ const hasParagraphs = /<p[^>]*>[^<]+<\/p>/gi.test(html);
367
+ if (editKeys.length === 0 && (hasHeadings || hasParagraphs)) {
368
+ errors.push(`MISSING INLINE EDITING: Static page has no data-edit-key attributes.
369
+
370
+ Without data-edit-key, users CANNOT edit text content in the visual editor!
371
+
372
+ WRONG (no inline editing):
373
+ <h1>Welcome</h1>
374
+ <p>Introduction text here.</p>
375
+
376
+ CORRECT (enables inline editing):
377
+ <h1 data-edit-key="hero-title">Welcome</h1>
378
+ <p data-edit-key="hero-subtitle">Introduction text here.</p>
379
+
380
+ REQUIRED: Add data-edit-key="unique-name" to each text element that should be editable.
381
+ Every heading, paragraph, button label, etc. needs its own unique key.`);
382
+ }
383
+ else if (editKeys.length > 0) {
384
+ suggestions.push(`- Found ${editKeys.length} inline editing point(s) with data-edit-key`);
385
+ }
386
+ }
387
+ // Also check CMS templates for data-edit-key on hardcoded content
388
+ if (templateType === 'custom_index' || templateType === 'custom_detail') {
389
+ const editKeys = html.match(/data-edit-key="[^"]+"/g) || [];
390
+ // Check for non-CMS content that might be editable (like page headers outside loops)
391
+ const outsideLoopContent = html.replace(/\{\{#each[\s\S]*?\{\{\/each\}\}/g, '');
392
+ const hasHardcodedHeadings = /<h[1-6][^>]*>(?!.*\{\{)[^<]+<\/h[1-6]>/gi.test(outsideLoopContent);
393
+ if (hasHardcodedHeadings && editKeys.length === 0) {
394
+ suggestions.push(`- Consider adding data-edit-key to static text in templates (like page headers) to make them editable`);
395
+ }
396
+ }
397
+ // Check asset paths
398
+ const wrongAssetPaths = html.match(/(href|src)=["'](?!\/public\/|https?:\/\/|#|\/\?|mailto:|tel:)[^"']+["']/g) || [];
399
+ for (const path of wrongAssetPaths.slice(0, 5)) { // Limit to 5 examples
400
+ if (!path.includes('{{') && !path.includes('data:')) {
401
+ warnings.push(`- Asset path may need /public/ prefix: ${path}`);
402
+ }
403
+ }
404
+ if (wrongAssetPaths.length > 5) {
405
+ warnings.push(`- ...and ${wrongAssetPaths.length - 5} more asset paths that may need /public/ prefix`);
406
+ }
407
+ // ============ Smart Field Detection (Suggestions) ============
408
+ // Detect YouTube/Vimeo URLs that could benefit from videoEmbed field type
409
+ const videoPatterns = [/youtube\.com\/watch/i, /youtu\.be\//i, /vimeo\.com\//i, /wistia\.com\//i, /loom\.com\//i];
410
+ let videoPatternFound = false;
411
+ for (const pattern of videoPatterns) {
412
+ if (pattern.test(html)) {
413
+ videoPatternFound = true;
414
+ break;
415
+ }
416
+ }
417
+ if (videoPatternFound) {
418
+ suggestions.push(`TIP: Video content detected. For CMS-managed videos, use the "videoEmbed" field type with:
419
+ {{#videoEmbed videoFieldName}}{{/videoEmbed}}
420
+ This creates responsive iframes with correct YouTube settings.`);
421
+ }
422
+ // Check for tokens that look like video fields but might be using wrong helper
423
+ const videoTokensWithoutHelper = html.match(/\{\{(?!#videoEmbed)([^}]*video[^}]*)\}\}/gi) || [];
424
+ if (videoTokensWithoutHelper.length > 0 && !html.includes('{{#videoEmbed')) {
425
+ const tokens = videoTokensWithoutHelper.slice(0, 3).map(t => t.replace(/\{|\}/g, '')).join(', ');
426
+ suggestions.push(`TIP: Found video-related token(s): ${tokens}. If these are video URLs, consider using:
427
+ {{#videoEmbed fieldName}}{{/videoEmbed}}
428
+ This outputs a responsive iframe instead of just the URL.`);
429
+ }
430
+ // Detect dot notation that suggests relation fields
431
+ const dotNotationTokens = html.match(/\{\{(\w+)\.(\w+)\}\}/g) || [];
432
+ const relationHints = new Set();
433
+ for (const token of dotNotationTokens) {
434
+ const match = token.match(/\{\{(\w+)\.(\w+)\}\}/);
435
+ if (match && !['site', 'this', 'root'].includes(match[1])) {
436
+ relationHints.add(match[1]);
437
+ }
438
+ }
439
+ if (relationHints.size > 0) {
440
+ const fields = Array.from(relationHints).slice(0, 3).join(', ');
441
+ suggestions.push(`RELATION FIELDS: Using ${fields} with dot notation (e.g., {{author.name}}).
442
+ Make sure these are created as "relation" type fields with sync_schema, not "text".
443
+ Relation fields link to items in another collection.`);
444
+ }
445
+ // Check YouTube iframes for required attributes
446
+ const allIframes = html.match(/<iframe[^>]*>/gi) || [];
447
+ for (const iframe of allIframes) {
448
+ const isYouTubeEmbed = /youtube\.com|youtu\.be/i.test(iframe);
449
+ const hasTemplateSrc = /src=["'][^"']*\{\{[^}]+\}\}[^"']*["']/i.test(iframe);
450
+ if (isYouTubeEmbed || hasTemplateSrc) {
451
+ if (!/referrerpolicy/i.test(iframe)) {
452
+ errors.push(`- YouTube iframe missing referrerpolicy attribute. Add: referrerpolicy="strict-origin-when-cross-origin" (without this, YouTube will show Error 153)`);
453
+ }
454
+ if (!/allowfullscreen/i.test(iframe)) {
455
+ warnings.push(`- iframe missing allowfullscreen attribute - fullscreen button won't work`);
456
+ }
457
+ if (!/title=/i.test(iframe)) {
458
+ suggestions.push(`- Consider adding a title attribute to iframe for accessibility`);
459
+ }
460
+ }
461
+ }
462
+ // ============ Form Validation ============
463
+ const formValidation = validateForms(html);
464
+ errors.push(...formValidation.errors);
465
+ warnings.push(...formValidation.warnings);
466
+ suggestions.push(...formValidation.suggestions);
467
+ // Validate site tokens
468
+ const siteTokens = html.match(/\{\{site\.(\w+)\}\}/g) || [];
469
+ for (const token of siteTokens) {
470
+ const fieldMatch = token.match(/\{\{site\.(\w+)\}\}/);
471
+ if (fieldMatch) {
472
+ const field = fieldMatch[1];
473
+ const validSiteFields = ['site_name', 'siteName', 'name'];
474
+ if (!validSiteFields.includes(field)) {
475
+ warnings.push(`- Unknown site token: ${token} - valid fields are: site_name, siteName, name`);
476
+ }
477
+ }
478
+ }
479
+ if (siteTokens.length > 0) {
480
+ suggestions.push(`- Found ${siteTokens.length} site token(s) like {{site.site_name}} - these come from manifest.json`);
481
+ }
482
+ // Validate parent context references (../)
483
+ const parentRefs = html.match(/\{\{\.\.\/([\w.]+)\}\}/g) || [];
484
+ if (parentRefs.length > 0) {
485
+ if (eachLoops.length === 0) {
486
+ warnings.push(`- Found ${parentRefs.length} parent reference(s) like {{../name}} but no {{#each}} loop - these only work inside loops`);
487
+ }
488
+ else {
489
+ suggestions.push(`- Found ${parentRefs.length} parent context reference(s) ({{../fieldName}}) - accesses parent scope in loops`);
490
+ }
491
+ }
492
+ // Validate nested loops with parent context in where clause
493
+ // Supports both {{#each collection}} and {{#each @root.collection}}
494
+ const nestedLoopPattern = /\{\{#each\s+(?:@root\.)?(\w+)\s+[^}]*where="[^:]+:\{\{(\w+)\}\}"[^}]*\}\}/g;
495
+ const nestedLoops = html.match(nestedLoopPattern) || [];
496
+ if (nestedLoops.length > 0) {
497
+ suggestions.push(`- Found ${nestedLoops.length} nested loop(s) with parent context filtering (e.g., where="field:{{parentField}}")`);
498
+ // Check if there's an outer loop to provide context
499
+ // Match both {{#each collection}} and {{#each @root.collection}}
500
+ const allLoopMatches = html.match(/\{\{#each\s+(?:@root\.)?\w+/g) || [];
501
+ if (allLoopMatches.length < 2 && nestedLoops.length > 0) {
502
+ warnings.push(`- Nested loop with where="...{{field}}" found but no outer loop detected. The {{field}} needs to come from an outer loop's item.`);
503
+ }
504
+ }
505
+ // Detect common nested loop patterns
506
+ const hierarchicalPatterns = [
507
+ { outer: 'categories', inner: 'posts', relation: 'category' },
508
+ { outer: 'doc_categories', inner: 'doc_pages', relation: 'category' },
509
+ { outer: 'authors', inner: 'posts', relation: 'author' },
510
+ { outer: 'tags', inner: 'posts', relation: 'tags' },
511
+ ];
512
+ for (const pattern of hierarchicalPatterns) {
513
+ const outerMatch = new RegExp(`\\{\\{#each\\s+${pattern.outer}`, 'g').test(html);
514
+ const innerMatch = new RegExp(`\\{\\{#each\\s+${pattern.inner}`, 'g').test(html);
515
+ if (outerMatch && innerMatch) {
516
+ // Check if inner loop has proper where clause
517
+ const innerWithWhere = new RegExp(`\\{\\{#each\\s+${pattern.inner}\\s+[^}]*where=`, 'g').test(html);
518
+ if (!innerWithWhere) {
519
+ warnings.push(`- Nested loops: ${pattern.outer} → ${pattern.inner} detected. Consider adding where="${pattern.relation}.slug:{{slug}}" to filter ${pattern.inner} by parent ${pattern.outer}.`);
520
+ }
521
+ }
522
+ }
523
+ // Validate equality helper syntax
524
+ const eqHelpers = html.match(/\{\{#if\s+\(eq\s+[^)]+\)\s*\}\}/g) || [];
525
+ if (eqHelpers.length > 0) {
526
+ suggestions.push(`- Found ${eqHelpers.length} equality comparison(s) like {{#if (eq field1 field2)}} - compares two values`);
527
+ for (const helper of eqHelpers) {
528
+ if (!html.includes('{{/if}}')) {
529
+ errors.push(`- Missing {{/if}} to close: ${helper}`);
530
+ }
531
+ }
532
+ }
533
+ // Validate {{#eq}} blocks
534
+ const eqBlocks = html.match(/\{\{#eq\s+[\w.]+\s+"[^"]+"\s*\}\}/g) || [];
535
+ const eqCloses = (html.match(/\{\{\/eq\}\}/g) || []).length;
536
+ if (eqBlocks.length !== eqCloses) {
537
+ errors.push(`- Unbalanced {{#eq}}: ${eqBlocks.length} opens, ${eqCloses} closes`);
538
+ }
539
+ // ============ Schema Validation (if authenticated with projectId) ============
540
+ let schemaValidation = '';
541
+ if (projectId && !(await (0, api_client_1.needsAuthentication)())) {
542
+ // Resolve project
543
+ const resolved = await resolveProjectId(projectId);
544
+ if (resolved) {
545
+ const schema = await fetchTenantSchema(resolved.tenantId);
546
+ if (schema) {
547
+ // For static pages, validate any collection references in {{#each}} loops
548
+ if (templateType === 'static_page') {
549
+ const referencedCollections = extractCollectionReferences(html);
550
+ if (referencedCollections.length > 0) {
551
+ suggestions.push(`- Found ${referencedCollections.length} collection reference(s) in static page: ${referencedCollections.join(', ')}`);
552
+ // Check each referenced collection exists
553
+ const missingCollections = [];
554
+ const existingCollections = [];
555
+ for (const collSlug of referencedCollections) {
556
+ const exists = schema.collections.some(c => c.slug === collSlug);
557
+ if (exists) {
558
+ existingCollections.push(collSlug);
559
+ }
560
+ else {
561
+ missingCollections.push(collSlug);
562
+ }
563
+ }
564
+ if (missingCollections.length > 0) {
565
+ schemaValidation += `
566
+
567
+ ## ACTION REQUIRED: Missing Collections
568
+
569
+ The following collections are referenced in this static page but **do not exist**:
570
+
571
+ ${missingCollections.map(c => `- \`${c}\``).join('\n')}
572
+
573
+ You must create these collections using \`sync_schema\` before deployment.
574
+
575
+ **Example:**
576
+ \`\`\`json
577
+ {
578
+ "projectId": "${resolved.tenantId}",
579
+ "collections": [
580
+ ${missingCollections.map(c => ` {
581
+ "slug": "${c}",
582
+ "name": "${c.charAt(0).toUpperCase() + c.slice(1)}",
583
+ "nameSingular": "${c.endsWith('s') ? c.slice(0, -1).charAt(0).toUpperCase() + c.slice(0, -1).slice(1) : c}",
584
+ "fields": []
585
+ }`).join(',\n')}
586
+ ]
587
+ }
588
+ \`\`\`
589
+ `;
590
+ }
591
+ if (existingCollections.length > 0) {
592
+ schemaValidation += `
593
+
594
+ ## Static Page Collection References Validated
595
+
596
+ The following collections exist and can be used: ${existingCollections.map(c => `\`${c}\``).join(', ')}
597
+ `;
598
+ }
599
+ }
600
+ }
601
+ const targetCollection = collectionSlug || '';
602
+ if (targetCollection && templateType !== 'static_page') {
603
+ // Get richText fields for triple brace validation
604
+ richTextFields = getRichTextFields(targetCollection, schema);
605
+ // Check richText fields are using triple braces
606
+ for (const fieldSlug of richTextFields) {
607
+ const doublePattern = new RegExp(`\\{\\{${fieldSlug}\\}\\}`, 'g');
608
+ const triplePattern = new RegExp(`\\{\\{\\{${fieldSlug}\\}\\}\\}`, 'g');
609
+ const hasDouble = doublePattern.test(html);
610
+ const hasTriple = triplePattern.test(html);
611
+ if (hasDouble && !hasTriple) {
612
+ errors.push(`- {{${fieldSlug}}} must use triple braces {{{${fieldSlug}}}} because it contains HTML (richText field)`);
613
+ }
614
+ }
615
+ // Extract fields used in template
616
+ const usedFields = extractTokenFieldNames(html);
617
+ // Check if collection exists
618
+ const collectionExists = schema.collections.some(c => c.slug === targetCollection);
619
+ if (!collectionExists) {
620
+ // Collection doesn't exist - need to create it
621
+ schemaValidation = `
622
+
623
+ ## ACTION REQUIRED: Collection Does Not Exist
624
+
625
+ The collection "${targetCollection}" does **not exist** in this project.
626
+
627
+ ---
628
+
629
+ ### YOU MUST CREATE THIS COLLECTION
630
+
631
+ Use the \`sync_schema\` tool to create the collection and its fields before deploying.
632
+
633
+ **Step 1:** First, call \`get_field_types\` to see available field types.
634
+
635
+ **Step 2:** Then call \`sync_schema\` to create the collection with its fields:
636
+
637
+ \`\`\`json
638
+ {
639
+ "projectId": "${resolved.tenantId}",
640
+ "collections": [
641
+ {
642
+ "slug": "${targetCollection}",
643
+ "name": "${targetCollection.charAt(0).toUpperCase() + targetCollection.slice(1)}",
644
+ "nameSingular": "${targetCollection.endsWith('s') ? targetCollection.slice(0, -1).charAt(0).toUpperCase() + targetCollection.slice(0, -1).slice(1) : targetCollection.charAt(0).toUpperCase() + targetCollection.slice(1)}",
645
+ "fields": [
646
+ ${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')}
647
+ ]
648
+ }
649
+ ]
650
+ }
651
+ \`\`\`
652
+
653
+ **Common field types:**
654
+ - \`text\` - Short text (titles, names)
655
+ - \`richText\` - Long formatted content with HTML
656
+ - \`image\` - Image upload
657
+ - \`url\` - Links
658
+ - \`boolean\` - True/false toggles
659
+ - \`date\` - Date picker
660
+ - \`number\` - Numeric values
661
+ - \`select\` - Dropdown (requires \`options\` parameter)
662
+ - \`relation\` - Link to another collection (requires \`referenceCollection\` parameter)
663
+
664
+ **DO NOT SKIP THIS STEP** - Templates will not render without the collection.
665
+ `;
666
+ }
667
+ else {
668
+ // Find missing fields
669
+ const missingFields = findMissingFields(usedFields, targetCollection, schema);
670
+ if (missingFields.length > 0) {
671
+ schemaValidation = `
672
+
673
+ ## ACTION REQUIRED: Missing Fields
674
+
675
+ The following fields are used in the template but **do not exist** in the "${targetCollection}" collection:
676
+
677
+ ${missingFields.map(f => `- \`${f}\``).join('\n')}
678
+
679
+ ---
680
+
681
+ ### YOU MUST CREATE THESE FIELDS
682
+
683
+ Use the \`sync_schema\` tool to create the missing fields before deploying. This is required for your template to work correctly.
684
+
685
+ **Step 1:** First, call \`get_field_types\` to see available field types.
686
+
687
+ **Step 2:** Then call \`sync_schema\` with the following structure (replace YOUR_TYPE with actual field types):
688
+
689
+ \`\`\`json
690
+ {
691
+ "projectId": "${resolved.tenantId}",
692
+ "fieldsToAdd": [
693
+ {
694
+ "collectionSlug": "${targetCollection}",
695
+ "fields": [
696
+ ${missingFields.map(f => ` { "slug": "${f}", "name": "${f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').trim()}", "type": "YOUR_TYPE" }`).join(',\n')}
697
+ ]
698
+ }
699
+ ]
700
+ }
701
+ \`\`\`
702
+
703
+ **Common field types:**
704
+ - \`text\` - Short text (titles, names)
705
+ - \`richText\` - Long formatted content with HTML
706
+ - \`image\` - Image upload
707
+ - \`url\` - Links
708
+ - \`boolean\` - True/false toggles
709
+ - \`date\` - Date picker
710
+ - \`number\` - Numeric values
711
+ - \`select\` - Dropdown (requires \`options\` parameter)
712
+ - \`relation\` - Link to another collection (requires \`referenceCollection\` parameter)
713
+
714
+ **DO NOT SKIP THIS STEP** - Templates will not render correctly without these fields.
715
+ `;
716
+ }
717
+ else {
718
+ schemaValidation = `
719
+
720
+ ## Schema Validation Passed
721
+
722
+ All fields used in this template exist in the "${targetCollection}" collection.
723
+ `;
724
+ }
725
+ }
726
+ }
727
+ }
728
+ }
729
+ }
730
+ else if (projectId) {
731
+ // projectId provided but not authenticated
732
+ schemaValidation = `
733
+
734
+ ## Schema Validation Skipped
735
+
736
+ A projectId was provided but you're not authenticated.
737
+ To validate against your project's schema, authenticate first using the device flow or FASTMODE_AUTH_TOKEN.
738
+
739
+ Without authentication, only syntax validation is performed.
740
+ `;
741
+ }
742
+ // Build result
743
+ let output = '';
744
+ if (errors.length === 0 && warnings.length === 0) {
745
+ output = `TEMPLATE VALID
746
+
747
+ The ${templateType} template structure looks correct.
748
+
749
+ Found:
750
+ - ${doubleTokens.length} double-brace tokens
751
+ - ${tripleTokens.length} triple-brace tokens
752
+ - ${eachLoops.length} {{#each}} loop(s)
753
+ - ${conditionals.length} {{#if}} conditional(s)`;
754
+ if (suggestions.length > 0) {
755
+ output += `
756
+
757
+ Suggestions:
758
+ ${suggestions.join('\n')}`;
759
+ }
760
+ }
761
+ else if (errors.length === 0) {
762
+ output = `TEMPLATE VALID WITH WARNINGS
763
+
764
+ Warnings:
765
+ ${warnings.join('\n')}`;
766
+ if (suggestions.length > 0) {
767
+ output += `
768
+
769
+ Suggestions:
770
+ ${suggestions.join('\n')}`;
771
+ }
772
+ }
773
+ else {
774
+ output = `TEMPLATE HAS ERRORS
775
+
776
+ Errors (must fix):
777
+ ${errors.join('\n')}`;
778
+ if (warnings.length > 0) {
779
+ output += `
780
+
781
+ Warnings:
782
+ ${warnings.join('\n')}`;
783
+ }
784
+ }
785
+ // Add schema validation results if available
786
+ if (schemaValidation) {
787
+ output += schemaValidation;
788
+ }
789
+ return output;
790
+ }