Skip to content

Commit 1e1d31c

Browse files
swisskymarcusbellamyshaw-cell
authored andcommitted
feat: render hreflang alternates in page head for translated content (emdash-cms#1907)
* feat: render hreflang alternates in page head for translated content Multilingual SEO previously stopped at the sitemap: translation siblings were cross-linked with xhtml alternates there, but rendered pages carried no hreflang annotations in their <head>. Add a getHreflangAlternates() helper that resolves the alternate set for a content entry with the same semantics as the sitemap route: one alternate per published translation sibling (including a self-referencing entry, per Google's recommendation), x-default on the default-locale variant (falling back to the first routable variant), unroutable locales dropped, drafts excluded, and an empty result when i18n is disabled (no queries run). URLs are built from the collection's urlPattern and localized through the Astro i18n routing config, so head and sitemap always agree. EmDashHead emits the links automatically when the page context carries a content reference, as base-layer contributions so plugins can override individual hreflang entries. The helper is also exported from "emdash" and "emdash/seo" for hand-rolled heads, with a *WithDb variant for callers that already hold a database handle. Result resolution is request-cached. Fixes emdash-cms#1690 * fix: exclude noindex variants from hreflang and validate site URL scheme Match the sitemap's _emdash_seo filter: variants flagged noindex are excluded from alternate sets, and a noindex entry emits no hreflang set at all, so head and sitemap can't disagree on which variants are discoverable. Also reject relative site URLs — hreflang requires fully-qualified URLs, and EmDashHead's isSafeHref would silently drop the malformed links.
1 parent a2dc01c commit 1e1d31c

7 files changed

Lines changed: 496 additions & 3 deletions

File tree

.changeset/hreflang-alternates.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Adds hreflang alternate links to the page head for translated content. `<EmDashHead>` now emits a `<link rel="alternate" hreflang="...">` per published translation (plus `x-default`) automatically, and a new `getHreflangAlternates()` helper resolves the same set for hand-rolled heads.

docs/src/content/docs/guides/internationalization.mdx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,43 @@ Translation siblings are cross-linked with `xhtml:link` alternates so search eng
378378

379379
Siblings are grouped by `translation_group`, so a row added later (a new locale variant of an existing post) automatically appears as an alternate on every other variant. Sites with a single locale produce a plain sitemap with no `xhtml` namespace.
380380

381+
## hreflang Alternates in the Page Head
382+
383+
The same alternates belong in the `<head>` of every content page. If your layout uses [`<EmDashHead>`](/plugins/creating-plugins/hooks/#pagemetadata), this is automatic: when i18n is enabled and the page context includes `content`, it emits one `<link rel="alternate">` per published translation sibling — including a self-referencing link, as Google recommends — plus `x-default`:
384+
385+
```html
386+
<link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
387+
<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
388+
<link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
389+
```
390+
391+
For hand-rolled heads, resolve the alternates with `getHreflangAlternates`:
392+
393+
```astro title="src/pages/blog/[slug].astro"
394+
---
395+
import { getEmDashEntry, getHreflangAlternates } from "emdash";
396+
397+
const { entry } = await getEmDashEntry("posts", Astro.params.slug);
398+
const alternates = await getHreflangAlternates("posts", entry.data.id, {
399+
siteUrl: Astro.url.origin,
400+
});
401+
---
402+
403+
<head>
404+
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
405+
</head>
406+
```
407+
408+
Behaviour matches the sitemap exactly:
409+
410+
- **`x-default`** points at the default-locale variant. When the default locale has no published translation, it falls back to the first routable variant, so the set never lacks an `x-default`.
411+
- **Unpublished siblings are excluded** — draft translations never leak into alternates.
412+
- **Unroutable locales are dropped.** A row whose locale isn't in your configured `i18n.locales` can't be served, and linking search engines to a 404 is worse than no link.
413+
- **Untranslated entries** still get a self-referencing alternate and `x-default` when i18n is enabled, mirroring the sitemap.
414+
- With **i18n disabled**, the result is empty and no queries run.
415+
416+
URLs are built from the collection's `urlPattern` and localized through your Astro i18n routing config (`prefixDefaultLocale`, custom locale `path` mappings), so head and sitemap always agree.
417+
381418
## Importing Multilingual Content
382419

383420
Import WordPress content through the admin migration toolsee [Content Import](/migration/content-import/) and [Migrate from WordPress](/migration/from-wordpress/). A WXR export does not carry the locale and translation-group structure that WPML or Polylang add, so imported content lands in your default locale.

packages/core/src/components/EmDashHead.astro

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { renderSiteIdentity } from "../page/site-identity.js";
3333
import { getPageRuntime } from "../page/index.js";
3434
import { getSiteSettings } from "../settings/index.js";
3535
import { absolutizeMediaUrl } from "../page/absolute-url.js";
36+
import { getHreflangAlternates } from "../seo/hreflang.js";
37+
import { isI18nEnabled } from "../i18n/config.js";
3638
3739
interface Props {
3840
page: PublicPageContext;
@@ -83,8 +85,32 @@ if (runtime) {
8385
defaultOgImage,
8486
);
8587
88+
// hreflang alternates for translated content entries (#1690). Only
89+
// queried when i18n is enabled and this page renders a content entry;
90+
// non-i18n sites skip this entirely. Contributions sit with the base
91+
// layer, so plugins can override individual hreflang entries (dedup
92+
// key is the hreflang value).
93+
let hreflangContributions: PageMetadataContribution[] = [];
94+
if (page.content && isI18nEnabled()) {
95+
const siteUrl = page.siteUrl || siteSettings.url || new URL(page.url).origin;
96+
const alternates = await getHreflangAlternates(page.content.collection, page.content.id, {
97+
siteUrl,
98+
});
99+
hreflangContributions = alternates.map((a) => ({
100+
kind: "link",
101+
rel: "alternate",
102+
href: a.href,
103+
hreflang: a.hreflang,
104+
}));
105+
}
106+
86107
const siteContributions = generateSiteSeoContributions(siteSettings.seo);
87-
const allContributions = [...pluginContributions, ...siteContributions, ...baseContributions];
108+
const allContributions = [
109+
...pluginContributions,
110+
...siteContributions,
111+
...baseContributions,
112+
...hreflangContributions,
113+
];
88114
const resolved = resolvePageMetadata(allContributions);
89115
jsonLdScripts = resolved.jsonld;
90116
metadataHtml = renderPageMetadata(resolved, { includeJsonLd: false });

packages/core/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,8 @@ export type {
404404
} from "./settings/types.js";
405405

406406
// SEO
407-
export { getSeoMeta, getContentSeo } from "./seo/index.js";
408-
export type { SeoMeta, SeoMetaOptions } from "./seo/index.js";
407+
export { getSeoMeta, getContentSeo, getHreflangAlternates } from "./seo/index.js";
408+
export type { SeoMeta, SeoMetaOptions, HreflangAlternate, HreflangOptions } from "./seo/index.js";
409409

410410
// Public page contribution types
411411
export type {

packages/core/src/seo/hreflang.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* hreflang alternate resolution for translated content (#1690).
3+
*
4+
* Mirrors the sitemap route's semantics so the render path and the
5+
* sitemap always agree:
6+
*
7+
* - Alternates are emitted per published locale variant of a
8+
* translation group, including a self-referencing entry (Google
9+
* recommends the page annotate itself).
10+
* - `x-default` points at the default-locale variant, falling back to
11+
* the first routable variant when the default locale has no
12+
* translation.
13+
* - Variants whose locale isn't in the configured `i18n.locales` list
14+
* are dropped: linking to a route the site can't serve is worse than
15+
* no link at all.
16+
* - Variants flagged `noindex` in the SEO panel are excluded — the
17+
* sitemap doesn't list them, and pointing search engines at a page
18+
* that asks not to be indexed is a contradictory signal. When the
19+
* entry itself is noindex the result is empty: a page opting out of
20+
* indexing shouldn't announce an hreflang set at all.
21+
* - When i18n is disabled (or no absolute site URL can be resolved,
22+
* which hreflang requires) the result is empty and no queries run.
23+
*/
24+
25+
import type { Kysely } from "kysely";
26+
27+
import { ContentRepository } from "../database/repositories/content.js";
28+
import type { Database } from "../database/types.js";
29+
import { getI18nConfig, isI18nEnabled } from "../i18n/config.js";
30+
import { interpolateUrlPattern, localizePath } from "../i18n/resolve.js";
31+
import { requestCached } from "../request-cache.js";
32+
import { getCollectionInfoWithDb } from "../schema/query.js";
33+
import { getSiteSettingsWithDb } from "../settings/index.js";
34+
35+
const TRAILING_SLASH_RE = /\/$/;
36+
const ABSOLUTE_URL_RE = /^https?:\/\//i;
37+
38+
/**
39+
* IDs of variants flagged `noindex` in the SEO panel. Entries without
40+
* an `_emdash_seo` row are indexable by default (same as the sitemap).
41+
* The id list is bounded by the number of configured locales, so no
42+
* chunking is needed.
43+
*/
44+
async function findNoindexIds(
45+
db: Kysely<Database>,
46+
collection: string,
47+
ids: string[],
48+
): Promise<Set<string>> {
49+
if (ids.length === 0) return new Set();
50+
const rows = await db
51+
.selectFrom("_emdash_seo")
52+
.select("content_id")
53+
.where("collection", "=", collection)
54+
.where("content_id", "in", ids)
55+
.where("seo_no_index", "=", 1)
56+
.execute();
57+
return new Set(rows.map((r) => r.content_id));
58+
}
59+
60+
/** A single alternate link, ready to render as `<link rel="alternate">`. */
61+
export interface HreflangAlternate {
62+
/** Locale code, or `"x-default"` for the default variant */
63+
hreflang: string;
64+
/** Absolute URL of the variant */
65+
href: string;
66+
}
67+
68+
export interface HreflangOptions {
69+
/**
70+
* Absolute site origin (e.g. `https://example.com`) used to build
71+
* the alternate URLs. Falls back to the site settings URL; when
72+
* neither is available the result is empty, since hreflang
73+
* requires fully-qualified URLs.
74+
*/
75+
siteUrl?: string;
76+
}
77+
78+
/**
79+
* Resolve hreflang alternates for a content entry.
80+
*
81+
* @example
82+
* ```astro
83+
* ---
84+
* import { getHreflangAlternates } from "emdash";
85+
*
86+
* const alternates = await getHreflangAlternates("posts", entry.data.id, {
87+
* siteUrl: Astro.url.origin,
88+
* });
89+
* ---
90+
* <head>
91+
* {alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
92+
* </head>
93+
* ```
94+
*/
95+
export async function getHreflangAlternates(
96+
collection: string,
97+
entryId: string,
98+
options: HreflangOptions = {},
99+
): Promise<HreflangAlternate[]> {
100+
if (!isI18nEnabled()) return [];
101+
const key = `hreflang:${collection}:${entryId}:${options.siteUrl ?? ""}`;
102+
return requestCached(key, async () => {
103+
const { getDb } = await import("../loader.js");
104+
const db = await getDb();
105+
return getHreflangAlternatesWithDb(db, collection, entryId, options);
106+
});
107+
}
108+
109+
/**
110+
* Resolve hreflang alternates with an explicit db handle.
111+
*
112+
* @internal Use `getHreflangAlternates()` in templates. This variant is
113+
* for routes/components that already have a database handle.
114+
*/
115+
export async function getHreflangAlternatesWithDb(
116+
db: Kysely<Database>,
117+
collection: string,
118+
entryId: string,
119+
options: HreflangOptions = {},
120+
): Promise<HreflangAlternate[]> {
121+
if (!isI18nEnabled()) return [];
122+
123+
let siteUrl = options.siteUrl;
124+
if (!siteUrl) {
125+
const settings = await getSiteSettingsWithDb(db);
126+
siteUrl = settings.url;
127+
}
128+
// hreflang requires fully-qualified URLs; a relative site URL would
129+
// produce invalid output (and EmDashHead's isSafeHref would drop it).
130+
if (!siteUrl || !ABSOLUTE_URL_RE.test(siteUrl)) return [];
131+
siteUrl = siteUrl.replace(TRAILING_SLASH_RE, "");
132+
133+
const repo = new ContentRepository(db);
134+
const item = await repo.findByIdOrSlug(collection, entryId);
135+
if (!item) return [];
136+
137+
// Legacy rows imported before i18n may have no translation_group;
138+
// treat them as a single-variant group.
139+
const group = item.translationGroup || item.id;
140+
let variants = await repo.findTranslations(collection, group);
141+
if (variants.length === 0) variants = [item];
142+
143+
let published = variants.filter((v) => v.status === "published");
144+
if (published.length === 0) return [];
145+
146+
// Exclude noindex variants, matching the sitemap's `_emdash_seo`
147+
// filter so head and sitemap agree on which variants are
148+
// discoverable. When the requested entry itself is noindex, emit
149+
// nothing: a page opting out of indexing shouldn't announce an
150+
// hreflang set.
151+
const noindexIds = await findNoindexIds(
152+
db,
153+
collection,
154+
published.map((v) => v.id),
155+
);
156+
if (noindexIds.has(item.id)) return [];
157+
published = published.filter((v) => !noindexIds.has(v.id));
158+
159+
const info = await getCollectionInfoWithDb(db, collection);
160+
const urlPattern = info?.urlPattern ?? null;
161+
162+
const resolved: Array<{ locale: string; href: string }> = [];
163+
for (const variant of published) {
164+
const locale = variant.locale || "en";
165+
const path = interpolateUrlPattern({
166+
pattern: urlPattern,
167+
collection,
168+
slug: variant.slug || variant.id,
169+
id: variant.id,
170+
});
171+
const localized = await localizePath(path, locale);
172+
if (localized === null) continue;
173+
resolved.push({ locale, href: `${siteUrl}${localized}` });
174+
}
175+
if (resolved.length === 0) return [];
176+
177+
resolved.sort((a, b) => a.locale.localeCompare(b.locale));
178+
const alternates: HreflangAlternate[] = resolved.map((r) => ({
179+
hreflang: r.locale,
180+
href: r.href,
181+
}));
182+
183+
// x-default: default-locale variant, else the first routable one.
184+
// Same fallback the sitemap route uses.
185+
const defaultLocale = getI18nConfig()?.defaultLocale;
186+
const xDefault = resolved.find((r) => r.locale === defaultLocale) ?? resolved[0];
187+
if (xDefault) {
188+
alternates.push({ hreflang: "x-default", href: xDefault.href });
189+
}
190+
191+
return alternates;
192+
}

packages/core/src/seo/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
import type { ContentSeo } from "../database/repositories/types.js";
3333
import { buildSeoImageUrl } from "./media-url.js";
3434

35+
export { getHreflangAlternates, getHreflangAlternatesWithDb } from "./hreflang.js";
36+
export type { HreflangAlternate, HreflangOptions } from "./hreflang.js";
37+
3538
const TRAILING_SLASH_RE = /\/$/;
3639
const ABSOLUTE_URL_RE = /^https?:\/\//i;
3740

0 commit comments

Comments
 (0)