From bdcb9d3b75312a3c541ef68f51da7ac8452f5851 Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Thu, 4 Sep 2025 10:41:27 +0200 Subject: [PATCH] AEO --- .claude/settings.local.json | 3 +- .env.example | 12 + README.md | 84 +++- package-lock.json | 605 +++++++++++++++++++++++++++++ package.json | 9 +- playwright.config.ts | 61 +++ public/robots.txt | 8 + public/sitemap.xml | 46 +++ scripts/ping-indexnow.ts | 37 ++ src/components/HeroSection.tsx | 28 +- src/components/seo/Canonical.tsx | 45 +++ src/components/seo/ContentMeta.tsx | 101 +++++ src/components/seo/JsonLd.tsx | 44 +++ src/lib/indexnow.ts | 266 +++++++++++++ src/lib/schema.ts | 279 +++++++++++++ src/lib/sitemap.ts | 181 +++++++++ src/pages/InstallateurFinden.tsx | 83 ++-- src/pages/examples/AeoExample.tsx | 244 ++++++++++++ tests/e2e/canonical.spec.ts | 59 +++ tests/e2e/robots.spec.ts | 37 ++ tests/e2e/schema.spec.ts | 98 +++++ tests/e2e/sitemap.spec.ts | 77 ++++ 22 files changed, 2349 insertions(+), 58 deletions(-) create mode 100644 .env.example create mode 100644 playwright.config.ts create mode 100644 public/sitemap.xml create mode 100644 scripts/ping-indexnow.ts create mode 100644 src/components/seo/Canonical.tsx create mode 100644 src/components/seo/ContentMeta.tsx create mode 100644 src/components/seo/JsonLd.tsx create mode 100644 src/lib/indexnow.ts create mode 100644 src/lib/schema.ts create mode 100644 src/lib/sitemap.ts create mode 100644 src/pages/examples/AeoExample.tsx create mode 100644 tests/e2e/canonical.spec.ts create mode 100644 tests/e2e/robots.spec.ts create mode 100644 tests/e2e/schema.spec.ts create mode 100644 tests/e2e/sitemap.spec.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b9c0375..df68258 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(npm run dev:*)" + "Bash(npm run dev:*)", + "Bash(npm install:*)" ], "deny": [], "ask": [] diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9cde2c --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 4de372d..63a394a 100644 --- a/README.md +++ b/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 **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 ( + <> + + + + + ); + } + ``` + +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 ']*>' +``` ## How can I edit this code? diff --git a/package-lock.json b/package-lock.json index 047b871..d1422b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0", + "dotenv": "^17.2.2", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.462.0", @@ -61,6 +62,7 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", + "@playwright/test": "^1.55.0", "@tailwindcss/typography": "^0.5.16", "@types/node": "^22.16.5", "@types/react": "^18.3.23", @@ -74,6 +76,7 @@ "lovable-tagger": "^1.1.9", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", + "tsx": "^4.20.5", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.19" @@ -507,6 +510,23 @@ "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": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -952,6 +972,22 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3909,6 +3945,18 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4404,6 +4452,19 @@ "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": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -5550,6 +5611,53 @@ "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": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -6033,6 +6141,16 @@ "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": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -6458,6 +6576,493 @@ "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 5262dac..10b9297 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "build": "vite build", "build:dev": "vite build --mode development", "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": { "@hookform/resolvers": "^3.10.0", @@ -45,6 +49,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0", + "dotenv": "^17.2.2", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.462.0", @@ -64,6 +69,7 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", + "@playwright/test": "^1.55.0", "@tailwindcss/typography": "^0.5.16", "@types/node": "^22.16.5", "@types/react": "^18.3.23", @@ -77,6 +83,7 @@ "lovable-tagger": "^1.1.9", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", + "tsx": "^4.20.5", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.19" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4e3460b --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt index 6018e70..f68e235 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -10,5 +10,13 @@ Allow: / User-agent: facebookexternalhit Allow: / +User-agent: PerplexityBot +Allow: / + +User-agent: GPTBot +Allow: / + User-agent: * Allow: / + +Sitemap: https://energie-profis.de/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..1b91535 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,46 @@ + + + + + https://energie-profis.de/ + 2025-01-28 + weekly + 1.0 + + + + https://energie-profis.de/solar + 2025-01-28 + weekly + 0.9 + + + + https://energie-profis.de/wind + 2025-01-28 + weekly + 0.9 + + + + https://energie-profis.de/installateur-finden + 2025-01-04 + daily + 0.8 + + + + https://energie-profis.de/kostenlose-beratung + 2024-12-05 + monthly + 0.7 + + + + https://energie-profis.de/unternehmen-listen + 2025-01-04 + daily + 0.6 + + + \ No newline at end of file diff --git a/scripts/ping-indexnow.ts b/scripts/ping-indexnow.ts new file mode 100644 index 0000000..eaec743 --- /dev/null +++ b/scripts/ping-indexnow.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env tsx +/** + * Manual IndexNow ping script for CI/CD and manual testing + * Usage: npm run ping-indexnow ... + */ + +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 ...'); + 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); \ No newline at end of file diff --git a/src/components/HeroSection.tsx b/src/components/HeroSection.tsx index bb0e087..f75d8bc 100644 --- a/src/components/HeroSection.tsx +++ b/src/components/HeroSection.tsx @@ -74,20 +74,6 @@ const HeroSection = () => { - - {/* Clean CTA buttons */} -
- -
@@ -133,6 +119,20 @@ const HeroSection = () => { + + {/* Moved "Alle Installateure Ansehen" button to upper right corner */} +
+ +
); }; diff --git a/src/components/seo/Canonical.tsx b/src/components/seo/Canonical.tsx new file mode 100644 index 0000000..68dbd1d --- /dev/null +++ b/src/components/seo/Canonical.tsx @@ -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 = ({ + 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; \ No newline at end of file diff --git a/src/components/seo/ContentMeta.tsx b/src/components/seo/ContentMeta.tsx new file mode 100644 index 0000000..6972269 --- /dev/null +++ b/src/components/seo/ContentMeta.tsx @@ -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 = ({ + 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 ( +
+ {/* Last Updated - Most important for AEO */} +
+ + + Zuletzt aktualisiert: + + +
+ + {/* Author Information */} +
+ + + Autor: + {authorUrl ? ( + + {authorName} + + ) : ( + {authorName} + )} + +
+ + {/* Published Date (if different from last updated) */} + {publishedDate && publishedDate.getTime() !== lastUpdated.getTime() && ( +
+ + + Veröffentlicht: + + +
+ )} + + {/* Read Time */} + {readTime && ( +
+ + {readTime} Lesezeit +
+ )} +
+ ); +}; + +export default ContentMeta; \ No newline at end of file diff --git a/src/components/seo/JsonLd.tsx b/src/components/seo/JsonLd.tsx new file mode 100644 index 0000000..b3a19e6 --- /dev/null +++ b/src/components/seo/JsonLd.tsx @@ -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; +} + +const JsonLd: React.FC = ({ 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; \ No newline at end of file diff --git a/src/lib/indexnow.ts b/src/lib/indexnow.ts new file mode 100644 index 0000000..7a9b935 --- /dev/null +++ b/src/lib/indexnow.ts @@ -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 = 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 { + 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 { + 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 { + 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 +} \ No newline at end of file diff --git a/src/lib/schema.ts b/src/lib/schema.ts new file mode 100644 index 0000000..70dc9be --- /dev/null +++ b/src/lib/schema.ts @@ -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 = {}): Record { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'] +}; \ No newline at end of file diff --git a/src/lib/sitemap.ts b/src/lib/sitemap.ts new file mode 100644 index 0000000..dc80e97 --- /dev/null +++ b/src/lib/sitemap.ts @@ -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 => ` + + ${this.escapeXml(url.loc)} + ${url.lastmod} + ${url.changefreq ? `${url.changefreq}` : ''} + ${url.priority ? `${url.priority}` : ''} + `).join(''); + + return ` + +${urlElements} +`; + } + + /** + * 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 => ` + + ${this.escapeXml(url.loc)} + + + EnergieProfis + de + + ${url.lastmod} + ${this.getPageTitle(url.loc)} + + `).join(''); + + return ` + +${newsItems} +`; + } + + /** + * 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 = { + '/': '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, '''); + } +} + +// 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(); +} \ No newline at end of file diff --git a/src/pages/InstallateurFinden.tsx b/src/pages/InstallateurFinden.tsx index 1dad4fe..de6d545 100644 --- a/src/pages/InstallateurFinden.tsx +++ b/src/pages/InstallateurFinden.tsx @@ -499,47 +499,6 @@ const InstallateurFinden = () => { - {/* SEO: FAQ Section for better content depth */} - - - - - Häufige Fragen zu Solar- und Wind-Installation - - - -
-
-

Welche Förderungen gibt es 2025 für Solaranlagen?

-

- 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. -

-
-
-

Brauche ich eine Genehmigung fĂĽr eine Kleinwindanlage?

-

- 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. -

-
-
-

Wie finde ich den besten Installateur in meiner Region?

-

- Nutzen Sie unseren Vergleich: Bewertungen, Erfahrung, Zertifizierungen und - regionale Expertise vergleichen. Lokale Installateure kennen die regionalen Vorgaben. -

-
-
-

Was kostet eine Solaranlage 2025?

-

- Balkonkraftwerk: 400-800€, Komplette Dachanlage: 8.000-15.000€ je kWp. - Preise variieren je nach Region, Anbieter und Ausstattung. Nutzen Sie unseren Kostenrechner. -

-
-
-
-
{/* Debug Section - Only show in development */} {process.env.NODE_ENV === 'development' && ( @@ -731,6 +690,48 @@ const InstallateurFinden = () => { )} + {/* SEO: FAQ Section for better content depth */} + + + + + Häufige Fragen zu Solar- und Wind-Installation + + + +
+
+

Welche Förderungen gibt es 2025 für Solaranlagen?

+

+ 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. +

+
+
+

Brauche ich eine Genehmigung fĂĽr eine Kleinwindanlage?

+

+ 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. +

+
+
+

Wie finde ich den besten Installateur in meiner Region?

+

+ Nutzen Sie unseren Vergleich: Bewertungen, Erfahrung, Zertifizierungen und + regionale Expertise vergleichen. Lokale Installateure kennen die regionalen Vorgaben. +

+
+
+

Was kostet eine Solaranlage 2025?

+

+ Balkonkraftwerk: 400-800€, Komplette Dachanlage: 8.000-15.000€ je kWp. + Preise variieren je nach Region, Anbieter und Ausstattung. Nutzen Sie unseren Kostenrechner. +

+
+
+
+
+ {/* SEO: Internal linking section */}

Weitere nĂĽtzliche Tools

diff --git a/src/pages/examples/AeoExample.tsx b/src/pages/examples/AeoExample.tsx new file mode 100644 index 0000000..2e705ec --- /dev/null +++ b/src/pages/examples/AeoExample.tsx @@ -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 */} + + + + + + + {/* Visible Page Content */} +
+
+ + {/* Breadcrumb Navigation */} + + + {/* Article Header */} +
+

+ {articleData.headline} +

+

+ {articleData.description} +

+ + {/* Content Metadata - Visible to users and AI */} + +
+ + {/* Main Article Content */} +
+ + {/* Introduction with rich context */} +
+

Warum eine professionelle Solaranlage-Installation?

+

+ 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. +

+ + + + + + Wichtige Vorteile 2025 + + + +
    +
  • + + BAFA-Förderung bis 500€ fĂĽr Balkonkraftwerke +
  • +
  • + + KfW-Kredite mit gĂĽnstigen Zinsen +
  • +
  • + + Wegfall der EEG-Umlage fĂĽr Eigenverbrauch +
  • +
+
+
+
+ + {/* HowTo Section - Structured for AI understanding */} +
+

{exampleHowTo.name}

+

{exampleHowTo.description}

+ +
+ {exampleHowTo.steps.map((step, index) => ( + + + + Schritt {index + 1}: {step.name} + + + +

{step.text}

+
+
+ ))} +
+ +
+

Geschätzte Kosten und Zeitaufwand

+

Kosten: {exampleHowTo.estimatedCost}€

+

Zeitaufwand: 2 Wochen (von Planung bis Installation)

+
+
+ + {/* FAQ Section - Optimized for AI answers */} +
+

Häufig gestellte Fragen zur Solar-Installation

+
+ {exampleFAQs.map((faq, index) => ( + + + {faq.question} + + +

{faq.answer}

+
+
+ ))} +
+
+ + {/* Expert Insights for Authority */} +
+

Expertentipps fĂĽr 2025

+
+

+ Aktuelle Marktentwicklungen +

+
    +
  • • Modulpreise sind 2025 um 20% gefallen
  • +
  • • Neue Speichertechnologien mit längerer Lebensdauer
  • +
  • • Vereinfachte Anmeldeverfahren in allen Bundesländern
  • +
  • • Smart Home Integration wird Standard
  • +
+
+
+
+ + {/* Call to Action */} +
+ + +

+ Bereit fĂĽr Ihre Solaranlage? +

+

+ Finden Sie qualifizierte Installateure in Ihrer Region und + erhalten Sie kostenlose Angebote. +

+ +
+
+
+ +
+
+ + ); +}; + +export default AeoExample; \ No newline at end of file diff --git a/tests/e2e/canonical.spec.ts b/tests/e2e/canonical.spec.ts new file mode 100644 index 0000000..06bd69c --- /dev/null +++ b/tests/e2e/canonical.spec.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/tests/e2e/robots.spec.ts b/tests/e2e/robots.spec.ts new file mode 100644 index 0000000..0e75452 --- /dev/null +++ b/tests/e2e/robots.spec.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/tests/e2e/schema.spec.ts b/tests/e2e/schema.spec.ts new file mode 100644 index 0000000..a2ec842 --- /dev/null +++ b/tests/e2e/schema.spec.ts @@ -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(); + } + } + } + }); +}); \ No newline at end of file diff --git a/tests/e2e/sitemap.spec.ts b/tests/e2e/sitemap.spec.ts new file mode 100644 index 0000000..3529169 --- /dev/null +++ b/tests/e2e/sitemap.spec.ts @@ -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(''); + expect(content).toContain(''); + expect(content).toContain(''); + }); + + 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(`${url}`); + } + + // Check for lastmod tags + expect(content).toMatch(/\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]*?1\.0<\/priority>/); + + // Should have valid changefreq values + const validChangeFreqs = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']; + const changefreqMatches = content.match(/([^<]+)<\/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>/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'); + } + }); +}); \ No newline at end of file