import { useEffect, useRef } from 'react'; import { LOCALES, LOCALE_META, DEFAULT_LOCALE } from '../i18n/config'; import { getLocaleFromPath, stripLocaleFromPath, localizedPath } from '../i18n/path'; import { SEO_ROUTES } from '../seoRoutes'; export interface SEOMeta { title: string; description: string; url: string; ogImage?: string; /** Module-level constant: injected once on mount, removed on unmount. */ jsonLd?: object | object[]; /** If true, sets robots meta to "noindex, nofollow" to prevent indexing. */ noindex?: boolean; } function qs(selector: string): HTMLMetaElement | null { return document.querySelector(selector) as HTMLMetaElement | null; } /** * Canonical + og:url must carry a trailing slash to match the URL the server * actually serves (nginx 301-redirects the slash-less form), so the canonical * never points at a redirecting URL and project pages agree with their * sitemap entry. Query/hash are preserved. */ function withTrailingSlash(u: string): string { try { const parsed = new URL(u, 'https://velxio.dev'); if (!parsed.pathname.endsWith('/')) parsed.pathname += '/'; return parsed.toString(); } catch { return u.endsWith('/') ? u : `${u}/`; } } const DOMAIN = 'https://velxio.dev'; const HREFLANG_ATTR = 'data-seo-hreflang'; /** * Pages that exist once per locale (`/es/esp32-simulator/` renders the same * marketing page in Spanish): the static SEO routes plus the home. Everything * else — user projects, profiles, gallery example pages — is the same * English content under every prefix, so it keeps a single locale-less * canonical and no hreflang. */ const LOCALIZED_ROUTE_PATHS = new Set( SEO_ROUTES.filter((r) => !r.noindex).map((r) => (r.path === '/' ? '/' : r.path.replace(/\/$/, ''))), ); function isLocalizedRoute(strippedPath: string): boolean { const key = strippedPath === '/' ? '/' : strippedPath.replace(/\/$/, ''); return LOCALIZED_ROUTE_PATHS.has(key); } /** * Canonical + hreflang for the current URL. For a localized route the * canonical is the page's OWN locale variant (`/es/esp32-simulator/`), and * every locale variant is listed as an alternate with `x-default` on the * locale-less English URL. Declaring canonical=/ from /es/ told Google the * Spanish page was a duplicate while it was ranking on its own; this * matches what Google already does with the pages. */ function localeLinks(url: string): { canonical: string; alternates: { hreflang: string; href: string }[] } { const canonicalDefault = withTrailingSlash(url); let strippedPath: string; try { strippedPath = stripLocaleFromPath(new URL(url, DOMAIN).pathname); } catch { return { canonical: canonicalDefault, alternates: [] }; } if (!isLocalizedRoute(strippedPath)) return { canonical: canonicalDefault, alternates: [] }; // A page whose canonical is another page (/v2 -> /) keeps that canonical // as given and declares no alternates. if (typeof window !== 'undefined') { const herePath = stripLocaleFromPath(window.location.pathname).replace(/\/+$/, '') || '/'; const urlPath = strippedPath.replace(/\/+$/, '') || '/'; if (herePath !== urlPath) return { canonical: canonicalDefault, alternates: [] }; } const current = typeof window !== 'undefined' ? getLocaleFromPath(window.location.pathname) : DEFAULT_LOCALE; const variant = (l: (typeof LOCALES)[number]) => withTrailingSlash(`${DOMAIN}${localizedPath(strippedPath, l)}`); return { canonical: variant(current), alternates: [ ...LOCALES.map((l) => ({ hreflang: LOCALE_META[l].htmlLang, href: variant(l) })), { hreflang: 'x-default', href: variant(DEFAULT_LOCALE) }, ], }; } /** * Updates document.title, meta description, OG/Twitter tags, and canonical * to reflect the current page. Restores originals on unmount. * * jsonLd (if provided) is injected as a