diff --git a/app/sitemap.ts b/app/sitemap.ts index 3875ecd5771..614a0b25cc8 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,11 +1,9 @@ import type { MetadataRoute } from "next" import { getFullUrl } from "@/lib/utils/url" -import { getVideoSlugs } from "@/lib/utils/videos" import { DEFAULT_LOCALE } from "@/lib/constants" -import { routing } from "@/i18n/routing" import { getAllPagesWithTranslations } from "@/lib/i18n/translationRegistry" export default async function sitemap(): Promise { @@ -47,34 +45,5 @@ export default async function sitemap(): Promise { } } - // Add video pages (dynamic routes not discovered by getAllPagesWithTranslations) - const videoSlugs = await getVideoSlugs() - for (const slug of videoSlugs) { - const videoSlug = `/videos/${slug}/` - const alternates = { - languages: { - "x-default": getFullUrl(DEFAULT_LOCALE, videoSlug), - ...Object.fromEntries( - routing.locales.map((locale) => [ - locale, - getFullUrl(locale, videoSlug), - ]) - ), - }, - } - - for (const locale of routing.locales) { - const url = getFullUrl(locale, videoSlug) - if (seenUrls.has(url)) continue - seenUrls.add(url) - entries.push({ - url, - alternates, - changeFrequency: "monthly", - priority: 0.6, - }) - } - } - return entries } diff --git a/src/lib/i18n/translationRegistry.ts b/src/lib/i18n/translationRegistry.ts index 0e2a00daf1d..839e2d51cab 100644 --- a/src/lib/i18n/translationRegistry.ts +++ b/src/lib/i18n/translationRegistry.ts @@ -1,54 +1,36 @@ import { existsSync } from "fs" import { join } from "path" -import { appsCategories } from "@/data/apps/categories" -import { DEV_TOOL_CATEGORY_SLUG_LIST } from "@/data/developerTools" - import { DEFAULT_LOCALE, LOCALES_CODES } from "@/lib/constants" import { getPostSlugs } from "../utils/md" import { getStaticPagePaths } from "../utils/staticPages" import { getPrimaryNamespaceForPath } from "../utils/translations" -import { addSlashes, slugify } from "../utils/url" +import { addSlashes } from "../utils/url" +import { getVideoSlugs } from "../utils/videos" import { areNamespacesTranslated } from "./translationStatus" -import { getAppsData } from "@/lib/data" - -async function isMdPageTranslated( - locale: string, - slug: string -): Promise { - if (locale === DEFAULT_LOCALE) { - return true - } +const CONTENT_ROOT = "public/content" +const TRANSLATIONS_ROOT = "public/content/translations" - const translationPath = join( - "public/content/translations", - locale, - slug, - "index.md" - ) - return existsSync(translationPath) +function normalizeContentSlug(slug: string): string { + return slug.replace(/^\/+|\/+$/g, "") } -async function isIntlPageTranslated( +/** + * Whether the markdown source for a slug exists on disk for a given locale. + * Slug must be normalized (no leading/trailing slashes). + */ +export function hasContentForLocale( 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" + contentSlug: string +): boolean { + const file = + locale === DEFAULT_LOCALE + ? join(CONTENT_ROOT, contentSlug, "index.md") + : join(TRANSLATIONS_ROOT, locale, contentSlug, "index.md") + return existsSync(file) } // Cache of translated locales per slug, ensuring consistent results across @@ -59,35 +41,47 @@ const translatedLocalesCache = new Map() /** * Get all translated locales for a given page slug. - * Works for both MD pages and intl pages. + * + * Resolution is content-first: + * 1. If English markdown exists at public/content//index.md, the page + * is content-driven. Translation status = does the localized markdown + * exist for that locale (UI string fallback is acceptable). + * 2. Otherwise the page is UI-driven; translation status = does the primary + * namespace mapped to this path exist for that locale. + * 3. If neither source is found, only the default locale is returned. + * * Results are cached per slug for build-time consistency. * - * @param slug - Page slug/path (e.g., "about" for MD or "/wallets/" for intl) + * @param slug - Page slug/path (with or without surrounding slashes) * @returns Promise resolving to array of locale codes that have translations * @example - * await getTranslatedLocales("about") // => ["en", "es", "fr"] - * await getTranslatedLocales("/wallets/") // => ["en", "es"] + * await getTranslatedLocales("about") // => ["en", "es", "fr"] + * await getTranslatedLocales("/wallets/") // => ["en", "es", ...] + * await getTranslatedLocales("videos/foo") // => ["en", "ar", "de", ...] */ export async function getTranslatedLocales(slug: string): Promise { const cached = translatedLocalesCache.get(slug) if (cached) return cached - const pageType = getPageType(slug) + const contentSlug = normalizeContentSlug(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 (hasContentForLocale(DEFAULT_LOCALE, contentSlug)) { + for (const locale of LOCALES_CODES) { + if (hasContentForLocale(locale, contentSlug)) { + translatedLocales.push(locale) + } } - - if (isTranslated) { - translatedLocales.push(locale) + } else { + const primaryNamespace = getPrimaryNamespaceForPath(addSlashes(slug)) + if (primaryNamespace) { + for (const locale of LOCALES_CODES) { + if (await areNamespacesTranslated(locale, [primaryNamespace])) { + translatedLocales.push(locale) + } + } + } else { + translatedLocales.push(DEFAULT_LOCALE) } } @@ -98,10 +92,23 @@ export async function getTranslatedLocales(slug: string): Promise { type PageWithTranslations = { slug: string translatedLocales: string[] - type: "md" | "intl" } async function getDynamicIntlPagePaths(): Promise { + // Imports are deferred so test environments that don't transform SVG / + // Next.js-only modules can still load this file to test getTranslatedLocales. + const [ + { appsCategories }, + { DEV_TOOL_CATEGORY_SLUG_LIST }, + { getAppsData }, + { slugify }, + ] = await Promise.all([ + import("@/data/apps/categories"), + import("@/data/developerTools"), + import("@/lib/data"), + import("../utils/url"), + ]) + // discoverStaticPages() excludes dynamic segments, so add known // generateStaticParams() routes that should be present in sitemap output. const devToolPaths = DEV_TOOL_CATEGORY_SLUG_LIST.map( @@ -130,28 +137,26 @@ export async function getAllPagesWithTranslations(): Promise< const pages: PageWithTranslations[] = [] const mdSlugs = await getPostSlugs("/") + + // Video detail pages live under public/content/videos/ but are excluded from + // getPostSlugs() because they have a dedicated [slug] route. Surface them + // here so they flow through the same content-driven translation resolution. + const videoSlugs = (await getVideoSlugs()).map((slug) => `videos/${slug}`) + const intlPaths = [ ...getStaticPagePaths(), ...(await getDynamicIntlPagePaths()), ] const uniqueIntlPaths = Array.from(new Set(intlPaths)) - for (const slug of mdSlugs) { + for (const slug of [...mdSlugs, ...videoSlugs]) { const translatedLocales = await getTranslatedLocales(slug) - pages.push({ - slug, - translatedLocales, - type: "md", - }) + pages.push({ slug, translatedLocales }) } for (const path of uniqueIntlPaths) { const translatedLocales = await getTranslatedLocales(path) - pages.push({ - slug: path, - translatedLocales, - type: "intl", - }) + pages.push({ slug: path, translatedLocales }) } return pages diff --git a/tests/unit/i18n/translation-registry.spec.ts b/tests/unit/i18n/translation-registry.spec.ts new file mode 100644 index 00000000000..72901551773 --- /dev/null +++ b/tests/unit/i18n/translation-registry.spec.ts @@ -0,0 +1,102 @@ +// Tests run against the real filesystem so they stay valid as content is +// translated or removed; assertions compare resolver output to disk state +// rather than hardcoded locale lists. + +import { expect, test } from "@playwright/test" + +import { LOCALES_CODES } from "@/lib/constants" + +import { + getTranslatedLocales, + hasContentForLocale, +} from "@/lib/i18n/translationRegistry" +import { areNamespacesTranslated } from "@/lib/i18n/translationStatus" + +function localesWithContent(slug: string): string[] { + return LOCALES_CODES.filter((loc) => hasContentForLocale(loc, slug)) +} + +async function localesWithNamespace(namespace: string): Promise { + const checks = await Promise.all( + LOCALES_CODES.map((loc) => areNamespacesTranslated(loc, [namespace])) + ) + return LOCALES_CODES.filter((_, i) => checks[i]) +} + +test.describe("getTranslatedLocales — content-first resolution", () => { + test("video detail page returns exactly the locales whose markdown exists", async () => { + const slug = "videos/decentralized-social-media" + const result = await getTranslatedLocales(slug) + const expected = localesWithContent(slug) + + expect(result).toContain("en") + expect(expected.length).toBeGreaterThan(1) + expect([...result].sort()).toEqual([...expected].sort()) + }) + + test("hybrid page (markdown + UI namespace) gates on markdown, not namespace", async () => { + // /community/research/ has both a markdown source AND a page-community + // namespace mapped via prefix. Resolution must follow markdown. + const slug = "community/research" + const result = await getTranslatedLocales(slug) + const expected = localesWithContent(slug) + + expect([...result].sort()).toEqual([...expected].sort()) + }) + + test("every returned locale has a corresponding markdown source", async () => { + // The strongest invariant: for any content-driven slug, the resolver + // must never claim translation for a locale that has no md on disk. + const slugs = [ + "videos/decentralized-social-media", + "videos/blockchain-eth-build", + "developers/docs/accounts", + "developers/tutorials/gasless-token", + "community/research", + "about", + ] + for (const slug of slugs) { + const result = await getTranslatedLocales(slug) + for (const loc of result) { + expect( + hasContentForLocale(loc, slug), + `${slug} reported translated for ${loc} but content does not exist` + ).toBe(true) + } + } + }) +}) + +test.describe("getTranslatedLocales — pure-intl fallback", () => { + test("pages without markdown fall back to namespace presence", async () => { + // /wallets/ has no public/content/wallets/index.md. Resolution should + // fall back to the page-wallets namespace check. + const slug = "/wallets/" + const result = await getTranslatedLocales(slug) + const expected = await localesWithNamespace("page-wallets") + + expect(result).toContain("en") + expect([...result].sort()).toEqual([...expected].sort()) + }) +}) + +test.describe("getTranslatedLocales — input handling", () => { + test("slug normalization: with and without surrounding slashes return the same set", async () => { + const a = await getTranslatedLocales("videos/decentralized-social-media") + const b = await getTranslatedLocales("/videos/decentralized-social-media/") + expect([...a].sort()).toEqual([...b].sort()) + }) + + test("default locale is always present for any known page", async () => { + const slugs = [ + "videos/decentralized-social-media", + "developers/tutorials/gasless-token", + "/wallets/", + "community/research", + ] + for (const slug of slugs) { + const result = await getTranslatedLocales(slug) + expect(result, `default locale missing for ${slug}`).toContain("en") + } + }) +})