From 079804ac133a951825a8db2cb8c122c8798cebea Mon Sep 17 00:00:00 2001 From: cawcenter Date: Fri, 12 Dec 2025 09:42:40 -0500 Subject: [PATCH] feat: Add dynamic SVG featured image generation with SEO optimization --- frontend/src/lib/seo/image-generator.ts | 175 ++++++++++++++++++ .../src/pages/api/seo/generate-article.ts | 16 +- 2 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/seo/image-generator.ts 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: ` + + + + + + + + + {title} + + + {subtitle} + +`, + 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 `${escapeXml(image.alt)}`; +} + +/** + * 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 }) );