Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 0 additions & 31 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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<MetadataRoute.Sitemap> {
Expand Down Expand Up @@ -47,34 +45,5 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}
}

// 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
}
135 changes: 70 additions & 65 deletions src/lib/i18n/translationRegistry.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
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
Expand All @@ -59,35 +41,47 @@ const translatedLocalesCache = new Map<string, string[]>()

/**
* 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/<slug>/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<string[]> {
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)
}
}

Expand All @@ -98,10 +92,23 @@ export async function getTranslatedLocales(slug: string): Promise<string[]> {
type PageWithTranslations = {
slug: string
translatedLocales: string[]
type: "md" | "intl"
}

async function getDynamicIntlPagePaths(): Promise<string[]> {
// 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(
Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions tests/unit/i18n/translation-registry.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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")
}
})
})
Loading