Merge origin/master

This commit is contained in:
2026-01-25 19:39:43 +01:00
375 changed files with 68701 additions and 36624 deletions
+352
View File
@@ -0,0 +1,352 @@
# Scripts Directory
Utility scripts for development, build optimization, and content generation.
## Overview
This directory contains **11 utility scripts** that automate various development tasks including image optimization, sitemap generation, performance testing, translation conversion, font downloads, icon generation, and documentation creation.
## Scripts
### Image Optimization
#### `optimize-images.js`
**Purpose:** Optimizes project cover images with responsive sizes and modern formats
**Usage:**
```bash
node scripts/optimize-images.js
# or
npm run build:images
```
**What it does:**
- Processes images from `source-images/projects/<project-slug>/cover.jpg`
- Generates multiple responsive sizes: 200w, 300w, 400w, 600w, 800w, 1200w
- Outputs both JPG (quality 80) and WebP (quality 75) formats
- Creates optimized original at 85% quality
- Outputs to `public/images/projects/<project-slug>/`
**Requirements:** `sharp`
---
#### `optimize-images.mjs`
**Purpose:** Optimizes gallery photos with predefined naming mapping
**Usage:**
```bash
node scripts/optimize-images.mjs
```
**What it does:**
- Converts and renames photos from `./photos` directory
- Resizes images to max 1200px width
- Outputs WebP format (quality 80)
- Maps cryptic filenames to semantic names (e.g., `thinker-dark.webp`, `studio-seated.webp`)
- Outputs to `public/images/gallery/`
**Requirements:** `sharp`
---
### SEO & Performance
#### `generate-sitemap.js`
**Purpose:** Generates XML sitemap with multi-language support and robots.txt
**Usage:**
```bash
node scripts/generate-sitemap.js
```
**What it does:**
- Generates `public/sitemap.xml` for all static pages, blog posts, and portfolio projects
- Supports 3 languages: German (default), English, Serbian
- Includes hreflang alternate links for SEO
- Sets appropriate priority and changefreq values
- Generates/updates `public/robots.txt` with sitemap references
**Configuration:**
- `SITE_URL`: https://damjan-savic.com
- `LANGUAGES`: ['de', 'en', 'sr']
- `DEFAULT_LANGUAGE`: 'de'
**Output:** `public/sitemap.xml`, `public/robots.txt`
---
#### `pagespeed-check.js`
**Purpose:** Tests website performance using Google PageSpeed Insights API
**Usage:**
```bash
node scripts/pagespeed-check.js
```
**What it does:**
- Runs PageSpeed tests for both mobile and desktop
- Displays Performance, Accessibility, Best Practices, and SEO scores
- Shows Core Web Vitals: FCP, LCP, TBT, CLS, Speed Index
- Lists top 5 optimization opportunities with time savings
- Identifies diagnostic issues with low scores
- Color-coded results (green ≥90, yellow ≥50, red <50)
**Requirements:** Google PageSpeed API key (configured in script)
**Note:** Disables SSL verification for local testing (line 10)
---
### Internationalization
#### `convert-translations.js`
**Purpose:** Converts TypeScript translation files to JSON format for next-intl
**Usage:**
```bash
node scripts/convert-translations.js
```
**What it does:**
- Reads TypeScript translations from `src/i18n/locales/<locale>/index.ts`
- Converts all 3 locales: de, en, sr
- Outputs formatted JSON to `src/messages/<locale>.json`
- Preserves nested structure and formatting
**Output:** `src/messages/de.json`, `src/messages/en.json`, `src/messages/sr.json`
---
### Assets & Resources
#### `download-fonts.js` (CommonJS)
**Purpose:** Downloads Inter variable font for self-hosting
**Usage:**
```bash
node scripts/download-fonts.js
```
**What it does:**
- Downloads InterVariable.woff2 from https://rsms.me/inter/
- Saves to `public/fonts/inter-var.woff2`
- Creates fonts directory if it doesn't exist
- Shows progress and error handling
**Requirements:** Node.js with CommonJS support
---
#### `download-fonts.mjs` (ES Module)
**Purpose:** Downloads Inter variable font for self-hosting (ES Module version)
**Usage:**
```bash
node scripts/download-fonts.mjs
```
**What it does:**
- Same functionality as download-fonts.js
- ES Module syntax with `import` statements
- Downloads InterVariable.woff2 from https://rsms.me/inter/
- Saves to `public/fonts/inter-var.woff2`
**Requirements:** Node.js with ES Module support
---
### Icon Generation
#### `generate-icons.mjs`
**Purpose:** Generates PWA icons from logo.svg or creates placeholders
**Usage:**
```bash
node scripts/generate-icons.mjs
```
**What it does:**
- Attempts to generate icons from `public/logo.svg`
- If logo.svg doesn't exist, creates placeholder icons with "DS" text
- Generates two sizes: 192x192 and 512x512 PNG
- Outputs to `public/icon-192x192.png` and `public/icon-512x512.png`
- Uses dark background (#18181b) with white text
**Requirements:** `sharp`
---
#### `generate-icons-simple.mjs`
**Purpose:** Creates simple SVG placeholder icons without Sharp dependency
**Usage:**
```bash
node scripts/generate-icons-simple.mjs
```
**What it does:**
- Generates SVG icons (not PNG) with "DS" text
- Creates 192x192 and 512x512 versions
- No image processing library required
- Outputs to `public/icon-192x192.svg` and `public/icon-512x512.svg`
- Dark background (#18181b) with white text
**Note:** Outputs SVG files instead of PNG - manifest.json may need updating
---
### Documentation
#### `generate-github-readme.js`
**Purpose:** Generates GitHub profile README and project README template
**Usage:**
```bash
node scripts/generate-github-readme.js
```
**What it does:**
- Generates comprehensive GitHub profile README with:
- Tech stack badges (Python, JavaScript, React, TypeScript, etc.)
- GitHub stats widgets
- Featured projects section
- Skills and specializations
- Contact information
- Creates project README template with:
- Standard sections (Overview, Installation, Usage, etc.)
- Tech stack badges
- Configuration examples
- API documentation template
- License and author info
**Output:**
- `github-profile-README.md` - For GitHub profile
- `project-readme-template.md` - Template for new projects
---
#### `generate-og-image.html`
**Purpose:** HTML template for creating Open Graph social media preview images
**Usage:**
```bash
# Open in browser and screenshot, or use with headless browser:
npx playwright screenshot scripts/generate-og-image.html og-image.png --viewport-size 1200,630
```
**What it does:**
- Provides ready-to-screenshot HTML for OG image (1200x630px)
- Features dark gradient background with pattern overlay
- Displays logo, name, subtitle, and skills
- Optimized dimensions for social media (Twitter, Facebook, LinkedIn)
- Modern, professional design matching portfolio branding
**Suggested tools:**
- Puppeteer
- Playwright
- Browser DevTools screenshot
---
## Dependencies
Scripts in this directory require the following npm packages:
### Required
- `sharp` - Image processing (optimize-images, generate-icons)
### Built-in Node.js Modules
- `fs` / `fs/promises` - File system operations
- `path` - Path manipulation
- `https` - HTTP requests
- `url` - URL utilities
## NPM Scripts
Some scripts are aliased in `package.json` for convenience:
```bash
npm run build:images # Runs optimize-images.js
```
## Best Practices
### Running Scripts
- Always run from project root: `node scripts/<script-name>`
- Check for required dependencies before running
- Review output messages for errors or warnings
### Adding New Scripts
1. Add `.js` or `.mjs` file to `scripts/` directory
2. Use ES Module syntax (`.mjs`) for new scripts
3. Include usage comments at the top
4. Add error handling and progress logging
5. Update this README with documentation
### Maintenance
- Keep scripts focused on single responsibility
- Use descriptive filenames
- Add npm aliases for frequently used scripts
- Test scripts after dependency updates
## Common Issues
### Image Optimization
**Issue:** "Source directory not found"
**Solution:** Ensure images exist in `source-images/projects/<slug>/cover.jpg`
### Font Download
**Issue:** SSL/Certificate errors
**Solution:** Script includes `NODE_TLS_REJECT_UNAUTHORIZED='0'` for local testing
### Icon Generation
**Issue:** Sharp installation fails
**Solution:** Use `generate-icons-simple.mjs` as fallback (generates SVG instead)
### Sitemap Generation
**Issue:** Missing routes in sitemap
**Solution:** Update static page, blog post, or project arrays in `generate-sitemap.js`
## Missing Scripts
### `generate-blog-images.mjs`
**Status:****MISSING** - Referenced in package.json but not implemented
**Package.json References:**
```json
"generate:images": "node scripts/generate-blog-images.mjs"
"generate:images:dry": "node scripts/generate-blog-images.mjs --dry-run"
```
**Description (from spec):**
This script is intended to generate AI-powered images for blog posts using the OpenAI API. It should support a `--dry-run` flag for testing without actually generating images.
**Recommended Action:**
Choose one of the following:
1. **Remove references** - If AI image generation is not needed, remove the npm scripts from package.json
2. **Implement the script** - Create the script with the following features:
- OpenAI API integration for image generation
- `--dry-run` flag support for testing
- Process blog posts and generate featured images
- Proper error handling and progress logging
- API key management (environment variables)
**Impact:**
Running `npm run generate:images` or `npm run generate:images:dry` will fail with "Cannot find module" error until this is resolved.
---
## Notes
- Scripts use both `.js` (CommonJS) and `.mjs` (ES Module) extensions
- Duplicate scripts (e.g., download-fonts) exist for compatibility
- Some scripts include development-specific configurations (API keys, SSL bypass)
- HTML file (`generate-og-image.html`) requires manual screenshot or headless browser
---
**Total Scripts:** 11
**Last Updated:** 2026-01
**Maintained By:** Development Team
+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);
});
+24 -17
View File
@@ -9,10 +9,12 @@ const __dirname = dirname(__filename);
// Mehr Größen für bessere Mobile-Unterstützung
const SIZES = [200, 300, 400, 600, 800, 1200];
const INPUT_DIR = join(__dirname, '../source-images/projects');
const OUTPUT_DIR = join(__dirname, '../public/images/projects');
const PROJECTS_INPUT_DIR = join(__dirname, '../source-images/projects');
const PROJECTS_OUTPUT_DIR = join(__dirname, '../public/images/projects');
const POSTS_INPUT_DIR = join(__dirname, '../source-images/posts');
const POSTS_OUTPUT_DIR = join(__dirname, '../public/images/posts');
async function processImage(inputPath, projectSlug) {
async function processImage(inputPath, slug, outputBaseDir) {
const filename = basename(inputPath, extname(inputPath));
const ext = extname(inputPath).toLowerCase();
@@ -20,10 +22,10 @@ async function processImage(inputPath, projectSlug) {
return;
}
const outputDir = join(OUTPUT_DIR, projectSlug);
const outputDir = join(outputBaseDir, slug);
await mkdir(outputDir, { recursive: true });
console.log(`Processing: ${projectSlug}/${filename}${ext}`);
console.log(`Processing: ${slug}/${filename}${ext}`);
// Optimierte Originalversion (cover.jpg)
const originalOutputPath = join(outputDir, `${filename}.jpg`);
@@ -88,23 +90,21 @@ async function processImage(inputPath, projectSlug) {
}
}
async function processDirectory(dirPath) {
if (!existsSync(dirPath)) {
console.error(`Error: Source directory not found: ${dirPath}`);
console.log('\nPlease ensure original images are placed in:');
console.log(' source-images/projects/<project-slug>/cover.jpg');
process.exit(1);
async function processDirectory(inputDir, outputDir, typeName) {
if (!existsSync(inputDir)) {
console.log(`Skipping ${typeName}: Source directory not found: ${inputDir}`);
return;
}
const entries = await readdir(dirPath, { withFileTypes: true });
const entries = await readdir(inputDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
const fullPath = join(inputDir, entry.name);
if (entry.isDirectory()) {
const coverPath = join(fullPath, 'cover.jpg');
if (existsSync(coverPath)) {
await processImage(coverPath, entry.name);
await processImage(coverPath, entry.name, outputDir);
} else {
console.log(`Skipping ${entry.name}: no cover.jpg found`);
}
@@ -114,12 +114,19 @@ async function processDirectory(dirPath) {
async function main() {
console.log('Starting image optimization...');
console.log(`Source: ${INPUT_DIR}`);
console.log(`Output: ${OUTPUT_DIR}`);
console.log(`Sizes: ${SIZES.join(', ')}px\n`);
try {
await processDirectory(INPUT_DIR);
console.log('Processing projects...');
console.log(`Source: ${PROJECTS_INPUT_DIR}`);
console.log(`Output: ${PROJECTS_OUTPUT_DIR}\n`);
await processDirectory(PROJECTS_INPUT_DIR, PROJECTS_OUTPUT_DIR, 'projects');
console.log('\nProcessing posts...');
console.log(`Source: ${POSTS_INPUT_DIR}`);
console.log(`Output: ${POSTS_OUTPUT_DIR}\n`);
await processDirectory(POSTS_INPUT_DIR, POSTS_OUTPUT_DIR, 'posts');
console.log('\nImage optimization complete!');
} catch (error) {
console.error('Error during optimization:', error);