Merge origin/master

This commit is contained in:
2026-01-25 19:46:04 +01:00
429 changed files with 77997 additions and 25554 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
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
/**
* Script to apply Supabase migration
*
* This script applies the rate_limits table migration to Supabase.
*
* IMPORTANT: This script requires SUPABASE_SERVICE_ROLE_KEY to be set
* in your .env.local file to execute DDL statements.
*
* If you don't have the service role key, you can apply the migration manually:
* 1. Go to https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/sql
* 2. Copy the contents of supabase/migrations/20260125_create_rate_limits_table.sql
* 3. Paste into the SQL editor
* 4. Click "Run" to execute
* 5. Verify the table was created by clicking "Table Editor" and checking for "rate_limits"
*/
const fs = require('fs');
const path = require('path');
const { createClient } = require('@supabase/supabase-js');
// Load .env.local manually
const envPath = path.join(__dirname, '..', '.env.local');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf8');
envContent.split('\n').forEach(line => {
const trimmed = line.trim();
const match = trimmed.match(/^([^=:#]+)=(.*)$/);
if (match) {
const key = match[1].trim();
const value = match[2].trim();
if (!process.env[key]) {
process.env[key] = value;
}
}
});
}
async function applyMigration() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl) {
console.error('❌ Error: NEXT_PUBLIC_SUPABASE_URL not found in .env.local');
process.exit(1);
}
if (!serviceRoleKey) {
console.error('❌ Error: SUPABASE_SERVICE_ROLE_KEY not found in .env.local');
console.error('\n📝 Manual Migration Instructions:');
console.error(' 1. Go to https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/sql');
console.error(' 2. Open: supabase/migrations/20260125_create_rate_limits_table.sql');
console.error(' 3. Copy the SQL content and paste into the SQL editor');
console.error(' 4. Click "Run" to execute the migration');
console.error(' 5. Verify the "rate_limits" table appears in Table Editor\n');
process.exit(1);
}
console.log('🚀 Applying migration to Supabase...\n');
// Create Supabase client with service role key
const supabase = createClient(supabaseUrl, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
// Read migration file
const migrationPath = path.join(__dirname, '..', 'supabase', 'migrations', '20260125_create_rate_limits_table.sql');
const migrationSQL = fs.readFileSync(migrationPath, 'utf8');
console.log('📄 Migration file loaded:', migrationPath);
try {
// Execute the migration
const { data, error } = await supabase.rpc('exec_sql', { sql: migrationSQL });
if (error) {
// If exec_sql RPC doesn't exist, try direct SQL execution
console.log('⚠️ exec_sql RPC not found, attempting direct SQL execution...\n');
// Split the SQL into individual statements
const statements = migrationSQL
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0 && !s.startsWith('--'));
for (const statement of statements) {
const { error: execError } = await supabase.from('_sql').select().sql(statement);
if (execError) {
throw execError;
}
}
}
console.log('✅ Migration applied successfully!\n');
// Verify the table was created
console.log('🔍 Verifying table creation...');
const { data: tableData, error: tableError } = await supabase
.from('rate_limits')
.select('*')
.limit(1);
if (tableError && tableError.code !== 'PGRST116') { // PGRST116 = no rows found, which is OK
throw tableError;
}
console.log('✅ Table "rate_limits" verified successfully!\n');
console.log('📊 Migration complete. You can view the table at:');
console.log(` https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/editor/rate_limits\n`);
} catch (error) {
console.error('❌ Error applying migration:', error.message);
console.error('\n📝 Please apply the migration manually:');
console.error(' 1. Go to https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/sql');
console.error(' 2. Copy the contents of supabase/migrations/20260125_create_rate_limits_table.sql');
console.error(' 3. Paste into the SQL editor and click "Run"\n');
process.exit(1);
}
}
applyMigration();
+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);
+527
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* Lighthouse Audit for All Locales
<<<<<<< HEAD
*
* Runs Lighthouse performance audits against all locales (de, en, sr)
* and key pages using Playwright-Lighthouse integration.
@@ -293,4 +294,530 @@ async function main() {
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
=======
* 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);
>>>>>>> origin/master
});
+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);
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# Reset Rate Limit - Utility Script
# Helps reset rate limits during testing
echo "================================================"
echo "Rate Limit Reset Utility"
echo "================================================"
echo ""
echo "This script helps you reset rate limits for testing."
echo ""
echo "Options:"
echo "1. Reset for 'unknown-ip' (default dev identifier)"
echo "2. Reset for specific IP address"
echo "3. Reset ALL rate limits (use with caution)"
echo "4. Show current rate limit records"
echo ""
read -p "Select option (1-4): " option
case $option in
1)
echo ""
echo "SQL to run in Supabase SQL Editor:"
echo "https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/sql"
echo ""
echo "DELETE FROM rate_limits WHERE identifier = 'unknown-ip';"
echo ""
echo "After running this query, rate limits will reset for the dev environment."
;;
2)
echo ""
read -p "Enter IP address to reset: " ip_address
echo ""
echo "SQL to run in Supabase SQL Editor:"
echo "https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/sql"
echo ""
echo "DELETE FROM rate_limits WHERE identifier = '$ip_address';"
echo ""
;;
3)
echo ""
echo "⚠️ WARNING: This will reset ALL rate limits!"
read -p "Are you sure? (yes/no): " confirm
if [ "$confirm" = "yes" ]; then
echo ""
echo "SQL to run in Supabase SQL Editor:"
echo "https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/sql"
echo ""
echo "TRUNCATE TABLE rate_limits;"
echo ""
else
echo "Cancelled."
fi
;;
4)
echo ""
echo "SQL to run in Supabase SQL Editor:"
echo "https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/sql"
echo ""
echo "SELECT * FROM rate_limits ORDER BY window_start DESC;"
echo ""
echo "This will show all current rate limit records."
;;
*)
echo "Invalid option."
exit 1
;;
esac
echo ""
echo "Note: Rate limits can also naturally expire after 1 hour."
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# Concurrent Rate Limiting Test
# Tests that rate limiting correctly handles simultaneous requests
echo "================================================"
echo "Concurrent Rate Limiting Test"
echo "================================================"
echo ""
API_URL="http://localhost:3000/api/contact"
CONCURRENT_REQUESTS=7
echo "Sending $CONCURRENT_REQUESTS concurrent requests..."
echo "Expected: First 5 should succeed (200), remaining should fail (429)"
echo ""
# Create a temporary directory for results
TMP_DIR=$(mktemp -d)
trap "rm -rf $TMP_DIR" EXIT
# Send concurrent requests
for i in $(seq 1 $CONCURRENT_REQUESTS); do
{
response=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d "{\"name\":\"Concurrent Test $i\",\"email\":\"test$i@example.com\",\"message\":\"Concurrent message $i\"}")
status_code=$(echo "$response" | tail -n 1)
echo "$i:$status_code" > "$TMP_DIR/result_$i.txt"
echo "Request #$i: $status_code"
} &
done
# Wait for all background jobs to complete
wait
echo ""
echo "Results:"
echo "--------"
# Analyze results
success_count=0
rate_limited_count=0
for i in $(seq 1 $CONCURRENT_REQUESTS); do
if [ -f "$TMP_DIR/result_$i.txt" ]; then
result=$(cat "$TMP_DIR/result_$i.txt")
status_code=$(echo "$result" | cut -d':' -f2)
if [ "$status_code" = "200" ]; then
((success_count++))
echo "✅ Request #$i: 200 OK"
elif [ "$status_code" = "429" ]; then
((rate_limited_count++))
echo "🚫 Request #$i: 429 Rate Limited"
else
echo "⚠️ Request #$i: $status_code (Unexpected)"
fi
fi
done
echo ""
echo "Summary:"
echo "--------"
echo "Successful: $success_count"
echo "Rate Limited: $rate_limited_count"
echo ""
# Verify expectations
if [ $success_count -le 5 ] && [ $rate_limited_count -ge 2 ]; then
echo "✅ Concurrent rate limiting works correctly!"
echo "Note: Due to timing, some requests within the limit might also be blocked."
echo "This is acceptable behavior for concurrent requests."
else
echo "⚠️ Results might indicate an issue:"
echo "Expected: ~5 successful, ~2 rate limited"
echo "Got: $success_count successful, $rate_limited_count rate limited"
fi
+33
View File
@@ -0,0 +1,33 @@
const fs = require('fs');
const path = require('path');
// Load .env.local manually
const envPath = path.join(__dirname, '..', '.env.local');
console.log('Loading from:', envPath);
console.log('File exists:', fs.existsSync(envPath));
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf8');
console.log('\nFile content length:', envContent.length);
console.log('First 200 chars:', envContent.substring(0, 200));
envContent.split('\n').forEach((line, idx) => {
const trimmed = line.trim();
console.log(`Line ${idx}: "${trimmed.substring(0, 50)}"`);
const match = trimmed.match(/^([^=:#]+)=(.*)$/);
if (match) {
const key = match[1].trim();
const value = match[2].trim();
console.log(` -> Matched: ${key} = ${value.substring(0, 20)}...`);
if (!process.env[key]) {
process.env[key] = value;
}
} else if (trimmed && !trimmed.startsWith('#')) {
console.log(' -> No match!');
}
});
}
console.log('\nFinal env vars:');
console.log('NEXT_PUBLIC_SUPABASE_URL:', process.env.NEXT_PUBLIC_SUPABASE_URL);
console.log('NEXT_PUBLIC_SUPABASE_ANON_KEY:', process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ? 'SET' : 'NOT SET');
+219
View File
@@ -0,0 +1,219 @@
#!/bin/bash
# End-to-End Rate Limiting Verification Script
# This script tests the Supabase-based rate limiting implementation
set -e
echo "================================================"
echo "E2E Rate Limiting Verification"
echo "================================================"
echo ""
# Configuration
API_URL="http://localhost:3000/api/contact"
MAX_REQUESTS=5
EXPECTED_WINDOW_MS=3600000
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Test counters
TESTS_PASSED=0
TESTS_FAILED=0
# Helper function to print test result
print_result() {
local test_name=$1
local result=$2
if [ "$result" = "PASS" ]; then
echo -e "${GREEN}✅ PASS${NC}: $test_name"
((TESTS_PASSED++))
else
echo -e "${RED}❌ FAIL${NC}: $test_name"
((TESTS_FAILED++))
fi
}
# Helper function to make API request
make_request() {
local name=$1
local email=$2
local message=$3
curl -s -w "\n%{http_code}\n%{header_json}" -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$name\",\"email\":\"$email\",\"message\":\"$message\"}"
}
echo "Step 1: Check if dev server is running..."
if ! curl -s -f -o /dev/null "$API_URL" -X POST -H "Content-Type: application/json" -d '{}'; then
if [ $? -eq 7 ]; then
echo -e "${RED}❌ Dev server is not running!${NC}"
echo "Please start it with: npm run dev"
exit 1
fi
fi
echo -e "${GREEN}✅ Dev server is running${NC}"
echo ""
echo "Step 2: Testing basic rate limiting flow..."
echo "Sending $MAX_REQUESTS requests (should all succeed)..."
# Track response headers
declare -a status_codes
declare -a remaining_counts
for i in $(seq 1 $MAX_REQUESTS); do
echo -n "Request #$i... "
response=$(make_request "Test User $i" "test$i@example.com" "Test message $i")
# Extract HTTP status code
status_code=$(echo "$response" | tail -n 2 | head -n 1)
status_codes[$i]=$status_code
# Extract X-RateLimit-Remaining header
remaining=$(echo "$response" | grep -i "x-ratelimit-remaining" | grep -oP '\d+' | head -n 1 || echo "N/A")
remaining_counts[$i]=$remaining
if [ "$status_code" = "200" ]; then
echo -e "${GREEN}200 OK${NC} (Remaining: $remaining)"
else
echo -e "${RED}$status_code${NC} (Unexpected!)"
fi
sleep 0.5
done
echo ""
# Verify all requests succeeded
echo "Verifying first $MAX_REQUESTS requests..."
all_success=true
for i in $(seq 1 $MAX_REQUESTS); do
if [ "${status_codes[$i]}" != "200" ]; then
all_success=false
print_result "Request #$i should return 200" "FAIL"
fi
done
if [ "$all_success" = true ]; then
print_result "First $MAX_REQUESTS requests succeeded" "PASS"
fi
echo ""
# Verify remaining counts decrement
echo "Verifying rate limit counters..."
for i in $(seq 1 $((MAX_REQUESTS - 1))); do
current=${remaining_counts[$i]}
next=${remaining_counts[$((i + 1))]}
if [ "$current" != "N/A" ] && [ "$next" != "N/A" ]; then
expected=$((current - 1))
if [ "$next" -eq "$expected" ]; then
print_result "Counter decremented from $current to $next" "PASS"
else
print_result "Counter should decrement (expected $expected, got $next)" "FAIL"
fi
fi
done
echo ""
echo "Step 3: Testing rate limit enforcement..."
echo "Sending 6th request (should be rate limited)..."
response=$(make_request "Test User 6" "test6@example.com" "Test message 6")
status_code=$(echo "$response" | tail -n 2 | head -n 1)
body=$(echo "$response" | head -n -2)
echo "Status Code: $status_code"
echo "Response: $body"
echo ""
if [ "$status_code" = "429" ]; then
print_result "6th request returns 429 Too Many Requests" "PASS"
# Check for Retry-After header
retry_after=$(echo "$response" | grep -i "retry-after" | grep -oP '\d+' || echo "")
if [ -n "$retry_after" ]; then
print_result "Retry-After header present ($retry_after seconds)" "PASS"
else
print_result "Retry-After header should be present" "FAIL"
fi
# Check for X-RateLimit-Remaining: 0
remaining=$(echo "$response" | grep -i "x-ratelimit-remaining" | grep -oP '\d+' || echo "")
if [ "$remaining" = "0" ]; then
print_result "X-RateLimit-Remaining is 0" "PASS"
else
print_result "X-RateLimit-Remaining should be 0" "FAIL"
fi
# Check for error message in body
if echo "$body" | grep -q "too many\|rate limit"; then
print_result "Response body contains rate limit error message" "PASS"
else
print_result "Response body should contain rate limit error" "FAIL"
fi
else
print_result "6th request should return 429" "FAIL"
fi
echo ""
echo "Step 4: Testing rate limit persistence..."
echo "Sending another request (should still be rate limited)..."
response=$(make_request "Test User 7" "test7@example.com" "Test message 7")
status_code=$(echo "$response" | tail -n 2 | head -n 1)
if [ "$status_code" = "429" ]; then
print_result "Subsequent request still rate limited" "PASS"
else
print_result "Rate limiting should persist" "FAIL"
fi
echo ""
echo "Step 5: Testing validation (should not count against rate limit)..."
echo "Sending request with invalid data..."
response=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d '{"name":"","email":"invalid","message":""}')
status_code=$(echo "$response" | tail -n 1)
if [ "$status_code" = "400" ] || [ "$status_code" = "429" ]; then
print_result "Invalid data returns 400 or still rate limited (429)" "PASS"
else
print_result "Invalid data handling (got $status_code)" "FAIL"
fi
echo ""
echo "================================================"
echo "Test Summary"
echo "================================================"
echo -e "${GREEN}Passed: $TESTS_PASSED${NC}"
echo -e "${RED}Failed: $TESTS_FAILED${NC}"
echo ""
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}✅ All tests passed!${NC}"
echo ""
echo "Next steps:"
echo "1. Verify database records in Supabase dashboard"
echo "2. Test browser UI at http://localhost:3000/en/contact"
echo "3. Test persistence across page refreshes"
echo "4. Complete manual verification scenarios in E2E_VERIFICATION.md"
exit 0
else
echo -e "${RED}❌ Some tests failed${NC}"
echo "Please review the failures above and check:"
echo "- Is the migration applied in Supabase?"
echo "- Are environment variables set correctly?"
echo "- Check server logs for errors"
exit 1
fi
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Script to verify the rate_limits table exists in Supabase
*/
const { createClient } = require('@supabase/supabase-js');
const fs = require('fs');
const path = require('path');
// Load .env.local manually
const envPath = path.join(__dirname, '..', '.env.local');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf8');
envContent.split('\n').forEach(line => {
const trimmed = line.trim();
const match = trimmed.match(/^([^=:#]+)=(.*)$/);
if (match) {
const key = match[1].trim();
const value = match[2].trim();
if (!process.env[key]) {
process.env[key] = value;
}
}
});
}
async function verifyMigration() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
console.error('❌ Error: Missing Supabase credentials in .env.local');
process.exit(1);
}
console.log('🔍 Verifying rate_limits table in Supabase...\n');
const supabase = createClient(supabaseUrl, supabaseAnonKey);
try {
// Try to query the rate_limits table
const { data, error } = await supabase
.from('rate_limits')
.select('*')
.limit(1);
if (error) {
if (error.code === '42P01') {
console.error('❌ Table "rate_limits" does not exist yet.\n');
console.error('📝 Please apply the migration manually:');
console.error(' 1. Go to https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/sql');
console.error(' 2. Copy the contents of supabase/migrations/20260125_create_rate_limits_table.sql');
console.error(' 3. Paste into the SQL editor and click "Run"');
console.error(' 4. Run this script again to verify\n');
process.exit(1);
} else if (error.code === 'PGRST301' || error.message.includes('JWT')) {
console.error('❌ Authentication error. Table may exist but is not accessible with anon key.\n');
console.error('📝 Please verify manually:');
console.error(' 1. Go to https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/editor');
console.error(' 2. Check if "rate_limits" table appears in the table list\n');
process.exit(1);
} else {
throw error;
}
}
console.log('✅ Table "rate_limits" exists and is accessible!');
console.log(`📊 Current records: ${data ? data.length : 0}\n`);
// Try to get table schema info
console.log('🔍 Verifying table schema...');
const { data: schemaData, error: schemaError } = await supabase
.from('rate_limits')
.select('identifier, count, window_start, created_at, updated_at')
.limit(0);
if (schemaError && !schemaError.message.includes('no rows')) {
console.warn('⚠️ Could not verify all columns:', schemaError.message);
} else {
console.log('✅ All expected columns are present!\n');
}
console.log('✅ Migration verification successful!');
console.log('📊 View table at: https://app.supabase.com/project/mxadgucxhmstlzsbgmoz/editor/rate_limits\n');
} catch (error) {
console.error('❌ Error during verification:', error.message);
console.error('\nDetails:', error);
process.exit(1);
}
}
verifyMigration();