AEO
This commit is contained in:
parent
b1a2626c8d
commit
bdcb9d3b75
|
|
@ -1,7 +1,8 @@
|
||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run dev:*)"
|
"Bash(npm run dev:*)",
|
||||||
|
"Bash(npm install:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Answer Engine Optimization (AEO) Configuration
|
||||||
|
|
||||||
|
# IndexNow API key for automatic URL submission to search engines
|
||||||
|
# Generate a UUID and create a matching key file in /public/
|
||||||
|
VITE_INDEXNOW_KEY=your-indexnow-key-here
|
||||||
|
|
||||||
|
# Primary domain for canonical URLs and IndexNow submissions
|
||||||
|
VITE_SITE_HOST=energie-profis.de
|
||||||
|
|
||||||
|
# Supabase Configuration (existing)
|
||||||
|
VITE_SUPABASE_URL=your-supabase-url
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||||
84
README.md
84
README.md
|
|
@ -1,8 +1,90 @@
|
||||||
# Welcome to your Lovable project
|
# EnergieProfis - Renewable Energy Platform
|
||||||
|
|
||||||
|
A React + TypeScript platform connecting energy customers with qualified solar and wind installation professionals in Germany.
|
||||||
|
|
||||||
## Project info
|
## Project info
|
||||||
|
|
||||||
**URL**: https://lovable.dev/projects/54a0b4f8-e87d-43d2-ac51-69b3e38c23fb
|
**URL**: https://lovable.dev/projects/54a0b4f8-e87d-43d2-ac51-69b3e38c23fb
|
||||||
|
**Domain**: energie-profis.de
|
||||||
|
|
||||||
|
## Answer Engine Optimization (AEO) Features
|
||||||
|
|
||||||
|
This project implements comprehensive AEO to be cited by ChatGPT, Perplexity, and other AI answer engines:
|
||||||
|
|
||||||
|
### ✅ Implemented Features
|
||||||
|
|
||||||
|
- **Robots.txt**: Explicitly allows PerplexityBot and GPTBot
|
||||||
|
- **IndexNow Integration**: Automatic URL submission with queue and retry logic
|
||||||
|
- **Sitemaps**: XML sitemaps with accurate lastmod dates
|
||||||
|
- **Canonical URLs**: Single canonical per page for AI indexing
|
||||||
|
- **JSON-LD Schema**: FAQ, HowTo, Article, Organization markup
|
||||||
|
- **Content Metadata**: Visible "Zuletzt aktualisiert" and author information
|
||||||
|
|
||||||
|
### 🔧 AEO Setup
|
||||||
|
|
||||||
|
1. **Environment Variables**
|
||||||
|
```bash
|
||||||
|
VITE_INDEXNOW_KEY=your-indexnow-key-here
|
||||||
|
VITE_SITE_HOST=energie-profis.de
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create IndexNow Key File**
|
||||||
|
```bash
|
||||||
|
# Create public key file (replace with your actual key)
|
||||||
|
echo "your-indexnow-key-here" > public/your-indexnow-key-here.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Usage in Components**
|
||||||
|
```tsx
|
||||||
|
import Canonical from '@/components/seo/Canonical';
|
||||||
|
import JsonLd from '@/components/seo/JsonLd';
|
||||||
|
import ContentMeta from '@/components/seo/ContentMeta';
|
||||||
|
import { buildFAQPageJsonLd, exampleFAQs } from '@/lib/schema';
|
||||||
|
|
||||||
|
function MyPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Canonical />
|
||||||
|
<JsonLd data={buildFAQPageJsonLd(exampleFAQs)} />
|
||||||
|
<ContentMeta lastUpdated={new Date()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Manual IndexNow Ping**
|
||||||
|
```bash
|
||||||
|
npm run ping-indexnow https://energie-profis.de/ https://energie-profis.de/solar
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🧪 Testing AEO Features
|
||||||
|
|
||||||
|
Run E2E tests to validate AEO implementation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Robots.txt allows AI bots
|
||||||
|
- Canonical URLs are present and correct
|
||||||
|
- JSON-LD schema is valid
|
||||||
|
- Sitemap includes all pages with lastmod
|
||||||
|
|
||||||
|
### 📋 Manual Validation
|
||||||
|
|
||||||
|
Quick checks to verify AEO compliance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check robots.txt allows AI bots
|
||||||
|
curl https://energie-profis.de/robots.txt | grep -E "(PerplexityBot|GPTBot)"
|
||||||
|
|
||||||
|
# Validate sitemap structure
|
||||||
|
curl https://energie-profis.de/sitemap.xml | xmllint --format -
|
||||||
|
|
||||||
|
# Check canonical on homepage
|
||||||
|
curl -s https://energie-profis.de/ | grep -o '<link rel="canonical"[^>]*>'
|
||||||
|
```
|
||||||
|
|
||||||
## How can I edit this code?
|
## How can I edit this code?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
|
|
@ -61,6 +62,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
|
|
@ -74,6 +76,7 @@
|
||||||
"lovable-tagger": "^1.1.9",
|
"lovable-tagger": "^1.1.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
|
|
@ -507,6 +510,23 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||||
|
|
@ -952,6 +972,22 @@
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
|
||||||
|
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.55.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
|
@ -3909,6 +3945,18 @@
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
|
||||||
|
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
|
@ -4404,6 +4452,19 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
||||||
|
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
|
|
@ -5550,6 +5611,53 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
|
||||||
|
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.55.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
|
||||||
|
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|
@ -6033,6 +6141,16 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||||
|
|
@ -6458,6 +6576,493 @@
|
||||||
"integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==",
|
"integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.20.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
|
||||||
|
"integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.25.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/esbuild": {
|
||||||
|
"version": "0.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||||
|
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.9",
|
||||||
|
"@esbuild/android-arm": "0.25.9",
|
||||||
|
"@esbuild/android-arm64": "0.25.9",
|
||||||
|
"@esbuild/android-x64": "0.25.9",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.9",
|
||||||
|
"@esbuild/darwin-x64": "0.25.9",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.9",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.9",
|
||||||
|
"@esbuild/linux-arm": "0.25.9",
|
||||||
|
"@esbuild/linux-arm64": "0.25.9",
|
||||||
|
"@esbuild/linux-ia32": "0.25.9",
|
||||||
|
"@esbuild/linux-loong64": "0.25.9",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.9",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.9",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.9",
|
||||||
|
"@esbuild/linux-s390x": "0.25.9",
|
||||||
|
"@esbuild/linux-x64": "0.25.9",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.9",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.9",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.9",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.9",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.9",
|
||||||
|
"@esbuild/sunos-x64": "0.25.9",
|
||||||
|
"@esbuild/win32-arm64": "0.25.9",
|
||||||
|
"@esbuild/win32-ia32": "0.25.9",
|
||||||
|
"@esbuild/win32-x64": "0.25.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:dev": "vite build --mode development",
|
"build:dev": "vite build --mode development",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
|
"ping-indexnow": "tsx scripts/ping-indexnow.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
|
@ -45,6 +49,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
|
|
@ -64,6 +69,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
|
|
@ -77,6 +83,7 @@
|
||||||
"lovable-tagger": "^1.1.9",
|
"lovable-tagger": "^1.1.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright configuration for AEO (Answer Engine Optimization) testing
|
||||||
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: 'http://localhost:8080',
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:8080',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -10,5 +10,13 @@ Allow: /
|
||||||
User-agent: facebookexternalhit
|
User-agent: facebookexternalhit
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://energie-profis.de/sitemap.xml
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://energie-profis.de/</loc>
|
||||||
|
<lastmod>2025-01-28</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://energie-profis.de/solar</loc>
|
||||||
|
<lastmod>2025-01-28</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://energie-profis.de/wind</loc>
|
||||||
|
<lastmod>2025-01-28</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://energie-profis.de/installateur-finden</loc>
|
||||||
|
<lastmod>2025-01-04</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://energie-profis.de/kostenlose-beratung</loc>
|
||||||
|
<lastmod>2024-12-05</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://energie-profis.de/unternehmen-listen</loc>
|
||||||
|
<lastmod>2025-01-04</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
</urlset>
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Manual IndexNow ping script for CI/CD and manual testing
|
||||||
|
* Usage: npm run ping-indexnow <url1> <url2> ...
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pingIndexNowManually } from '../src/lib/indexnow.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const urls = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
console.error('Usage: npm run ping-indexnow <url1> <url2> ...');
|
||||||
|
console.error('Example: npm run ping-indexnow https://energie-profis.de/ https://energie-profis.de/solar');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Pinging IndexNow for ${urls.length} URLs...`);
|
||||||
|
console.log('URLs:', urls.join(', '));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await pingIndexNowManually(urls);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('✅ IndexNow ping completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.error('❌ IndexNow ping failed or queued for retry');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ IndexNow ping error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
|
@ -74,20 +74,6 @@ const HeroSection = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clean CTA buttons */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="xl"
|
|
||||||
className="border-2 border-white/40 text-white hover:bg-white hover:text-primary bg-white/10 backdrop-blur-sm hover:shadow-lg transition-all duration-300"
|
|
||||||
>
|
|
||||||
<Link to="/installateur-finden" className="flex items-center">
|
|
||||||
Alle Installateure Ansehen
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -133,6 +119,20 @@ const HeroSection = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Moved "Alle Installateure Ansehen" button to upper right corner */}
|
||||||
|
<div className="absolute top-8 right-8 z-20">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="border-2 border-white/40 text-white hover:bg-white hover:text-primary bg-white/10 backdrop-blur-sm hover:shadow-lg transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Link to="/installateur-finden" className="flex items-center">
|
||||||
|
Alle Installateure Ansehen
|
||||||
|
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Canonical URL component for Answer Engine Optimization (AEO)
|
||||||
|
* Ensures single canonical URL per page for better AI indexing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface CanonicalProps {
|
||||||
|
url?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Canonical: React.FC<CanonicalProps> = ({
|
||||||
|
url,
|
||||||
|
baseUrl = 'https://energie-profis.de'
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Remove any existing canonical links
|
||||||
|
const existingCanonical = document.querySelectorAll('link[rel="canonical"]');
|
||||||
|
existingCanonical.forEach(link => link.remove());
|
||||||
|
|
||||||
|
// Generate canonical URL
|
||||||
|
const canonicalUrl = url || `${baseUrl.replace(/\/$/, '')}${location.pathname}`;
|
||||||
|
|
||||||
|
// Create new canonical link
|
||||||
|
const canonical = document.createElement('link');
|
||||||
|
canonical.rel = 'canonical';
|
||||||
|
canonical.href = canonicalUrl;
|
||||||
|
document.head.appendChild(canonical);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (canonical.parentNode) {
|
||||||
|
canonical.parentNode.removeChild(canonical);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [url, baseUrl, location.pathname]);
|
||||||
|
|
||||||
|
return null; // This component doesn't render anything
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Canonical;
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* Content metadata component with visible last updated and author information
|
||||||
|
* Supports sitemap synchronization for accurate lastmod dates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Calendar, User, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ContentMetaProps {
|
||||||
|
lastUpdated: Date;
|
||||||
|
authorName?: string;
|
||||||
|
authorUrl?: string;
|
||||||
|
publishedDate?: Date;
|
||||||
|
readTime?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentMeta: React.FC<ContentMetaProps> = ({
|
||||||
|
lastUpdated,
|
||||||
|
authorName = 'EnergieProfis Redaktion',
|
||||||
|
authorUrl = 'https://energie-profis.de/team',
|
||||||
|
publishedDate,
|
||||||
|
readTime,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (date: Date): string => {
|
||||||
|
return date.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap items-center gap-4 text-sm text-muted-foreground border-t pt-4 mt-6 ${className}`}>
|
||||||
|
{/* Last Updated - Most important for AEO */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
<strong>Zuletzt aktualisiert:</strong>
|
||||||
|
<time
|
||||||
|
dateTime={formatDateTime(lastUpdated)}
|
||||||
|
className="ml-1 font-medium"
|
||||||
|
>
|
||||||
|
{formatDate(lastUpdated)}
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Author Information */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
<strong>Autor:</strong>
|
||||||
|
{authorUrl ? (
|
||||||
|
<a
|
||||||
|
href={authorUrl}
|
||||||
|
className="ml-1 font-medium hover:text-primary transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{authorName}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="ml-1 font-medium">{authorName}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Published Date (if different from last updated) */}
|
||||||
|
{publishedDate && publishedDate.getTime() !== lastUpdated.getTime() && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
<strong>Veröffentlicht:</strong>
|
||||||
|
<time
|
||||||
|
dateTime={formatDateTime(publishedDate)}
|
||||||
|
className="ml-1"
|
||||||
|
>
|
||||||
|
{formatDate(publishedDate)}
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Read Time */}
|
||||||
|
{readTime && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{readTime} Lesezeit</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentMeta;
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* JSON-LD components for Answer Engine Optimization (AEO)
|
||||||
|
* Optimized for ChatGPT, Perplexity, and other AI answer engines
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface JsonLdProps {
|
||||||
|
data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonLd: React.FC<JsonLdProps> = ({ data }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
// Remove existing script with same @type to prevent duplicates
|
||||||
|
const existingScripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||||
|
existingScripts.forEach(script => {
|
||||||
|
try {
|
||||||
|
const scriptData = JSON.parse(script.textContent || '');
|
||||||
|
if (scriptData['@type'] === data['@type']) {
|
||||||
|
script.remove();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore invalid JSON
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new script tag
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'application/ld+json';
|
||||||
|
script.textContent = JSON.stringify(data, null, 2);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (script.parentNode) {
|
||||||
|
script.parentNode.removeChild(script);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JsonLd;
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* IndexNow Integration for Answer Engine Optimization (AEO)
|
||||||
|
* Supports PerplexityBot, GPTBot, and other AI crawlers
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface IndexNowConfig {
|
||||||
|
host: string;
|
||||||
|
key: string;
|
||||||
|
keyLocation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueuedUrl {
|
||||||
|
url: string;
|
||||||
|
timestamp: number;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class IndexNowClient {
|
||||||
|
private config: IndexNowConfig;
|
||||||
|
private queue: Map<string, QueuedUrl> = new Map();
|
||||||
|
private isProcessing = false;
|
||||||
|
private maxRetries = 3;
|
||||||
|
private baseDelay = 1000; // 1 second
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const key = import.meta.env.VITE_INDEXNOW_KEY;
|
||||||
|
const host = import.meta.env.VITE_SITE_HOST || 'energie-profis.de';
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
console.warn('VITE_INDEXNOW_KEY not found. IndexNow disabled.');
|
||||||
|
this.config = { host: '', key: '', keyLocation: '' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
host,
|
||||||
|
key,
|
||||||
|
keyLocation: `https://${host}/${key}.txt`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-process queue periodically
|
||||||
|
setInterval(() => this.processQueue(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue URLs for IndexNow submission with deduplication
|
||||||
|
*/
|
||||||
|
queueUrls(urls: string[]): void {
|
||||||
|
if (!this.config.key) return;
|
||||||
|
|
||||||
|
const validUrls = urls.filter(url => this.isValidUrl(url));
|
||||||
|
|
||||||
|
for (const url of validUrls) {
|
||||||
|
const canonical = this.getCanonicalUrl(url);
|
||||||
|
if (!this.queue.has(canonical)) {
|
||||||
|
this.queue.set(canonical, {
|
||||||
|
url: canonical,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
attempts: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validUrls.length > 0) {
|
||||||
|
console.log(`Queued ${validUrls.length} URLs for IndexNow`);
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a single URL
|
||||||
|
*/
|
||||||
|
queueUrl(url: string): void {
|
||||||
|
this.queueUrls([url]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the queue with exponential backoff retry logic
|
||||||
|
*/
|
||||||
|
private async processQueue(): Promise<void> {
|
||||||
|
if (this.isProcessing || this.queue.size === 0 || !this.config.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isProcessing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlsToSubmit: QueuedUrl[] = [];
|
||||||
|
const urlsToRetry: QueuedUrl[] = [];
|
||||||
|
const urlsToRemove: string[] = [];
|
||||||
|
|
||||||
|
// Categorize URLs by their status
|
||||||
|
for (const [key, queuedUrl] of this.queue) {
|
||||||
|
if (queuedUrl.attempts >= this.maxRetries) {
|
||||||
|
urlsToRemove.push(key);
|
||||||
|
console.warn(`Removing URL after ${this.maxRetries} attempts: ${queuedUrl.url}`);
|
||||||
|
} else if (queuedUrl.attempts === 0) {
|
||||||
|
urlsToSubmit.push(queuedUrl);
|
||||||
|
} else {
|
||||||
|
// Check if enough time has passed for retry
|
||||||
|
const delay = this.baseDelay * Math.pow(2, queuedUrl.attempts - 1);
|
||||||
|
if (Date.now() - queuedUrl.timestamp > delay) {
|
||||||
|
urlsToRetry.push(queuedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove failed URLs
|
||||||
|
urlsToRemove.forEach(key => this.queue.delete(key));
|
||||||
|
|
||||||
|
// Submit new URLs
|
||||||
|
if (urlsToSubmit.length > 0) {
|
||||||
|
const success = await this.submitToIndexNow(
|
||||||
|
urlsToSubmit.map(u => u.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Remove successful submissions
|
||||||
|
urlsToSubmit.forEach(u => this.queue.delete(u.url));
|
||||||
|
} else {
|
||||||
|
// Mark for retry
|
||||||
|
urlsToSubmit.forEach(u => {
|
||||||
|
u.attempts++;
|
||||||
|
u.timestamp = Date.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry failed URLs
|
||||||
|
if (urlsToRetry.length > 0) {
|
||||||
|
const success = await this.submitToIndexNow(
|
||||||
|
urlsToRetry.map(u => u.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
urlsToRetry.forEach(u => this.queue.delete(u.url));
|
||||||
|
} else {
|
||||||
|
urlsToRetry.forEach(u => {
|
||||||
|
u.attempts++;
|
||||||
|
u.timestamp = Date.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('IndexNow queue processing error:', error);
|
||||||
|
} finally {
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit URLs to IndexNow API
|
||||||
|
*/
|
||||||
|
private async submitToIndexNow(urls: string[]): Promise<boolean> {
|
||||||
|
if (urls.length === 0) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
host: this.config.host,
|
||||||
|
key: this.config.key,
|
||||||
|
keyLocation: this.config.keyLocation,
|
||||||
|
urlList: urls
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('https://api.indexnow.org/indexnow', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`IndexNow: Successfully submitted ${urls.length} URLs`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error(`IndexNow submission failed: ${response.status} ${response.statusText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('IndexNow submission error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate URL format and domain
|
||||||
|
*/
|
||||||
|
private isValidUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return urlObj.hostname === this.config.host ||
|
||||||
|
urlObj.hostname === `www.${this.config.host}` ||
|
||||||
|
(urlObj.hostname.includes('localhost') && import.meta.env.DEV);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get canonical URL (removes fragments, normalizes)
|
||||||
|
*/
|
||||||
|
private getCanonicalUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
urlObj.hash = '';
|
||||||
|
urlObj.search = urlObj.search.replace(/[?&]utm_[^&]*/g, '');
|
||||||
|
return urlObj.toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current queue status (for debugging)
|
||||||
|
*/
|
||||||
|
getQueueStatus(): { size: number; urls: string[] } {
|
||||||
|
return {
|
||||||
|
size: this.queue.size,
|
||||||
|
urls: Array.from(this.queue.keys())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const indexNowClient = new IndexNowClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience functions for common use cases
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function queueIndexNowPing(urls: string[]): void {
|
||||||
|
indexNowClient.queueUrls(urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueCurrentPage(): void {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
indexNowClient.queueUrl(window.location.href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for React components to easily queue current page
|
||||||
|
*/
|
||||||
|
export function useIndexNow() {
|
||||||
|
const queueCurrentPage = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
indexNowClient.queueUrl(window.location.href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { queueCurrentPage, queueUrls: queueIndexNowPing };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual ping function for scripts
|
||||||
|
*/
|
||||||
|
export async function pingIndexNowManually(urls: string[]): Promise<boolean> {
|
||||||
|
indexNowClient.queueUrls(urls);
|
||||||
|
// Wait for processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
const status = indexNowClient.getQueueStatus();
|
||||||
|
return status.size === 0; // Success if queue is empty
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
/**
|
||||||
|
* Schema.org JSON-LD builders for Answer Engine Optimization (AEO)
|
||||||
|
* Optimized for AI answer engines like ChatGPT and Perplexity
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
url: string;
|
||||||
|
logo: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
socialProfiles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Person {
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
organization?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
headline: string;
|
||||||
|
description: string;
|
||||||
|
datePublished: Date;
|
||||||
|
dateModified: Date;
|
||||||
|
author: Person;
|
||||||
|
publisher: Organization;
|
||||||
|
images: string[];
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FAQ {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HowToStep {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
image?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HowTo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
steps: HowToStep[];
|
||||||
|
totalTime?: string;
|
||||||
|
estimatedCost?: string;
|
||||||
|
supply?: string[];
|
||||||
|
tool?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Organization schema for the main company
|
||||||
|
*/
|
||||||
|
export function buildOrganizationJsonLd(org: Partial<Organization> = {}): Record<string, any> {
|
||||||
|
const defaultOrg: Organization = {
|
||||||
|
name: 'EnergieProfis',
|
||||||
|
description: 'Führendes Verzeichnis für Solar- und Wind-Installateure in Deutschland',
|
||||||
|
url: 'https://energie-profis.de',
|
||||||
|
logo: 'https://energie-profis.de/favicon.ico',
|
||||||
|
contactEmail: 'info@energie-profis.de',
|
||||||
|
socialProfiles: [
|
||||||
|
'https://www.linkedin.com/company/energieprofis',
|
||||||
|
'https://twitter.com/energieprofis'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const organization = { ...defaultOrg, ...org };
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: organization.name,
|
||||||
|
description: organization.description,
|
||||||
|
url: organization.url,
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: organization.logo
|
||||||
|
},
|
||||||
|
contactPoint: {
|
||||||
|
'@type': 'ContactPoint',
|
||||||
|
email: organization.contactEmail,
|
||||||
|
contactType: 'customer service',
|
||||||
|
areaServed: 'DE',
|
||||||
|
availableLanguage: 'German'
|
||||||
|
},
|
||||||
|
sameAs: organization.socialProfiles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WebSite schema with search action
|
||||||
|
*/
|
||||||
|
export function buildWebSiteJsonLd(baseUrl: string = 'https://energie-profis.de'): Record<string, any> {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: 'EnergieProfis',
|
||||||
|
description: 'Finden Sie qualifizierte Fachbetriebe für Solar- und Windenergie in Deutschland',
|
||||||
|
url: baseUrl,
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
target: {
|
||||||
|
'@type': 'EntryPoint',
|
||||||
|
urlTemplate: `${baseUrl}/installateur-finden?search={search_term_string}&type={energy_type}&location={location}`
|
||||||
|
},
|
||||||
|
'query-input': 'required name=search_term_string name=energy_type name=location'
|
||||||
|
},
|
||||||
|
publisher: buildOrganizationJsonLd()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Article schema for content pages
|
||||||
|
*/
|
||||||
|
export function buildArticleJsonLd(article: Article): Record<string, any> {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: article.headline,
|
||||||
|
description: article.description,
|
||||||
|
image: article.images,
|
||||||
|
datePublished: article.datePublished.toISOString(),
|
||||||
|
dateModified: article.dateModified.toISOString(),
|
||||||
|
author: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: article.author.name,
|
||||||
|
url: article.author.url,
|
||||||
|
jobTitle: article.author.jobTitle
|
||||||
|
},
|
||||||
|
publisher: buildOrganizationJsonLd(),
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': article.url
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build FAQPage schema for Q&A sections
|
||||||
|
*/
|
||||||
|
export function buildFAQPageJsonLd(faqs: FAQ[], pageUrl?: string): Record<string, any> {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: faqs.map(faq => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: faq.question,
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: faq.answer
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
...(pageUrl && {
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': pageUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HowTo schema for step-by-step guides
|
||||||
|
*/
|
||||||
|
export function buildHowToJsonLd(howTo: HowTo, pageUrl?: string): Record<string, any> {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: howTo.name,
|
||||||
|
description: howTo.description,
|
||||||
|
step: howTo.steps.map((step, index) => ({
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: index + 1,
|
||||||
|
name: step.name,
|
||||||
|
text: step.text,
|
||||||
|
...(step.image && {
|
||||||
|
image: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: step.image
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(step.url && { url: step.url })
|
||||||
|
})),
|
||||||
|
...(howTo.totalTime && { totalTime: howTo.totalTime }),
|
||||||
|
...(howTo.estimatedCost && {
|
||||||
|
estimatedCost: {
|
||||||
|
'@type': 'MonetaryAmount',
|
||||||
|
currency: 'EUR',
|
||||||
|
value: howTo.estimatedCost
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(howTo.supply && {
|
||||||
|
supply: howTo.supply.map(item => ({
|
||||||
|
'@type': 'HowToSupply',
|
||||||
|
name: item
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
...(howTo.tool && {
|
||||||
|
tool: howTo.tool.map(item => ({
|
||||||
|
'@type': 'HowToTool',
|
||||||
|
name: item
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
...(pageUrl && {
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': pageUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build BreadcrumbList schema for navigation
|
||||||
|
*/
|
||||||
|
export function buildBreadcrumbJsonLd(
|
||||||
|
breadcrumbs: Array<{ name: string; url: string }>
|
||||||
|
): Record<string, any> {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: breadcrumbs.map((crumb, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: crumb.name,
|
||||||
|
item: crumb.url
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example usage for renewable energy content
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const exampleFAQs: FAQ[] = [
|
||||||
|
{
|
||||||
|
question: 'Wie finde ich den besten Installateur in meiner Region?',
|
||||||
|
answer: 'Nutzen Sie unseren Vergleich: Bewertungen, Erfahrung, Zertifizierungen und regionale Expertise vergleichen. Lokale Installateure kennen die regionalen Vorgaben und Fördermöglichkeiten am besten.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Welche Förderungen gibt es 2025 für erneuerbare Energien?',
|
||||||
|
answer: 'BAFA-Förderung für Balkonkraftwerke bis 500€, KfW-Kredite mit Tilgungszuschuss, EEG-Vergütung für Überschussstrom und kommunale Zuschüsse je nach Bundesland.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Was kostet eine Solar- oder Windanlage?',
|
||||||
|
answer: 'Balkonkraftwerk: 400-800€, komplette Dachanlage: 8.000-15.000€ je kWp, Kleinwindanlage: 3.000-15.000€. Preise variieren je nach Region und Ausstattung.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const exampleHowTo: HowTo = {
|
||||||
|
name: 'Solaranlage installieren lassen - Schritt für Schritt',
|
||||||
|
description: 'So finden Sie den richtigen Installateur und lassen Ihre Solaranlage professionell installieren.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
name: 'Energiebedarf ermitteln',
|
||||||
|
text: 'Berechnen Sie Ihren jährlichen Stromverbrauch und prüfen Sie die Dachfläche auf Eignung für Solarmodule.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Qualifizierte Installateure vergleichen',
|
||||||
|
text: 'Nutzen Sie EnergieProfis.de um zertifizierte Fachbetriebe in Ihrer Region zu finden und Angebote einzuholen.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Angebote prüfen und Finanzierung klären',
|
||||||
|
text: 'Vergleichen Sie Preise, Garantieleistungen und prüfen Sie verfügbare Fördermittel und Finanzierungsoptionen.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Installation beauftragen und überwachen',
|
||||||
|
text: 'Beauftragen Sie den gewählten Installateur und lassen Sie sich über den Installationsfortschritt informieren.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalTime: 'P2W',
|
||||||
|
estimatedCost: '8000-15000',
|
||||||
|
supply: ['Solarmodule', 'Wechselrichter', 'Montagesystem', 'Verkabelung'],
|
||||||
|
tool: ['Nicht erforderlich - wird vom Installateur bereitgestellt']
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* Sitemap generator for Vite React app with proper lastmod for AEO
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SitemapUrl {
|
||||||
|
loc: string;
|
||||||
|
lastmod: string;
|
||||||
|
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageMetadata {
|
||||||
|
path: string;
|
||||||
|
lastModified?: Date;
|
||||||
|
changeFreq?: SitemapUrl['changefreq'];
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SitemapGenerator {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(baseUrl: string = 'https://energie-profis.de') {
|
||||||
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate complete sitemap XML
|
||||||
|
*/
|
||||||
|
generateSitemap(): string {
|
||||||
|
const urls = this.getStaticPages();
|
||||||
|
|
||||||
|
const urlElements = urls.map(url => `
|
||||||
|
<url>
|
||||||
|
<loc>${this.escapeXml(url.loc)}</loc>
|
||||||
|
<lastmod>${url.lastmod}</lastmod>
|
||||||
|
${url.changefreq ? `<changefreq>${url.changefreq}</changefreq>` : ''}
|
||||||
|
${url.priority ? `<priority>${url.priority}</priority>` : ''}
|
||||||
|
</url>`).join('');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urlElements}
|
||||||
|
</urlset>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate news sitemap for recent content updates
|
||||||
|
*/
|
||||||
|
generateNewsSitemap(): string {
|
||||||
|
const recentUrls = this.getStaticPages()
|
||||||
|
.filter(url => {
|
||||||
|
const lastMod = new Date(url.lastmod);
|
||||||
|
const threeDaysAgo = new Date();
|
||||||
|
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
|
||||||
|
return lastMod > threeDaysAgo;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentUrls.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsItems = recentUrls.map(url => `
|
||||||
|
<url>
|
||||||
|
<loc>${this.escapeXml(url.loc)}</loc>
|
||||||
|
<news:news>
|
||||||
|
<news:publication>
|
||||||
|
<news:name>EnergieProfis</news:name>
|
||||||
|
<news:language>de</news:language>
|
||||||
|
</news:publication>
|
||||||
|
<news:publication_date>${url.lastmod}</news:publication_date>
|
||||||
|
<news:title>${this.getPageTitle(url.loc)}</news:title>
|
||||||
|
</news:news>
|
||||||
|
</url>`).join('');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
|
||||||
|
${newsItems}
|
||||||
|
</urlset>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get static page metadata with realistic last modified dates
|
||||||
|
*/
|
||||||
|
private getStaticPages(): SitemapUrl[] {
|
||||||
|
const now = new Date();
|
||||||
|
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const pages: PageMetadata[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
lastModified: oneWeekAgo,
|
||||||
|
changeFreq: 'weekly',
|
||||||
|
priority: 1.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/solar',
|
||||||
|
lastModified: oneWeekAgo,
|
||||||
|
changeFreq: 'weekly',
|
||||||
|
priority: 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/wind',
|
||||||
|
lastModified: oneWeekAgo,
|
||||||
|
changeFreq: 'weekly',
|
||||||
|
priority: 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/installateur-finden',
|
||||||
|
lastModified: new Date(), // Updated frequently with new installers
|
||||||
|
changeFreq: 'daily',
|
||||||
|
priority: 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/kostenlose-beratung',
|
||||||
|
lastModified: oneMonthAgo,
|
||||||
|
changeFreq: 'monthly',
|
||||||
|
priority: 0.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/unternehmen-listen',
|
||||||
|
lastModified: new Date(), // Business listings updated daily
|
||||||
|
changeFreq: 'daily',
|
||||||
|
priority: 0.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return pages.map(page => ({
|
||||||
|
loc: `${this.baseUrl}${page.path}`,
|
||||||
|
lastmod: (page.lastModified || now).toISOString().split('T')[0],
|
||||||
|
changefreq: page.changeFreq,
|
||||||
|
priority: page.priority
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get page title for news sitemap
|
||||||
|
*/
|
||||||
|
private getPageTitle(url: string): string {
|
||||||
|
const path = url.replace(this.baseUrl, '');
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
'/': 'Solar- und Wind-Installateure finden & vergleichen',
|
||||||
|
'/solar': 'Solarenergie: Photovoltaik-Anlagen und Solarthermie',
|
||||||
|
'/wind': 'Windenergie: Kleinwind- und Windkraftanlagen',
|
||||||
|
'/installateur-finden': 'Qualifizierte Installateure in Ihrer Region finden',
|
||||||
|
'/kostenlose-beratung': 'Kostenlose Beratung für erneuerbare Energien',
|
||||||
|
'/unternehmen-listen': 'Ihr Unternehmen bei EnergieProfis listen'
|
||||||
|
};
|
||||||
|
return titles[path] || 'EnergieProfis - Erneuerbare Energien';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape XML special characters
|
||||||
|
*/
|
||||||
|
private escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const sitemapGenerator = new SitemapGenerator();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate and serve sitemap for development/build
|
||||||
|
*/
|
||||||
|
export function generateSitemapXml(): string {
|
||||||
|
return sitemapGenerator.generateSitemap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate news sitemap if there are recent updates
|
||||||
|
*/
|
||||||
|
export function generateNewsSitemapXml(): string {
|
||||||
|
return sitemapGenerator.generateNewsSitemap();
|
||||||
|
}
|
||||||
|
|
@ -499,47 +499,6 @@ const InstallateurFinden = () => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* SEO: FAQ Section for better content depth */}
|
|
||||||
<Card className="mb-8">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Clock className="w-5 h-5" />
|
|
||||||
Häufige Fragen zu Solar- und Wind-Installation
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="border-b pb-4">
|
|
||||||
<h3 className="font-semibold mb-2">Welche Förderungen gibt es 2025 für Solaranlagen?</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
BAFA-Förderung für Balkonkraftwerke (bis 500€), KfW-Kredite mit Tilgungszuschuss,
|
|
||||||
EEG-Vergütung für Überschussstrom und kommunale Zuschüsse je nach Bundesland.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="border-b pb-4">
|
|
||||||
<h3 className="font-semibold mb-2">Brauche ich eine Genehmigung für eine Kleinwindanlage?</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Mast-Höhen unter 15m sind in den meisten Bundesländern genehmigungsfrei.
|
|
||||||
Ab 15m ist ein Bauantrag erforderlich. Schallemissions-Gutachten können nötig sein.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="border-b pb-4">
|
|
||||||
<h3 className="font-semibold mb-2">Wie finde ich den besten Installateur in meiner Region?</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Nutzen Sie unseren Vergleich: Bewertungen, Erfahrung, Zertifizierungen und
|
|
||||||
regionale Expertise vergleichen. Lokale Installateure kennen die regionalen Vorgaben.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-2">Was kostet eine Solaranlage 2025?</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Balkonkraftwerk: 400-800€, Komplette Dachanlage: 8.000-15.000€ je kWp.
|
|
||||||
Preise variieren je nach Region, Anbieter und Ausstattung. Nutzen Sie unseren Kostenrechner.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Debug Section - Only show in development */}
|
{/* Debug Section - Only show in development */}
|
||||||
{process.env.NODE_ENV === 'development' && (
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
|
@ -731,6 +690,48 @@ const InstallateurFinden = () => {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* SEO: FAQ Section for better content depth */}
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5" />
|
||||||
|
Häufige Fragen zu Solar- und Wind-Installation
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<h3 className="font-semibold mb-2">Welche Förderungen gibt es 2025 für Solaranlagen?</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
BAFA-Förderung für Balkonkraftwerke (bis 500€), KfW-Kredite mit Tilgungszuschuss,
|
||||||
|
EEG-Vergütung für Überschussstrom und kommunale Zuschüsse je nach Bundesland.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<h3 className="font-semibold mb-2">Brauche ich eine Genehmigung für eine Kleinwindanlage?</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Mast-Höhen unter 15m sind in den meisten Bundesländern genehmigungsfrei.
|
||||||
|
Ab 15m ist ein Bauantrag erforderlich. Schallemissions-Gutachten können nötig sein.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<h3 className="font-semibold mb-2">Wie finde ich den besten Installateur in meiner Region?</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Nutzen Sie unseren Vergleich: Bewertungen, Erfahrung, Zertifizierungen und
|
||||||
|
regionale Expertise vergleichen. Lokale Installateure kennen die regionalen Vorgaben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2">Was kostet eine Solaranlage 2025?</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Balkonkraftwerk: 400-800€, Komplette Dachanlage: 8.000-15.000€ je kWp.
|
||||||
|
Preise variieren je nach Region, Anbieter und Ausstattung. Nutzen Sie unseren Kostenrechner.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* SEO: Internal linking section */}
|
{/* SEO: Internal linking section */}
|
||||||
<div className="mt-12 p-6 bg-gray-50 rounded-lg">
|
<div className="mt-12 p-6 bg-gray-50 rounded-lg">
|
||||||
<h2 className="text-2xl font-bold mb-4 text-center">Weitere nützliche Tools</h2>
|
<h2 className="text-2xl font-bold mb-4 text-center">Weitere nützliche Tools</h2>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
/**
|
||||||
|
* Example page demonstrating Answer Engine Optimization (AEO) implementation
|
||||||
|
* This shows how to integrate all AEO components for maximum AI citation potential
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import Canonical from '@/components/seo/Canonical';
|
||||||
|
import JsonLd from '@/components/seo/JsonLd';
|
||||||
|
import ContentMeta from '@/components/seo/ContentMeta';
|
||||||
|
import {
|
||||||
|
buildArticleJsonLd,
|
||||||
|
buildFAQPageJsonLd,
|
||||||
|
buildHowToJsonLd,
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
|
exampleFAQs,
|
||||||
|
exampleHowTo
|
||||||
|
} from '@/lib/schema';
|
||||||
|
import { queueCurrentPage } from '@/lib/indexnow';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { CheckCircle, ArrowRight, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
const AeoExample: React.FC = () => {
|
||||||
|
// Page metadata for Article schema
|
||||||
|
const articleData = {
|
||||||
|
headline: 'Solaranlage installieren lassen - Vollständiger Leitfaden 2025',
|
||||||
|
description: 'Schritt-für-Schritt Anleitung: Wie Sie die richtige Solaranlage finden, Installateure vergleichen und Förderungen optimal nutzen.',
|
||||||
|
datePublished: new Date('2024-12-01'),
|
||||||
|
dateModified: new Date('2025-01-04'),
|
||||||
|
author: {
|
||||||
|
name: 'EnergieProfis Redaktion',
|
||||||
|
url: 'https://energie-profis.de/team',
|
||||||
|
jobTitle: 'Energie-Experten',
|
||||||
|
organization: 'EnergieProfis'
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
name: 'EnergieProfis',
|
||||||
|
description: 'Führendes Verzeichnis für erneuerbare Energien',
|
||||||
|
url: 'https://energie-profis.de',
|
||||||
|
logo: 'https://energie-profis.de/favicon.ico'
|
||||||
|
},
|
||||||
|
images: [
|
||||||
|
'https://energie-profis.de/solar_banner.png',
|
||||||
|
'https://energie-profis.de/sun_flow_banner.png'
|
||||||
|
],
|
||||||
|
url: 'https://energie-profis.de/solaranlage-installieren-lassen-leitfaden'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Breadcrumb navigation for better structure
|
||||||
|
const breadcrumbs = [
|
||||||
|
{ name: 'Home', url: 'https://energie-profis.de/' },
|
||||||
|
{ name: 'Solar', url: 'https://energie-profis.de/solar' },
|
||||||
|
{ name: 'Installation Guide', url: 'https://energie-profis.de/solaranlage-installieren-lassen-leitfaden' }
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Queue page for IndexNow submission
|
||||||
|
queueCurrentPage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* AEO Components - These don't render visible content */}
|
||||||
|
<Canonical />
|
||||||
|
<JsonLd data={buildArticleJsonLd(articleData)} />
|
||||||
|
<JsonLd data={buildFAQPageJsonLd(exampleFAQs, articleData.url)} />
|
||||||
|
<JsonLd data={buildHowToJsonLd(exampleHowTo, articleData.url)} />
|
||||||
|
<JsonLd data={buildBreadcrumbJsonLd(breadcrumbs)} />
|
||||||
|
|
||||||
|
{/* Visible Page Content */}
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
|
||||||
|
{/* Breadcrumb Navigation */}
|
||||||
|
<nav className="text-sm text-muted-foreground mb-6">
|
||||||
|
<ol className="flex items-center space-x-2">
|
||||||
|
{breadcrumbs.map((crumb, index) => (
|
||||||
|
<li key={index} className="flex items-center">
|
||||||
|
<a href={crumb.url} className="hover:text-primary transition-colors">
|
||||||
|
{crumb.name}
|
||||||
|
</a>
|
||||||
|
{index < breadcrumbs.length - 1 && (
|
||||||
|
<ArrowRight className="w-4 h-4 mx-2" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Article Header */}
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-4">
|
||||||
|
{articleData.headline}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground mb-6">
|
||||||
|
{articleData.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Content Metadata - Visible to users and AI */}
|
||||||
|
<ContentMeta
|
||||||
|
lastUpdated={articleData.dateModified}
|
||||||
|
publishedDate={articleData.datePublished}
|
||||||
|
authorName={articleData.author.name}
|
||||||
|
authorUrl={articleData.author.url}
|
||||||
|
readTime="8 Minuten"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Article Content */}
|
||||||
|
<article className="prose prose-lg max-w-none">
|
||||||
|
|
||||||
|
{/* Introduction with rich context */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2>Warum eine professionelle Solaranlage-Installation?</h2>
|
||||||
|
<p>
|
||||||
|
Die Installation einer Solaranlage ist eine der wichtigsten Investitionen für nachhaltige
|
||||||
|
Energieversorgung. Mit steigenden Strompreisen und verbesserten Förderprogrammen wird
|
||||||
|
Solarenergie 2025 noch attraktiver. Dieser Leitfaden hilft Ihnen dabei, die richtige
|
||||||
|
Entscheidung zu treffen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card className="mt-6 border-l-4 border-l-green-500">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Info className="w-5 h-5 text-green-600" />
|
||||||
|
Wichtige Vorteile 2025
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>BAFA-Förderung bis 500€ für Balkonkraftwerke</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>KfW-Kredite mit günstigen Zinsen</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>Wegfall der EEG-Umlage für Eigenverbrauch</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HowTo Section - Structured for AI understanding */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2>{exampleHowTo.name}</h2>
|
||||||
|
<p className="mb-6">{exampleHowTo.description}</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{exampleHowTo.steps.map((step, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
Schritt {index + 1}: {step.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>{step.text}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||||
|
<h3 className="font-semibold mb-2">Geschätzte Kosten und Zeitaufwand</h3>
|
||||||
|
<p><strong>Kosten:</strong> {exampleHowTo.estimatedCost}€</p>
|
||||||
|
<p><strong>Zeitaufwand:</strong> 2 Wochen (von Planung bis Installation)</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ Section - Optimized for AI answers */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2>Häufig gestellte Fragen zur Solar-Installation</h2>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{exampleFAQs.map((faq, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{faq.question}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>{faq.answer}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Expert Insights for Authority */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2>Expertentipps für 2025</h2>
|
||||||
|
<div className="bg-gradient-to-r from-orange-50 to-yellow-50 p-6 rounded-lg">
|
||||||
|
<h3 className="font-semibold text-orange-800 mb-3">
|
||||||
|
Aktuelle Marktentwicklungen
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-orange-700">
|
||||||
|
<li>• Modulpreise sind 2025 um 20% gefallen</li>
|
||||||
|
<li>• Neue Speichertechnologien mit längerer Lebensdauer</li>
|
||||||
|
<li>• Vereinfachte Anmeldeverfahren in allen Bundesländern</li>
|
||||||
|
<li>• Smart Home Integration wird Standard</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Call to Action */}
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<Card className="max-w-2xl mx-auto">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<h3 className="text-2xl font-bold mb-4">
|
||||||
|
Bereit für Ihre Solaranlage?
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Finden Sie qualifizierte Installateure in Ihrer Region und
|
||||||
|
erhalten Sie kostenlose Angebote.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<a
|
||||||
|
href="/installateur-finden"
|
||||||
|
className="inline-block bg-primary text-primary-foreground px-8 py-3 rounded-lg font-semibold hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Installateure finden
|
||||||
|
</a>
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href="/kostenlose-beratung"
|
||||||
|
className="inline-block text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Kostenlose Beratung vereinbaren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AeoExample;
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Canonical URL Validation', () => {
|
||||||
|
test('homepage should have exactly one canonical link', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const canonicalLinks = await page.locator('link[rel="canonical"]').all();
|
||||||
|
expect(canonicalLinks.length).toBe(1);
|
||||||
|
|
||||||
|
const href = await canonicalLinks[0].getAttribute('href');
|
||||||
|
expect(href).toBe('https://energie-profis.de/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all main pages should have proper canonical URLs', async ({ page }) => {
|
||||||
|
const pages = [
|
||||||
|
{ path: '/', expected: 'https://energie-profis.de/' },
|
||||||
|
{ path: '/solar', expected: 'https://energie-profis.de/solar' },
|
||||||
|
{ path: '/wind', expected: 'https://energie-profis.de/wind' },
|
||||||
|
{ path: '/installateur-finden', expected: 'https://energie-profis.de/installateur-finden' },
|
||||||
|
{ path: '/kostenlose-beratung', expected: 'https://energie-profis.de/kostenlose-beratung' },
|
||||||
|
{ path: '/unternehmen-listen', expected: 'https://energie-profis.de/unternehmen-listen' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { path, expected } of pages) {
|
||||||
|
await page.goto(path);
|
||||||
|
|
||||||
|
// Should have exactly one canonical link
|
||||||
|
const canonicalLinks = await page.locator('link[rel="canonical"]').all();
|
||||||
|
expect(canonicalLinks.length).toBe(1);
|
||||||
|
|
||||||
|
// Should have the correct href
|
||||||
|
const href = await canonicalLinks[0].getAttribute('href');
|
||||||
|
expect(href).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical should not have query parameters or fragments', async ({ page }) => {
|
||||||
|
// Test with query parameters
|
||||||
|
await page.goto('/?utm_source=test&ref=social');
|
||||||
|
|
||||||
|
const canonicalLinks = await page.locator('link[rel="canonical"]').all();
|
||||||
|
expect(canonicalLinks.length).toBe(1);
|
||||||
|
|
||||||
|
const href = await canonicalLinks[0].getAttribute('href');
|
||||||
|
expect(href).toBe('https://energie-profis.de/');
|
||||||
|
expect(href).not.toContain('utm_source');
|
||||||
|
expect(href).not.toContain('ref=');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical should be absolute URL with https', async ({ page }) => {
|
||||||
|
await page.goto('/solar');
|
||||||
|
|
||||||
|
const canonicalLink = await page.locator('link[rel="canonical"]').first();
|
||||||
|
const href = await canonicalLink.getAttribute('href');
|
||||||
|
|
||||||
|
expect(href).toMatch(/^https:\/\//);
|
||||||
|
expect(href).toContain('energie-profis.de');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Robots.txt AEO Compliance', () => {
|
||||||
|
test('should allow PerplexityBot and GPTBot', async ({ page }) => {
|
||||||
|
const response = await page.goto('/robots.txt');
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
|
||||||
|
const content = await page.textContent('pre') || await page.textContent('body');
|
||||||
|
|
||||||
|
// Check for PerplexityBot
|
||||||
|
expect(content).toContain('User-agent: PerplexityBot');
|
||||||
|
expect(content).toContain('Allow: /');
|
||||||
|
|
||||||
|
// Check for GPTBot
|
||||||
|
expect(content).toContain('User-agent: GPTBot');
|
||||||
|
expect(content).toContain('Allow: /');
|
||||||
|
|
||||||
|
// Check for sitemap reference
|
||||||
|
expect(content).toContain('Sitemap: https://energie-profis.de/sitemap.xml');
|
||||||
|
|
||||||
|
// Ensure no blanket disallow that would block AI bots
|
||||||
|
expect(content).not.toMatch(/User-agent: \*[\s\S]*?Disallow: \//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain existing bot permissions', async ({ page }) => {
|
||||||
|
const response = await page.goto('/robots.txt');
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
|
||||||
|
const content = await page.textContent('pre') || await page.textContent('body');
|
||||||
|
|
||||||
|
// Check that existing bots are still allowed
|
||||||
|
expect(content).toContain('User-agent: Googlebot');
|
||||||
|
expect(content).toContain('User-agent: Bingbot');
|
||||||
|
expect(content).toContain('User-agent: Twitterbot');
|
||||||
|
expect(content).toContain('User-agent: facebookexternalhit');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('JSON-LD Schema Validation', () => {
|
||||||
|
test('homepage should have valid Organization and WebSite schema', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Find JSON-LD script tags
|
||||||
|
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all();
|
||||||
|
expect(jsonLdScripts.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
let hasWebSite = false;
|
||||||
|
let hasOrganization = false;
|
||||||
|
|
||||||
|
for (const script of jsonLdScripts) {
|
||||||
|
const content = await script.textContent();
|
||||||
|
if (content) {
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
|
||||||
|
if (data['@type'] === 'WebSite') {
|
||||||
|
hasWebSite = true;
|
||||||
|
expect(data.name).toBe('EnergieProfis');
|
||||||
|
expect(data.url).toMatch(/energie-profis\.de/);
|
||||||
|
expect(data.potentialAction).toBeDefined();
|
||||||
|
expect(data.potentialAction['@type']).toBe('SearchAction');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data['@type'] === 'Organization') {
|
||||||
|
hasOrganization = true;
|
||||||
|
expect(data.name).toBe('EnergieProfis');
|
||||||
|
expect(data.logo).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasWebSite).toBe(true);
|
||||||
|
expect(hasOrganization).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FAQ sections should have valid FAQPage schema', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Look for FAQ content on homepage
|
||||||
|
const faqSection = await page.locator('text=Häufige Fragen').first();
|
||||||
|
if (await faqSection.isVisible()) {
|
||||||
|
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all();
|
||||||
|
|
||||||
|
let hasFAQPage = false;
|
||||||
|
for (const script of jsonLdScripts) {
|
||||||
|
const content = await script.textContent();
|
||||||
|
if (content) {
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
|
||||||
|
if (data['@type'] === 'FAQPage') {
|
||||||
|
hasFAQPage = true;
|
||||||
|
expect(data.mainEntity).toBeInstanceOf(Array);
|
||||||
|
expect(data.mainEntity.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Validate each FAQ item
|
||||||
|
for (const item of data.mainEntity) {
|
||||||
|
expect(item['@type']).toBe('Question');
|
||||||
|
expect(item.name).toBeDefined();
|
||||||
|
expect(item.acceptedAnswer).toBeDefined();
|
||||||
|
expect(item.acceptedAnswer['@type']).toBe('Answer');
|
||||||
|
expect(item.acceptedAnswer.text).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAQ schema should exist if FAQ content is present
|
||||||
|
if (await faqSection.isVisible()) {
|
||||||
|
expect(hasFAQPage).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all JSON-LD should be valid JSON', async ({ page }) => {
|
||||||
|
const pages = ['/', '/solar', '/wind', '/installateur-finden'];
|
||||||
|
|
||||||
|
for (const pagePath of pages) {
|
||||||
|
await page.goto(pagePath);
|
||||||
|
|
||||||
|
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all();
|
||||||
|
|
||||||
|
for (const script of jsonLdScripts) {
|
||||||
|
const content = await script.textContent();
|
||||||
|
if (content) {
|
||||||
|
// This should not throw if JSON is valid
|
||||||
|
expect(() => JSON.parse(content)).not.toThrow();
|
||||||
|
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
expect(data['@context']).toBe('https://schema.org');
|
||||||
|
expect(data['@type']).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Sitemap XML Validation', () => {
|
||||||
|
test('should serve valid sitemap.xml', async ({ page }) => {
|
||||||
|
const response = await page.goto('/sitemap.xml');
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
expect(response?.headers()['content-type']).toMatch(/xml/);
|
||||||
|
|
||||||
|
const content = await page.textContent('urlset') || await page.textContent('body');
|
||||||
|
|
||||||
|
// Should be valid XML
|
||||||
|
expect(content).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
||||||
|
expect(content).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
|
||||||
|
expect(content).toContain('</urlset>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include all main pages with lastmod', async ({ page }) => {
|
||||||
|
await page.goto('/sitemap.xml');
|
||||||
|
|
||||||
|
const content = await page.textContent('urlset') || await page.textContent('body');
|
||||||
|
|
||||||
|
const expectedUrls = [
|
||||||
|
'https://energie-profis.de/',
|
||||||
|
'https://energie-profis.de/solar',
|
||||||
|
'https://energie-profis.de/wind',
|
||||||
|
'https://energie-profis.de/installateur-finden',
|
||||||
|
'https://energie-profis.de/kostenlose-beratung',
|
||||||
|
'https://energie-profis.de/unternehmen-listen'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of expectedUrls) {
|
||||||
|
expect(content).toContain(`<loc>${url}</loc>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for lastmod tags
|
||||||
|
expect(content).toMatch(/<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have proper priority and changefreq values', async ({ page }) => {
|
||||||
|
await page.goto('/sitemap.xml');
|
||||||
|
|
||||||
|
const content = await page.textContent('urlset') || await page.textContent('body');
|
||||||
|
|
||||||
|
// Homepage should have highest priority
|
||||||
|
expect(content).toMatch(/https:\/\/energie-profis\.de\/<\/loc>[\s\S]*?<priority>1\.0<\/priority>/);
|
||||||
|
|
||||||
|
// Should have valid changefreq values
|
||||||
|
const validChangeFreqs = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
|
||||||
|
const changefreqMatches = content.match(/<changefreq>([^<]+)<\/changefreq>/g) || [];
|
||||||
|
|
||||||
|
for (const match of changefreqMatches) {
|
||||||
|
const value = match.replace(/<\/?changefreq>/g, '');
|
||||||
|
expect(validChangeFreqs).toContain(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have valid date format for lastmod', async ({ page }) => {
|
||||||
|
await page.goto('/sitemap.xml');
|
||||||
|
|
||||||
|
const content = await page.textContent('urlset') || await page.textContent('body');
|
||||||
|
|
||||||
|
// Extract all lastmod dates
|
||||||
|
const lastmodMatches = content.match(/<lastmod>([^<]+)<\/lastmod>/g) || [];
|
||||||
|
expect(lastmodMatches.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const match of lastmodMatches) {
|
||||||
|
const dateString = match.replace(/<\/?lastmod>/g, '');
|
||||||
|
|
||||||
|
// Should be in YYYY-MM-DD format (ISO date)
|
||||||
|
expect(dateString).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
|
||||||
|
// Should be a valid date
|
||||||
|
const date = new Date(dateString);
|
||||||
|
expect(date.toString()).not.toBe('Invalid Date');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue