feat(seo): validate translation relationships and generate hreflang

This commit is contained in:
DeCentN2Madness committed 2026-10-08 13:53:31 -07:00
1 parent f28bf9ad06
commit 32112edbde
8 files changed
+686 -23

No files matched your search

+8 -13
View File
@@ -1,8 +1,7 @@
---
import { getPublicationManifest } from '../utils/publication';
const pathname = Astro.url.pathname.replace(/\/$/, '') || '/';
const bare = pathname.replace(/^\/(ar|es)(?=\/|$)/, '') || '/';
const available = new Set((await getPublicationManifest()).filter((page) => page.eligible).map((page) => page.route));
import { getPublication } from '../utils/publication';
const publication = await getPublication(Astro.url.pathname);
const pathname = publication.route;
const langMeta: Record<string, { short: string; full: string }> = {
en: { short: 'EN', full: 'English' },
@@ -10,23 +9,19 @@ const langMeta: Record<string, { short: string; full: string }> = {
ar: { short: 'ع', full: 'عربي' },
};
const choices = [
{ code: 'en', href: bare },
{ code: 'es', href: `/es${bare === '/' ? '' : bare}` },
{ code: 'ar', href: `/ar${bare === '/' ? '' : bare}` },
].filter(({ href }) => available.has(href));
const choices = publication.translations.map(({ lang, route }) => ({ code: lang, href: route }));
const activeCode = pathname.startsWith('/ar') ? 'ar' : pathname.startsWith('/es') ? 'es' : 'en';
const activeCode = publication.entry.data.lang;
const activeShort = langMeta[activeCode]?.short ?? 'EN';
---
<nav class="lang-switcher" aria-label="Language">
<div class="lang-switcher__trigger" tabindex="0" aria-haspopup="listbox" aria-label={`Language: ${activeCode.toUpperCase()}`}>
<div class="lang-switcher__trigger" tabindex={choices.length ? 0 : undefined} aria-haspopup={choices.length ? 'listbox' : undefined} aria-label={`Language: ${activeCode.toUpperCase()}`}>
<span class="lang-switcher__label">{activeShort}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z" />
</svg>
</div>
<ul class="lang-switcher__menu" role="listbox">
{choices.length > 0 && <ul class="lang-switcher__menu" role="listbox">
{choices.map(({ code, href }) => (
<li>
<a href={href} lang={code} aria-current={pathname === href || (href === '/' && pathname === '/') ? 'page' : undefined}>
@@ -34,7 +29,7 @@ const activeShort = langMeta[activeCode]?.short ?? 'EN';
</a>
</li>
))}
</ul>
</ul>}
</nav>
<style>
+3 -2
View File
@@ -1,10 +1,10 @@
---
import { allowIndexing } from '../utils/publication';
import { robotsFor, type Publication } from '../utils/publication-policy';
import { hreflangLinksFor, robotsFor, type TranslatedPublication } from '../utils/publication-policy';
interface Props {
title: string;
description?: string;
publication: Publication;
publication: TranslatedPublication;
image?: string;
lang?: string;
type?: 'website' | 'article';
@@ -24,6 +24,7 @@ const absoluteImage = image ? new URL(image, Astro.site ?? Astro.url).href : und
<meta name="description" content={description} />
<meta name="robots" content={robotsFor(publication, allowIndexing)} />
<link rel="canonical" href={canonical} />
{hreflangLinksFor(publication, allowIndexing).map(({ lang, href }) => <link rel="alternate" hreflang={lang} href={href} />)}
<meta property="og:type" content={type} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
+5 -2
View File
@@ -4,6 +4,9 @@ import { z } from 'astro/zod';
import { homePageSchema, homeSectionsSchema } from './types/home-sections';
const language = z.enum(['en', 'ar', 'es']);
const translationKey = z.string().min(1).refine((value) => value === value.trim(), {
message: 'translationKey must not have surrounding whitespace'
}).optional();
const shared = {
title: z.string(),
description: z.string().default(''),
@@ -12,7 +15,7 @@ const shared = {
featuredImage: z.string().optional(),
alt: z.string().optional(),
lang: language,
translationKey: z.string().optional(),
translationKey,
draft: z.boolean().default(false),
noindex: z.boolean().default(false),
home: homePageSchema.optional(),
@@ -56,7 +59,7 @@ const authors = defineCollection({
description: z.string().optional(),
avatar: z.string().optional(),
lang: language,
translationKey: z.string().optional()
translationKey
})
});
+82 -2
View File
@@ -7,6 +7,7 @@ export interface PublicationEntry {
draft?: boolean;
noindex?: boolean;
canonical?: string;
translationKey?: string;
};
}
@@ -20,6 +21,18 @@ export interface Publication {
sitemapEligible: boolean;
}
export interface TranslationEquivalent {
lang: string;
route: string;
url: string;
}
export interface TranslatedPublication extends Publication {
translations: TranslationEquivalent[];
}
type TranslationMember = TranslatedPublication & { entry: PublicationEntry };
/** Public document paths have no query/fragment and no trailing slash except /. */
export function normalizeRoute(value: string): string {
if (!value.startsWith('/') || value.startsWith('//') || /[?#\\\s]/u.test(value)) {
@@ -82,7 +95,7 @@ export function publicationFor(entry: PublicationEntry, site: string): Publicati
export function createPublicationManifest<T extends PublicationEntry>(entries: T[], site: string) {
const routes = new Map<string, string>();
const canonicals = new Map<string, string>();
return entries.map((entry) => {
const manifest = entries.map((entry) => {
const publication = publicationFor(entry, site);
const source = `${entry.collection}/${entry.id}`;
const previous = routes.get(publication.route);
@@ -93,8 +106,75 @@ export function createPublicationManifest<T extends PublicationEntry>(entries: T
if (duplicate) throw new Error(`Duplicate canonical: ${publication.canonical} (${duplicate}, ${source})`);
canonicals.set(publication.canonical, source);
}
return { ...publication, entry };
return { ...publication, entry, translations: [] as TranslationEquivalent[] };
}).sort((a, b) => a.route.localeCompare(b.route, 'en'));
// Keys declare equivalence within a collection, never across URL suffixes.
// Validate even unpublished members so drafts cannot conceal ambiguity.
const groups = new Map<string, Map<string, typeof manifest[number]>>();
for (const page of manifest) {
const { translationKey, lang } = page.entry.data;
if (translationKey === undefined) continue;
if (typeof translationKey !== 'string' || !translationKey || translationKey !== translationKey.trim()) {
throw new Error(`Invalid translationKey: ${page.entry.collection}/${page.entry.id}`);
}
const key = `${page.entry.collection}:${translationKey}`;
const group = groups.get(key) ?? new Map();
const duplicate = group.get(lang);
if (duplicate) throw new Error(`Ambiguous translation: ${key} (${lang}: ${duplicate.entry.id}, ${page.entry.id})`);
group.set(lang, page);
groups.set(key, group);
}
for (const group of groups.values()) {
// Reuse the production publication gate: generated, indexable, self-canonical.
const members = [...group.values()].filter((page) => page.sitemapEligible);
if (members.length < 2) continue;
const translations = members.map(({ entry, route, url }) => ({ lang: entry.data.lang, route, url }))
.sort((a, b) => ['en', 'es', 'ar'].indexOf(a.lang) - ['en', 'es', 'ar'].indexOf(b.lang));
for (const page of members) page.translations = translations;
}
validateTranslationGraph(manifest);
return manifest;
}
/** Fail on dangling, ineligible, cross-family or nonreciprocal references. */
export function validateTranslationGraph(manifest: TranslationMember[]): void {
const routes = new Map(manifest.map((page) => [page.route, page]));
for (const page of manifest) {
if (!page.translations.length) continue;
if (!page.sitemapEligible || !page.entry.data.translationKey) {
throw new Error(`Ineligible translation source: ${page.route}`);
}
const languages = new Set<string>();
for (const translation of page.translations) {
const target = routes.get(translation.route);
if (!target) throw new Error(`Missing translation target: ${page.route} -> ${translation.route}`);
if (!target.sitemapEligible) throw new Error(`Ineligible translation target: ${translation.route}`);
if (target.entry.collection !== page.entry.collection || target.entry.data.translationKey !== page.entry.data.translationKey) {
throw new Error(`Translation family/key mismatch: ${page.route} -> ${translation.route}`);
}
if (translation.lang !== target.entry.data.lang || translation.url !== target.url || languages.has(translation.lang)) {
throw new Error(`Invalid or duplicate translation reference: ${page.route} -> ${translation.route}`);
}
languages.add(translation.lang);
if (JSON.stringify(target.translations) !== JSON.stringify(page.translations)) {
throw new Error(`Nonreciprocal translations: ${page.route} -> ${translation.route}`);
}
}
if (languages.size < 2 || !page.translations.some((translation) => translation.route === page.route)) {
throw new Error(`Translation set must include itself and an equivalent: ${page.route}`);
}
}
}
export function hreflangLinksFor(page: TranslatedPublication, allowIndexing: boolean) {
// Staging keeps language navigation but emits no indexing-oriented alternates.
if (!allowIndexing || !page.sitemapEligible || page.translations.length < 2) return [];
const links = page.translations.map(({ lang, url }) => ({ lang, href: url }));
// English is the project's default language; never substitute another route.
const fallback = page.translations.find(({ lang }) => lang === 'en');
if (fallback) links.push({ lang: 'x-default', href: fallback.url });
return links;
}
export function robotsFor(publication: Publication, allowIndexing: boolean): string {