Add blog posts, cleanup unused files, update components
- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
import { readdir, readFile, mkdir, writeFile } from 'fs/promises';
|
||||
import { join, dirname, basename } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Website CI/Style Guide
|
||||
const CI_STYLE = {
|
||||
colors: {
|
||||
primary: 'sage green (#697565)',
|
||||
background: 'dark moss (#181C14)',
|
||||
accent: 'dark sage (#465B50)',
|
||||
text: 'cream/off-white (#ECDFCC)'
|
||||
},
|
||||
style: 'minimalist, glass-morphism, organic, professional, modern',
|
||||
aesthetic: 'dark background with sage green and cream accents, subtle gradients, clean lines'
|
||||
};
|
||||
|
||||
// Directories
|
||||
const BLOG_POSTS_DIR = join(__dirname, '../blog-posts');
|
||||
const OUTPUT_DIR = join(__dirname, '../source-images/posts');
|
||||
|
||||
/**
|
||||
* Call Google Gemini / Nano Banana Pro Image Generation API
|
||||
*/
|
||||
async function callGeminiImageAPI(prompt, apiKey) {
|
||||
const model = 'gemini-2.0-flash-exp'; // Nano Banana model
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{
|
||||
parts: [{ text: prompt }]
|
||||
}],
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT', 'IMAGE']
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
const errorMessage = error.error?.message || response.statusText;
|
||||
throw new Error(`Gemini API Error: ${response.status} - ${errorMessage}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenAI Image Generation API directly using fetch
|
||||
*/
|
||||
async function callOpenAIImageAPI(prompt, apiKey) {
|
||||
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-image-1.5',
|
||||
prompt: prompt,
|
||||
n: 1,
|
||||
size: '1536x1024', // 3:2 landscape (closest to 16:9 for gpt-image-1.5)
|
||||
quality: 'high'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(`OpenAI API Error: ${response.status} - ${error.error?.message || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image prompts from a blog post markdown file
|
||||
*/
|
||||
function extractImagePrompts(content, filename) {
|
||||
const prompts = [];
|
||||
|
||||
// Find the "Bildprompts" section
|
||||
const bildpromptsMatch = content.match(/## Bildprompts[\s\S]*?(?=##|---|\n\n\n|$)/i);
|
||||
|
||||
if (bildpromptsMatch) {
|
||||
const section = bildpromptsMatch[0];
|
||||
|
||||
// Extract prompts in quotes
|
||||
const promptMatches = section.matchAll(/"([^"]+)"/g);
|
||||
|
||||
for (const match of promptMatches) {
|
||||
prompts.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for image prompt patterns
|
||||
if (prompts.length === 0) {
|
||||
const fallbackMatches = content.matchAll(/\*\*Bild \d+[^*]*\*\*[:\s]*"?([^"\n]+)"?/gi);
|
||||
for (const match of fallbackMatches) {
|
||||
prompts.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return prompts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt a prompt to match the website's CI
|
||||
*/
|
||||
function adaptPromptToCI(originalPrompt, isHeroImage = false) {
|
||||
// Extract the core concept from the original prompt
|
||||
let concept = originalPrompt
|
||||
.replace(/dark blue and cyan/gi, '')
|
||||
.replace(/blue accents/gi, '')
|
||||
.replace(/white background/gi, '')
|
||||
.replace(/bright colors/gi, '')
|
||||
.replace(/neon/gi, '')
|
||||
.replace(/vibrant/gi, '')
|
||||
.replace(/cyan/gi, '')
|
||||
.replace(/8k ultra-realistic/gi, '')
|
||||
.replace(/cinematic lighting/gi, '')
|
||||
.replace(/color scheme/gi, '')
|
||||
.trim();
|
||||
|
||||
// Build CI-compliant prompt - REALISTIC style with CI colors
|
||||
const ciPrompt = `
|
||||
Photorealistic, cinematic image for a professional tech blog. 16:9 wide format, 4K quality.
|
||||
|
||||
COLOR GRADING (apply as color filter/mood):
|
||||
- Overall mood: Dark, moody atmosphere with deep shadows
|
||||
- Dominant tones: Dark greenish-black (#181C14), muted sage green (#697565)
|
||||
- Accent lighting: Warm cream/off-white (#ECDFCC) highlights
|
||||
- Color temperature: Slightly warm, earthy undertones
|
||||
- NO bright blue, NO cyan, NO neon colors
|
||||
|
||||
PHOTOGRAPHY STYLE:
|
||||
- Ultra-realistic, photographic quality
|
||||
- Cinematic wide-angle composition
|
||||
- Professional lighting with dramatic shadows
|
||||
- Shallow depth of field where appropriate
|
||||
- High-end commercial photography look
|
||||
- Shot on Sony A7R IV, 24-70mm f/2.8 lens
|
||||
|
||||
SCENE: ${concept}
|
||||
|
||||
Create a realistic, professional photograph. The lighting should create a moody, sophisticated atmosphere using the sage green and cream color palette as accent lighting or environmental color.
|
||||
|
||||
NO text overlays, NO watermarks, NO logos.
|
||||
`.trim();
|
||||
|
||||
return ciPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an image using OpenAI's gpt-image-1.5
|
||||
*/
|
||||
async function generateImage(prompt, outputPath, apiKey, provider = 'gemini', retries = 3) {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
console.log(` Generating image (attempt ${attempt}/${retries}) via ${provider}...`);
|
||||
console.log(` Prompt: ${prompt.substring(0, 100)}...`);
|
||||
|
||||
let imageBuffer;
|
||||
|
||||
if (provider === 'gemini') {
|
||||
// Use Google Gemini / Nano Banana Pro
|
||||
const response = await callGeminiImageAPI(prompt, apiKey);
|
||||
|
||||
// Extract image from Gemini response
|
||||
const candidate = response.candidates?.[0];
|
||||
if (!candidate) {
|
||||
throw new Error('No candidates in response');
|
||||
}
|
||||
|
||||
const parts = candidate.content?.parts || [];
|
||||
const imagePart = parts.find(p => p.inlineData);
|
||||
|
||||
if (!imagePart?.inlineData?.data) {
|
||||
// Check if there's text response (might be safety block)
|
||||
const textPart = parts.find(p => p.text);
|
||||
if (textPart) {
|
||||
console.log(` Response text: ${textPart.text.substring(0, 100)}...`);
|
||||
}
|
||||
throw new Error('No image data in Gemini response');
|
||||
}
|
||||
|
||||
imageBuffer = Buffer.from(imagePart.inlineData.data, 'base64');
|
||||
|
||||
} else {
|
||||
// Use OpenAI
|
||||
const response = await callOpenAIImageAPI(prompt, apiKey);
|
||||
const imageResult = response.data[0];
|
||||
|
||||
if (imageResult.b64_json) {
|
||||
imageBuffer = Buffer.from(imageResult.b64_json, 'base64');
|
||||
} else if (imageResult.url) {
|
||||
console.log(` Downloading from URL...`);
|
||||
const imageResponse = await fetch(imageResult.url);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`Failed to download image: ${imageResponse.status}`);
|
||||
}
|
||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
||||
imageBuffer = Buffer.from(arrayBuffer);
|
||||
} else {
|
||||
throw new Error('No image data in response');
|
||||
}
|
||||
}
|
||||
|
||||
// Save the image
|
||||
await writeFile(outputPath, imageBuffer);
|
||||
console.log(` ✓ Saved: ${outputPath}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(` ✗ Attempt ${attempt} failed:`, error.message);
|
||||
|
||||
if (attempt === retries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait before retry
|
||||
await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single blog post
|
||||
*/
|
||||
async function processBlogPost(filename, apiKey, options = {}) {
|
||||
const { dryRun = false, forceRegenerate = false, onlyHero = true, provider = 'gemini' } = options;
|
||||
|
||||
const filePath = join(BLOG_POSTS_DIR, filename);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
|
||||
// Extract slug from filename (e.g., "01-agentic-ai-2026.md" -> "01-agentic-ai-2026")
|
||||
const slug = basename(filename, '.md');
|
||||
|
||||
// Extract image prompts
|
||||
const prompts = extractImagePrompts(content, filename);
|
||||
|
||||
if (prompts.length === 0) {
|
||||
console.log(`⚠ No image prompts found in ${filename}`);
|
||||
return { filename, slug, generated: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
console.log(`\n📄 ${filename}`);
|
||||
console.log(` Found ${prompts.length} image prompt(s)`);
|
||||
|
||||
// Create output directory
|
||||
const postOutputDir = join(OUTPUT_DIR, slug);
|
||||
if (!dryRun) {
|
||||
await mkdir(postOutputDir, { recursive: true });
|
||||
}
|
||||
|
||||
let generated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Process prompts (only hero image by default)
|
||||
const promptsToProcess = onlyHero ? [prompts[0]] : prompts;
|
||||
|
||||
for (let i = 0; i < promptsToProcess.length; i++) {
|
||||
const prompt = promptsToProcess[i];
|
||||
const imageName = i === 0 ? 'cover.jpg' : `image-${i + 1}.jpg`;
|
||||
const outputPath = join(postOutputDir, imageName);
|
||||
|
||||
// Check if image already exists
|
||||
if (existsSync(outputPath) && !forceRegenerate) {
|
||||
console.log(` ⏭ Skipping ${imageName} (already exists)`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Adapt prompt to CI
|
||||
const adaptedPrompt = adaptPromptToCI(prompt, i === 0);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(` [DRY RUN] Would generate: ${imageName}`);
|
||||
console.log(` Original: ${prompt.substring(0, 80)}...`);
|
||||
console.log(` Adapted: ${adaptedPrompt.substring(0, 80)}...`);
|
||||
} else {
|
||||
try {
|
||||
await generateImage(adaptedPrompt, outputPath, apiKey, provider);
|
||||
generated++;
|
||||
|
||||
// Rate limiting: wait between requests
|
||||
if (i < promptsToProcess.length - 1) {
|
||||
console.log(' Waiting 2s before next image...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(` ✗ Failed to generate ${imageName}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { filename, slug, generated, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
console.log('🎨 Blog Image Generator');
|
||||
console.log('========================\n');
|
||||
|
||||
// Get API key from environment or command line
|
||||
const apiKey = process.env.OPENAI_API_KEY ||
|
||||
process.argv.find(a => a.startsWith('--api-key='))?.split('=')[1];
|
||||
|
||||
// Check for API key
|
||||
if (!apiKey) {
|
||||
console.error('❌ Error: OPENAI_API_KEY not provided');
|
||||
console.log('\nUsage:');
|
||||
console.log(' export OPENAI_API_KEY=sk-your-key-here');
|
||||
console.log(' node scripts/generate-blog-images.js');
|
||||
console.log('\nOr:');
|
||||
console.log(' node scripts/generate-blog-images.js --api-key=sk-your-key-here');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Detect provider based on API key format
|
||||
const provider = apiKey.startsWith('AIzaSy') ? 'gemini' : 'openai';
|
||||
console.log(`Provider: ${provider === 'gemini' ? 'Google Gemini (Nano Banana)' : 'OpenAI'}\n`);
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
dryRun: args.includes('--dry-run'),
|
||||
forceRegenerate: args.includes('--force'),
|
||||
onlyHero: !args.includes('--all-images'),
|
||||
single: args.find(a => a.startsWith('--file='))?.split('=')[1],
|
||||
range: args.find(a => a.startsWith('--range='))?.split('=')[1],
|
||||
provider
|
||||
};
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('🔍 DRY RUN MODE - No images will be generated\n');
|
||||
}
|
||||
|
||||
console.log(`CI Style: ${CI_STYLE.style}`);
|
||||
console.log(`Colors: ${Object.values(CI_STYLE.colors).join(', ')}\n`);
|
||||
|
||||
// Get list of blog posts
|
||||
let files;
|
||||
|
||||
if (options.single) {
|
||||
files = [options.single];
|
||||
} else {
|
||||
files = (await readdir(BLOG_POSTS_DIR))
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.sort();
|
||||
|
||||
// Filter by range if specified (e.g., --range=1-10)
|
||||
if (options.range) {
|
||||
const [start, end] = options.range.split('-').map(Number);
|
||||
files = files.filter(f => {
|
||||
const num = parseInt(f.split('-')[0], 10);
|
||||
return num >= start && num <= end;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${files.length} blog post(s) to process\n`);
|
||||
|
||||
// Process each blog post
|
||||
const results = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const result = await processBlogPost(file, apiKey, options);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing ${file}:`, error.message);
|
||||
results.push({ filename: file, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n========================');
|
||||
console.log('📊 Summary\n');
|
||||
|
||||
const totalGenerated = results.reduce((sum, r) => sum + (r.generated || 0), 0);
|
||||
const totalSkipped = results.reduce((sum, r) => sum + (r.skipped || 0), 0);
|
||||
const totalErrors = results.filter(r => r.error).length;
|
||||
|
||||
console.log(`✓ Generated: ${totalGenerated} images`);
|
||||
console.log(`⏭ Skipped: ${totalSkipped} images (already exist)`);
|
||||
console.log(`✗ Errors: ${totalErrors}`);
|
||||
|
||||
if (!options.dryRun && totalGenerated > 0) {
|
||||
console.log('\n📝 Next steps:');
|
||||
console.log('1. Review generated images in source-images/posts/');
|
||||
console.log('2. Run `npm run build:images` to optimize images');
|
||||
}
|
||||
}
|
||||
|
||||
// Run
|
||||
main().catch(console.error);
|
||||
Reference in New Issue
Block a user