diff --git a/app/[locale]/community/page.tsx b/app/[locale]/community/page.tsx index 87d942f33f8..092e6cfa28b 100644 --- a/app/[locale]/community/page.tsx +++ b/app/[locale]/community/page.tsx @@ -51,7 +51,6 @@ export async function generateMetadata({ const { locale } = params const t = await getTranslations({ locale, namespace: "page-community" }) - return await getMetadata({ locale, slug: ["community"], diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 1e577616ebc..3c5d9aa7009 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -3,9 +3,10 @@ import { getRequestConfig } from "next-intl/server" import { Lang } from "@/lib/types" -import { loadMessages } from "./loadMessages" import { routing } from "./routing" +import { loadMessages } from "@/lib/i18n/loadMessages" + export default getRequestConfig(async ({ requestLocale }) => { // This typically corresponds to the `[locale]` segment let locale = await requestLocale diff --git a/src/i18n/loadMessages.ts b/src/lib/i18n/loadMessages.ts similarity index 90% rename from src/i18n/loadMessages.ts rename to src/lib/i18n/loadMessages.ts index 84a2e8d1063..8b4b0033eda 100644 --- a/src/i18n/loadMessages.ts +++ b/src/lib/i18n/loadMessages.ts @@ -23,7 +23,7 @@ export async function loadMessages(locale: string) { const namespaces = getNamespaces(localePath) for (const ns of namespaces) { - messages[ns] = (await import(`../intl/${locale}/${ns}.json`)).default + messages[ns] = (await import(`../../intl/${locale}/${ns}.json`)).default } } diff --git a/src/lib/i18n/pageTranslation.ts b/src/lib/i18n/pageTranslation.ts new file mode 100644 index 00000000000..d8b00d3dac3 --- /dev/null +++ b/src/lib/i18n/pageTranslation.ts @@ -0,0 +1,30 @@ +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/translationStatus.ts b/src/lib/i18n/translationStatus.ts new file mode 100644 index 00000000000..4d8e0df29ba --- /dev/null +++ b/src/lib/i18n/translationStatus.ts @@ -0,0 +1,19 @@ +import { DEFAULT_LOCALE } from "@/lib/constants" + +import { loadMessages } from "@/lib/i18n/loadMessages" + +/** + * Determine whether all required i18n namespaces exist for a given locale. + * Default locale is always considered translated. + */ +export async function areNamespacesTranslated( + locale: string, + namespaces: string[] +): Promise { + if (locale === DEFAULT_LOCALE) return true + + const localeMessages = await loadMessages(locale) + return namespaces.every((ns) => + Object.prototype.hasOwnProperty.call(localeMessages, ns) + ) +} diff --git a/src/lib/md/metadata.ts b/src/lib/md/metadata.ts index cd74c0b97a7..828b3e45b18 100644 --- a/src/lib/md/metadata.ts +++ b/src/lib/md/metadata.ts @@ -12,7 +12,7 @@ export const getMdMetadata = async ({ }) => { const slug = slugArray.join("/") - const { markdown } = await importMd(locale, slug) + const { markdown, isTranslated } = await importMd(locale, slug) const { frontmatter } = await compile({ markdown, slugArray: slug.split("/"), @@ -28,12 +28,14 @@ export const getMdMetadata = async ({ const image = frontmatter.image const author = frontmatter.author - return await getMetadata({ + const metadata = await getMetadata({ locale, slug: slugArray, title: pageTitle, description, image, author, + noIndex: !isTranslated, }) + return metadata } diff --git a/src/lib/utils/metadata.ts b/src/lib/utils/metadata.ts index 13ea37cf538..a8859a035d7 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 { isPageTranslated } from "../i18n/pageTranslation" + import { isLocaleValidISO639_1 } from "./translations" import { getFullUrl } from "./url" @@ -42,6 +44,7 @@ export const getMetadata = async ({ twitterDescription, image, author, + noIndex = false, }: { locale: string slug: string[] @@ -50,6 +53,7 @@ export const getMetadata = async ({ twitterDescription?: string image?: string author?: string + noIndex?: boolean }): Promise => { const slugString = slug.join("/") const t = await getTranslations({ locale, namespace: "common" }) @@ -66,7 +70,7 @@ export const getMetadata = async ({ /* Set fallback ogImage based on path */ const ogImage = image || getOgImage(slug) - return { + const base: Metadata = { title, description, metadataBase: new URL(SITE_URL), @@ -110,4 +114,13 @@ export const getMetadata = async ({ "docsearch:description": description, }, } + + if (noIndex) { + return { ...base, robots: { index: false } } + } + + const isTranslated = await isPageTranslated(locale, slugString) + + // If the page is not translated, do not index the page + return isTranslated ? base : { ...base, robots: { index: false } } } diff --git a/src/lib/utils/translations.ts b/src/lib/utils/translations.ts index 80c6eb0a46b..508a7efc6a1 100644 --- a/src/lib/utils/translations.ts +++ b/src/lib/utils/translations.ts @@ -72,16 +72,111 @@ export const getRequiredNamespacesForPage = ( const getRequiredNamespacesForPath = (relativePath: string) => { const path = url.addSlashes(relativePath) - let primaryNamespace: string | undefined // the primary namespace for the page + 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 (path.startsWith("/roadmap/vision/")) { + requiredNamespaces = [ + ...requiredNamespaces, + "page-upgrades-index", + "page-roadmap-vision", + ] + } + + 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] +} + +export const getPrimaryNamespaceForPath = (relativePath: string) => { + const path = url.addSlashes(relativePath) + + let primaryNamespace: string | undefined + if (path === "/assets/") { primaryNamespace = "page-assets" } if (path === "/") { primaryNamespace = "page-index" - requiredNamespaces = [...requiredNamespaces, "page-10-year-anniversary"] } if (path === "/collectibles/") { @@ -106,17 +201,12 @@ const getRequiredNamespacesForPath = (relativePath: string) => { if (path.startsWith("/energy-consumption/")) { primaryNamespace = "page-energy-consumption" - requiredNamespaces = [...requiredNamespaces, "page-about"] } if (path.startsWith("/eth/")) { primaryNamespace = "page-eth" } - if (path.startsWith("/glossary/")) { - requiredNamespaces = [...requiredNamespaces, "glossary"] - } - if (path.startsWith("/ethereum-forks/")) { primaryNamespace = "page-history" } @@ -157,25 +247,12 @@ const getRequiredNamespacesForPath = (relativePath: string) => { primaryNamespace = "page-developers-tutorials" } - if (path.startsWith("/developers/docs/scaling/")) { - requiredNamespaces = [...requiredNamespaces, "page-layer-2"] - } - if (path === "/get-eth/") { primaryNamespace = "page-get-eth" } - if (path.startsWith("/roadmap/vision/")) { - requiredNamespaces = [ - ...requiredNamespaces, - "page-upgrades-index", - "page-roadmap-vision", - ] - } - if (path.startsWith("/gas/")) { primaryNamespace = "page-gas" - requiredNamespaces = [...requiredNamespaces, "page-gas", "page-community"] } if (path.startsWith("/what-is-ethereum/")) { @@ -196,7 +273,6 @@ const getRequiredNamespacesForPath = (relativePath: string) => { if (path.endsWith("/wallets/find-wallet/")) { primaryNamespace = "page-wallets-find-wallet" - requiredNamespaces = [...requiredNamespaces, "page-wallets", "table"] } // TODO: Remove this when the page is translated @@ -210,7 +286,6 @@ const getRequiredNamespacesForPath = (relativePath: string) => { if (path.startsWith("/layer-2/networks/")) { primaryNamespace = "page-layer-2-networks" - requiredNamespaces = [...requiredNamespaces, "table"] } if (path.startsWith("/roadmap/")) { @@ -219,62 +294,13 @@ const getRequiredNamespacesForPath = (relativePath: string) => { if (path.startsWith("/start/")) { primaryNamespace = "page-start" - requiredNamespaces = [...requiredNamespaces] } if (path.startsWith("/contributing/translation-program/translatathon/")) { primaryNamespace = "page-translatathon" } - 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] } const getRequiredNamespacesForLayout = (layout?: string) => {