Source: SEO/tags.tsx

import React from 'react';
import { getLang, cleanTrailingSlash, jsonLdString } from '@/helpers';
import { articleJsonLd, breadcrumbJsonLd, faqJsonLd } from './jsonLd';

/**
 * Tag builders for <SEO />.
 *
 * These are plain functions returning arrays of elements, deliberately NOT React
 * components. `next/head` only picks up tags that are direct children, inside a
 * single React.Fragment, or in an array — a custom component renders server-side
 * and then vanishes on client-side navigation (vercel/next.js#8384). Arrays are
 * documented as supported, so this split is safe where a <MetaTags /> would not be.
 */

export type SeoMeta = {
    noindex?: boolean;
    title: string;
    /**
     * The locale the content itself is written in, from post frontmatter. Distinct from the router
     * locale on purpose: `resolveSeoUrls` builds the canonical from this, so a page cannot declare
     * whatever prefix it happened to be requested under to be canonical (SDD-L04).
     */
    locale?: string;
    author?: string;
    description?: string;
    image?: string;
    category?: string;
    alternate?: Array<{ lang: string; url: string }>;
    slug?: string;
    url?: string;
    date?: string | null;
    updated?: string | null;
    faq?: Array<{ question: string; answer: string }> | null;
};

export const OG_LOCALES: Record<string, string> = {
    en: 'en_US',
    es: 'es_ES',
    gl: 'gl_ES',
};

const jsonLdScript = (testId: string, key: string, payload: unknown) => (
    <script
        data-testid={testId}
        type="application/ld+json"
        key={key}
        // skipcq: JS-0440 - dangerouslySetInnerHTML is safe here for JSON-LD structured data
        dangerouslySetInnerHTML={{ __html: jsonLdString(payload) }}
    />
);

type UrlInput = { meta?: SeoMeta; isBlog?: boolean; locale?: string; pathname: string };

/**
 * @description Resolve the canonical URL and the derived values every tag group needs.
 * @returns {object} Canonical url, image url, category slug and the x-default blog url.
 */
export const resolveSeoUrls = ({ meta, isBlog, locale, pathname }: UrlInput) => {
    const domain = process.env.NEXT_PUBLIC_DOMAIN;
    /**
     * SDD-L04: prefer the post's own locale over the router's.
     *
     * This used to be `getLang(locale)` unconditionally, so a page served under the wrong locale
     * prefix declared *that* prefix canonical — it self-canonicalised instead of pointing at the one
     * true URL. The locale guard in the post's getStaticProps now 404s that case, so this is
     * belt-and-braces; it is here because a canonical built from where the request happened to land,
     * rather than from what the content is, is wrong regardless of who else is guarding it.
     *
     * Non-blog pages have no `meta.locale`, so they keep using the router locale, which is correct
     * for them — the same path exists in all three.
     */
    const langPrefix = getLang(meta?.locale ?? locale);
    const category = meta?.category?.toLowerCase();
    // meta.url lets dynamic non-blog routes (/legal/[slug], /blog/[category]) provide their
    // real path — router.pathname would leak the bracket placeholder into the canonical URL
    const pagePath = meta?.url ?? pathname;
    const url = isBlog
        ? `${domain}${langPrefix}/blog/${category}/${meta?.slug}`
        : `${domain}${langPrefix}${cleanTrailingSlash(pagePath)}`;

    const image = meta?.image ?? '/og-home.jpg';
    const imageUrl = `${domain}${image.startsWith('/') ? '' : '/'}${image}`;

    // For blog posts, the English version is either the current page or listed in the alternates
    const englishAlternate = meta?.alternate?.find(({ lang }) => lang === 'en');
    const englishAlternateUrl = englishAlternate ? `${domain}/blog/${category}/${englishAlternate.url}` : undefined;

    return {
        domain,
        langPrefix,
        category,
        pagePath,
        url,
        imageUrl,
        defaultBlogUrl: locale === 'en' ? url : englishAlternateUrl,
    };
};

type BlogTagsInput = {
    meta?: SeoMeta;
    locale?: string;
    author: string;
    urls: ReturnType<typeof resolveSeoUrls>;
};

/** @description Structured data and hreflang links for a blog post. */
export const blogTags = ({ meta, locale, author, urls }: BlogTagsInput) => {
    const { url, imageUrl, category, langPrefix, domain, defaultBlogUrl } = urls;

    const tags = [
        jsonLdScript(
            'json-ld',
            'item-jsonld',
            articleJsonLd({
                title: meta?.title,
                description: meta?.description,
                url,
                locale,
                imageUrl,
                date: meta?.date,
                updated: meta?.updated,
                author,
                domain,
            })
        ),
        jsonLdScript(
            'breadcrumb-jsonld',
            'breadcrumb-jsonld',
            breadcrumbJsonLd({
                title: meta?.title,
                url,
                category: meta?.category,
                categorySlug: category,
                langPrefix,
                domain,
            })
        ),
    ];

    if (meta?.faq && meta.faq.length > 0) {
        tags.push(jsonLdScript('faq-jsonld', 'faq-jsonld', faqJsonLd(meta.faq)));
    }
    if (locale) {
        tags.push(<link rel="alternate" hrefLang={locale} href={url} key="self-alternate" />);
    }
    if (defaultBlogUrl) {
        tags.push(<link rel="alternate" hrefLang="x-default" href={defaultBlogUrl} key="x-default" />);
    }

    return tags;
};

