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) => {