feat(seo): validate translation relationships and generate hreflang
This commit is contained in:
8 files changed
+686
-23
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user