Skip to content

Commit 3e99fe6

Browse files
authored
Merge pull request #16790 from ethereum/fix-hreflangs-untranslated-pages
Fix hreflang tags to only include translated locales
2 parents 3565072 + 1fcc157 commit 3e99fe6

File tree

13 files changed

+439
-368
lines changed

13 files changed

+439
-368
lines changed

.github/labeler.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
"config ⚙️":
1818
- i18n.config.json
1919
- next.config.js
20-
- next-sitemap.config.js
2120
- tsconfig.json
2221
- .nvmrc
2322
- .eslintignore

CLAUDE.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ pnpm events-import # Import community events
137137

138138
## SEO & Meta
139139

140-
- Sitemap generation with `next-sitemap`
140+
- Sitemap generation in `app/sitemap.ts`
141141
- Meta tags and Open Graph optimization
142142
- Structured data for search engines
143143
- Security headers (X-Frame-Options: DENY)
@@ -210,26 +210,28 @@ The site uses a GDPR-compliant, cookie-less A/B testing system integrated with M
210210
### Adding a New A/B Test
211211

212212
1. **Create experiment in Matomo dashboard**:
213+
213214
- Go to Experiments → Manage Experiments
214215
- Create new experiment with desired name (e.g., "HomepageHero")
215216
- Add variations with weights (original is implicit)
216217
- Set status to "running"
217218

218219
2. **Implement in component**:
220+
219221
```tsx
220222
import ABTestWrapper from "@/components/AB/TestWrapper"
221-
222-
<ABTestWrapper
223-
testKey="HomepageHero" // Must match Matomo experiment name exactly
223+
;<ABTestWrapper
224+
testKey="HomepageHero" // Must match Matomo experiment name exactly
224225
variants={[
225-
<OriginalComponent key="current-hero" />, // Index 0: Original
226-
<NewComponent key="redesigned-hero" /> // Index 1: Variation
226+
<OriginalComponent key="current-hero" />, // Index 0: Original
227+
<NewComponent key="redesigned-hero" />, // Index 1: Variation
227228
]}
228229
fallback={<OriginalComponent />}
229230
/>
230231
```
231232

232233
**Important**:
234+
233235
- Variants matched by **array index**, not names
234236
- Array order must match Matomo experiment order exactly
235237
- JSX `key` props become debug panel labels: `"redesigned-hero"``"Redesigned Hero"`
@@ -244,6 +246,7 @@ The site uses a GDPR-compliant, cookie-less A/B testing system integrated with M
244246
### Environment Variables
245247

246248
Required for Matomo integration:
249+
247250
- `NEXT_PUBLIC_MATOMO_URL` - Matomo instance URL
248251
- `NEXT_PUBLIC_MATOMO_SITE_ID` - Site ID in Matomo
249252
- `MATOMO_API_TOKEN` - API token with experiments access

app/sitemap.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { MetadataRoute } from "next"
2+
3+
import { getFullUrl } from "@/lib/utils/url"
4+
5+
import { DEFAULT_LOCALE } from "@/lib/constants"
6+
7+
import { getAllPagesWithTranslations } from "@/lib/i18n/translationRegistry"
8+
9+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
10+
const pages = await getAllPagesWithTranslations()
11+
12+
const entries: MetadataRoute.Sitemap = []
13+
14+
for (const { slug, translatedLocales } of pages) {
15+
const normalizedSlug = slug.startsWith("/") ? slug : `/${slug}`
16+
17+
for (const locale of translatedLocales) {
18+
const url = getFullUrl(locale, normalizedSlug)
19+
20+
// Drop the `/en` root entry to avoid duplicating `/`
21+
// This happens when slug is "/" and locale is default
22+
if (
23+
locale === DEFAULT_LOCALE &&
24+
(normalizedSlug === "/" || normalizedSlug === "")
25+
) {
26+
continue
27+
}
28+
29+
const isDefaultLocale = locale === DEFAULT_LOCALE
30+
31+
entries.push({
32+
url,
33+
changeFrequency: isDefaultLocale ? "weekly" : "monthly",
34+
priority: isDefaultLocale ? 0.7 : 0.5,
35+
lastModified: new Date(),
36+
})
37+
}
38+
}
39+
40+
return entries
41+
}

next-sitemap.config.js

