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:
2026-01-25 11:42:11 +01:00
co-authored by Claude Opus 4.5
parent cba6c66ccc
commit 43484c5023
164 changed files with 60969 additions and 23346 deletions
+493
View File
@@ -0,0 +1,493 @@
/**
* Axe Accessibility Audit Script
*
* This script runs axe-core accessibility scans on all main pages of the portfolio site.
* It generates a comprehensive report with WCAG references.
*
* Usage:
* node scripts/axe-accessibility-audit.js
*
* Prerequisites:
* - Development server running on http://localhost:3000
* - Install deps: npm install puppeteer @axe-core/puppeteer --no-save
*/
const { AxePuppeteer } = require('@axe-core/puppeteer');
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
// Configuration
const BASE_URL = 'http://localhost:3000';
const LOCALES = ['de', 'en', 'sr'];
const OUTPUT_DIR = path.join(__dirname, '..', '.auto-claude', 'specs', '001-suche-nach-optimierungsvorschl-gen');
// Pages to audit (main public pages)
const PAGES = [
{ path: '', name: 'Home' },
{ path: '/about', name: 'About' },
{ path: '/blog', name: 'Blog Index' },
{ path: '/contact', name: 'Contact' },
{ path: '/leistungen', name: 'Services' },
{ path: '/portfolio', name: 'Portfolio Index' },
{ path: '/imprint', name: 'Imprint' },
{ path: '/privacy', name: 'Privacy' },
{ path: '/terms', name: 'Terms' },
];
// WCAG 2.1 Impact mapping
const IMPACT_ORDER = {
'critical': 0,
'serious': 1,
'moderate': 2,
'minor': 3
};
const WCAG_TAGS = {
'wcag2a': 'WCAG 2.0 Level A',
'wcag2aa': 'WCAG 2.0 Level AA',
'wcag2aaa': 'WCAG 2.0 Level AAA',
'wcag21a': 'WCAG 2.1 Level A',
'wcag21aa': 'WCAG 2.1 Level AA',
'wcag22aa': 'WCAG 2.2 Level AA',
'best-practice': 'Best Practice'
};
/**
* Run axe audit on a single page
*/
async function auditPage(browser, url, pageName) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
console.log(` Scanning: ${url}`);
try {
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
// Wait for dynamic content to load
await page.waitForTimeout(1000);
// Run axe analysis
const results = await new AxePuppeteer(page)
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'])
.analyze();
await page.close();
return {
url,
pageName,
timestamp: new Date().toISOString(),
violations: results.violations,
passes: results.passes.length,
incomplete: results.incomplete,
inapplicable: results.inapplicable.length
};
} catch (error) {
await page.close();
return {
url,
pageName,
timestamp: new Date().toISOString(),
error: error.message,
violations: [],
passes: 0,
incomplete: [],
inapplicable: 0
};
}
}
/**
* Format violations for markdown output
*/
function formatViolation(violation) {
const wcagTags = violation.tags
.filter(tag => tag.startsWith('wcag') || tag === 'best-practice')
.map(tag => WCAG_TAGS[tag] || tag)
.join(', ');
const nodes = violation.nodes.map(node => ({
html: node.html.substring(0, 200),
target: node.target.join(', '),
failureSummary: node.failureSummary
}));
return {
id: violation.id,
impact: violation.impact,
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
wcagTags,
nodeCount: nodes.length,
nodes: nodes.slice(0, 5) // Limit to first 5 examples
};
}
/**
* Aggregate violations across all pages
*/
function aggregateViolations(allResults) {
const violationMap = new Map();
for (const result of allResults) {
if (result.error) continue;
for (const violation of result.violations) {
const key = violation.id;
if (!violationMap.has(key)) {
violationMap.set(key, {
...formatViolation(violation),
affectedPages: [],
totalInstances: 0
});
}
const aggregated = violationMap.get(key);
aggregated.affectedPages.push({
url: result.url,
pageName: result.pageName,
instances: violation.nodes.length
});
aggregated.totalInstances += violation.nodes.length;
}
}
// Sort by impact severity, then by total instances
return Array.from(violationMap.values()).sort((a, b) => {
const impactDiff = IMPACT_ORDER[a.impact] - IMPACT_ORDER[b.impact];
if (impactDiff !== 0) return impactDiff;
return b.totalInstances - a.totalInstances;
});
}
/**
* Generate markdown report
*/
function generateMarkdownReport(allResults, aggregatedViolations) {
const timestamp = new Date().toISOString();
// Calculate statistics
const totalPages = allResults.filter(r => !r.error).length;
const errorPages = allResults.filter(r => r.error).length;
const totalViolations = aggregatedViolations.length;
const totalInstances = aggregatedViolations.reduce((sum, v) => sum + v.totalInstances, 0);
const bySeverity = {
critical: aggregatedViolations.filter(v => v.impact === 'critical'),
serious: aggregatedViolations.filter(v => v.impact === 'serious'),
moderate: aggregatedViolations.filter(v => v.impact === 'moderate'),
minor: aggregatedViolations.filter(v => v.impact === 'minor')
};
let report = `# Axe Accessibility Audit Report
**Generated:** ${timestamp}
**Tool:** axe-core via @axe-core/puppeteer
**Standards:** WCAG 2.0 A/AA, WCAG 2.1 A/AA, Best Practices
---
## Executive Summary
| Metric | Value |
|--------|-------|
| Pages Scanned | ${totalPages} |
| Pages with Errors | ${errorPages} |
| Unique Violations | ${totalViolations} |
| Total Instances | ${totalInstances} |
| Critical Issues | ${bySeverity.critical.length} |
| Serious Issues | ${bySeverity.serious.length} |
| Moderate Issues | ${bySeverity.moderate.length} |
| Minor Issues | ${bySeverity.minor.length} |
`;
// Severity breakdown
report += `### Severity Distribution
`;
if (bySeverity.critical.length > 0) {
report += `🔴 **Critical (${bySeverity.critical.length}):** ${bySeverity.critical.map(v => v.id).join(', ')}\n\n`;
} else {
report += `✅ **Critical (0):** No critical issues found\n\n`;
}
if (bySeverity.serious.length > 0) {
report += `🟠 **Serious (${bySeverity.serious.length}):** ${bySeverity.serious.map(v => v.id).join(', ')}\n\n`;
} else {
report += `✅ **Serious (0):** No serious issues found\n\n`;
}
if (bySeverity.moderate.length > 0) {
report += `🟡 **Moderate (${bySeverity.moderate.length}):** ${bySeverity.moderate.map(v => v.id).join(', ')}\n\n`;
} else {
report += `✅ **Moderate (0):** No moderate issues found\n\n`;
}
if (bySeverity.minor.length > 0) {
report += `🟢 **Minor (${bySeverity.minor.length}):** ${bySeverity.minor.map(v => v.id).join(', ')}\n\n`;
} else {
report += `✅ **Minor (0):** No minor issues found\n\n`;
}
report += `---
## Page-by-Page Results
`;
for (const result of allResults) {
const locale = result.url.split('/')[3] || 'root';
const violationCount = result.violations?.length || 0;
const status = result.error ? '❌ Error' : (violationCount === 0 ? '✅ Pass' : `⚠️ ${violationCount} issues`);
report += `### ${result.pageName} (${locale.toUpperCase()})
**URL:** ${result.url}
**Status:** ${status}
`;
if (result.error) {
report += `**Error:** ${result.error}\n\n`;
} else {
report += `**Passes:** ${result.passes} | **Violations:** ${violationCount} | **Incomplete:** ${result.incomplete.length}\n\n`;
if (violationCount > 0) {
report += `**Issues found:**\n`;
for (const v of result.violations) {
report += `- \`${v.id}\` (${v.impact}): ${v.help} - ${v.nodes.length} instance(s)\n`;
}
report += '\n';
}
}
}
report += `---
## Detailed Violations
`;
for (const violation of aggregatedViolations) {
const impactEmoji = {
'critical': '🔴',
'serious': '🟠',
'moderate': '🟡',
'minor': '🟢'
}[violation.impact];
report += `### ${impactEmoji} ${violation.id}
**Impact:** ${violation.impact.toUpperCase()}
**WCAG:** ${violation.wcagTags || 'N/A'}
**Help:** ${violation.help}
**Total Instances:** ${violation.totalInstances}
**Documentation:** [Deque ${violation.id}](${violation.helpUrl})
**Description:** ${violation.description}
**Affected Pages:**
`;
for (const page of violation.affectedPages) {
report += `- ${page.pageName} (${page.url}): ${page.instances} instance(s)\n`;
}
report += '\n**Example Elements:**\n\n```html\n';
for (const node of violation.nodes.slice(0, 3)) {
report += `<!-- Target: ${node.target} -->\n`;
report += `${node.html}\n\n`;
}
report += '```\n\n';
if (violation.nodes[0]?.failureSummary) {
report += `**Fix:** ${violation.nodes[0].failureSummary}\n\n`;
}
report += `---\n\n`;
}
// Recommendations section
report += `## Recommendations
### Immediate Actions (Critical & Serious)
`;
for (const v of [...bySeverity.critical, ...bySeverity.serious]) {
report += `1. **Fix \`${v.id}\`**: ${v.help} - Affects ${v.affectedPages.length} page(s), ${v.totalInstances} total instance(s)\n`;
}
if (bySeverity.critical.length === 0 && bySeverity.serious.length === 0) {
report += `✅ No critical or serious issues to fix immediately.\n`;
}
report += `
### Short-term Improvements (Moderate)
`;
for (const v of bySeverity.moderate) {
report += `1. **Fix \`${v.id}\`**: ${v.help} - Affects ${v.affectedPages.length} page(s)\n`;
}
if (bySeverity.moderate.length === 0) {
report += `✅ No moderate issues to address.\n`;
}
report += `
### Nice-to-have (Minor & Best Practices)
`;
for (const v of bySeverity.minor) {
report += `1. **Consider \`${v.id}\`**: ${v.help}\n`;
}
if (bySeverity.minor.length === 0) {
report += `✅ No minor issues found.\n`;
}
report += `
---
## WCAG Compliance Summary
Based on axe automated testing, the following WCAG conformance levels have been evaluated:
| Level | Status | Notes |
|-------|--------|-------|
| WCAG 2.0 A | ${bySeverity.critical.length === 0 ? '✅ Pass' : '❌ Fail'} | ${bySeverity.critical.length > 0 ? `${bySeverity.critical.length} critical violations` : 'No critical violations'} |
| WCAG 2.0 AA | ${(bySeverity.critical.length + bySeverity.serious.length) === 0 ? '✅ Pass' : '⚠️ Issues'} | ${(bySeverity.critical.length + bySeverity.serious.length) > 0 ? `${bySeverity.critical.length + bySeverity.serious.length} issues` : 'No serious violations'} |
| WCAG 2.1 AA | ${(bySeverity.critical.length + bySeverity.serious.length) === 0 ? '✅ Pass' : '⚠️ Issues'} | Same as above |
**Note:** Automated testing can only catch ~30-40% of accessibility issues. Manual testing for keyboard navigation,
screen reader compatibility, and cognitive accessibility is also required for full WCAG compliance.
---
## Testing Methodology
- **Tool:** axe-core via @axe-core/puppeteer
- **Browser:** Chromium (Puppeteer)
- **Viewport:** 1280x800 (Desktop)
- **Standards Tested:** wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice
- **Locales Tested:** German (de), English (en), Serbian (sr)
## Next Steps
1. Address all critical and serious violations first
2. Fix moderate issues for full WCAG 2.1 AA compliance
3. Perform manual keyboard navigation testing
4. Test with screen readers (NVDA, VoiceOver)
5. Review color contrast manually for custom components
6. Test with real users with disabilities if possible
`;
return report;
}
/**
* Main execution
*/
async function main() {
console.log('🔍 Axe Accessibility Audit Starting...\n');
// Check if dev server is running
try {
const http = require('http');
await new Promise((resolve, reject) => {
http.get(BASE_URL, (res) => {
if (res.statusCode === 200 || res.statusCode === 301 || res.statusCode === 302) {
resolve();
} else {
reject(new Error(`Server returned ${res.statusCode}`));
}
}).on('error', reject);
});
} catch (error) {
console.error('❌ Development server not running!');
console.error(' Please start the server with: pnpm dev');
process.exit(1);
}
console.log('✅ Development server detected\n');
// Launch browser
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const allResults = [];
try {
// Test each locale and page combination
for (const locale of LOCALES) {
console.log(`\n📍 Scanning locale: ${locale.toUpperCase()}`);
for (const page of PAGES) {
const url = `${BASE_URL}/${locale}${page.path}`;
const result = await auditPage(browser, url, page.name);
allResults.push(result);
}
}
console.log('\n\n📊 Processing results...');
// Aggregate violations
const aggregatedViolations = aggregateViolations(allResults);
// Generate report
const report = generateMarkdownReport(allResults, aggregatedViolations);
// Save report
const reportPath = path.join(OUTPUT_DIR, 'axe-accessibility-audit.md');
fs.writeFileSync(reportPath, report);
// Save JSON data for further analysis
const jsonPath = path.join(OUTPUT_DIR, 'axe-accessibility-audit.json');
fs.writeFileSync(jsonPath, JSON.stringify({
timestamp: new Date().toISOString(),
summary: {
pagesScanned: allResults.filter(r => !r.error).length,
uniqueViolations: aggregatedViolations.length,
totalInstances: aggregatedViolations.reduce((sum, v) => sum + v.totalInstances, 0),
bySeverity: {
critical: aggregatedViolations.filter(v => v.impact === 'critical').length,
serious: aggregatedViolations.filter(v => v.impact === 'serious').length,
moderate: aggregatedViolations.filter(v => v.impact === 'moderate').length,
minor: aggregatedViolations.filter(v => v.impact === 'minor').length
}
},
violations: aggregatedViolations,
pageResults: allResults
}, null, 2));
console.log(`\n✅ Audit complete!`);
console.log(` Report: ${reportPath}`);
console.log(` JSON: ${jsonPath}`);
// Print summary
console.log('\n📈 Summary:');
console.log(` Pages scanned: ${allResults.filter(r => !r.error).length}`);
console.log(` Unique violations: ${aggregatedViolations.length}`);
console.log(` Critical: ${aggregatedViolations.filter(v => v.impact === 'critical').length}`);
console.log(` Serious: ${aggregatedViolations.filter(v => v.impact === 'serious').length}`);
console.log(` Moderate: ${aggregatedViolations.filter(v => v.impact === 'moderate').length}`);
console.log(` Minor: ${aggregatedViolations.filter(v => v.impact === 'minor').length}`);
} finally {
await browser.close();
}
}
main().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
+406
View File
@@ -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);
+528
View File
@@ -0,0 +1,528 @@
#!/usr/bin/env node
/**
* Lighthouse Audit for All Locales
* Runs PageSpeed Insights API tests for all three language versions (de, en, sr)
* Documents Core Web Vitals (LCP, CLS, INP) for both mobile and desktop modes
*/
import https from 'https';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Disable SSL verification for local testing (network proxy issue)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const API_KEY = 'AIzaSyA4qo0LfLy_ps-SyTQTQpFrFRjDnAUn_K4';
const BASE_URL = 'https://www.damjan-savic.com';
const LOCALES = ['de', 'en', 'sr'];
const STRATEGIES = ['mobile', 'desktop'];
const REPORTS_DIR = path.join(__dirname, '..', '.auto-claude', 'specs', '001-suche-nach-optimierungsvorschl-gen', 'lighthouse-reports');
function httpsGet(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`JSON Parse Error: ${e.message}`));
}
});
}).on('error', reject);
});
}
async function runPageSpeedTest(url, strategy = 'mobile') {
const apiUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&key=${API_KEY}&strategy=${strategy}&category=performance&category=accessibility&category=best-practices&category=seo`;
console.log(`\n Testing ${url} (${strategy})...`);
console.log(' Please wait, this may take 30-60 seconds...\n');
try {
const data = await httpsGet(apiUrl);
if (data.error) {
throw new Error(`API Error: ${data.error.message}`);
}
return data;
} catch (error) {
console.error(` Error testing ${url}:`, error.message);
throw error;
}
}
function extractCoreWebVitals(data) {
const lighthouse = data.lighthouseResult;
const categories = lighthouse.categories;
const audits = lighthouse.audits;
const fcp = audits['first-contentful-paint'];
const lcp = audits['largest-contentful-paint'];
const tbt = audits['total-blocking-time'];
const cls = audits['cumulative-layout-shift'];
const si = audits['speed-index'];
const inp = audits['interaction-to-next-paint'] || audits['experimental-interaction-to-next-paint'];
// Extract opportunities
const opportunities = Object.values(audits)
.filter(audit => audit.details?.type === 'opportunity' && audit.details?.overallSavingsMs > 0)
.sort((a, b) => (b.details?.overallSavingsMs || 0) - (a.details?.overallSavingsMs || 0))
.map(opp => ({
title: opp.title,
description: opp.description,
savings_ms: opp.details?.overallSavingsMs || 0,
savings_bytes: opp.details?.overallSavingsBytes || 0
}));
// Extract diagnostics
const diagnostics = Object.values(audits)
.filter(audit => audit.details?.type === 'table' && audit.score !== null && audit.score < 0.9)
.map(diag => ({
title: diag.title,
description: diag.description,
score: diag.score
}));
return {
url: lighthouse.finalUrl,
fetchTime: lighthouse.fetchTime,
userAgent: lighthouse.userAgent,
scores: {
performance: Math.round(categories.performance.score * 100),
accessibility: Math.round(categories.accessibility.score * 100),
bestPractices: Math.round(categories['best-practices'].score * 100),
seo: Math.round(categories.seo.score * 100)
},
coreWebVitals: {
FCP: {
value: fcp?.numericValue || null,
displayValue: fcp?.displayValue || 'N/A',
score: fcp?.score || null
},
LCP: {
value: lcp?.numericValue || null,
displayValue: lcp?.displayValue || 'N/A',
score: lcp?.score || null
},
CLS: {
value: cls?.numericValue || null,
displayValue: cls?.displayValue || 'N/A',
score: cls?.score || null
},
TBT: {
value: tbt?.numericValue || null,
displayValue: tbt?.displayValue || 'N/A',
score: tbt?.score || null
},
INP: {
value: inp?.numericValue || null,
displayValue: inp?.displayValue || 'N/A',
score: inp?.score || null
},
SpeedIndex: {
value: si?.numericValue || null,
displayValue: si?.displayValue || 'N/A',
score: si?.score || null
}
},
opportunities: opportunities.slice(0, 10),
diagnostics: diagnostics.slice(0, 10)
};
}
function formatTime(ms) {
if (ms === null || ms === undefined) return 'N/A';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function getScoreEmoji(score) {
if (score >= 90) return 'green';
if (score >= 50) return 'yellow';
return 'red';
}
function generateMarkdownReport(allResults) {
const timestamp = new Date().toISOString();
let markdown = `# Lighthouse Audit Report - Core Web Vitals
**Generated:** ${timestamp}
**Base URL:** ${BASE_URL}
**Locales Tested:** ${LOCALES.join(', ')}
---
## Executive Summary
This report documents Core Web Vitals (LCP, CLS, INP/TBT) measurements for all three language versions of the portfolio website in both mobile and desktop modes.
### Key Metrics Explained:
- **LCP (Largest Contentful Paint):** Measures loading performance. Good: < 2.5s, Needs Improvement: 2.5-4s, Poor: > 4s
- **CLS (Cumulative Layout Shift):** Measures visual stability. Good: < 0.1, Needs Improvement: 0.1-0.25, Poor: > 0.25
- **INP/TBT (Interaction to Next Paint / Total Blocking Time):** Measures interactivity. TBT Good: < 200ms, INP Good: < 200ms
- **FCP (First Contentful Paint):** Measures perceived load speed. Good: < 1.8s
- **Speed Index:** How quickly content is visually displayed. Good: < 3.4s
---
## Results Summary Table
### Mobile Results
| Locale | Performance | LCP | CLS | TBT | FCP | Accessibility | SEO |
|--------|-------------|-----|-----|-----|-----|---------------|-----|
`;
// Add mobile results
LOCALES.forEach(locale => {
const result = allResults[`${locale}_mobile`];
if (result) {
const cwv = result.coreWebVitals;
markdown += `| ${locale.toUpperCase()} | ${result.scores.performance}% | ${cwv.LCP.displayValue} | ${cwv.CLS.displayValue} | ${cwv.TBT.displayValue} | ${cwv.FCP.displayValue} | ${result.scores.accessibility}% | ${result.scores.seo}% |\n`;
}
});
markdown += `
### Desktop Results
| Locale | Performance | LCP | CLS | TBT | FCP | Accessibility | SEO |
|--------|-------------|-----|-----|-----|-----|---------------|-----|
`;
// Add desktop results
LOCALES.forEach(locale => {
const result = allResults[`${locale}_desktop`];
if (result) {
const cwv = result.coreWebVitals;
markdown += `| ${locale.toUpperCase()} | ${result.scores.performance}% | ${cwv.LCP.displayValue} | ${cwv.CLS.displayValue} | ${cwv.TBT.displayValue} | ${cwv.FCP.displayValue} | ${result.scores.accessibility}% | ${result.scores.seo}% |\n`;
}
});
markdown += `
---
## Detailed Results by Locale
`;
// Detailed results for each locale
LOCALES.forEach(locale => {
markdown += `### ${locale.toUpperCase()} - ${locale === 'de' ? 'German' : locale === 'en' ? 'English' : 'Serbian'}
**URL:** ${BASE_URL}/${locale}
`;
STRATEGIES.forEach(strategy => {
const result = allResults[`${locale}_${strategy}`];
if (result) {
const cwv = result.coreWebVitals;
markdown += `#### ${strategy.charAt(0).toUpperCase() + strategy.slice(1)}
**Scores:**
- Performance: ${result.scores.performance}%
- Accessibility: ${result.scores.accessibility}%
- Best Practices: ${result.scores.bestPractices}%
- SEO: ${result.scores.seo}%
**Core Web Vitals:**
| Metric | Value | Score |
|--------|-------|-------|
| First Contentful Paint (FCP) | ${cwv.FCP.displayValue} | ${cwv.FCP.score !== null ? Math.round(cwv.FCP.score * 100) + '%' : 'N/A'} |
| Largest Contentful Paint (LCP) | ${cwv.LCP.displayValue} | ${cwv.LCP.score !== null ? Math.round(cwv.LCP.score * 100) + '%' : 'N/A'} |
| Cumulative Layout Shift (CLS) | ${cwv.CLS.displayValue} | ${cwv.CLS.score !== null ? Math.round(cwv.CLS.score * 100) + '%' : 'N/A'} |
| Total Blocking Time (TBT) | ${cwv.TBT.displayValue} | ${cwv.TBT.score !== null ? Math.round(cwv.TBT.score * 100) + '%' : 'N/A'} |
| Speed Index | ${cwv.SpeedIndex.displayValue} | ${cwv.SpeedIndex.score !== null ? Math.round(cwv.SpeedIndex.score * 100) + '%' : 'N/A'} |
`;
if (result.opportunities && result.opportunities.length > 0) {
markdown += `**Optimization Opportunities:**
`;
result.opportunities.slice(0, 5).forEach(opp => {
markdown += `- **${opp.title}**: Potential savings of ${formatTime(opp.savings_ms)}`;
if (opp.savings_bytes > 0) {
markdown += ` / ${(opp.savings_bytes / 1024).toFixed(1)} KB`;
}
markdown += `\n`;
});
markdown += '\n';
}
if (result.diagnostics && result.diagnostics.length > 0) {
markdown += `**Diagnostics (Items Needing Attention):**
`;
result.diagnostics.slice(0, 5).forEach(diag => {
markdown += `- ${diag.title} (Score: ${Math.round(diag.score * 100)}%)\n`;
});
markdown += '\n';
}
}
});
});
markdown += `---
## Core Web Vitals Analysis
### LCP (Largest Contentful Paint) Summary
`;
// LCP analysis
const lcpValues = [];
Object.entries(allResults).forEach(([key, result]) => {
if (result && result.coreWebVitals.LCP.value) {
lcpValues.push({
test: key,
value: result.coreWebVitals.LCP.value,
displayValue: result.coreWebVitals.LCP.displayValue
});
}
});
if (lcpValues.length > 0) {
const avgLCP = lcpValues.reduce((sum, item) => sum + item.value, 0) / lcpValues.length;
const maxLCP = Math.max(...lcpValues.map(item => item.value));
const minLCP = Math.min(...lcpValues.map(item => item.value));
markdown += `- **Average LCP:** ${formatTime(avgLCP)}
- **Best LCP:** ${formatTime(minLCP)}
- **Worst LCP:** ${formatTime(maxLCP)}
- **Target:** < 2.5s for Good score
`;
}
markdown += `### CLS (Cumulative Layout Shift) Summary
`;
// CLS analysis
const clsValues = [];
Object.entries(allResults).forEach(([key, result]) => {
if (result && result.coreWebVitals.CLS.value !== null) {
clsValues.push({
test: key,
value: result.coreWebVitals.CLS.value,
displayValue: result.coreWebVitals.CLS.displayValue
});
}
});
if (clsValues.length > 0) {
const avgCLS = clsValues.reduce((sum, item) => sum + item.value, 0) / clsValues.length;
const maxCLS = Math.max(...clsValues.map(item => item.value));
markdown += `- **Average CLS:** ${avgCLS.toFixed(3)}
- **Worst CLS:** ${maxCLS.toFixed(3)}
- **Target:** < 0.1 for Good score
`;
}
markdown += `### TBT (Total Blocking Time) Summary
`;
// TBT analysis
const tbtValues = [];
Object.entries(allResults).forEach(([key, result]) => {
if (result && result.coreWebVitals.TBT.value !== null) {
tbtValues.push({
test: key,
value: result.coreWebVitals.TBT.value,
displayValue: result.coreWebVitals.TBT.displayValue
});
}
});
if (tbtValues.length > 0) {
const avgTBT = tbtValues.reduce((sum, item) => sum + item.value, 0) / tbtValues.length;
const maxTBT = Math.max(...tbtValues.map(item => item.value));
markdown += `- **Average TBT:** ${formatTime(avgTBT)}
- **Worst TBT:** ${formatTime(maxTBT)}
- **Target:** < 200ms for Good score
`;
}
markdown += `---
## Recommendations Summary
Based on the audit results, here are the key areas for improvement:
### High Priority (Performance Impact)
1. **Largest Contentful Paint (LCP):** Focus on optimizing the hero image and main content loading
2. **Total Blocking Time (TBT):** Reduce JavaScript execution time, consider code splitting
3. **First Contentful Paint (FCP):** Optimize critical rendering path
### Medium Priority (User Experience)
1. **Cumulative Layout Shift (CLS):** Ensure images have explicit dimensions, avoid layout shifts from fonts
2. **Speed Index:** Optimize above-the-fold content delivery
### Common Opportunities Across All Tests
`;
// Aggregate opportunities
const allOpportunities = {};
Object.values(allResults).forEach(result => {
if (result && result.opportunities) {
result.opportunities.forEach(opp => {
if (!allOpportunities[opp.title]) {
allOpportunities[opp.title] = {
count: 0,
totalSavings: 0,
title: opp.title
};
}
allOpportunities[opp.title].count++;
allOpportunities[opp.title].totalSavings += opp.savings_ms;
});
}
});
const sortedOpportunities = Object.values(allOpportunities)
.sort((a, b) => b.totalSavings - a.totalSavings)
.slice(0, 10);
sortedOpportunities.forEach((opp, index) => {
markdown += `${index + 1}. **${opp.title}** - Appeared in ${opp.count} tests, avg. savings: ${formatTime(opp.totalSavings / opp.count)}\n`;
});
markdown += `
---
## Raw Data Files
Individual JSON files with complete audit data are saved in the \`lighthouse-reports\` directory:
`;
Object.keys(allResults).forEach(key => {
markdown += `- \`${key}.json\`\n`;
});
markdown += `
---
*Report generated by lighthouse-audit-all-locales.js*
`;
return markdown;
}
async function main() {
console.log('\n========================================');
console.log(' Lighthouse Audit - All Locales');
console.log('========================================\n');
console.log(`Base URL: ${BASE_URL}`);
console.log(`Locales: ${LOCALES.join(', ')}`);
console.log(`Strategies: ${STRATEGIES.join(', ')}`);
console.log(`Reports Directory: ${REPORTS_DIR}`);
console.log('\n----------------------------------------\n');
// Ensure reports directory exists
if (!fs.existsSync(REPORTS_DIR)) {
fs.mkdirSync(REPORTS_DIR, { recursive: true });
}
const allResults = {};
const errors = [];
// Run tests for each locale and strategy
for (const locale of LOCALES) {
const url = `${BASE_URL}/${locale}`;
console.log(`\n>>> Testing ${locale.toUpperCase()} (${url})`);
for (const strategy of STRATEGIES) {
const key = `${locale}_${strategy}`;
try {
const data = await runPageSpeedTest(url, strategy);
const results = extractCoreWebVitals(data);
allResults[key] = results;
// Save individual JSON file
const jsonPath = path.join(REPORTS_DIR, `${key}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2));
console.log(` Saved: ${key}.json`);
// Display quick summary
console.log(` Performance: ${results.scores.performance}% | LCP: ${results.coreWebVitals.LCP.displayValue} | CLS: ${results.coreWebVitals.CLS.displayValue}`);
} catch (error) {
console.error(` FAILED: ${key} - ${error.message}`);
errors.push({ key, error: error.message });
}
}
}
// Generate and save markdown report
console.log('\n----------------------------------------');
console.log('Generating comprehensive report...');
const markdownReport = generateMarkdownReport(allResults);
const reportPath = path.join(REPORTS_DIR, 'LIGHTHOUSE_REPORT.md');
fs.writeFileSync(reportPath, markdownReport);
console.log(`Report saved: ${reportPath}`);
// Save combined JSON
const combinedPath = path.join(REPORTS_DIR, 'all_results.json');
fs.writeFileSync(combinedPath, JSON.stringify({
timestamp: new Date().toISOString(),
baseUrl: BASE_URL,
locales: LOCALES,
strategies: STRATEGIES,
results: allResults,
errors: errors
}, null, 2));
console.log(`Combined data saved: ${combinedPath}`);
// Final summary
console.log('\n========================================');
console.log(' AUDIT COMPLETE');
console.log('========================================\n');
console.log('Results Summary:');
console.log('----------------');
LOCALES.forEach(locale => {
console.log(`\n${locale.toUpperCase()}:`);
STRATEGIES.forEach(strategy => {
const result = allResults[`${locale}_${strategy}`];
if (result) {
console.log(` ${strategy.padEnd(8)}: Performance ${result.scores.performance}% | LCP ${result.coreWebVitals.LCP.displayValue.padEnd(6)} | CLS ${result.coreWebVitals.CLS.displayValue}`);
} else {
console.log(` ${strategy.padEnd(8)}: FAILED`);
}
});
});
if (errors.length > 0) {
console.log(`\n${errors.length} test(s) failed. Check the logs above for details.`);
}
console.log('\n----------------------------------------');
console.log(`Reports saved to: ${REPORTS_DIR}`);
console.log('----------------------------------------\n');
}
main().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});