- Customer accounts now require email verification (hashed, single-use, time-limited tokens) before they can request/confirm bookings, with resend flows on login/account/booking widget and rate limiting. - Admins get a private, rotatable iCalendar (ICS) subscription feed of their confirmed bookings and public events, timezone-correct for Europe/Berlin including DST, never exposing meeting passwords. - Adds a full SEO layer: per-page canonical/OG/Twitter metadata with CMS-editable overrides and content-derived fallbacks, a dynamic sitemap.xml and robots.txt driven by real published content, JSON-LD (Organization/LocalBusiness, WebSite, WebPage, BreadcrumbList, Service, Event, BlogPosting) that never fabricates data, and a CMS-managed redirect table for changed slugs. - Global ANOUMA-naming audit: the brand name is never used to label personal account/calendar areas anywhere in the app, CMS, or emails. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
/**
|
||
* Optional, dev-only SEO sanity check — NOT part of the build or deploy
|
||
* pipeline, never blocks production. Run manually:
|
||
*
|
||
* npm run seo:check
|
||
*
|
||
* Only ever warns; exits 0 regardless of findings (a content gap here isn't
|
||
* a broken deploy).
|
||
*/
|
||
import { getPayload } from "payload";
|
||
import config from "../payload.config";
|
||
|
||
let warnings = 0;
|
||
|
||
function warn(message: string) {
|
||
warnings++;
|
||
console.warn(` ⚠ ${message}`);
|
||
}
|
||
|
||
async function checkCollection(
|
||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||
collection: "offers" | "events" | "posts",
|
||
label: string,
|
||
) {
|
||
console.log(`${label}:`);
|
||
const { docs } = await payload.find({ collection, limit: 500, depth: 0 });
|
||
|
||
const slugCounts = new Map<string, number>();
|
||
for (const doc of docs as { slug?: string | null; title?: string }[]) {
|
||
if (doc.slug) slugCounts.set(doc.slug, (slugCounts.get(doc.slug) ?? 0) + 1);
|
||
}
|
||
for (const [slug, count] of slugCounts) {
|
||
if (count > 1) warn(`Slug „${slug}“ kommt ${count}× vor.`);
|
||
}
|
||
|
||
for (const doc of docs as { slug?: string | null; title?: string; seo?: { description?: string | null } | null }[]) {
|
||
if (!doc.title?.trim()) warn(`Dokument ohne Titel (slug: ${doc.slug ?? "—"}).`);
|
||
if (!doc.slug?.trim()) warn(`Dokument ohne Slug: „${doc.title ?? "—"}“.`);
|
||
}
|
||
|
||
if (docs.length === 0) console.log(" (keine Einträge)");
|
||
}
|
||
|
||
async function checkMedia(payload: Awaited<ReturnType<typeof getPayload>>) {
|
||
console.log("Medien (Alt-Texte):");
|
||
const { docs } = await payload.find({ collection: "media", limit: 1000, depth: 0 });
|
||
const missingAlt = (docs as { alt?: string; filename?: string }[]).filter((doc) => !doc.alt?.trim());
|
||
if (missingAlt.length === 0) {
|
||
console.log(" Alle Bilder haben einen Alt-Text.");
|
||
} else {
|
||
for (const doc of missingAlt) warn(`Bild ohne Alt-Text: ${doc.filename ?? "unbekannt"}`);
|
||
}
|
||
}
|
||
|
||
async function run() {
|
||
const payload = await getPayload({ config });
|
||
|
||
await checkCollection(payload, "offers", "Angebote");
|
||
await checkCollection(payload, "events", "Termine");
|
||
await checkCollection(payload, "posts", "Aktuelles");
|
||
await checkMedia(payload);
|
||
|
||
console.log(`\n${warnings === 0 ? "Keine Auffälligkeiten gefunden." : `${warnings} Hinweis(e) gefunden — siehe oben.`}`);
|
||
process.exit(0);
|
||
}
|
||
|
||
run().catch((err) => {
|
||
console.error(err);
|
||
process.exit(0);
|
||
});
|