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 = {}; 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; 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 { 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 { 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; /** 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 `