/**
 * Paths whose content is identical in every locale, so no `es`/`gl` alternate may be claimed.
 *
 * SDD-L08. `data/legal/` holds three files with no locale suffix and `legal/[slug].tsx` never
 * receives the locale, so the same English document is served at `/legal/*`, `/es/legal/*` and
 * `/gl/legal/*` — while these tags told Google three translations existed. hreflang is a
 * reciprocal claim about *translated* content; pointing it at the same English text is telling
 * search engines something untrue, and it invites them to serve a Spanish-speaking reader an
 * English page they cannot use.
 *
 * This is a stopgap, not the fix. The fix is L08-T2 — actual `es`/`gl` legal documents — which is
 * owner work: these texts invoke Spanish law (Ley 34/2002, LOPD-GDD, RD 1720/2007) and make
 * representations to users about data handling. A machine translation is not adequate. Until then,
 * claiming nothing beats claiming falsely.
 */
const UNTRANSLATED_PATH_PREFIXES = ['/legal/'];

/** @description hreflang links for the static, non-blog pages. */
export const staticHreflangTags = (domain: string | undefined, pagePath: string) => {
    const path = cleanTrailingSlash(pagePath);

    if (UNTRANSLATED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix))) {
        return [
            <link hrefLang="en" rel="alternate" href={`${domain}${path}`} key="hreflang-en" />,
            <link hrefLang="x-default" rel="alternate" href={`${domain}${path}`} key="hreflang-default" />,
        ];
    }

    return [
        <link hrefLang="en" rel="alternate" href={`${domain}${path}`} key="hreflang-en" />,
        <link hrefLang="es" rel="alternate" href={`${domain}/es${path}`} key="hreflang-es" />,
        <link hrefLang="gl" rel="alternate" href={`${domain}/gl${path}`} key="hreflang-gl" />,
        <link hrefLang="x-default" rel="alternate" href={`${domain}${path}`} key="hreflang-default" />,
    ];
};

export const robotsTags = (noindex?: boolean) => {
    // SDD-L04: `max-image-preview:large` opts into large image thumbnails in Search and
    // Discover. Without it Google defaults to a small preview, which for a blog whose posts all
    // ship an OG image is leaving the image on the table. Only meaningful on indexable pages.
    const content = noindex ? 'noindex' : 'index,follow,max-image-preview:large';
    return [
        <meta name="robots" content={content} key="robots" />,
        <meta name="googlebot" content={content} key="googlebot" />,
    ];
};

/** @description article:* metadata, emitted for blog posts only. */
export const articleTags = (isBlog: boolean | undefined, meta?: SeoMeta) => {
    if (!isBlog) return [];

    const tags = [];
    if (meta?.date) {
        tags.push(<meta property="article:published_time" content={meta.date} key="published" />);
    }
    if (meta?.updated) {
        tags.push(<meta property="article:modified_time" content={meta.updated} key="modified" />);
    }
    if (meta?.category) {
        tags.push(<meta property="article:section" content={meta.category} key="section" />);
    }
    return tags;
};

/**
 * SDD-L04: dimensions and alt added alongside the URL. Without `og:image:width`/`height` a scraper
 * has to fetch the image before it can lay out a card, and some (LinkedIn in particular) fall back to
 * a small preview or no image rather than wait. Every OG image this site references is 1200x630 —
 * `og-home.jpg` and all 15 post images in `public/posts/` — so the values are accurate rather than
 * guessed. `og:image:alt` is the accessible description consumers read out.
 */
const OG_IMAGE_WIDTH = '1200';
const OG_IMAGE_HEIGHT = '630';

export const imageTags = (noimage: boolean, imageUrl: string, title?: string) =>
    noimage
        ? [
              <meta property="og:image" content={imageUrl} key="og-image" />,
              <meta property="og:image:width" content={OG_IMAGE_WIDTH} key="og-image-width" />,
              <meta property="og:image:height" content={OG_IMAGE_HEIGHT} key="og-image-height" />,
              ...(title ? [<meta property="og:image:alt" content={title} key="og-image-alt" />] : []),
              <meta name="twitter:image" content={imageUrl} key="twitter-image" />,
          ]
        : [];

/**
 * @description og:locale alone tells a scraper which language THIS page is; the alternates
 * declare that the other translations exist. hreflang covers this for search engines, but
 * Open Graph consumers (LinkedIn, Facebook, Slack) do not read it.
 */
export const ogLocaleTags = (locale: string | undefined) => {
    const current = locale ?? 'en';
    return [
        <meta property="og:locale" content={OG_LOCALES[current] ?? 'en_US'} key="og-locale" />,
        ...Object.entries(OG_LOCALES)
            .filter(([lang]) => lang !== current)
            .map(([lang, ogLocale]) => (
                <meta property="og:locale:alternate" content={ogLocale} key={`og-locale-${lang}`} />
            )),
    ];
};

/** @description Translated URLs for the current blog post. */
export const alternateLinks = (meta: SeoMeta | undefined, domain: string | undefined, category?: string) =>
    (meta?.alternate ?? []).map(({ lang, url }) => (
        <link
            data-testid="blog-alternate"
            key={`alternate-${lang}`}
            rel="alternate"
            href={`${domain}${getLang(lang)}/blog/${category}/${url}`}
            hrefLang={lang}
            title={`Alternate url for language ${lang}`}
        />
    ));