import type { AppLocale } from "./locale"; /** * Site-wide SEO configuration stored as one JSON blob in `app_config` * (key `seo_settings`). Everything here is pure: parsing/normalizing only. * Read/write goes through `lib/app-config.ts`. */ export const SEO_SETTINGS_KEY = "seo_settings"; export type SeoStructuredDataType = "Person" | "Organization"; export type SeoLocaleSettings = { /** Comma-separated keywords (optional, low SEO weight but harmless). */ keywords: string; }; export type SeoSettings = { /** Master switch: false → robots disallow all + `noindex` on every page. */ allowIndexing: boolean; /** `google-site-verification` meta value. */ googleSiteVerification: string; /** `msvalidate.01` meta value (Bing Webmaster). */ bingSiteVerification: string; /** `@handle` used for twitter:site / twitter:creator. */ twitterHandle: string; /** Publisher shape used for JSON-LD on the home page. */ structuredDataType: SeoStructuredDataType; /** Name shown in JSON-LD (falls back to the site name when empty). */ structuredDataName: string; /** Person job title / Organization tagline used in JSON-LD. */ structuredDataJobTitle: string; /** Social profile URLs for `sameAs` in JSON-LD. */ sameAs: string[]; locales: Record; }; export function buildDefaultSeoSettings(): SeoSettings { return { allowIndexing: true, googleSiteVerification: "", bingSiteVerification: "", twitterHandle: "", structuredDataType: "Person", structuredDataName: "", structuredDataJobTitle: "", sameAs: [], locales: { ar: { keywords: "" }, en: { keywords: "" }, de: { keywords: "" }, }, }; } function normalizeText(value: unknown, maxLength = 500): string { return typeof value === "string" ? value.trim().slice(0, maxLength) : ""; } /** Meta verification tokens are alphanumeric with `-` / `_`; anything else is dropped. */ export function normalizeVerificationToken(value: unknown): string { const text = normalizeText(value, 200); return /^[A-Za-z0-9_-]+$/.test(text) ? text : ""; } export function normalizeTwitterHandle(value: unknown): string { const text = normalizeText(value, 60).replace(/^https?:\/\/(www\.)?(twitter|x)\.com\//i, "").replace(/^@+/, ""); return /^[A-Za-z0-9_]{1,15}$/.test(text) ? `@${text}` : ""; } export function normalizeSameAs(value: unknown): string[] { const rawList = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[\n,]+/) : []; const urls = rawList .map((entry) => normalizeText(entry, 500)) .filter((entry) => /^https:\/\/[^\s]+$/i.test(entry)); return Array.from(new Set(urls)).slice(0, 20); } export function normalizeStructuredDataType(value: unknown): SeoStructuredDataType { return value === "Organization" ? "Organization" : "Person"; } export function normalizeKeywords(value: unknown): string { const text = normalizeText(value, 1000); return text .split(",") .map((keyword) => keyword.trim()) .filter(Boolean) .slice(0, 30) .join(", "); } export function parseSeoSettingsValue(rawValue: string | null | undefined): SeoSettings { const defaults = buildDefaultSeoSettings(); if (!rawValue) { return defaults; } try { const parsed = JSON.parse(rawValue) as Record; const locales = parsed.locales && typeof parsed.locales === "object" ? (parsed.locales as Record | undefined>) : {}; return { allowIndexing: parsed.allowIndexing !== false, googleSiteVerification: normalizeVerificationToken(parsed.googleSiteVerification), bingSiteVerification: normalizeVerificationToken(parsed.bingSiteVerification), twitterHandle: normalizeTwitterHandle(parsed.twitterHandle), structuredDataType: normalizeStructuredDataType(parsed.structuredDataType), structuredDataName: normalizeText(parsed.structuredDataName, 120), structuredDataJobTitle: normalizeText(parsed.structuredDataJobTitle, 160), sameAs: normalizeSameAs(parsed.sameAs), locales: { ar: { keywords: normalizeKeywords(locales.ar?.keywords) }, en: { keywords: normalizeKeywords(locales.en?.keywords) }, de: { keywords: normalizeKeywords(locales.de?.keywords) }, }, }; } catch { return defaults; } } /** Map an app locale to the Open Graph `og:locale` format. */ export function toOpenGraphLocale(locale: AppLocale): string { switch (locale) { case "ar": return "ar_AR"; case "en": return "en_US"; default: return "de_DE"; } }