Merge origin/master
This commit is contained in:
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user