feat: Add dynamic SVG featured image generation with SEO optimization
This commit is contained in:
175
frontend/src/lib/seo/image-generator.ts
Normal file
175
frontend/src/lib/seo/image-generator.ts
Normal file
@@ -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: `<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:{gradient_start};stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:{gradient_end};stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="{width}" height="{height}" fill="url(#grad)"/>
|
||||||
|
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="{font_family}" font-weight="bold" font-size="{title_size}" fill="{text_color}">
|
||||||
|
{title}
|
||||||
|
</text>
|
||||||
|
<text x="50%" y="85%" text-anchor="middle" font-family="{font_family}" font-size="{subtitle_size}" fill="rgba(255,255,255,0.7)">
|
||||||
|
{subtitle}
|
||||||
|
</text>
|
||||||
|
</svg>`,
|
||||||
|
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 = `<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="${template.font_family}" font-weight="bold" font-size="${template.title_font_size}" fill="${template.text_color}">${escapeXml(input.title)}</text>`;
|
||||||
|
} else {
|
||||||
|
const lineHeight = (template.title_font_size || 48) * 1.2;
|
||||||
|
const startY = (height / 2) - ((titleLines.length - 1) * lineHeight / 2);
|
||||||
|
titleSvg = titleLines.map((line, i) =>
|
||||||
|
`<text x="50%" y="${startY + (i * lineHeight)}" dominant-baseline="middle" text-anchor="middle" font-family="${template.font_family}" font-weight="bold" font-size="${template.title_font_size}" fill="${template.text_color}">${escapeXml(line)}</text>`
|
||||||
|
).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 `<img src="${src}" alt="${escapeXml(image.alt)}" width="${image.width}" height="${image.height}" loading="lazy" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape XML special characters
|
||||||
|
*/
|
||||||
|
function escapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
|
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||||
import { parseSpintaxRandom, injectVariables } from '@/lib/seo/cartesian';
|
import { parseSpintaxRandom, injectVariables } from '@/lib/seo/cartesian';
|
||||||
|
import { generateFeaturedImage, type ImageTemplate } from '@/lib/seo/image-generator';
|
||||||
import type { VariableMap } from '@/types/cartesian';
|
import type { VariableMap } from '@/types/cartesian';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,7 +204,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
? fragments[0].replace(/<[^>]*>/g, '').substring(0, 155)
|
? fragments[0].replace(/<[^>]*>/g, '').substring(0, 155)
|
||||||
: metaTitle;
|
: 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(
|
const article = await directus.request(
|
||||||
createItem('generated_articles', {
|
createItem('generated_articles', {
|
||||||
site: siteId || campaign.site,
|
site: siteId || campaign.site,
|
||||||
@@ -216,7 +225,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
is_published: false,
|
is_published: false,
|
||||||
location_city: locationVars.city || null,
|
location_city: locationVars.city || null,
|
||||||
location_county: locationVars.county || 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
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user