diff --git a/frontend/src/lib/seo/image-generator.ts b/frontend/src/lib/seo/image-generator.ts
new file mode 100644
index 0000000..5e90198
--- /dev/null
+++ b/frontend/src/lib/seo/image-generator.ts
@@ -0,0 +1,175 @@
+/**
+ * SVG Featured Image Generator
+ *
+ * Generates SEO-optimized featured images from templates.
+ * - Replaces {title}, {subtitle}, colors, fonts
+ * - Returns SVG string and base64 data URI
+ * - Generates SEO-friendly filenames from titles
+ */
+
+export interface ImageGeneratorInput {
+ title: string;
+ subtitle?: string;
+ template?: ImageTemplate;
+}
+
+export interface ImageTemplate {
+ svg_source: string;
+ width?: number;
+ height?: number;
+ background_gradient_start?: string;
+ background_gradient_end?: string;
+ text_color?: string;
+ font_family?: string;
+ title_font_size?: number;
+ subtitle_text?: string;
+ subtitle_font_size?: number;
+}
+
+export interface GeneratedImage {
+ svg: string;
+ dataUri: string;
+ filename: string;
+ alt: string;
+ width: number;
+ height: number;
+}
+
+// Default professional template
+const DEFAULT_TEMPLATE: ImageTemplate = {
+ svg_source: ``,
+ width: 1200,
+ height: 630,
+ background_gradient_start: '#2563eb',
+ background_gradient_end: '#1d4ed8',
+ text_color: '#ffffff',
+ font_family: 'Arial, sans-serif',
+ title_font_size: 48,
+ subtitle_text: '',
+ subtitle_font_size: 18
+};
+
+/**
+ * Generate SEO-friendly filename from title
+ * "Best Dentist in Austin, TX" -> "best-dentist-in-austin-tx.svg"
+ */
+export function generateFilename(title: string): string {
+ return title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special chars
+ .replace(/\s+/g, '-') // Spaces to dashes
+ .replace(/-+/g, '-') // Multiple dashes to single
+ .substring(0, 60) // Limit length
+ + '.svg';
+}
+
+/**
+ * Wrap long titles to multiple lines if needed
+ */
+function wrapTitle(title: string, maxCharsPerLine: number = 40): string[] {
+ const words = title.split(' ');
+ const lines: string[] = [];
+ let currentLine = '';
+
+ for (const word of words) {
+ if ((currentLine + ' ' + word).trim().length <= maxCharsPerLine) {
+ currentLine = (currentLine + ' ' + word).trim();
+ } else {
+ if (currentLine) lines.push(currentLine);
+ currentLine = word;
+ }
+ }
+ if (currentLine) lines.push(currentLine);
+
+ return lines.slice(0, 3); // Max 3 lines
+}
+
+/**
+ * Generate a featured image from a template
+ */
+export function generateFeaturedImage(input: ImageGeneratorInput): GeneratedImage {
+ const template = input.template || DEFAULT_TEMPLATE;
+ const width = template.width || 1200;
+ const height = template.height || 630;
+
+ // Process title for multi-line if needed
+ const titleLines = wrapTitle(input.title);
+ const isSingleLine = titleLines.length === 1;
+
+ // Build title text elements
+ let titleSvg: string;
+ if (isSingleLine) {
+ titleSvg = `${escapeXml(input.title)}`;
+ } else {
+ const lineHeight = (template.title_font_size || 48) * 1.2;
+ const startY = (height / 2) - ((titleLines.length - 1) * lineHeight / 2);
+ titleSvg = titleLines.map((line, i) =>
+ `${escapeXml(line)}`
+ ).join('\n');
+ }
+
+ // Replace template variables
+ let svg = template.svg_source
+ .replace(/{width}/g, String(width))
+ .replace(/{height}/g, String(height))
+ .replace(/{width-80}/g, String(width - 80))
+ .replace(/{height-80}/g, String(height - 80))
+ .replace(/{gradient_start}/g, template.background_gradient_start || '#2563eb')
+ .replace(/{gradient_end}/g, template.background_gradient_end || '#1d4ed8')
+ .replace(/{text_color}/g, template.text_color || '#ffffff')
+ .replace(/{accent_color}/g, template.background_gradient_start || '#2563eb')
+ .replace(/{font_family}/g, template.font_family || 'Arial, sans-serif')
+ .replace(/{title_size}/g, String(template.title_font_size || 48))
+ .replace(/{subtitle_size}/g, String(template.subtitle_font_size || 18))
+ .replace(/{title}/g, escapeXml(input.title))
+ .replace(/{subtitle}/g, escapeXml(input.subtitle || template.subtitle_text || ''));
+
+ // Generate base64 data URI for inline use
+ const base64 = typeof Buffer !== 'undefined'
+ ? Buffer.from(svg).toString('base64')
+ : btoa(unescape(encodeURIComponent(svg)));
+ const dataUri = `data:image/svg+xml;base64,${base64}`;
+
+ return {
+ svg,
+ dataUri,
+ filename: generateFilename(input.title),
+ alt: `${input.title} - Featured Image`,
+ width,
+ height
+ };
+}
+
+/**
+ * Generate HTML img tag for the featured image
+ */
+export function generateImageTag(image: GeneratedImage, useSrcPath?: string): string {
+ const src = useSrcPath || image.dataUri;
+ return `
`;
+}
+
+/**
+ * Escape XML special characters
+ */
+function escapeXml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/frontend/src/pages/api/seo/generate-article.ts b/frontend/src/pages/api/seo/generate-article.ts
index f689bd4..693f02a 100644
--- a/frontend/src/pages/api/seo/generate-article.ts
+++ b/frontend/src/pages/api/seo/generate-article.ts
@@ -1,6 +1,7 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
import { parseSpintaxRandom, injectVariables } from '@/lib/seo/cartesian';
+import { generateFeaturedImage, type ImageTemplate } from '@/lib/seo/image-generator';
import type { VariableMap } from '@/types/cartesian';
/**
@@ -203,7 +204,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
? fragments[0].replace(/<[^>]*>/g, '').substring(0, 155)
: metaTitle;
- // Create article record
+ // Generate featured image from template
+ const featuredImage = generateFeaturedImage({
+ title: processedHeadline,
+ subtitle: locationVars.city
+ ? `${locationVars.city}, ${locationVars.state_code || locationVars.state}`
+ : undefined
+ });
+
+ // Create article record with featured image
const article = await directus.request(
createItem('generated_articles', {
site: siteId || campaign.site,
@@ -216,7 +225,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
is_published: false,
location_city: locationVars.city || null,
location_county: locationVars.county || null,
- location_state: locationVars.state || null
+ location_state: locationVars.state || null,
+ featured_image_svg: featuredImage.svg,
+ featured_image_filename: featuredImage.filename,
+ featured_image_alt: featuredImage.alt
})
);