SEO - New Settings > SEO admin page (seo_settings in app_config): indexing switch, Google/Bing verification, X handle, JSON-LD identity (Person/Organization, sameAs), per-locale keywords, readiness checklist and open links for sitemap.xml / robots.txt / manifest. - robots.txt is now dynamic: disallows admin, api, success and coming-soon paths; blocks everything while indexing is off or maintenance is on. - sitemap.xml carries hreflang alternates per URL, lists only categories with published projects, and is empty while hidden. - Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form, alternateLocale, twitter site/creator, project cover as OG image with article type, noindex on /success and /coming-soon. - JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per project (view-mode independent). Security - Maintenance bypass now requires a correctly signed admin cookie; the middleware previously only checked the cookie existed. Token helpers moved to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts). - Media uploads: magic-byte validation against the declared type, SVG sanitization (script/handlers/foreignObject/javascript: rejected), upload folder sanitized, kind inferred from the real file. - Media route: fixed prefix-based path check that accepted sibling directories, unknown extensions return 404, nosniff header, CSP sandbox on SVG, gif content type added. - External media URLs: protocol-relative (//host) URLs rejected. Portfolio - Project and category slugs share /portfolio/[slug]; saving now rejects a slug already used on the other side instead of silently shadowing it. Tooling/docs - Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import. - New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin spec and CLAUDE.md updated. - Tests for all of the above (unit + integration); suite green.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import type { Metadata } from "next";
|
|
|
|
import { appLocales } from "../i18n/routing";
|
|
import {
|
|
PAGE_TITLE_TOKEN,
|
|
SITE_NAME_TOKEN,
|
|
type SiteSettings,
|
|
type SiteSettingsMediaBindings,
|
|
} from "./site-settings";
|
|
import {
|
|
getSiteSettings,
|
|
getSiteSettingsMediaBindings,
|
|
} from "./app-config";
|
|
|
|
export function getSiteUrl(): URL {
|
|
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
|
}
|
|
|
|
export function toAbsoluteUrl(pathname: string): string {
|
|
return new URL(pathname, getSiteUrl()).toString();
|
|
}
|
|
|
|
const NOINDEX_ROBOTS: Metadata["robots"] = {
|
|
index: false,
|
|
follow: false,
|
|
nocache: true,
|
|
googleBot: { index: false, follow: false, noimageindex: true },
|
|
};
|
|
|
|
const INDEX_ROBOTS: Metadata["robots"] = {
|
|
index: true,
|
|
follow: true,
|
|
googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, "max-video-preview": -1 },
|
|
};
|
|
|
|
export function buildRobotsMetadata(seo: SeoSettings, noIndex = false): Metadata["robots"] {
|
|
return seo.allowIndexing && !noIndex ? INDEX_ROBOTS : NOINDEX_ROBOTS;
|
|
}
|
|
|
|
function buildVerification(seo: SeoSettings): Metadata["verification"] {
|
|
const verification: NonNullable<Metadata["verification"]> = {};
|
|
|
|
if (seo.googleSiteVerification) {
|
|
verification.google = seo.googleSiteVerification;
|
|
}
|
|
|
|
if (seo.bingSiteVerification) {
|
|
verification.other = { "msvalidate.01": seo.bingSiteVerification };
|
|
}
|
|
|
|
return Object.keys(verification).length > 0 ? verification : undefined;
|
|
}
|
|
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
|
|
import { getSeoSettings } from "./app-config";
|
|
import { buildDefaultSeoSettings, toOpenGraphLocale, type SeoSettings } from "./seo-settings";
|
|
import { buildSiteIconUrls } from "./site-icons";
|
|
|
|
export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
|
|
const languages = Object.fromEntries(
|
|
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]),
|
|
) as Record<AppLocale, string>;
|
|
|
|
return {
|
|
canonical: toAbsoluteUrl(getLocalizedPathWithDefault(defaultLocale, pathname, defaultLocale)),
|
|
languages: {
|
|
...languages,
|
|
"x-default": toAbsoluteUrl(getLocalizedPathWithDefault(defaultLocale, pathname, defaultLocale)),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function applyTitleTemplateFn(title: string, template: string, siteName: string): string {
|
|
const safeTemplate = template.includes(PAGE_TITLE_TOKEN)
|
|
? template
|
|
: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`;
|
|
|
|
return safeTemplate
|
|
.replaceAll(SITE_NAME_TOKEN, siteName)
|
|
.replace(PAGE_TITLE_TOKEN, title);
|
|
}
|
|
|
|
function buildMetadataImages(imageUrl?: string | null, alt?: string) {
|
|
if (!imageUrl) {
|
|
return undefined;
|
|
}
|
|
|
|
return [
|
|
{
|
|
url: toAbsoluteUrl(imageUrl),
|
|
alt,
|
|
},
|
|
];
|
|
}
|
|
|
|
export async function buildAppMetadata(): Promise<Metadata> {
|
|
const [settings, bindings, seo] = await Promise.all([
|
|
getSiteSettings(),
|
|
getSiteSettingsMediaBindings(),
|
|
getSeoSettings(),
|
|
]);
|
|
|
|
return buildAppMetadataFromConfig(settings, bindings, seo);
|
|
}
|
|
|
|
export function buildAppMetadataFromConfig(
|
|
settings: SiteSettings,
|
|
bindings: SiteSettingsMediaBindings,
|
|
seo: SeoSettings = buildDefaultSeoSettings(),
|
|
): Metadata {
|
|
const defaultLocaleSettings = settings.locales[settings.defaultLocale];
|
|
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url, defaultLocaleSettings.siteName);
|
|
const keywords = seo.locales[settings.defaultLocale].keywords;
|
|
const siteIconUrls = buildSiteIconUrls({
|
|
siteName: defaultLocaleSettings.siteName,
|
|
faviconVersion: bindings.favicon?.version,
|
|
faviconUrl: bindings.favicon?.url,
|
|
});
|
|
|
|
return {
|
|
metadataBase: getSiteUrl(),
|
|
title: defaultLocaleSettings.siteName,
|
|
description: defaultLocaleSettings.siteDescription,
|
|
applicationName: defaultLocaleSettings.siteName,
|
|
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
|
|
robots: buildRobotsMetadata(seo),
|
|
verification: buildVerification(seo),
|
|
formatDetection: { telephone: false },
|
|
manifest: siteIconUrls.manifestHref,
|
|
icons: {
|
|
icon: [{ url: siteIconUrls.faviconHref }],
|
|
shortcut: [{ url: siteIconUrls.faviconHref }],
|
|
apple: [{ url: siteIconUrls.appleIconHref }],
|
|
},
|
|
openGraph: {
|
|
title: defaultLocaleSettings.siteName,
|
|
description: defaultLocaleSettings.siteDescription,
|
|
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
|
|
siteName: defaultLocaleSettings.siteName,
|
|
locale: toOpenGraphLocale(settings.defaultLocale),
|
|
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
|
|
type: "website",
|
|
images: openGraphImages,
|
|
},
|
|
twitter: {
|
|
card: openGraphImages ? "summary_large_image" : "summary",
|
|
title: defaultLocaleSettings.siteName,
|
|
description: defaultLocaleSettings.siteDescription,
|
|
site: seo.twitterHandle || undefined,
|
|
creator: seo.twitterHandle || undefined,
|
|
images: openGraphImages?.map((image) => image.url),
|
|
},
|
|
};
|
|
}
|
|
|
|
type LocalizedMetadataOptions = {
|
|
/** Page-specific share image (e.g. a project cover). Falls back to the default OG image. */
|
|
image?: string | null;
|
|
/** Force `noindex` (thank-you pages, coming-soon, etc.). */
|
|
noIndex?: boolean;
|
|
/** Open Graph object type. Portfolio projects use `article`. */
|
|
type?: "website" | "article";
|
|
publishedTime?: Date | null;
|
|
modifiedTime?: Date | null;
|
|
};
|
|
|
|
type LocalizedMetadataInput = LocalizedMetadataOptions & {
|
|
locale: string;
|
|
pathname: string;
|
|
title: string;
|
|
description?: string;
|
|
applyTitleTemplate?: boolean;
|
|
};
|
|
|
|
export async function buildLocalizedMetadata({
|
|
locale,
|
|
pathname,
|
|
title,
|
|
description,
|
|
applyTitleTemplate,
|
|
...options
|
|
}: LocalizedMetadataInput): Promise<Metadata> {
|
|
const [settings, bindings, seo] = await Promise.all([
|
|
getSiteSettings(),
|
|
getSiteSettingsMediaBindings(),
|
|
getSeoSettings(),
|
|
]);
|
|
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
|
|
|
return buildLocalizedMetadataFromConfig({
|
|
settings,
|
|
bindings,
|
|
seo,
|
|
locale: localeKey,
|
|
pathname,
|
|
title,
|
|
description,
|
|
applyTitleTemplate,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
export function buildLocalizedMetadataFromConfig(
|
|
input: LocalizedMetadataOptions & {
|
|
settings: SiteSettings;
|
|
bindings: SiteSettingsMediaBindings;
|
|
seo?: SeoSettings;
|
|
locale: AppLocale;
|
|
pathname: string;
|
|
title: string;
|
|
description?: string;
|
|
applyTitleTemplate?: boolean;
|
|
},
|
|
): Metadata {
|
|
const {
|
|
settings,
|
|
bindings,
|
|
seo = buildDefaultSeoSettings(),
|
|
locale,
|
|
pathname,
|
|
title,
|
|
description,
|
|
applyTitleTemplate = true,
|
|
image,
|
|
noIndex = false,
|
|
type = "website",
|
|
publishedTime,
|
|
modifiedTime,
|
|
} = input;
|
|
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
|
const localeSettings = settings.locales[localeKey];
|
|
const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300);
|
|
const resolvedTitle = applyTitleTemplate
|
|
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
|
|
: title;
|
|
const openGraphImages = buildMetadataImages(image || bindings.defaultOgImage?.url, title);
|
|
const keywords = seo.locales[localeKey].keywords;
|
|
|
|
return {
|
|
title: resolvedTitle,
|
|
description: resolvedDescription,
|
|
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
|
|
robots: buildRobotsMetadata(seo, noIndex),
|
|
alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
|
|
openGraph: {
|
|
title: resolvedTitle,
|
|
description: resolvedDescription,
|
|
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
|
|
siteName: localeSettings.siteName,
|
|
locale: toOpenGraphLocale(localeKey),
|
|
alternateLocale: appLocales.filter((entry) => entry !== localeKey).map(toOpenGraphLocale),
|
|
images: openGraphImages,
|
|
...(type === "article"
|
|
? {
|
|
type: "article" as const,
|
|
publishedTime: publishedTime?.toISOString(),
|
|
modifiedTime: (modifiedTime ?? publishedTime)?.toISOString(),
|
|
}
|
|
: { type: "website" as const }),
|
|
},
|
|
twitter: {
|
|
card: openGraphImages ? "summary_large_image" : "summary",
|
|
title: resolvedTitle,
|
|
description: resolvedDescription,
|
|
site: seo.twitterHandle || undefined,
|
|
creator: seo.twitterHandle || undefined,
|
|
images: openGraphImages?.map((entry) => entry.url),
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- JSON-LD ----------------------------------------------------------------
|
|
|
|
type JsonLd = Record<string, unknown>;
|
|
|
|
/** WebSite + publisher (Person/Organization) graph for the home page. */
|
|
export function buildSiteJsonLd(input: {
|
|
settings: SiteSettings;
|
|
seo: SeoSettings;
|
|
bindings: SiteSettingsMediaBindings;
|
|
locale: AppLocale;
|
|
}): JsonLd {
|
|
const { settings, seo, bindings, locale } = input;
|
|
const localeSettings = settings.locales[locale];
|
|
const siteUrl = getSiteUrl().toString();
|
|
const publisherName = seo.structuredDataName || localeSettings.siteName;
|
|
const logoUrl = bindings.siteLogoLight?.url ?? bindings.defaultOgImage?.url ?? null;
|
|
|
|
const publisher: JsonLd = {
|
|
"@type": seo.structuredDataType,
|
|
"@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}`,
|
|
name: publisherName,
|
|
url: siteUrl,
|
|
};
|
|
|
|
if (seo.structuredDataJobTitle) {
|
|
publisher[seo.structuredDataType === "Person" ? "jobTitle" : "slogan"] = seo.structuredDataJobTitle;
|
|
}
|
|
|
|
if (logoUrl) {
|
|
publisher[seo.structuredDataType === "Person" ? "image" : "logo"] = toAbsoluteUrl(logoUrl);
|
|
}
|
|
|
|
if (seo.sameAs.length > 0) {
|
|
publisher.sameAs = seo.sameAs;
|
|
}
|
|
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@graph": [
|
|
{
|
|
"@type": "WebSite",
|
|
"@id": `${siteUrl}#website`,
|
|
url: siteUrl,
|
|
name: localeSettings.siteName,
|
|
description: localeSettings.siteDescription || undefined,
|
|
inLanguage: appLocales,
|
|
publisher: { "@id": publisher["@id"] },
|
|
},
|
|
publisher,
|
|
],
|
|
};
|
|
}
|
|
|
|
/** CreativeWork for a single portfolio project (any view mode). */
|
|
export function buildProjectJsonLd(input: {
|
|
settings: SiteSettings;
|
|
seo: SeoSettings;
|
|
locale: AppLocale;
|
|
pathname: string;
|
|
title: string;
|
|
description: string;
|
|
image?: string | null;
|
|
datePublished?: Date | null;
|
|
dateModified?: Date | null;
|
|
genre?: string;
|
|
keywords?: string[];
|
|
clientName?: string;
|
|
}): JsonLd {
|
|
const { settings, seo, locale, pathname } = input;
|
|
const siteUrl = getSiteUrl().toString();
|
|
const url = toAbsoluteUrl(getLocalizedPath(locale, pathname, settings.defaultLocale));
|
|
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "CreativeWork",
|
|
"@id": `${url}#work`,
|
|
url,
|
|
name: input.title,
|
|
headline: input.title,
|
|
description: input.description || undefined,
|
|
image: input.image ? toAbsoluteUrl(input.image) : undefined,
|
|
inLanguage: locale,
|
|
genre: input.genre || undefined,
|
|
keywords: input.keywords && input.keywords.length > 0 ? input.keywords.join(", ") : undefined,
|
|
datePublished: input.datePublished?.toISOString(),
|
|
dateModified: (input.dateModified ?? input.datePublished)?.toISOString(),
|
|
author: { "@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}` },
|
|
sourceOrganization: input.clientName ? { "@type": "Organization", name: input.clientName } : undefined,
|
|
isPartOf: { "@id": `${siteUrl}#website` },
|
|
};
|
|
}
|
|
|
|
/** Serialize JSON-LD safely for a `<script type="application/ld+json">` tag. */
|
|
export function serializeJsonLd(data: JsonLd): string {
|
|
return JSON.stringify(data).replace(/</g, "\\u003c");
|
|
}
|