Lines changed: 0 additions & 31 deletions
This file was deleted.

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"scripts": {
77
"dev": "next dev",
88
"build": "next build",
9-
"postbuild": "next-sitemap",
109
"start": "next start",
1110
"lint": "next lint",
1211
"lint:fix": "next lint --fix",
@@ -82,7 +81,6 @@
8281
"next": "^14.2.32",
8382
"next-intl": "^3.26.3",
8483
"next-mdx-remote": "^5.0.0",
85-
"next-sitemap": "^4.2.3",
8684
"next-themes": "^0.3.0",
8785
"prism-react-renderer": "1.1.0",
8886
"prismjs": "^1.30.0",

pnpm-lock.yaml

Lines changed: 0 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/i18n/pageTranslation.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { existsSync } from "fs"
2+
import { join } from "path"
3+
4+
import {
5+
DEFAULT_LOCALE,
6+
LOCALES_CODES,
7+
TRANSLATIONS_DIR,
8+
} from "@/lib/constants"
9+
10+
import { getPostSlugs } from "../utils/md"
11+
import { getStaticPagePaths } from "../utils/staticPages"
12+
import { getPrimaryNamespaceForPath } from "../utils/translations"
13+
import { addSlashes } from "../utils/url"
14+
15+
import { areNamespacesTranslated } from "./translationStatus"
16+
17+
async function isMdPageTranslated(
18+
locale: string,
19+
slug: string
20+
): Promise<boolean> {
21+
if (locale === DEFAULT_LOCALE) {
22+
return true
23+
}
24+
25+
const translationPath = join(TRANSLATIONS_DIR, locale, slug, "index.md")
26+
return existsSync(translationPath)
27+
}
28+
29+
async function isIntlPageTranslated(
30+
locale: string,
31+
path: string
32+
): Promise<boolean> {
33+
const primaryNamespace = getPrimaryNamespaceForPath(path)
34+
35+
if (!primaryNamespace) {
36+
return locale === DEFAULT_LOCALE
37+
}
38+
39+
return areNamespacesTranslated(locale, [primaryNamespace])
40+
}
41+
42+
function getPageType(slug: string): "md" | "intl" {
43+
const normalizedSlug = addSlashes(slug)
44+
const primaryNamespace = getPrimaryNamespaceForPath(normalizedSlug)
45+
return primaryNamespace ? "intl" : "md"
46+
}
47+
48+
/**
49+
* Get all translated locales for a given page slug.
50+
* Works for both MD pages and intl pages.
51+
*
52+
* @param slug - Page slug/path (e.g., "about" for MD or "/wallets/" for intl)
53+
* @returns Promise resolving to array of locale codes that have translations
54+
* @example
55+
* await getTranslatedLocales("about") // => ["en", "es", "fr"]
56+
* await getTranslatedLocales("/wallets/") // => ["en", "es"]
57+
*/
58+
export async function getTranslatedLocales(slug: string): Promise<string[]> {
59+
const pageType = getPageType(slug)
60+
const translatedLocales: string[] = []
61+
62+
for (const locale of LOCALES_CODES) {
63+
let isTranslated: boolean
64+
65+
if (pageType === "md") {
66+
const mdSlug = slug.replace(/^\/+|\/+$/g, "")
67+
isTranslated = await isMdPageTranslated(locale, mdSlug)
68+
} else {
69+
const normalizedPath = addSlashes(slug)
70+
isTranslated = await isIntlPageTranslated(locale, normalizedPath)
71+
}
72+
73+
if (isTranslated) {
74+
translatedLocales.push(locale)
75+
}
76+
}
77+
78+
return translatedLocales
79+
}
80+
81+
type PageWithTranslations = {
82+
slug: string
83+
translatedLocales: string[]
84+
type: "md" | "intl"
85+
}
86+
87+
export async function getAllPagesWithTranslations(): Promise<
88+
PageWithTranslations[]
89+
> {
90+
const pages: PageWithTranslations[] = []
91+
92+
const mdSlugs = await getPostSlugs("/")
93+
const intlPaths = getStaticPagePaths()
94+
95+
for (const slug of mdSlugs) {
96+
const translatedLocales = await getTranslatedLocales(slug)
97+
pages.push({
98+
slug,
99+
translatedLocales,
100+
type: "md",
101+
})
102+
}
103+
104+
for (const path of intlPaths) {
105+
const translatedLocales = await getTranslatedLocales(path)
106+
pages.push({
107+
slug: path,
108+
translatedLocales,
109+
type: "intl",
110+
})
111+
}
112+
113+
return pages
114+
}

src/lib/i18n/translationStatus.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { DEFAULT_LOCALE } from "@/lib/constants"
1+
import { existsSync } from "fs"
2+
import { join } from "path"
23

3-
import { loadMessages } from "@/lib/i18n/loadMessages"
4+
import { DEFAULT_LOCALE } from "@/lib/constants"
45

56
/**
67
* Determine whether all required i18n namespaces exist for a given locale.
@@ -12,8 +13,9 @@ export async function areNamespacesTranslated(
1213
): Promise<boolean> {
1314
if (locale === DEFAULT_LOCALE) return true
1415

15-
const localeMessages = await loadMessages(locale)
16+
const intlPath = join(process.cwd(), "src/intl")
17+
1618
return namespaces.every((ns) =>
17-
Object.prototype.hasOwnProperty.call(localeMessages, ns)
19+
existsSync(join(intlPath, locale, `${ns}.json`))
1820
)
1921
}

0 commit comments

Comments
 (0)