Skip to content

Commit 97a94c8

Browse files
committed
fix hreflang tags to only include translated locales
1 parent 49f1da8 commit 97a94c8

File tree

3 files changed

+92
-10
lines changed

3 files changed

+92
-10
lines changed

src/lib/md/metadata.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getMetadata } from "../utils/metadata"
2+
import { getTranslatedLocales } from "../utils/translatedLocales"
23

34
import { compile } from "./compile"
45
import { importMd } from "./import"
@@ -28,13 +29,16 @@ export const getMdMetadata = async ({
2829
const image = frontmatter.image
2930
const author = frontmatter.author
3031

32+
const translatedLocales = await getTranslatedLocales(slug, "md")
33+
3134
const metadata = await getMetadata({
3235
locale,
3336
slug: slugArray,
3437
title: pageTitle,
3538
description,
3639
image,
3740
author,
41+
translatedLocales,
3842
})
3943
return metadata
4044
}

src/lib/utils/metadata.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server"
33

44
import { DEFAULT_OG_IMAGE, SITE_URL } from "@/lib/constants"
55

6+
import { getTranslatedLocales } from "./translatedLocales"
67
import { isLocaleValidISO639_1 } from "./translations"
78
import { getFullUrl } from "./url"
89

@@ -43,6 +44,7 @@ export const getMetadata = async ({
4344
image,
4445
author,
4546
noIndex = false,
47+
translatedLocales,
4648
}: {
4749
locale: string
4850
slug: string[]
@@ -52,36 +54,56 @@ export const getMetadata = async ({
5254
image?: string
5355
author?: string
5456
noIndex?: boolean
57+
translatedLocales?: string[]
5558
}): Promise<Metadata> => {
5659
const slugString = slug.join("/")
5760
const t = await getTranslations({ locale, namespace: "common" })
5861

5962
const description = descriptionProp || t("site-description")
6063
const siteTitle = t("site-title")
6164

62-
// Set canonical URL w/ language path to avoid duplicate content
63-
const url = getFullUrl(locale, slugString)
65+
// Auto-detect translated locales if not provided
66+
const finalTranslatedLocales =
67+
translatedLocales ?? (await getTranslatedLocales(slugString, "intl"))
68+
69+
const isCurrentPageTranslated = finalTranslatedLocales.includes(locale)
70+
71+
// Set canonical URL
72+
// If current locale is NOT translated, set canonical to English version
73+
const canonicalLocale = isCurrentPageTranslated
74+
? locale
75+
: routing.defaultLocale
76+
const url = getFullUrl(canonicalLocale, slugString)
6477

6578
// Set x-default URL for hreflang
6679
const xDefault = getFullUrl(routing.defaultLocale, slugString)
6780

6881
/* Set fallback ogImage based on path */
6982
const ogImage = image || getOgImage(slug)
7083

84+
// Only include hreflang alternates if the current page is translated
85+
// Untranslated pages should not have hreflang tags
86+
const localesForHreflang = isCurrentPageTranslated
87+
? routing.locales.filter(
88+
(loc) =>
89+
finalTranslatedLocales.includes(loc) && isLocaleValidISO639_1(loc)
90+
)
91+
: []
92+
7193
const base: Metadata = {
7294
title,
7395
description,
7496
metadataBase: new URL(SITE_URL),
7597
alternates: {
7698
canonical: url,
77-
languages: {
78-
"x-default": xDefault,
79-
...Object.fromEntries(
80-
routing.locales
81-
.filter(isLocaleValidISO639_1)
82-
.map((locale) => [locale, getFullUrl(locale, slugString)])
83-
),
84-
},
99+
...(localesForHreflang.length > 0 && {
100+
languages: {
101+
"x-default": xDefault,
102+
...Object.fromEntries(
103+
localesForHreflang.map((loc) => [loc, getFullUrl(loc, slugString)])
104+
),
105+
},
106+
}),
85107
},
86108
openGraph: {
87109
title,
@@ -117,5 +139,9 @@ export const getMetadata = async ({
117139
return { ...base, robots: { index: false } }
118140
}
119141

142+
if (!isCurrentPageTranslated) {
143+
return { ...base, robots: { index: true, follow: true } }
144+
}
145+
120146
return base
121147
}

src/lib/utils/translatedLocales.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { existsSync } from "fs"
2+
import { join } from "path"
3+
4+
import { CONTENT_DIR, DEFAULT_LOCALE } from "@/lib/constants"
5+
6+
import { routing } from "@/i18n/routing"
7+
import { isPageTranslated } from "@/lib/i18n/pageTranslation"
8+
9+
/**
10+
* Get all locales that have a page translated.
11+
*
12+
* @param slug - Page slug/path (e.g., "developers/tutorials/intro" or "/wallets/")
13+
* @param pageType - Type of page: "md" for markdown pages, "intl" for next-intl pages
14+
* @returns Promise resolving to array of locale codes that have this page translated
15+
*/
16+
export async function getTranslatedLocales(
17+
slug: string,
18+
pageType: "md" | "intl"
19+
): Promise<string[]> {
20+
const normalizedSlug =
21+
pageType === "md" ? slug.replace(/^\/+|\/+$/g, "") : slug
22+
23+
const checks = routing.locales.map(async (locale) => {
24+
if (locale === DEFAULT_LOCALE) {
25+
return { locale, isTranslated: true }
26+
}
27+
28+
let isTranslated = false
29+
30+
if (pageType === "md") {
31+
const translationPath = join(
32+
process.cwd(),
33+
CONTENT_DIR,
34+
"translations",
35+
locale,
36+
normalizedSlug,
37+
"index.md"
38+
)
39+
isTranslated = existsSync(translationPath)
40+
} else {
41+
isTranslated = await isPageTranslated(locale, normalizedSlug)
42+
}
43+
44+
return { locale, isTranslated }
45+
})
46+
47+
const results = await Promise.all(checks)
48+
49+
return results
50+
.filter(({ isTranslated }) => isTranslated)
51+
.map(({ locale }) => locale)
52+
}

0 commit comments

Comments
 (0)