From 5592b25243dfa8049ab645f6018d24395bce5a60 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 13 May 2026 18:02:16 -0300 Subject: [PATCH] feat(docs-ui): [slug]/page.tsx serves locale-translated docs with English fallback Reads the current request locale via `getLocale()` from next-intl and prefers a translated markdown copy under `docs/i18n//docs/` when one exists. Falls back to the English source otherwise, so locales with partial coverage stay readable instead of 404ing. Path resolution is hardened: every read is resolved against an absolute docs root and verified to live inside it, so a stray filename in the docs index cannot escape the directory. This is the runtime counterpart to the DocsI18n removal: the locale preference set by the shared LanguageSelector in the docs layout now materially affects what content the page renders. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/docs/[slug]/page.tsx | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/docs/[slug]/page.tsx b/src/app/docs/[slug]/page.tsx index 27d8147dd9..44499f940a 100644 --- a/src/app/docs/[slug]/page.tsx +++ b/src/app/docs/[slug]/page.tsx @@ -8,6 +8,7 @@ import matter from "gray-matter"; import { marked } from "marked"; import DOMPurify from "isomorphic-dompurify"; import { Metadata } from "next"; +import { getLocale } from "next-intl/server"; import { DocCodeBlocks } from "../components/DocCodeBlocks"; import { FeedbackWidget } from "../components/FeedbackWidget"; import { DocsPageAnalytics } from "../components/DocsPageAnalytics"; @@ -127,8 +128,6 @@ function addProseClasses(html: string): string { return result; } - - export async function generateMetadata({ params, }: { @@ -168,8 +167,27 @@ export default async function DocPage({ params }: { params: Promise<{ slug: stri let mermaidCharts: string[] = []; try { - const docsRoot = path.join(process.cwd(), "docs"); - const fileContent = fs.readFileSync(path.join(docsRoot, item.fileName), "utf8"); + // Resolve the current request locale via next-intl. When it isn't `en` + // and a translated copy exists under `docs/i18n//docs/`, + // prefer the translated file. Otherwise fall back to the English source. + // Path resolution is hardened: the docs index supplies the safe filename + // (never user input), and we constrain the resolved path to the docs + // root so traversal sequences in a stray fileName cannot escape it. + const locale = await getLocale(); + const docsRoot = path.resolve(process.cwd(), "docs"); + const englishAbs = path.resolve(docsRoot, item.fileName); + if (!englishAbs.startsWith(docsRoot + path.sep) && englishAbs !== docsRoot) { + throw new Error("Refusing to read doc outside the docs root"); + } + let sourceAbs = englishAbs; + if (locale && locale !== "en") { + const localeRoot = path.resolve(docsRoot, "i18n", locale, "docs"); + const localeAbs = path.resolve(localeRoot, item.fileName); + if (localeAbs.startsWith(localeRoot + path.sep) && fs.existsSync(localeAbs)) { + sourceAbs = localeAbs; + } + } + const fileContent = fs.readFileSync(sourceAbs, "utf8"); const { content, data: frontmatter } = matter(fileContent); pageTitle = (frontmatter.title as string) || item.title; version = (frontmatter.version as string) || null;