diff --git a/.github/labeler.yml b/.github/labeler.yml
index e8baaa81854..2132c342090 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -17,7 +17,6 @@
"config ⚙️":
- i18n.config.json
- next.config.js
- - next-sitemap.config.js
- tsconfig.json
- .nvmrc
- .eslintignore
diff --git a/CLAUDE.md b/CLAUDE.md
index 4e3402901a9..95bd8a4be9d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -137,7 +137,7 @@ pnpm events-import # Import community events
## SEO & Meta
-- Sitemap generation with `next-sitemap`
+- Sitemap generation in `app/sitemap.ts`
- Meta tags and Open Graph optimization
- Structured data for search engines
- 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
### Adding a New A/B Test
1. **Create experiment in Matomo dashboard**:
+
- Go to Experiments → Manage Experiments
- Create new experiment with desired name (e.g., "HomepageHero")
- Add variations with weights (original is implicit)
- Set status to "running"
2. **Implement in component**:
+
```tsx
import ABTestWrapper from "@/components/AB/TestWrapper"
-
- , // Index 0: Original
- // Index 1: Variation
+ , // Index 0: Original
+ , // Index 1: Variation
]}
fallback={}
/>
```
**Important**:
+
- Variants matched by **array index**, not names
- Array order must match Matomo experiment order exactly
- 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
### Environment Variables
Required for Matomo integration:
+
- `NEXT_PUBLIC_MATOMO_URL` - Matomo instance URL
- `NEXT_PUBLIC_MATOMO_SITE_ID` - Site ID in Matomo
- `MATOMO_API_TOKEN` - API token with experiments access
diff --git a/app/sitemap.ts b/app/sitemap.ts
new file mode 100644
index 00000000000..8e24caf7124
--- /dev/null
+++ b/app/sitemap.ts
@@ -0,0 +1,41 @@
+import type { MetadataRoute } from "next"
+
+import { getFullUrl } from "@/lib/utils/url"
+
+import { DEFAULT_LOCALE } from "@/lib/constants"
+
+import { getAllPagesWithTranslations } from "@/lib/i18n/translationRegistry"
+
+export default async function sitemap(): Promise {
+ const pages = await getAllPagesWithTranslations()
+
+ const entries: MetadataRoute.Sitemap = []
+
+ for (const { slug, translatedLocales } of pages) {
+ const normalizedSlug = slug.startsWith("/") ? slug : `/${slug}`
+
+ for (const locale of translatedLocales) {
+ const url = getFullUrl(locale, normalizedSlug)
+
+ // Drop the `/en` root entry to avoid duplicating `/`
+ // This happens when slug is "/" and locale is default
+ if (
+ locale === DEFAULT_LOCALE &&
+ (normalizedSlug === "/" || normalizedSlug === "")
+ ) {
+ continue
+ }
+
+ const isDefaultLocale = locale === DEFAULT_LOCALE
+
+ entries.push({
+ url,
+ changeFrequency: isDefaultLocale ? "weekly" : "monthly",
+ priority: isDefaultLocale ? 0.7 : 0.5,
+ lastModified: new Date(),
+ })
+ }
+ }
+
+ return entries
+}
diff --git a/next-sitemap.config.js b/next-sitemap.config.js
deleted file mode 100644
index f669e865ff7..00000000000
--- a/next-sitemap.config.js
+++ /dev/null
@@ -1,31 +0,0 @@
-const i18nConfig = require("./i18n.config.json")
-const locales = i18nConfig.map(({ code }) => code)
-
-const defaultLocale = "en"
-
-/** @type {import('next-sitemap').IConfig} */
-module.exports = {
- siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://ethereum.org",
- transform: async (_, path) => {
- const rootPath = path.split("/")[1]
- if (path.endsWith("/404")) return null
- const isDefaultLocale =
- !locales.includes(rootPath) || rootPath === defaultLocale
-
- // Strip default-locale (en) prefix from paths; drop the `/en` root entry
- let loc = path
- if (rootPath === defaultLocale) {
- // Drop the `/en` root entry to avoid duplicating `/`
- if (path === `/${defaultLocale}` || path === `/${defaultLocale}/`)
- return null
- const defaultLocalePrefix = new RegExp(`^/${defaultLocale}(/|$)`)
- loc = path.replace(defaultLocalePrefix, "/")
- }
-
- return {
- loc,
- changefreq: isDefaultLocale ? "weekly" : "monthly",
- priority: isDefaultLocale ? 0.7 : 0.5,
- }
- },
-}
diff --git a/package.json b/package.json
index 41d80fdb3f5..15582da6b94 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,6 @@
"scripts": {
"dev": "next dev",
"build": "next build",
- "postbuild": "next-sitemap",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
@@ -82,7 +81,6 @@
"next": "^14.2.32",
"next-intl": "^3.26.3",
"next-mdx-remote": "^5.0.0",
- "next-sitemap": "^4.2.3",
"next-themes": "^0.3.0",
"prism-react-renderer": "1.1.0",
"prismjs": "^1.30.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 55d32a5024c..ee0f838a033 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -164,9 +164,6 @@ importers:
next-mdx-remote:
specifier: ^5.0.0
version: 5.0.0(@types/react@18.2.57)(acorn@8.14.1)(react@18.3.1)
- next-sitemap:
- specifier: ^4.2.3
- version: 4.2.3(next@14.2.32(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1189,9 +1186,6 @@ packages:
'@coinbase/wallet-sdk@4.3.0':
resolution: {integrity: sha512-T3+SNmiCw4HzDm4we9wCHCxlP0pqCiwKe4sOwPH3YAK2KSKjxPRydKu6UQJrdONFVLG7ujXvbd/6ZqmvJb8rkw==}
- '@corex/deepmerge@4.0.43':
- resolution: {integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==}
-
'@crowdin/crowdin-api-client@1.44.0':
resolution: {integrity: sha512-mDfow8999uC0jxoQ57yJACx6gYZohvrgbXN3/vW2E/sdrrnvYNOaYGG1o/QdNy9qq3PyKBMhc3SED7tRejigZw==}
engines: {node: '>=12.9.0'}
@@ -1887,9 +1881,6 @@ packages:
'@next/bundle-analyzer@14.2.29':
resolution: {integrity: sha512-5H2FPagh/K4g00MLHK0M70OnRfhN2rpb4Z6+jJZBNJ5VrFP7XkbUHlX4idhPwGNuwLAR2UbWZo4wEl6iPFukHw==}
- '@next/env@13.5.11':
- resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==}
-
'@next/env@14.2.32':
resolution: {integrity: sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==}
@@ -7137,13 +7128,6 @@ packages:
peerDependencies:
react: '>=16'
- next-sitemap@4.2.3:
- resolution: {integrity: sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==}
- engines: {node: '>=14.18'}
- hasBin: true
- peerDependencies:
- next: '*'
-
next-themes@0.3.0:
resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==}
peerDependencies:
@@ -10637,8 +10621,6 @@ snapshots:
eventemitter3: 5.0.1
preact: 10.26.8
- '@corex/deepmerge@4.0.43': {}
-
'@crowdin/crowdin-api-client@1.44.0':
dependencies:
axios: 1.9.0
@@ -11366,8 +11348,6 @@ snapshots:
- bufferutil
- utf-8-validate
- '@next/env@13.5.11': {}
-
'@next/env@14.2.32': {}
'@next/eslint-plugin-next@14.2.29':
@@ -18365,14 +18345,6 @@ snapshots:
- acorn
- supports-color
- next-sitemap@4.2.3(next@14.2.32(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
- dependencies:
- '@corex/deepmerge': 4.0.43
- '@next/env': 13.5.11
- fast-glob: 3.3.3
- minimist: 1.2.8
- next: 14.2.32(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
-
next-themes@0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
diff --git a/src/lib/i18n/pageTranslation.ts b/src/lib/i18n/pageTranslation.ts
deleted file mode 100644
index d8b00d3dac3..00000000000
--- a/src/lib/i18n/pageTranslation.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { getPrimaryNamespaceForPath } from "../utils/translations"
-
-import { areNamespacesTranslated } from "./translationStatus"
-
-/**
- * Determine if a page should be considered translated for a given locale.
- *
- * This checks only the primary namespace inferred from the provided path. When
- * no primary namespace exists for the path, the page is assumed translated
- * because it depends solely on globally available shared namespaces (like
- * "common") rather than page-specific strings.
- *
- * @param locale - Locale code, e.g., "en", "es"
- * @param slug - Page path or slug, e.g., "/wallets/"
- * @returns Promise resolving to whether the page is translated
- * @example
- * await isPageTranslated("es", "/wallets/") // => true | false
- */
-export async function isPageTranslated(
- locale: string,
- slug: string
-): Promise {
- const primaryNamespace = getPrimaryNamespaceForPath(slug)
-
- if (!primaryNamespace) {
- return true
- }
-
- return areNamespacesTranslated(locale, [primaryNamespace])
-}
diff --git a/src/lib/i18n/translationRegistry.ts b/src/lib/i18n/translationRegistry.ts
new file mode 100644
index 00000000000..fbdd248690e
--- /dev/null
+++ b/src/lib/i18n/translationRegistry.ts
@@ -0,0 +1,114 @@
+import { existsSync } from "fs"
+import { join } from "path"
+
+import {
+ DEFAULT_LOCALE,
+ LOCALES_CODES,
+ TRANSLATIONS_DIR,
+} from "@/lib/constants"
+
+import { getPostSlugs } from "../utils/md"
+import { getStaticPagePaths } from "../utils/staticPages"
+import { getPrimaryNamespaceForPath } from "../utils/translations"
+import { addSlashes } from "../utils/url"
+
+import { areNamespacesTranslated } from "./translationStatus"
+
+async function isMdPageTranslated(
+ locale: string,
+ slug: string
+): Promise {
+ if (locale === DEFAULT_LOCALE) {
+ return true
+ }
+
+ const translationPath = join(TRANSLATIONS_DIR, locale, slug, "index.md")
+ return existsSync(translationPath)
+}
+
+async function isIntlPageTranslated(
+ locale: string,
+ path: string
+): Promise {
+ const primaryNamespace = getPrimaryNamespaceForPath(path)
+
+ if (!primaryNamespace) {
+ return locale === DEFAULT_LOCALE
+ }
+
+ return areNamespacesTranslated(locale, [primaryNamespace])
+}
+
+function getPageType(slug: string): "md" | "intl" {
+ const normalizedSlug = addSlashes(slug)
+ const primaryNamespace = getPrimaryNamespaceForPath(normalizedSlug)
+ return primaryNamespace ? "intl" : "md"
+}
+
+/**
+ * Get all translated locales for a given page slug.
+ * Works for both MD pages and intl pages.
+ *
+ * @param slug - Page slug/path (e.g., "about" for MD or "/wallets/" for intl)
+ * @returns Promise resolving to array of locale codes that have translations
+ * @example
+ * await getTranslatedLocales("about") // => ["en", "es", "fr"]
+ * await getTranslatedLocales("/wallets/") // => ["en", "es"]
+ */
+export async function getTranslatedLocales(slug: string): Promise {
+ const pageType = getPageType(slug)
+ const translatedLocales: string[] = []
+
+ for (const locale of LOCALES_CODES) {
+ let isTranslated: boolean
+
+ if (pageType === "md") {
+ const mdSlug = slug.replace(/^\/+|\/+$/g, "")
+ isTranslated = await isMdPageTranslated(locale, mdSlug)
+ } else {
+ const normalizedPath = addSlashes(slug)
+ isTranslated = await isIntlPageTranslated(locale, normalizedPath)
+ }
+
+ if (isTranslated) {
+ translatedLocales.push(locale)
+ }
+ }
+
+ return translatedLocales
+}
+
+type PageWithTranslations = {
+ slug: string
+ translatedLocales: string[]
+ type: "md" | "intl"
+}
+
+export async function getAllPagesWithTranslations(): Promise<
+ PageWithTranslations[]
+> {
+ const pages: PageWithTranslations[] = []
+
+ const mdSlugs = await getPostSlugs("/")
+ const intlPaths = getStaticPagePaths()
+
+ for (const slug of mdSlugs) {
+ const translatedLocales = await getTranslatedLocales(slug)
+ pages.push({
+ slug,
+ translatedLocales,
+ type: "md",
+ })
+ }
+
+ for (const path of intlPaths) {
+ const translatedLocales = await getTranslatedLocales(path)
+ pages.push({
+ slug: path,
+ translatedLocales,
+ type: "intl",
+ })
+ }
+
+ return pages
+}
diff --git a/src/lib/i18n/translationStatus.ts b/src/lib/i18n/translationStatus.ts
index 4d8e0df29ba..62c03671684 100644
--- a/src/lib/i18n/translationStatus.ts
+++ b/src/lib/i18n/translationStatus.ts
@@ -1,6 +1,7 @@
-import { DEFAULT_LOCALE } from "@/lib/constants"
+import { existsSync } from "fs"
+import { join } from "path"
-import { loadMessages } from "@/lib/i18n/loadMessages"
+import { DEFAULT_LOCALE } from "@/lib/constants"
/**
* Determine whether all required i18n namespaces exist for a given locale.
@@ -12,8 +13,9 @@ export async function areNamespacesTranslated(
): Promise {
if (locale === DEFAULT_LOCALE) return true
- const localeMessages = await loadMessages(locale)
+ const intlPath = join(process.cwd(), "src/intl")
+
return namespaces.every((ns) =>
- Object.prototype.hasOwnProperty.call(localeMessages, ns)
+ existsSync(join(intlPath, locale, `${ns}.json`))
)
}
diff --git a/src/lib/utils/metadata.ts b/src/lib/utils/metadata.ts
index d4d48f87c45..7182be1132d 100644
--- a/src/lib/utils/metadata.ts
+++ b/src/lib/utils/metadata.ts
@@ -3,6 +3,8 @@ import { getTranslations } from "next-intl/server"
import { DEFAULT_OG_IMAGE, SITE_URL } from "@/lib/constants"
+import { getTranslatedLocales } from "../i18n/translationRegistry"
+
import { isLocaleValidISO639_1 } from "./translations"
import { getFullUrl } from "./url"
@@ -43,6 +45,7 @@ export const getMetadata = async ({
image,
author,
noIndex = false,
+ translatedLocales,
}: {
locale: string
slug: string[]
@@ -52,6 +55,7 @@ export const getMetadata = async ({
image?: string
author?: string
noIndex?: boolean
+ translatedLocales?: string[]
}): Promise => {
const slugString = slug.join("/")
const t = await getTranslations({ locale, namespace: "common" })
@@ -59,29 +63,48 @@ export const getMetadata = async ({
const description = descriptionProp || t("site-description")
const siteTitle = t("site-title")
- // Set canonical URL w/ language path to avoid duplicate content
- const url = getFullUrl(locale, slugString)
+ // Auto-detect translated locales if not provided
+ const finalTranslatedLocales =
+ translatedLocales ?? (await getTranslatedLocales(slugString))
+
+ const isCurrentPageTranslated = finalTranslatedLocales.includes(locale)
- // Set x-default URL for hreflang
+ // Set canonical URL
+ // If current locale is NOT translated, set canonical to English version
+ const canonicalLocale = isCurrentPageTranslated
+ ? locale
+ : routing.defaultLocale
+ const url = getFullUrl(canonicalLocale, slugString)
+
+ // Set x-default URL for hreflang (always use default locale)
const xDefault = getFullUrl(routing.defaultLocale, slugString)
/* Set fallback ogImage based on path */
const ogImage = image || getOgImage(slug)
+ // Only include hreflang alternates if the current page is translated
+ // Untranslated pages should not have hreflang tags
+ const localesForHreflang = isCurrentPageTranslated
+ ? routing.locales.filter(
+ (loc) =>
+ finalTranslatedLocales.includes(loc) && isLocaleValidISO639_1(loc)
+ )
+ : []
+
const base: Metadata = {
title,
description,
metadataBase: new URL(SITE_URL),
alternates: {
canonical: url,
- languages: {
- "x-default": xDefault,
- ...Object.fromEntries(
- routing.locales
- .filter(isLocaleValidISO639_1)
- .map((locale) => [locale, getFullUrl(locale, slugString)])
- ),
- },
+ ...(localesForHreflang.length > 0 && {
+ languages: {
+ "x-default": xDefault,
+ ...Object.fromEntries(
+ localesForHreflang.map((loc) => [loc, getFullUrl(loc, slugString)])
+ ),
+ },
+ }),
},
openGraph: {
title,
@@ -117,5 +140,9 @@ export const getMetadata = async ({
return { ...base, robots: { index: false } }
}
+ if (!isCurrentPageTranslated) {
+ return { ...base, robots: { index: true, follow: true } }
+ }
+
return base
}
diff --git a/src/lib/utils/staticPages.ts b/src/lib/utils/staticPages.ts
new file mode 100644
index 00000000000..7f06ebf6556
--- /dev/null
+++ b/src/lib/utils/staticPages.ts
@@ -0,0 +1,54 @@
+import { existsSync, readdirSync } from "fs"
+import { join } from "path"
+
+const APP_LOCALE_DIR = join(process.cwd(), "app/[locale]")
+
+/**
+ * Recursively discover all static page paths from app/[locale].
+ * Excludes dynamic routes like [slug], [...slug], [application].
+ *
+ * @returns Array of paths like ["/", "/apps", "/wallets/find-wallet", ...]
+ */
+export function discoverStaticPages(
+ dir: string = APP_LOCALE_DIR,
+ basePath: string = ""
+): string[] {
+ if (!existsSync(dir)) {
+ return []
+ }
+
+ const pages: string[] = []
+ const entries = readdirSync(dir, { withFileTypes: true })
+
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ // Skip dynamic routes (names starting with '[')
+ if (entry.name.startsWith("[")) continue
+
+ // Skip private folders starting with '_' (Next.js convention - not routable)
+ if (entry.name.startsWith("_")) continue
+
+ const newBasePath = `${basePath}/${entry.name}`
+ pages.push(...discoverStaticPages(join(dir, entry.name), newBasePath))
+ } else if (entry.name === "page.tsx") {
+ // Found a page - add the path
+ pages.push(basePath || "/")
+ }
+ }
+
+ return pages
+}
+
+let cachedStaticPages: string[] | null = null
+
+/**
+ * Get all static page paths from app/[locale], cached after first call.
+ * Returns paths with trailing slashes for consistency.
+ */
+export function getStaticPagePaths(): string[] {
+ if (!cachedStaticPages) {
+ cachedStaticPages = discoverStaticPages()
+ }
+ // Normalize to have trailing slashes
+ return cachedStaticPages.map((p) => (p === "/" ? "/" : `${p}/`))
+}
diff --git a/src/lib/utils/translations.ts b/src/lib/utils/translations.ts
index 508a7efc6a1..29bb4210d5d 100644
--- a/src/lib/utils/translations.ts
+++ b/src/lib/utils/translations.ts
@@ -11,6 +11,115 @@ export const languages: Languages = i18nConfig.reduce((result, config) => {
return { ...result, [config.code]: config }
}, {} as Languages)
+export const EXACT_PATH_NAMESPACE_MAP: Record = {
+ "/": "page-index",
+ "/10years/": "page-10-year-anniversary",
+ "/assets/": "page-assets",
+ "/collectibles/": "page-collectibles",
+ "/contributing/translation-program/acknowledgements/":
+ "page-contributing-translation-program-acknowledgements",
+ "/contributing/translation-program/contributors/":
+ "page-contributing-translation-program-contributors",
+ "/enterprise/": "page-enterprise",
+ "/ethereum-history-founder-and-ownership/":
+ "page-ethereum-history-founder-and-ownership",
+ "/ethereum-vs-bitcoin/": "page-ethereum-vs-bitcoin",
+ "/founders/": "page-founders",
+ "/get-eth/": "page-get-eth",
+ "/bug-bounty/": "page-bug-bounty",
+ "/quizzes/": "learn-quizzes",
+ "/trillion-dollar-security/": "page-trillion-dollar-security",
+ "/wallets/find-wallet/": "page-wallets-find-wallet",
+ "/wallets/": "page-wallets",
+ "/what-is-ether/": "page-what-is-ether",
+ "/what-is-the-ethereum-network/": "page-what-is-the-ethereum-network",
+}
+
+export const PREFIX_PATH_NAMESPACE_MAP: Array<[string, string]> = [
+ ["/staking/deposit-contract/", "page-staking-deposit-contract"],
+ ["/staking/", "page-staking"],
+ ["/layer-2/networks/", "page-layer-2-networks"],
+ ["/layer-2/learn/", "page-layer-2-learn"],
+ ["/layer-2/", "page-layer-2"],
+ ["/developers/local-environment/", "page-developers-local-environment"],
+ ["/developers/learning-tools/", "page-developers-learning-tools"],
+ ["/developers/tutorials/", "page-developers-tutorials"],
+ ["/developers/", "page-developers-index"],
+ ["/contributing/translation-program/translatathon/", "page-translatathon"],
+ ["/community/", "page-community"],
+ ["/apps/", "page-apps"],
+ ["/energy-consumption/", "page-energy-consumption"],
+ ["/eth/", "page-eth"],
+ ["/ethereum-forks/", "page-history"],
+ ["/resources/", "page-resources"],
+ ["/stablecoins/", "page-stablecoins"],
+ ["/learn/", "page-learn"],
+ ["/gas/", "page-gas"],
+ ["/what-is-ethereum/", "page-what-is-ethereum"],
+ ["/run-a-node/", "page-run-a-node"],
+ ["/roadmap/", "page-roadmap"],
+ ["/start/", "page-start"],
+]
+
+const EXACT_PATH_ADDITIONAL_NAMESPACES: Record = {
+ "/": ["page-10-year-anniversary"],
+}
+
+const PREFIX_PATH_ADDITIONAL_NAMESPACES: Array<[string, string[]]> = [
+ ["/developers/docs/scaling/", ["page-layer-2"]],
+ ["/roadmap/vision/", ["page-upgrades-index", "page-roadmap-vision"]],
+ ["/gas/", ["page-gas", "page-community"]],
+ ["/layer-2/networks/", ["table"]],
+ ["/energy-consumption/", ["page-about"]],
+ ["/glossary/", ["glossary"]],
+ ["/10years/", ["page-10-year-anniversary"]],
+]
+
+const SUFFIX_PATH_ADDITIONAL_NAMESPACES: Array<[string, string[]]> = [
+ ["/wallets/find-wallet/", ["page-wallets", "table"]],
+]
+
+const GLOSSARY_TOOLTIP_PREFIXES: string[] = [
+ "/layer-2/learn/",
+ "/layer-2/",
+ "/apps/",
+ "/get-eth/",
+ "/stablecoins/",
+ "/staking/",
+ "/run-a-node/",
+ "/what-is-ethereum/",
+ "/eth/",
+ "/wallets/",
+ "/gas/",
+]
+
+const QUIZZES_PREFIXES: string[] = [
+ "/layer-2/learn/",
+ "/layer-2/",
+ "/roadmap/merge/",
+ "/roadmap/scaling/",
+ "/staking/solo/",
+ "/defi/",
+ "/eth/",
+ "/gas/",
+ "/nft/",
+ "/quizzes/",
+ "/run-a-node/",
+ "/security/",
+ "/smart-contracts/",
+ "/stablecoins/",
+ "/wallets/",
+ "/web3/",
+ "/what-is-ethereum/",
+]
+
+const LAYOUT_NAMESPACES: Record = {
+ docs: ["page-developers-docs"],
+ "use-cases": ["template-usecase", "learn-quizzes"],
+ upgrade: ["page-upgrades", "page-upgrades-index"],
+ tutorial: ["page-developers-tutorials"],
+}
+
export const isLangRightToLeft = (lang: Lang): boolean => {
const langConfig = i18nConfig.filter((language) => language.code === lang)
@@ -52,288 +161,93 @@ export const formatLanguageNames = (languageCodes: string[]): string[] => {
.filter(Boolean)
}
-export const getRequiredNamespacesForPage = (
- path: string,
- layout?: string | undefined
-) => {
- const baseNamespaces = ["common"]
-
- const requiredNamespacesForPath = getRequiredNamespacesForPath(path)
- // TODO remove layout case since we can't use it anymore
- const requiredNamespacesForLayout = getRequiredNamespacesForLayout(layout)
-
- return [
- ...baseNamespaces,
- ...requiredNamespacesForPath,
- ...requiredNamespacesForLayout,
- ]
-}
-
-const getRequiredNamespacesForPath = (relativePath: string) => {
+export const getPrimaryNamespaceForPath = (
+ relativePath: string
+): string | undefined => {
const path = url.addSlashes(relativePath)
- const primaryNamespace = getPrimaryNamespaceForPath(path) // the primary namespace for the page
- let requiredNamespaces: string[] = [] // any additional namespaces required for the page
-
- if (path === "/") {
- requiredNamespaces = [...requiredNamespaces, "page-10-year-anniversary"]
- }
-
- if (path.startsWith("/energy-consumption/")) {
- requiredNamespaces = [...requiredNamespaces, "page-about"]
- }
-
- if (path.startsWith("/glossary/")) {
- requiredNamespaces = [...requiredNamespaces, "glossary"]
- }
-
- if (path.startsWith("/developers/docs/scaling/")) {
- requiredNamespaces = [...requiredNamespaces, "page-layer-2"]
+ if (EXACT_PATH_NAMESPACE_MAP[path]) {
+ return EXACT_PATH_NAMESPACE_MAP[path]
}
- if (path.startsWith("/roadmap/vision/")) {
- requiredNamespaces = [
- ...requiredNamespaces,
- "page-upgrades-index",
- "page-roadmap-vision",
- ]
+ for (const [prefix, ns] of PREFIX_PATH_NAMESPACE_MAP) {
+ if (path.startsWith(prefix)) {
+ return ns
+ }
}
- if (path.startsWith("/gas/")) {
- requiredNamespaces = [...requiredNamespaces, "page-gas", "page-community"]
- }
-
- if (path.endsWith("/wallets/find-wallet/")) {
- requiredNamespaces = [...requiredNamespaces, "page-wallets", "table"]
- }
-
- if (path.startsWith("/layer-2/networks/")) {
- requiredNamespaces = [...requiredNamespaces, "table"]
- }
-
- if (path.startsWith("/start/")) {
- requiredNamespaces = [...requiredNamespaces]
- }
-
- if (path.startsWith("/10years/")) {
- requiredNamespaces = [...requiredNamespaces, "page-10-year-anniversary"]
- }
-
- // Glossary tooltips
- if (
- path.startsWith("/apps/") ||
- path.startsWith("/layer-2/") ||
- path.startsWith("/layer-2/learn/") ||
- path.startsWith("/get-eth/") ||
- path.startsWith("/stablecoins/") ||
- path.startsWith("/staking/") ||
- path.startsWith("/run-a-node/") ||
- path.startsWith("/what-is-ethereum/") ||
- path.startsWith("/eth/") ||
- path.startsWith("/wallets/") ||
- path.startsWith("/gas/")
- ) {
- requiredNamespaces = [...requiredNamespaces, "glossary-tooltip"]
- }
-
- // Quizzes
- // Note: Add any URL paths that have quizzes here
- if (
- path.startsWith("/defi/") ||
- path.startsWith("/eth/") ||
- path.startsWith("/gas/") ||
- path.startsWith("/layer-2/") ||
- path.startsWith("/layer-2/learn/") ||
- path.startsWith("/nft/") ||
- path.startsWith("/quizzes/") ||
- path.startsWith("/roadmap/merge/") ||
- path.startsWith("/roadmap/scaling/") ||
- path.startsWith("/run-a-node/") ||
- path.startsWith("/security/") ||
- path.startsWith("/smart-contracts/") ||
- path.startsWith("/stablecoins/") ||
- path.startsWith("/staking/solo/") ||
- path.startsWith("/wallets/") ||
- path.startsWith("/web3/") ||
- path.startsWith("/what-is-ethereum/")
- ) {
- requiredNamespaces = [...requiredNamespaces, "learn-quizzes"]
- }
-
- // Ensures that the primary namespace is always the first item in the array
- return primaryNamespace
- ? [primaryNamespace, ...requiredNamespaces]
- : [...requiredNamespaces]
+ return undefined
}
-export const getPrimaryNamespaceForPath = (relativePath: string) => {
+const getRequiredNamespacesForPath = (relativePath: string) => {
const path = url.addSlashes(relativePath)
- let primaryNamespace: string | undefined
-
- if (path === "/assets/") {
- primaryNamespace = "page-assets"
- }
-
- if (path === "/") {
- primaryNamespace = "page-index"
- }
-
- if (path === "/collectibles/") {
- primaryNamespace = "page-collectibles"
- }
-
- if (path === "/contributing/translation-program/acknowledgements/") {
- primaryNamespace = "page-contributing-translation-program-acknowledgements"
- }
-
- if (path === "/contributing/translation-program/contributors/") {
- primaryNamespace = "page-contributing-translation-program-contributors"
- }
-
- if (path.startsWith("/community/")) {
- primaryNamespace = "page-community"
- }
-
- if (path.startsWith("/apps/")) {
- primaryNamespace = "page-apps"
- }
+ const primaryNamespace = getPrimaryNamespaceForPath(path)
+ const requiredNamespaces: string[] = []
- if (path.startsWith("/energy-consumption/")) {
- primaryNamespace = "page-energy-consumption"
+ if (EXACT_PATH_ADDITIONAL_NAMESPACES[path]) {
+ requiredNamespaces.push(...EXACT_PATH_ADDITIONAL_NAMESPACES[path])
}
- if (path.startsWith("/eth/")) {
- primaryNamespace = "page-eth"
+ for (const [prefix, namespaces] of PREFIX_PATH_ADDITIONAL_NAMESPACES) {
+ if (path.startsWith(prefix)) {
+ requiredNamespaces.push(...namespaces)
+ break
+ }
}
- if (path.startsWith("/ethereum-forks/")) {
- primaryNamespace = "page-history"
+ for (const [suffix, namespaces] of SUFFIX_PATH_ADDITIONAL_NAMESPACES) {
+ if (path.endsWith(suffix)) {
+ requiredNamespaces.push(...namespaces)
+ break
+ }
}
- if (path.startsWith("/resources/")) {
- primaryNamespace = "page-resources"
+ for (const prefix of GLOSSARY_TOOLTIP_PREFIXES) {
+ if (path.startsWith(prefix)) {
+ requiredNamespaces.push("glossary-tooltip")
+ break
+ }
}
- if (path.startsWith("/stablecoins/")) {
- primaryNamespace = "page-stablecoins"
- }
-
- if (path.startsWith("/staking/")) {
- primaryNamespace = "page-staking"
- }
-
- if (path.startsWith("/staking/deposit-contract/")) {
- primaryNamespace = "page-staking-deposit-contract"
- }
-
- if (path.startsWith("/developers/")) {
- primaryNamespace = "page-developers-index"
- }
-
- if (path.startsWith("/learn/")) {
- primaryNamespace = "page-learn"
- }
-
- if (path.startsWith("/developers/local-environment/")) {
- primaryNamespace = "page-developers-local-environment"
- }
-
- if (path.startsWith("/developers/learning-tools/")) {
- primaryNamespace = "page-developers-learning-tools"
- }
-
- if (path.startsWith("/developers/tutorials/")) {
- primaryNamespace = "page-developers-tutorials"
- }
-
- if (path === "/get-eth/") {
- primaryNamespace = "page-get-eth"
- }
-
- if (path.startsWith("/gas/")) {
- primaryNamespace = "page-gas"
- }
-
- if (path.startsWith("/what-is-ethereum/")) {
- primaryNamespace = "page-what-is-ethereum"
- }
-
- if (path === "/bug-bounty/") {
- primaryNamespace = "page-bug-bounty"
- }
-
- if (path.startsWith("/run-a-node/")) {
- primaryNamespace = "page-run-a-node"
- }
-
- if (path.endsWith("/wallets/")) {
- primaryNamespace = "page-wallets"
- }
-
- if (path.endsWith("/wallets/find-wallet/")) {
- primaryNamespace = "page-wallets-find-wallet"
- }
-
- // TODO: Remove this when the page is translated
- if (path.startsWith("/layer-2/")) {
- primaryNamespace = "page-layer-2"
- }
-
- if (path.startsWith("/layer-2/learn/")) {
- primaryNamespace = "page-layer-2-learn"
- }
-
- if (path.startsWith("/layer-2/networks/")) {
- primaryNamespace = "page-layer-2-networks"
- }
-
- if (path.startsWith("/roadmap/")) {
- primaryNamespace = "page-roadmap"
- }
-
- if (path.startsWith("/start/")) {
- primaryNamespace = "page-start"
- }
-
- if (path.startsWith("/contributing/translation-program/translatathon/")) {
- primaryNamespace = "page-translatathon"
+ for (const prefix of QUIZZES_PREFIXES) {
+ if (path.startsWith(prefix)) {
+ requiredNamespaces.push("learn-quizzes")
+ break
+ }
}
return primaryNamespace
+ ? [primaryNamespace, ...requiredNamespaces]
+ : [...requiredNamespaces]
}
const getRequiredNamespacesForLayout = (layout?: string) => {
- let requiredNamespaces: string[] = []
+ const requiredNamespaces: string[] = []
- // namespaces required for all layouts
if (layout) {
- requiredNamespaces = [...requiredNamespaces, "glossary-tooltip"]
- }
-
- if (layout === "docs") {
- requiredNamespaces = [...requiredNamespaces, "page-developers-docs"]
+ requiredNamespaces.push("glossary-tooltip")
}
- if (layout === "use-cases") {
- requiredNamespaces = [
- ...requiredNamespaces,
- "template-usecase",
- "learn-quizzes",
- ]
+ if (layout && LAYOUT_NAMESPACES[layout]) {
+ requiredNamespaces.push(...LAYOUT_NAMESPACES[layout])
}
- if (layout === "upgrade") {
- requiredNamespaces = [
- ...requiredNamespaces,
- "page-upgrades",
- "page-upgrades-index",
- ]
- }
+ return requiredNamespaces
+}
- if (layout === "tutorial") {
- requiredNamespaces = [...requiredNamespaces, "page-developers-tutorials"]
- }
+export const getRequiredNamespacesForPage = (
+ path: string,
+ layout?: string | undefined
+) => {
+ const baseNamespaces = ["common"]
+ const requiredNamespacesForPath = getRequiredNamespacesForPath(path)
+ // TODO remove layout case since we can't use it anymore
+ const requiredNamespacesForLayout = getRequiredNamespacesForLayout(layout)
- return requiredNamespaces
+ return [
+ ...baseNamespaces,
+ ...requiredNamespacesForPath,
+ ...requiredNamespacesForLayout,
+ ]
}
diff --git a/src/lib/utils/url.ts b/src/lib/utils/url.ts
index 6a567893312..f3c2f365619 100644
--- a/src/lib/utils/url.ts
+++ b/src/lib/utils/url.ts
@@ -51,10 +51,18 @@ export const addSlashes = (href: string): string => {
return join("/", href, "/")
}
-export const getFullUrl = (locale: string | undefined, path: string) =>
- DEFAULT_LOCALE === locale || !locale
- ? addSlashes(new URL(path, SITE_URL).href)
- : addSlashes(new URL(join(locale, path), SITE_URL).href)
+export const getFullUrl = (locale: string | undefined, path: string) => {
+ const url =
+ DEFAULT_LOCALE === locale || !locale
+ ? new URL(path, SITE_URL)
+ : new URL(join(locale, path), SITE_URL)
+
+ if (!url.pathname.endsWith("/")) {
+ url.pathname += "/"
+ }
+
+ return url.href
+}
// Remove trailing slash from slug and add leading slash
export const normalizeSlug = (slug: string) => {