## SEO Template Library - Add complete template library with 7 page types (state, city, category, brand, product, search, regeneration) - Add Template Library tab in SEO Orchestrator with accordion-based editors - Add template preview, validation, and variable injection engine - Add API endpoints: /api/seo/templates, preview, validate, generate, regenerate ## Discovery Pipeline - Add promotion.ts for discovery location validation and promotion - Add discover-all-states.ts script for multi-state discovery - Add promotion log migration (067) - Enhance discovery routes and types ## Orchestrator & Admin - Add crawl_enabled filter to stores page - Add API permissions page - Add job queue management - Add price analytics routes - Add markets and intelligence routes - Enhance dashboard and worker monitoring ## Infrastructure - Add migrations for worker definitions, SEO settings, field alignment - Add canonical pipeline for scraper v2 - Update hydration and sync orchestrator - Enhance multi-state query service 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
370 lines
10 KiB
TypeScript
370 lines
10 KiB
TypeScript
/**
|
|
* SEO Template Engine
|
|
*
|
|
* Handles template selection, variable injection, and content generation
|
|
* for different page types (state, city, category, brand, product, search).
|
|
*/
|
|
|
|
import { getAllSettings, getSetting } from './settings';
|
|
|
|
// Page types supported by the template engine
|
|
export type PageType = 'state' | 'city' | 'category' | 'brand' | 'product' | 'search';
|
|
|
|
// Template keys mapping
|
|
export const TEMPLATE_KEYS: Record<PageType, string> = {
|
|
state: 'state_page_template',
|
|
city: 'city_page_template',
|
|
category: 'category_page_template',
|
|
brand: 'brand_page_template',
|
|
product: 'product_page_template',
|
|
search: 'search_results_template',
|
|
};
|
|
|
|
// Sample mock data for previews
|
|
export const MOCK_DATA: Record<PageType, Record<string, any>> = {
|
|
state: {
|
|
state_name: 'Arizona',
|
|
state_code: 'AZ',
|
|
state_code_lower: 'az',
|
|
dispensary_count: 156,
|
|
product_count: 12450,
|
|
brand_count: 287,
|
|
category_count: 8,
|
|
top_brands: ['Raw Garden', 'Stiiizy', 'Select', 'Pax', 'Bloom'],
|
|
top_categories: ['Flower', 'Vape', 'Edibles', 'Concentrate', 'Pre-rolls'],
|
|
avg_price: 42.50,
|
|
last_updated: new Date().toISOString().split('T')[0],
|
|
},
|
|
city: {
|
|
city_name: 'Phoenix',
|
|
state_name: 'Arizona',
|
|
state_code: 'AZ',
|
|
dispensary_count: 45,
|
|
product_count: 3200,
|
|
brand_count: 120,
|
|
nearby_cities: ['Scottsdale', 'Tempe', 'Mesa', 'Glendale'],
|
|
popular_dispensaries: ['Harvest', 'Curaleaf', 'Zen Leaf'],
|
|
avg_price: 40.00,
|
|
},
|
|
category: {
|
|
category_name: 'Flower',
|
|
category_slug: 'flower',
|
|
product_count: 4500,
|
|
brand_count: 95,
|
|
state_name: 'Arizona',
|
|
avg_price: 35.00,
|
|
top_strains: ['Blue Dream', 'OG Kush', 'Girl Scout Cookies'],
|
|
subcategories: ['Indica', 'Sativa', 'Hybrid'],
|
|
},
|
|
brand: {
|
|
brand_name: 'Raw Garden',
|
|
brand_slug: 'raw-garden',
|
|
product_count: 156,
|
|
state_presence: ['AZ', 'CA', 'NV', 'CO'],
|
|
store_count: 89,
|
|
avg_price: 45.00,
|
|
categories: ['Concentrate', 'Vape', 'Live Resin'],
|
|
description: 'Premium cannabis products from California',
|
|
},
|
|
product: {
|
|
product_name: 'Blue Dream Cartridge',
|
|
brand_name: 'Select',
|
|
category: 'Vape',
|
|
thc_percent: 85.5,
|
|
cbd_percent: 0.5,
|
|
price: 45.00,
|
|
dispensary_name: 'Harvest HOC',
|
|
dispensary_city: 'Phoenix',
|
|
state_name: 'Arizona',
|
|
in_stock: true,
|
|
},
|
|
search: {
|
|
query: 'live resin',
|
|
result_count: 245,
|
|
product_results: 180,
|
|
dispensary_results: 45,
|
|
brand_results: 20,
|
|
state_name: 'Arizona',
|
|
top_categories: ['Concentrate', 'Vape'],
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Apply template variables to a template string
|
|
* Replaces {{variable}} with values from data object
|
|
*
|
|
* Rules:
|
|
* - Replace {{variable}} occurrences
|
|
* - Leave unknown variables unchanged
|
|
* - Prevent undefined values (replace with empty string)
|
|
* - Support arrays by joining with comma
|
|
*/
|
|
export function applyTemplateVariables(
|
|
template: string,
|
|
data: Record<string, any>
|
|
): string {
|
|
if (!template) return '';
|
|
|
|
let result = template;
|
|
|
|
// Find all {{variable}} patterns
|
|
const variablePattern = /\{\{(\w+)\}\}/g;
|
|
let match;
|
|
|
|
while ((match = variablePattern.exec(template)) !== null) {
|
|
const fullMatch = match[0];
|
|
const variableName = match[1];
|
|
|
|
if (variableName in data) {
|
|
let value = data[variableName];
|
|
|
|
// Handle different value types
|
|
if (value === undefined || value === null) {
|
|
value = '';
|
|
} else if (Array.isArray(value)) {
|
|
value = value.join(', ');
|
|
} else if (typeof value === 'object') {
|
|
value = JSON.stringify(value);
|
|
} else {
|
|
value = String(value);
|
|
}
|
|
|
|
// Replace all occurrences of this variable
|
|
result = result.split(fullMatch).join(value);
|
|
}
|
|
// Leave unknown variables unchanged
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get the correct template for a page type
|
|
* Uses case-insensitive matching
|
|
*/
|
|
export async function getTemplateForPageType(pageType: string): Promise<string> {
|
|
const normalizedType = pageType.toLowerCase().trim() as PageType;
|
|
const templateKey = TEMPLATE_KEYS[normalizedType];
|
|
|
|
if (!templateKey) {
|
|
console.warn(`[TemplateEngine] Unknown page type: ${pageType}, falling back to state template`);
|
|
return getSetting('state_page_template');
|
|
}
|
|
|
|
return getSetting(templateKey);
|
|
}
|
|
|
|
/**
|
|
* Get regeneration template
|
|
*/
|
|
export async function getRegenerationTemplate(): Promise<string> {
|
|
return getSetting('regeneration_template');
|
|
}
|
|
|
|
/**
|
|
* Generate content for a page using the appropriate template
|
|
*/
|
|
export async function generatePageContent(
|
|
pageType: string,
|
|
data: Record<string, any>
|
|
): Promise<{
|
|
content: string;
|
|
templateUsed: string;
|
|
variablesApplied: string[];
|
|
}> {
|
|
const template = await getTemplateForPageType(pageType);
|
|
const content = applyTemplateVariables(template, data);
|
|
|
|
// Extract which variables were actually used
|
|
const variablePattern = /\{\{(\w+)\}\}/g;
|
|
const variablesInTemplate: string[] = [];
|
|
let match;
|
|
while ((match = variablePattern.exec(template)) !== null) {
|
|
if (!variablesInTemplate.includes(match[1])) {
|
|
variablesInTemplate.push(match[1]);
|
|
}
|
|
}
|
|
|
|
const variablesApplied = variablesInTemplate.filter(v => v in data);
|
|
|
|
return {
|
|
content,
|
|
templateUsed: TEMPLATE_KEYS[pageType.toLowerCase() as PageType] || 'state_page_template',
|
|
variablesApplied,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a preview with mock data
|
|
*/
|
|
export async function generatePreview(
|
|
pageType: string,
|
|
customTemplate?: string
|
|
): Promise<{
|
|
preview: string;
|
|
template: string;
|
|
mockData: Record<string, any>;
|
|
availableVariables: string[];
|
|
}> {
|
|
const normalizedType = (pageType?.toLowerCase().trim() || 'state') as PageType;
|
|
const template = customTemplate || await getTemplateForPageType(normalizedType);
|
|
const mockData = MOCK_DATA[normalizedType] || MOCK_DATA.state;
|
|
|
|
const preview = applyTemplateVariables(template, mockData);
|
|
|
|
return {
|
|
preview,
|
|
template,
|
|
mockData,
|
|
availableVariables: Object.keys(mockData),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Regenerate content using regeneration template
|
|
*/
|
|
export async function regenerateContent(
|
|
pageType: string,
|
|
originalContent: string,
|
|
newData: Record<string, any>,
|
|
improvementAreas?: string[]
|
|
): Promise<{
|
|
content: string;
|
|
regenerationPrompt: string;
|
|
}> {
|
|
const regenerationTemplate = await getRegenerationTemplate();
|
|
const settings = await getAllSettings();
|
|
|
|
// Build regeneration context
|
|
const regenerationData = {
|
|
...newData,
|
|
original_content: originalContent,
|
|
page_type: pageType,
|
|
improvement_areas: improvementAreas?.join(', ') || 'SEO keywords, local relevance, data freshness',
|
|
tone: settings.tone_voice || 'informational',
|
|
length: settings.default_content_length || 'medium',
|
|
};
|
|
|
|
const regenerationPrompt = applyTemplateVariables(regenerationTemplate, regenerationData);
|
|
|
|
// Generate new content using the page template
|
|
const pageTemplate = await getTemplateForPageType(pageType);
|
|
const content = applyTemplateVariables(pageTemplate, newData);
|
|
|
|
return {
|
|
content,
|
|
regenerationPrompt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all available templates and their metadata
|
|
*/
|
|
export async function getAllTemplates(): Promise<Record<string, {
|
|
key: string;
|
|
template: string;
|
|
description: string;
|
|
availableVariables: string[];
|
|
}>> {
|
|
const settings = await getAllSettings();
|
|
|
|
return {
|
|
state: {
|
|
key: 'state_page_template',
|
|
template: settings.state_page_template || '',
|
|
description: 'Template for state landing pages (e.g., "Arizona Dispensaries")',
|
|
availableVariables: Object.keys(MOCK_DATA.state),
|
|
},
|
|
city: {
|
|
key: 'city_page_template',
|
|
template: settings.city_page_template || '',
|
|
description: 'Template for city landing pages (e.g., "Phoenix Dispensaries")',
|
|
availableVariables: Object.keys(MOCK_DATA.city),
|
|
},
|
|
category: {
|
|
key: 'category_page_template',
|
|
template: settings.category_page_template || '',
|
|
description: 'Template for category pages (e.g., "Flower", "Edibles")',
|
|
availableVariables: Object.keys(MOCK_DATA.category),
|
|
},
|
|
brand: {
|
|
key: 'brand_page_template',
|
|
template: settings.brand_page_template || '',
|
|
description: 'Template for brand pages (e.g., "Raw Garden Products")',
|
|
availableVariables: Object.keys(MOCK_DATA.brand),
|
|
},
|
|
product: {
|
|
key: 'product_page_template',
|
|
template: settings.product_page_template || '',
|
|
description: 'Template for individual product pages',
|
|
availableVariables: Object.keys(MOCK_DATA.product),
|
|
},
|
|
search: {
|
|
key: 'search_results_template',
|
|
template: settings.search_results_template || '',
|
|
description: 'Template for search results pages',
|
|
availableVariables: Object.keys(MOCK_DATA.search),
|
|
},
|
|
regeneration: {
|
|
key: 'regeneration_template',
|
|
template: settings.regeneration_template || '',
|
|
description: 'Template used when regenerating/improving existing content',
|
|
availableVariables: ['original_content', 'page_type', 'improvement_areas', 'tone', 'length', '...page-specific variables'],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate a template string
|
|
*/
|
|
export function validateTemplate(template: string): {
|
|
valid: boolean;
|
|
variables: string[];
|
|
unknownVariables: string[];
|
|
errors: string[];
|
|
} {
|
|
const errors: string[] = [];
|
|
const variables: string[] = [];
|
|
|
|
// Find all variables
|
|
const variablePattern = /\{\{(\w+)\}\}/g;
|
|
let match;
|
|
while ((match = variablePattern.exec(template)) !== null) {
|
|
if (!variables.includes(match[1])) {
|
|
variables.push(match[1]);
|
|
}
|
|
}
|
|
|
|
// Check for unclosed brackets
|
|
const openBrackets = (template.match(/\{\{/g) || []).length;
|
|
const closeBrackets = (template.match(/\}\}/g) || []).length;
|
|
if (openBrackets !== closeBrackets) {
|
|
errors.push('Mismatched template brackets: {{ and }} counts do not match');
|
|
}
|
|
|
|
// Check for empty variable names
|
|
if (template.includes('{{}}')) {
|
|
errors.push('Empty variable name found: {{}}');
|
|
}
|
|
|
|
// Get all known variables
|
|
const allKnownVariables = new Set<string>();
|
|
Object.values(MOCK_DATA).forEach(data => {
|
|
Object.keys(data).forEach(key => allKnownVariables.add(key));
|
|
});
|
|
allKnownVariables.add('original_content');
|
|
allKnownVariables.add('page_type');
|
|
allKnownVariables.add('improvement_areas');
|
|
allKnownVariables.add('tone');
|
|
allKnownVariables.add('length');
|
|
|
|
const unknownVariables = variables.filter(v => !allKnownVariables.has(v));
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
variables,
|
|
unknownVariables,
|
|
errors,
|
|
};
|
|
}
|