import {
apiRequest,
isApiError,
needsAuthentication,
} from '../lib/api-client';
type TemplateType = 'custom_index' | 'custom_detail' | 'static_page';
// Schema types for tenant validation
interface TenantWithRole {
id: string;
name: string;
subdomain: string;
role: string;
}
interface CollectionField {
slug: string;
name: string;
type: string;
}
interface Collection {
id: string;
slug: string;
name: string;
fields: CollectionField[];
}
interface TenantSchema {
collections: Collection[];
}
// Common wrong field names and suggestions (generic)
const COMMON_SUGGESTIONS: Record<string, string> = {
'body': 'Consider using a descriptive name like "content" or "description"',
'content': 'Good field name - make sure it exists in your collection',
'image': 'Consider using a descriptive name like "heroImage", "thumbnail", etc.',
'link': 'Use "url" as the field name for links',
'href': 'Use "url" as the field name for links',
'date': 'Use "publishedAt" (auto-tracked) or define a custom date field',
};
/**
* Resolve project identifier to tenant ID
*/
async function resolveProjectId(projectIdentifier: string): Promise<{ tenantId: string } | null> {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidPattern.test(projectIdentifier)) {
return { tenantId: projectIdentifier };
}
const response = await apiRequest<TenantWithRole[]>('/api/tenants');
if (isApiError(response)) {
return null;
}
const match = response.data.find(
p => p.name.toLowerCase() === projectIdentifier.toLowerCase() ||
p.name.toLowerCase().includes(projectIdentifier.toLowerCase())
);
return match ? { tenantId: match.id } : null;
}
/**
* Fetch tenant schema for validation
*/
async function fetchTenantSchema(tenantId: string): Promise<TenantSchema | null> {
// Fetch custom collections
const collectionsRes = await apiRequest<Collection[]>('/api/collections', { tenantId });
if (isApiError(collectionsRes)) {
return null;
}
return {
collections: collectionsRes.data,
};
}
/**
* Extract field names used in template tokens
*/
function extractTokenFieldNames(html: string): string[] {
const fields = new Set<string>();
// Match {{fieldName}} and {{{fieldName}}}
const doubleTokens = html.match(/\{\{([^{}#/][^{}]*)\}\}/g) || [];
const tripleTokens = html.match(/\{\{\{([^{}]+)\}\}\}/g) || [];
for (const token of [...doubleTokens, ...tripleTokens]) {
const fieldName = token.replace(/\{|\}/g, '').trim();
// Skip special tokens
if (fieldName.startsWith('#') || fieldName.startsWith('/') ||
fieldName === 'else' || fieldName.startsWith('@') ||
fieldName.startsWith('this') || fieldName.startsWith('../')) {
continue;
}
// Get the base field name (before any dots)
const baseName = fieldName.split('.')[0];
if (baseName && baseName !== 'site') {
fields.add(baseName);
}
}
return Array.from(fields);
}
/**
* Check which fields are missing from schema
*/
function findMissingFields(
usedFields: string[],
collectionType: string,
schema: TenantSchema
): string[] {
const missing: string[] = [];
// Check against custom collection fields
const collection = schema.collections.find(c => c.slug === collectionType);
if (collection) {
// Built-in fields available on all items
const builtInFields = ['name', 'slug', 'url', 'publishedAt', 'createdAt', 'updatedAt'];
const collectionFields = [...builtInFields, ...collection.fields.map(f => f.slug)];
for (const field of usedFields) {
if (!collectionFields.includes(field)) {
missing.push(field);
}
}
} else {
// Collection doesn't exist - all fields except built-ins are "missing"
const builtInFields = ['name', 'slug', 'url', 'publishedAt', 'createdAt', 'updatedAt'];
return usedFields.filter(f => !builtInFields.includes(f));
}
return missing;
}
/**
* Get richText fields from collection
*/
function getRichTextFields(collectionSlug: string, schema: TenantSchema): string[] {
const collection = schema.collections.find(c => c.slug === collectionSlug);
if (!collection) return [];
return collection.fields.filter(f => f.type === 'richText').map(f => f.slug);
}
/**
* Validate forms in HTML templates
* Checks for proper data-form attribute, input names, and submit buttons
*/
function validateForms(html: string): { errors: string[]; warnings: string[]; suggestions: string[] } {
const errors: string[] = [];
const warnings: string[] = [];
const suggestions: string[] = [];
// Find all form elements
const formPattern = /<form[^>]*>/gi;
const forms = html.match(formPattern) || [];
if (forms.length === 0) {
return { errors, warnings, suggestions };
}
// Track forms for validation
let formsWithDataForm = 0;
let formsWithLegacyAttr = 0;
for (const formTag of forms) {
// Check for data-form attribute (correct format)
const hasDataForm = /data-form=["'][^"']+["']/i.test(formTag);
// Check for legacy data-form-name attribute
const hasLegacyDataFormName = /data-form-name=["'][^"']+["']/i.test(formTag);
if (hasDataForm) {
formsWithDataForm++;
// Check if form has proper action attribute pointing to /_forms/
if (!formTag.includes('action=')) {
errors.push(`- Form has data-form but no action attribute. Add action="/_forms/formname" for the form to work.`);
} else if (!formTag.includes('/_forms/')) {
warnings.push(`- Form has action but it doesn't point to /_forms/. Fast Mode forms must submit to /_forms/{formName}.`);
}
} else if (hasLegacyDataFormName) {
formsWithLegacyAttr++;
warnings.push(`- Form uses deprecated data-form-name attribute. Migrate to data-form="formname" for consistency.`);
// Also check action for legacy forms
if (!formTag.includes('action=')) {
errors.push(`- Form has data-form-name but no action attribute. Add action="/_forms/formname" for the form to work.`);
} else if (!formTag.includes('/_forms/')) {
warnings.push(`- Form has action but it doesn't point to /_forms/. Fast Mode forms must submit to /_forms/{formName}.`);
}
} else {
// Form without any data-form attribute - check if it looks like a CMS form
// Don't error on forms that might be external (like search forms, login forms)
if (!formTag.includes('action=')) {
errors.push(`- Form is missing data-form attribute. Add data-form="formname" and action="/_forms/formname" to identify the form for CMS submission.`);
}
}
}
// Extract form content for deeper validation
// Find each form block
const formBlocks = html.match(/<form[^>]*data-form[^>]*>[\s\S]*?<\/form>/gi) || [];
for (const formBlock of formBlocks) {
// Check for inputs with name attributes
const inputs = formBlock.match(/<input[^>]*>/gi) || [];
const textareas = formBlock.match(/<textarea[^>]*>/gi) || [];
const selects = formBlock.match(/<select[^>]*>/gi) || [];
const allInputElements = [...inputs, ...textareas, ...selects];
let inputsWithName = 0;
let inputsWithoutName = 0;
for (const input of allInputElements) {
// Skip hidden inputs and submit buttons
if (/type=["'](?:submit|button|hidden)["']/i.test(input)) {
continue;
}
if (/name=["'][^"']+["']/i.test(input)) {
inputsWithName++;
} else {
inputsWithoutName++;
}
}
if (inputsWithoutName > 0) {
errors.push(`- Found ${inputsWithoutName} form input(s) without name attribute. All inputs must have name="fieldname" to be captured.`);
}
if (inputsWithName === 0 && allInputElements.length > 0) {
errors.push(`- Form has no inputs with name attributes - no data will be captured.`);
}
// Check for submit button
const hasSubmitButton = /<button[^>]*type=["']submit["'][^>]*>/i.test(formBlock) ||
/<input[^>]*type=["']submit["'][^>]*>/i.test(formBlock) ||
/<button[^>]*>(?!.*type=["']button["'])/i.test(formBlock); // button without type defaults to submit
if (!hasSubmitButton) {
warnings.push(`- Form may be missing a submit button. Add <button type="submit">Submit</button> for the form to work.`);
}
// Check for form handler script
const hasFormHandlerScript = html.includes("form[data-form]") || html.includes("form[data-form-name]");
if (!hasFormHandlerScript) {
suggestions.push(`- No form handler script detected. Make sure to include JavaScript that handles form submission to /_forms/{formName}`);
}
}
// Summary suggestion
if (formsWithDataForm > 0) {
suggestions.push(`- Found ${formsWithDataForm} form(s) with data-form attribute - forms will submit to /_forms/{formName}`);
}
// Check for thank-you page redirect pattern
const hasThankYouRedirect = /window\.location\.href\s*=\s*['"]\/thank-you['"]/i.test(html) ||
/window\.location\s*=\s*['"]\/thank-you['"]/i.test(html);
if (formsWithDataForm > 0 && !hasThankYouRedirect) {
suggestions.push(`- Consider adding a /thank-you page and redirecting there on successful submission for better UX`);
}
return { errors, warnings, suggestions };
}
/**
* Extract collection slugs referenced in {{#each}} loops on static pages
* Supports both {{#each collection}} and {{#each @root.collection}} syntax
*/
function extractCollectionReferences(html: string): string[] {
const collections: string[] = [];
// Match both {{#each collection}} and {{#each @root.collection}}
const eachLoops = html.match(/\{\{#each\s+(?:@root\.)?([\w]+)[^}]*\}\}/g) || [];
for (const loop of eachLoops) {
// Extract collection name, handling @root. prefix
const match = loop.match(/\{\{#each\s+(?:@root\.)?([\w]+)/);
if (match) {
collections.push(match[1]);
}
}
return [...new Set(collections)]; // Unique collections
}
/**
* Validate that loop variables (@first, @last, @index) are only used inside {{#each}} blocks
* Returns warnings for any usage found outside loops
*/
function validateLoopVariables(html: string): string[] {
const warnings: string[] = [];
// Find all @first, @last, @index, @length usage (standalone or in conditionals)
const loopVarPattern = /\{\{[#/]?(?:if|unless)?\s*@(first|last|index|length)[^}]*\}\}/g;
// Find all {{#each}}...{{/each}} blocks
// Use a simple approach: remove all loop content and check what's left
let outsideLoops = html;
// Repeatedly remove innermost loops until none left
// This handles nested loops correctly
let previousLength = 0;
while (outsideLoops.length !== previousLength) {
previousLength = outsideLoops.length;
// Match non-greedy: find {{#each...}} followed by content without nested {{#each}}, then {{/each}}
outsideLoops = outsideLoops.replace(/\{\{#each[^}]*\}\}(?:(?!\{\{#each)[\s\S])*?\{\{\/each\}\}/g, '');
}
// Now check if any loop variables remain in the content outside loops
const matches = outsideLoops.match(loopVarPattern);
if (matches && matches.length > 0) {
const uniqueVars = [...new Set(matches.map(m => {
const varMatch = m.match(/@(first|last|index|length)/);
return varMatch ? `@${varMatch[1]}` : m;
}))];
warnings.push(`- Loop variables (${uniqueVars.join(', ')}) found outside {{#each}} blocks. These only work inside loops.`);
}
return warnings;
}
/**
* Validates an HTML template for correct CMS token usage
*
* @param html - The HTML template content
* @param templateType - The type of template (custom_index, custom_detail, static_page)
* @param collectionSlug - For custom collections, the collection slug
* @param projectId - Optional: Project ID or name to validate against actual schema
*/
export async function validateTemplate(
html: string,
templateType: TemplateType,
collectionSlug?: string,
projectId?: string
): Promise<string> {
const errors: string[] = [];
const warnings: string[] = [];
const suggestions: string[] = [];
let richTextFields: string[] = [];
// Extract all tokens from the HTML
const doubleTokens = html.match(/\{\{([^{}#/]+)\}\}/g) || [];
const tripleTokens = html.match(/\{\{\{([^{}]+)\}\}\}/g) || [];
// Match both {{#each collection}} and {{#each @root.collection}}
const eachLoops = html.match(/\{\{#each\s+(?:@root\.)?([\w]+)[^}]*\}\}/g) || [];
const conditionals = html.match(/\{\{#if\s+([^}]+)\}\}/g) || [];
// Check for tokens with spaces (like {{ name }} instead of {{name}})
const tokensWithSpaces = html.match(/\{\{\s+[^{}]+\s*\}\}/g) || [];
const tokensWithSpaces2 = html.match(/\{\{[^{}]+\s+\}\}/g) || [];
if (tokensWithSpaces.length > 0 || tokensWithSpaces2.length > 0) {
warnings.push('- Some tokens have spaces inside braces (e.g., {{ name }} instead of {{name}}). While supported, {{name}} without spaces is preferred.');
}
// Check for common suggestions
for (const token of doubleTokens) {
const fieldName = token.replace(/\{\{|\}\}/g, '').trim();
// Skip control structures
if (fieldName.startsWith('#') || fieldName.startsWith('/') || fieldName === 'else' || fieldName.startsWith('@')) {
continue;
}
// Check against common suggestions
const baseName = fieldName.split('.')[0];
if (COMMON_SUGGESTIONS[baseName]) {
suggestions.push(`- Token {{${baseName}}}: ${COMMON_SUGGESTIONS[baseName]}`);
}
}
// Check loop syntax for index templates
if (templateType === 'custom_index') {
if (eachLoops.length === 0) {
errors.push(`MISSING LOOP: Index templates MUST have an {{#each}} loop.
An index template without a loop will show an empty page.
REQUIRED pattern:
{{#each ${collectionSlug || 'collection'}}}
<div>
<h2>{{name}}</h2>
<a href="{{url}}">Read more</a>
</div>
{{/each}}
Add an {{#each}} loop to iterate over collection items.`);
} else {
// Check if loop uses correct collection name (handles @root. prefix)
for (const loop of eachLoops) {
const match = loop.match(/\{\{#each\s+(?:@root\.)?([\w]+)/);
if (match) {
const usedCollection = match[1];
if (collectionSlug && usedCollection !== collectionSlug) {
errors.push(`WRONG COLLECTION: Loop uses '${usedCollection}' but template is for '${collectionSlug}'.
Change {{#each ${usedCollection}}} to {{#each ${collectionSlug}}}`);
}
}
}
}
}
// Check detail templates have CMS tokens (otherwise all item pages will be identical)
if (templateType === 'custom_detail') {
const hasItemTokens = /\{\{(name|slug|title|content|body|description)\}\}/i.test(html) ||
/\{\{\{[^}]+\}\}\}/i.test(html) ||
doubleTokens.length > 0;
if (!hasItemTokens) {
errors.push(`MISSING ITEM DATA: Detail template has no CMS tokens.
A detail template should display item data like:
- {{name}} - Item name
- {{{content}}} - Rich text content (triple braces!)
- {{image}} - Image URL
Without tokens, every item page will show the same static content.`);
}
}
// Check for {{#each}} without closing {{/each}}
const eachOpens = (html.match(/\{\{#each/g) || []).length;
const eachCloses = (html.match(/\{\{\/each\}\}/g) || []).length;
if (eachOpens !== eachCloses) {
errors.push(`- Unbalanced {{#each}} loops: ${eachOpens} opens, ${eachCloses} closes`);
}
// Check for {{#if}} without closing {{/if}}
const ifOpens = (html.match(/\{\{#if/g) || []).length;
const ifCloses = (html.match(/\{\{\/if\}\}/g) || []).length;
if (ifOpens !== ifCloses) {
errors.push(`- Unbalanced {{#if}} conditionals: ${ifOpens} opens, ${ifCloses} closes`);
}
// Check for {{#unless}} without closing
const unlessOpens = (html.match(/\{\{#unless/g) || []).length;
const unlessCloses = (html.match(/\{\{\/unless\}\}/g) || []).length;
if (unlessOpens !== unlessCloses) {
errors.push(`- Unbalanced {{#unless}}: ${unlessOpens} opens, ${unlessCloses} closes`);
}
// Check for loop variables (@first, @last, @index) used outside {{#each}} blocks
const loopVarWarnings = validateLoopVariables(html);
for (const warning of loopVarWarnings) {
warnings.push(warning);
}
// Check for data-edit-key in static pages (IMPORTANT for inline editing)
if (templateType === 'static_page') {
const editKeys = html.match(/data-edit-key="[^"]+"/g) || [];
// Check if page has meaningful text content but no edit keys
const hasHeadings = /<h[1-6][^>]*>[^<]+<\/h[1-6]>/gi.test(html);
const hasParagraphs = /<p[^>]*>[^<]+<\/p>/gi.test(html);
if (editKeys.length === 0 && (hasHeadings || hasParagraphs)) {
errors.push(`MISSING INLINE EDITING: Static page has no data-edit-key attributes.
Without data-edit-key, users CANNOT edit text content in the visual editor!
WRONG (no inline editing):
<h1>Welcome</h1>
<p>Introduction text here.</p>
CORRECT (enables inline editing):
<h1 data-edit-key="hero-title">Welcome</h1>
<p data-edit-key="hero-subtitle">Introduction text here.</p>
REQUIRED: Add data-edit-key="unique-name" to each text element that should be editable.
Every heading, paragraph, button label, etc. needs its own unique key.`);
} else if (editKeys.length > 0) {
suggestions.push(`- Found ${editKeys.length} inline editing point(s) with data-edit-key`);
}
}
// Also check CMS templates for data-edit-key on hardcoded content
if (templateType === 'custom_index' || templateType === 'custom_detail') {
const editKeys = html.match(/data-edit-key="[^"]+"/g) || [];
// Check for non-CMS content that might be editable (like page headers outside loops)
const outsideLoopContent = html.replace(/\{\{#each[\s\S]*?\{\{\/each\}\}/g, '');
const hasHardcodedHeadings = /<h[1-6][^>]*>(?!.*\{\{)[^<]+<\/h[1-6]>/gi.test(outsideLoopContent);
if (hasHardcodedHeadings && editKeys.length === 0) {
suggestions.push(`- Consider adding data-edit-key to static text in templates (like page headers) to make them editable`);
}
}
// Check asset paths
const wrongAssetPaths = html.match(/(href|src)=["'](?!\/public\/|https?:\/\/|#|\/\?|mailto:|tel:)[^"']+["']/g) || [];
for (const path of wrongAssetPaths.slice(0, 5)) { // Limit to 5 examples
if (!path.includes('{{') && !path.includes('data:')) {
warnings.push(`- Asset path may need /public/ prefix: ${path}`);
}
}
if (wrongAssetPaths.length > 5) {
warnings.push(`- ...and ${wrongAssetPaths.length - 5} more asset paths that may need /public/ prefix`);
}
// ============ Smart Field Detection (Suggestions) ============
// Detect YouTube/Vimeo URLs that could benefit from videoEmbed field type
const videoPatterns = [/youtube\.com\/watch/i, /youtu\.be\//i, /vimeo\.com\//i, /wistia\.com\//i, /loom\.com\//i];
let videoPatternFound = false;
for (const pattern of videoPatterns) {
if (pattern.test(html)) {
videoPatternFound = true;
break;
}
}
if (videoPatternFound) {
suggestions.push(`TIP: Video content detected. For CMS-managed videos, use the "videoEmbed" field type with:
{{#videoEmbed videoFieldName}}{{/videoEmbed}}
This creates responsive iframes with correct YouTube settings.`);
}
// Check for tokens that look like video fields but might be using wrong helper
const videoTokensWithoutHelper = html.match(/\{\{(?!#videoEmbed)([^}]*video[^}]*)\}\}/gi) || [];
if (videoTokensWithoutHelper.length > 0 && !html.includes('{{#videoEmbed')) {
const tokens = videoTokensWithoutHelper.slice(0, 3).map(t => t.replace(/\{|\}/g, '')).join(', ');
suggestions.push(`TIP: Found video-related token(s): ${tokens}. If these are video URLs, consider using:
{{#videoEmbed fieldName}}{{/videoEmbed}}
This outputs a responsive iframe instead of just the URL.`);
}
// Detect dot notation that suggests relation fields
const dotNotationTokens = html.match(/\{\{(\w+)\.(\w+)\}\}/g) || [];
const relationHints = new Set<string>();
for (const token of dotNotationTokens) {
const match = token.match(/\{\{(\w+)\.(\w+)\}\}/);
if (match && !['site', 'this', 'root'].includes(match[1])) {
relationHints.add(match[1]);
}
}
if (relationHints.size > 0) {
const fields = Array.from(relationHints).slice(0, 3).join(', ');
suggestions.push(`RELATION FIELDS: Using ${fields} with dot notation (e.g., {{author.name}}).
Make sure these are created as "relation" type fields with sync_schema, not "text".
Relation fields link to items in another collection.`);
}
// Check YouTube iframes for required attributes
const allIframes = html.match(/<iframe[^>]*>/gi) || [];
for (const iframe of allIframes) {
const isYouTubeEmbed = /youtube\.com|youtu\.be/i.test(iframe);
const hasTemplateSrc = /src=["'][^"']*\{\{[^}]+\}\}[^"']*["']/i.test(iframe);
if (isYouTubeEmbed || hasTemplateSrc) {
if (!/referrerpolicy/i.test(iframe)) {
errors.push(`- YouTube iframe missing referrerpolicy attribute. Add: referrerpolicy="strict-origin-when-cross-origin" (without this, YouTube will show Error 153)`);
}
if (!/allowfullscreen/i.test(iframe)) {
warnings.push(`- iframe missing allowfullscreen attribute - fullscreen button won't work`);
}
if (!/title=/i.test(iframe)) {
suggestions.push(`- Consider adding a title attribute to iframe for accessibility`);
}
}
}
// ============ Form Validation ============
const formValidation = validateForms(html);
errors.push(...formValidation.errors);
warnings.push(...formValidation.warnings);
suggestions.push(...formValidation.suggestions);
// Validate site tokens
const siteTokens = html.match(/\{\{site\.(\w+)\}\}/g) || [];
for (const token of siteTokens) {
const fieldMatch = token.match(/\{\{site\.(\w+)\}\}/);
if (fieldMatch) {
const field = fieldMatch[1];
const validSiteFields = ['site_name', 'siteName', 'name'];
if (!validSiteFields.includes(field)) {
warnings.push(`- Unknown site token: ${token} - valid fields are: site_name, siteName, name`);
}
}
}
if (siteTokens.length > 0) {
suggestions.push(`- Found ${siteTokens.length} site token(s) like {{site.site_name}} - these come from manifest.json`);
}
// Validate parent context references (../)
const parentRefs = html.match(/\{\{\.\.\/([\w.]+)\}\}/g) || [];
if (parentRefs.length > 0) {
if (eachLoops.length === 0) {
warnings.push(`- Found ${parentRefs.length} parent reference(s) like {{../name}} but no {{#each}} loop - these only work inside loops`);
} else {
suggestions.push(`- Found ${parentRefs.length} parent context reference(s) ({{../fieldName}}) - accesses parent scope in loops`);
}
}
// Validate nested loops with parent context in where clause
// Supports both {{#each collection}} and {{#each @root.collection}}
const nestedLoopPattern = /\{\{#each\s+(?:@root\.)?(\w+)\s+[^}]*where="[^:]+:\{\{(\w+)\}\}"[^}]*\}\}/g;
const nestedLoops = html.match(nestedLoopPattern) || [];
if (nestedLoops.length > 0) {
suggestions.push(`- Found ${nestedLoops.length} nested loop(s) with parent context filtering (e.g., where="field:{{parentField}}")`);
// Check if there's an outer loop to provide context
// Match both {{#each collection}} and {{#each @root.collection}}
const allLoopMatches = html.match(/\{\{#each\s+(?:@root\.)?\w+/g) || [];
if (allLoopMatches.length < 2 && nestedLoops.length > 0) {
warnings.push(`- Nested loop with where="...{{field}}" found but no outer loop detected. The {{field}} needs to come from an outer loop's item.`);
}
}
// Detect common nested loop patterns
const hierarchicalPatterns = [
{ outer: 'categories', inner: 'posts', relation: 'category' },
{ outer: 'doc_categories', inner: 'doc_pages', relation: 'category' },
{ outer: 'authors', inner: 'posts', relation: 'author' },
{ outer: 'tags', inner: 'posts', relation: 'tags' },
];
for (const pattern of hierarchicalPatterns) {
const outerMatch = new RegExp(`\\{\\{#each\\s+${pattern.outer}`, 'g').test(html);
const innerMatch = new RegExp(`\\{\\{#each\\s+${pattern.inner}`, 'g').test(html);
if (outerMatch && innerMatch) {
// Check if inner loop has proper where clause
const innerWithWhere = new RegExp(
`\\{\\{#each\\s+${pattern.inner}\\s+[^}]*where=`, 'g'
).test(html);
if (!innerWithWhere) {
warnings.push(`- Nested loops: ${pattern.outer} → ${pattern.inner} detected. Consider adding where="${pattern.relation}.slug:{{slug}}" to filter ${pattern.inner} by parent ${pattern.outer}.`);
}
}
}
// Validate equality helper syntax
const eqHelpers = html.match(/\{\{#if\s+\(eq\s+[^)]+\)\s*\}\}/g) || [];
if (eqHelpers.length > 0) {
suggestions.push(`- Found ${eqHelpers.length} equality comparison(s) like {{#if (eq field1 field2)}} - compares two values`);
for (const helper of eqHelpers) {
if (!html.includes('{{/if}}')) {
errors.push(`- Missing {{/if}} to close: ${helper}`);
}
}
}
// Validate {{#eq}} blocks
const eqBlocks = html.match(/\{\{#eq\s+[\w.]+\s+"[^"]+"\s*\}\}/g) || [];
const eqCloses = (html.match(/\{\{\/eq\}\}/g) || []).length;
if (eqBlocks.length !== eqCloses) {
errors.push(`- Unbalanced {{#eq}}: ${eqBlocks.length} opens, ${eqCloses} closes`);
}
// ============ Schema Validation (if authenticated with projectId) ============
let schemaValidation = '';
if (projectId && !(await needsAuthentication())) {
// Resolve project
const resolved = await resolveProjectId(projectId);
if (resolved) {
const schema = await fetchTenantSchema(resolved.tenantId);
if (schema) {
// For static pages, validate any collection references in {{#each}} loops
if (templateType === 'static_page') {
const referencedCollections = extractCollectionReferences(html);
if (referencedCollections.length > 0) {
suggestions.push(`- Found ${referencedCollections.length} collection reference(s) in static page: ${referencedCollections.join(', ')}`);
// Check each referenced collection exists
const missingCollections: string[] = [];
const existingCollections: string[] = [];
for (const collSlug of referencedCollections) {
const exists = schema.collections.some(c => c.slug === collSlug);
if (exists) {
existingCollections.push(collSlug);
} else {
missingCollections.push(collSlug);
}
}
if (missingCollections.length > 0) {
schemaValidation += `
## ACTION REQUIRED: Missing Collections
The following collections are referenced in this static page but **do not exist**:
${missingCollections.map(c => `- \`${c}\``).join('\n')}
You must create these collections using \`sync_schema\` before deployment.
**Example:**
\`\`\`json
{
"projectId": "${resolved.tenantId}",
"collections": [
${missingCollections.map(c => ` {
"slug": "${c}",
"name": "${c.charAt(0).toUpperCase() + c.slice(1)}",
"nameSingular": "${c.endsWith('s') ? c.slice(0, -1).charAt(0).toUpperCase() + c.slice(0, -1).slice(1) : c}",
"fields": []
}`).join(',\n')}
]
}
\`\`\`
`;
}
if (existingCollections.length > 0) {
schemaValidation += `
## Static Page Collection References Validated
The following collections exist and can be used: ${existingCollections.map(c => `\`${c}\``).join(', ')}
`;
}
}
}
const targetCollection = collectionSlug || '';
if (targetCollection && templateType !== 'static_page') {
// Get richText fields for triple brace validation
richTextFields = getRichTextFields(targetCollection, schema);
// Check richText fields are using triple braces
for (const fieldSlug of richTextFields) {
const doublePattern = new RegExp(`\\{\\{${fieldSlug}\\}\\}`, 'g');
const triplePattern = new RegExp(`\\{\\{\\{${fieldSlug}\\}\\}\\}`, 'g');
const hasDouble = doublePattern.test(html);
const hasTriple = triplePattern.test(html);
if (hasDouble && !hasTriple) {
errors.push(`- {{${fieldSlug}}} must use triple braces {{{${fieldSlug}}}} because it contains HTML (richText field)`);
}
}
// Extract fields used in template
const usedFields = extractTokenFieldNames(html);
// Check if collection exists
const collectionExists = schema.collections.some(c => c.slug === targetCollection);
if (!collectionExists) {
// Collection doesn't exist - need to create it
schemaValidation = `
## ACTION REQUIRED: Collection Does Not Exist
The collection "${targetCollection}" does **not exist** in this project.
---
### YOU MUST CREATE THIS COLLECTION
Use the \`sync_schema\` tool to create the collection and its fields before deploying.
**Step 1:** First, call \`get_field_types\` to see available field types.
**Step 2:** Then call \`sync_schema\` to create the collection with its fields:
\`\`\`json
{
"projectId": "${resolved.tenantId}",
"collections": [
{
"slug": "${targetCollection}",
"name": "${targetCollection.charAt(0).toUpperCase() + targetCollection.slice(1)}",
"nameSingular": "${targetCollection.endsWith('s') ? targetCollection.slice(0, -1).charAt(0).toUpperCase() + targetCollection.slice(0, -1).slice(1) : targetCollection.charAt(0).toUpperCase() + targetCollection.slice(1)}",
"fields": [
${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')}
]
}
]
}
\`\`\`
**Common field types:**
- \`text\` - Short text (titles, names)
- \`richText\` - Long formatted content with HTML
- \`image\` - Image upload
- \`url\` - Links
- \`boolean\` - True/false toggles
- \`date\` - Date picker
- \`number\` - Numeric values
- \`select\` - Dropdown (requires \`options\` parameter)
- \`relation\` - Link to another collection (requires \`referenceCollection\` parameter)
**DO NOT SKIP THIS STEP** - Templates will not render without the collection.
`;
} else {
// Find missing fields
const missingFields = findMissingFields(usedFields, targetCollection, schema);
if (missingFields.length > 0) {
schemaValidation = `
## ACTION REQUIRED: Missing Fields
The following fields are used in the template but **do not exist** in the "${targetCollection}" collection:
${missingFields.map(f => `- \`${f}\``).join('\n')}
---
### YOU MUST CREATE THESE FIELDS
Use the \`sync_schema\` tool to create the missing fields before deploying. This is required for your template to work correctly.
**Step 1:** First, call \`get_field_types\` to see available field types.
**Step 2:** Then call \`sync_schema\` with the following structure (replace YOUR_TYPE with actual field types):
\`\`\`json
{
"projectId": "${resolved.tenantId}",
"fieldsToAdd": [
{
"collectionSlug": "${targetCollection}",
"fields": [
${missingFields.map(f => ` { "slug": "${f}", "name": "${f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').trim()}", "type": "YOUR_TYPE" }`).join(',\n')}
]
}
]
}
\`\`\`
**Common field types:**
- \`text\` - Short text (titles, names)
- \`richText\` - Long formatted content with HTML
- \`image\` - Image upload
- \`url\` - Links
- \`boolean\` - True/false toggles
- \`date\` - Date picker
- \`number\` - Numeric values
- \`select\` - Dropdown (requires \`options\` parameter)
- \`relation\` - Link to another collection (requires \`referenceCollection\` parameter)
**DO NOT SKIP THIS STEP** - Templates will not render correctly without these fields.
`;
} else {
schemaValidation = `
## Schema Validation Passed
All fields used in this template exist in the "${targetCollection}" collection.
`;
}
}
}
}
}
} else if (projectId) {
// projectId provided but not authenticated
schemaValidation = `
## Schema Validation Skipped
A projectId was provided but you're not authenticated.
To validate against your project's schema, authenticate first using the device flow or FASTMODE_AUTH_TOKEN.
Without authentication, only syntax validation is performed.
`;
}
// Build result
let output = '';
if (errors.length === 0 && warnings.length === 0) {
output = `TEMPLATE VALID
The ${templateType} template structure looks correct.
Found:
- ${doubleTokens.length} double-brace tokens
- ${tripleTokens.length} triple-brace tokens
- ${eachLoops.length} {{#each}} loop(s)
- ${conditionals.length} {{#if}} conditional(s)`;
if (suggestions.length > 0) {
output += `
Suggestions:
${suggestions.join('\n')}`;
}
} else if (errors.length === 0) {
output = `TEMPLATE VALID WITH WARNINGS
Warnings:
${warnings.join('\n')}`;
if (suggestions.length > 0) {
output += `
Suggestions:
${suggestions.join('\n')}`;
}
} else {
output = `TEMPLATE HAS ERRORS
Errors (must fix):
${errors.join('\n')}`;
if (warnings.length > 0) {
output += `
Warnings:
${warnings.join('\n')}`;
}
}
// Add schema validation results if available
if (schemaValidation) {
output += schemaValidation;
}
return output;
}