From 389ceff1f2ad165e80c7ba0637ae57101c1e0d4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 20:05:32 +0000 Subject: [PATCH] feat(app): add public app/[lang] routes for locale migration L1 Mirror internal-marketing pilots under indexable app/[lang]/* paths with shared LocaleAppLayout and publicAppMetadata helpers. Extend proxy locale header, playground route parsing, preview smoke, and Playwright e2e. Profile pages stay noindex (matches Pages Router). Skip generateStaticParams on [lang] layout to avoid Next 16.3 SSG conflict with Pages i18n. --- AGENTS.md | 11 +--- e2e/app-locale-routes.spec.ts | 63 ++++++++++++++++++ src/app/[lang]/daily/page.tsx | 28 ++++++++ src/app/[lang]/layout.tsx | 15 +++++ src/app/[lang]/page.tsx | 32 +++++++++ .../[lang]/playground/[[...slug]]/page.tsx | 45 +++++++++++++ src/app/[lang]/privacy/page.tsx | 32 +++++++++ src/app/[lang]/profile/[userId]/page.tsx | 44 +++++++++++++ .../internal-marketing/[locale]/layout.tsx | 22 ++----- src/app/locale-app/LocaleAppLayout.tsx | 31 +++++++++ .../__tests__/publicAppMetadata.test.ts | 43 ++++++++++++ src/app/locale-app/publicAppMetadata.ts | 66 +++++++++++++++++++ .../publicPageMetadataFromTranslation.ts | 30 +++++++++ src/proxy.ts | 31 +++++++-- src/scripts/previewSmoke.ts | 6 ++ .../lib/__tests__/playgroundRoute.test.ts | 16 +++++ src/shared/lib/playgroundRoute.ts | 25 ++++++- vibe-docs/Instant-Navigations-TODO.md | 7 +- 18 files changed, 514 insertions(+), 33 deletions(-) create mode 100644 e2e/app-locale-routes.spec.ts create mode 100644 src/app/[lang]/daily/page.tsx create mode 100644 src/app/[lang]/layout.tsx create mode 100644 src/app/[lang]/page.tsx create mode 100644 src/app/[lang]/playground/[[...slug]]/page.tsx create mode 100644 src/app/[lang]/privacy/page.tsx create mode 100644 src/app/[lang]/profile/[userId]/page.tsx create mode 100644 src/app/locale-app/LocaleAppLayout.tsx create mode 100644 src/app/locale-app/__tests__/publicAppMetadata.test.ts create mode 100644 src/app/locale-app/publicAppMetadata.ts create mode 100644 src/app/locale-app/publicPageMetadataFromTranslation.ts diff --git a/AGENTS.md b/AGENTS.md index 883eb1da..c6b19628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,15 +1,10 @@ -# Next.js: ALWAYS read docs before coding +# This is NOT the Next.js you know -Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth. +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. -Good starting points in the bundle: - -- `01-app/02-guides/ai-agents.md` — agent setup and doc layout -- `01-app/01-getting-started/` — App Router basics -- `01-app/03-api-reference/` — APIs and file conventions -- `02-pages/` — **Pages Router** (this repo uses `src/pages/` — prefer this tree for `getServerSideProps`, API routes, and `next.config` under `02-pages/04-api-reference/`) +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. diff --git a/e2e/app-locale-routes.spec.ts b/e2e/app-locale-routes.spec.ts new file mode 100644 index 00000000..bf5b976a --- /dev/null +++ b/e2e/app-locale-routes.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; + +/** + * Smoke tests for public `app/[lang]/*` routes (locale migration L1). + * + * URLs use an explicit locale prefix (`/en`, `/de/...`). Unprefixed `/` and + * `/privacy` remain Pages Router until L2/L3 cutover. + */ +test.describe("app/[lang] public routes", () => { + test("en home is indexable with public canonical", async ({ page }) => { + await page.goto("/en"); + await expect(page).toHaveTitle(/dStruct/); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + "https://dstruct.pro/", + ); + }); + + test("en privacy is indexable with public canonical", async ({ page }) => { + await page.goto("/en/privacy"); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + "https://dstruct.pro/privacy", + ); + }); + + test("de daily is indexable with locale canonical", async ({ page }) => { + await page.goto("/de/daily"); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + "https://dstruct.pro/de/daily", + ); + }); + + test("en playground landing is indexable with public canonical", async ({ + page, + }) => { + await page.goto("/en/playground"); + await expect(page).toHaveTitle(/Playground/i); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + "https://dstruct.pro/playground", + ); + }); + + test("en profile is noindex with public canonical", async ({ page }) => { + const userId = "e2e-app-lang-user"; + await page.goto(`/en/profile/${userId}`); + await expect(page).toHaveTitle(/Profile/i); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + "content", + /noindex/i, + ); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + `https://dstruct.pro/profile/${userId}`, + ); + }); +}); diff --git a/src/app/[lang]/daily/page.tsx b/src/app/[lang]/daily/page.tsx new file mode 100644 index 00000000..a46ae8bf --- /dev/null +++ b/src/app/[lang]/daily/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; + +import { DailyPageView } from "#/features/homePage/ui/DailyPageView"; +import type { Locales } from "#/i18n/i18n-types"; +import { locales } from "#/i18n/i18n-util"; + +import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ lang: string }>; +}): Promise { + const { lang: langParam } = await params; + if (!locales.includes(langParam as Locales)) { + return {}; + } + const locale = langParam as Locales; + + return publicPageMetadataFromTranslation(locale, "/daily", (translation) => ({ + title: `${translation.HOME_DAILY_SECTION_TITLE} — dStruct`, + description: `${translation.HOME_DAILY_SECTION_TITLE}. ${translation.HOME_DAILY_SECTION_LEAD}`, + })); +} + +export default function LangDailyPage() { + return ; +} diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx new file mode 100644 index 00000000..54c0eff2 --- /dev/null +++ b/src/app/[lang]/layout.tsx @@ -0,0 +1,15 @@ +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; + +/** Locale marketing + app shell under `app/[lang]` (L1; Pages `i18n` still canonical for unprefixed `en` URLs). */ +export const dynamic = "force-dynamic"; + +export default async function LangLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + return {children}; +} diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx new file mode 100644 index 00000000..53ffbe1b --- /dev/null +++ b/src/app/[lang]/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; + +import { MarketingHomeView } from "#/features/homePage/ui/MarketingHomeView"; +import type { Locales } from "#/i18n/i18n-types"; +import { locales } from "#/i18n/i18n-util"; + +import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ lang: string }>; +}): Promise { + const { lang: langParam } = await params; + if (!locales.includes(langParam as Locales)) { + return {}; + } + const locale = langParam as Locales; + + return publicPageMetadataFromTranslation(locale, "/", (translation) => ({ + title: translation.SITE_SEO_TITLE, + description: translation.SITE_SEO_DESCRIPTION, + })); +} + +/** + * App Router home at `/{lang}` (e.g. `/en`, `/de`). + * Default-locale public `/` remains Pages until locale migration L2/L3. + */ +export default function LangHomePage() { + return ; +} diff --git a/src/app/[lang]/playground/[[...slug]]/page.tsx b/src/app/[lang]/playground/[[...slug]]/page.tsx new file mode 100644 index 00000000..09280ac6 --- /dev/null +++ b/src/app/[lang]/playground/[[...slug]]/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import React, { Suspense } from "react"; + +import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlaygroundPageSeo"; +import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView"; +import type { Locales } from "#/i18n/i18n-types"; +import { locales } from "#/i18n/i18n-util"; +import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; + +import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ lang: string; slug?: string[] }>; +}): Promise { + const { lang: langParam, slug } = await params; + if (!locales.includes(langParam as Locales)) { + return {}; + } + const locale = langParam as Locales; + const slugStr = slug?.[0]; + const pagePath = slugStr ? `/playground/${slugStr}` : "/playground"; + const { pageTitle, pageDescription } = await resolvePlaygroundPageSeo( + locale, + slugStr, + ); + + return publicAppMetadata({ + locale, + pagePath, + title: pageTitle, + description: pageDescription, + }); +} + +const PlaygroundFallback: React.FC = () => ; + +export default function LangPlaygroundPage() { + return ( + }> + + + ); +} diff --git a/src/app/[lang]/privacy/page.tsx b/src/app/[lang]/privacy/page.tsx new file mode 100644 index 00000000..4eac4f58 --- /dev/null +++ b/src/app/[lang]/privacy/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; + +import { PrivacyPageView } from "#/features/privacy/ui/PrivacyPageView"; +import type { Locales } from "#/i18n/i18n-types"; +import { locales } from "#/i18n/i18n-util"; + +import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ lang: string }>; +}): Promise { + const { lang: langParam } = await params; + if (!locales.includes(langParam as Locales)) { + return {}; + } + const locale = langParam as Locales; + + return publicPageMetadataFromTranslation( + locale, + "/privacy", + (translation) => ({ + title: `${translation.PRIVACY_PAGE_TITLE} — dStruct`, + description: translation.PRIVACY_INTRO, + }), + ); +} + +export default function LangPrivacyPage() { + return ; +} diff --git a/src/app/[lang]/profile/[userId]/page.tsx b/src/app/[lang]/profile/[userId]/page.tsx new file mode 100644 index 00000000..7c1dece7 --- /dev/null +++ b/src/app/[lang]/profile/[userId]/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { ProfilePageView } from "#/features/profile/ui/ProfilePageView"; +import type { Locales } from "#/i18n/i18n-types"; +import { locales } from "#/i18n/i18n-util"; + +import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ lang: string; userId: string }>; +}): Promise { + const { lang: langParam, userId } = await params; + if (!locales.includes(langParam as Locales) || !userId.trim()) { + return {}; + } + const locale = langParam as Locales; + const pagePath = `/profile/${userId}`; + + return publicPageMetadataFromTranslation( + locale, + pagePath, + (translation) => ({ + title: `${translation.PROFILE} — dStruct`, + description: translation.SITE_SEO_DESCRIPTION, + }), + { indexable: false }, + ); +} + +export default async function LangProfilePage({ + params, +}: { + params: Promise<{ lang: string; userId: string }>; +}) { + const { userId } = await params; + if (!userId.trim()) { + notFound(); + } + + return ; +} diff --git a/src/app/internal-marketing/[locale]/layout.tsx b/src/app/internal-marketing/[locale]/layout.tsx index 23059e6d..20b863a8 100644 --- a/src/app/internal-marketing/[locale]/layout.tsx +++ b/src/app/internal-marketing/[locale]/layout.tsx @@ -1,13 +1,8 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; -import { notFound } from "next/navigation"; -import type { Locales } from "#/i18n/i18n-types"; import { locales } from "#/i18n/i18n-util"; -import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; -import { authOptions } from "#/server/auth/authOptions"; -import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; /** All pilot routes stay noindex even when a child page omits metadata. */ export const metadata: Metadata = { @@ -25,16 +20,11 @@ export default async function InternalMarketingLocaleLayout({ params: Promise<{ locale: string }>; }) { const { locale: localeParam } = await params; - if (!locales.includes(localeParam as Locales)) { - notFound(); - } - const locale = localeParam as Locales; - const session = await getServerSession(authOptions); - const i18n = await loadI18nForLocale(locale); - return ( - - {children} - + {children} ); } + +export function generateStaticParams(): Array<{ locale: string }> { + return locales.map((locale) => ({ locale })); +} diff --git a/src/app/locale-app/LocaleAppLayout.tsx b/src/app/locale-app/LocaleAppLayout.tsx new file mode 100644 index 00000000..c1f4da66 --- /dev/null +++ b/src/app/locale-app/LocaleAppLayout.tsx @@ -0,0 +1,31 @@ +import { getServerSession } from "next-auth"; +import { notFound } from "next/navigation"; + +import type { Locales } from "#/i18n/i18n-types"; +import { locales } from "#/i18n/i18n-util"; +import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; +import { authOptions } from "#/server/auth/authOptions"; + +import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; + +/** Shared App Router locale layout for `app/[lang]` and internal-marketing pilot. */ +export async function LocaleAppLayout({ + children, + localeParam, +}: { + children: React.ReactNode; + localeParam: string; +}) { + if (!locales.includes(localeParam as Locales)) { + notFound(); + } + const locale = localeParam as Locales; + const session = await getServerSession(authOptions); + const i18n = await loadI18nForLocale(locale); + + return ( + + {children} + + ); +} diff --git a/src/app/locale-app/__tests__/publicAppMetadata.test.ts b/src/app/locale-app/__tests__/publicAppMetadata.test.ts new file mode 100644 index 00000000..d6e4a744 --- /dev/null +++ b/src/app/locale-app/__tests__/publicAppMetadata.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; + +describe("publicAppMetadata", () => { + it("sets indexable canonical for default-locale home via /en path", () => { + const metadata = publicAppMetadata({ + locale: "en", + pagePath: "/", + title: "dStruct", + description: "Test description", + }); + + expect(metadata.robots).toBeUndefined(); + expect(metadata.alternates?.canonical).toBe("https://dstruct.pro/"); + expect(metadata.title).toBe("dStruct"); + }); + + it("uses locale-prefixed canonical for non-default locales", () => { + const metadata = publicAppMetadata({ + locale: "de", + pagePath: "/privacy", + title: "Privacy", + description: "Intro", + }); + + expect(metadata.alternates?.canonical).toBe( + "https://dstruct.pro/de/privacy", + ); + }); + + it("emits noindex when indexable is false", () => { + const metadata = publicAppMetadata({ + locale: "en", + pagePath: "/profile/user-1", + title: "Profile", + description: "Intro", + indexable: false, + }); + + expect(metadata.robots).toEqual({ index: false, follow: true }); + }); +}); diff --git a/src/app/locale-app/publicAppMetadata.ts b/src/app/locale-app/publicAppMetadata.ts new file mode 100644 index 00000000..f9193de6 --- /dev/null +++ b/src/app/locale-app/publicAppMetadata.ts @@ -0,0 +1,66 @@ +import type { Metadata } from "next"; + +import type { Locales } from "#/i18n/i18n-types"; +import { localePathForPage } from "#/i18n/localePathForPage"; +import { + absoluteUrlFromPathname, + DEFAULT_OG_IMAGE_URL, + SITE_HOSTNAME, + truncateMetaDescription, +} from "#/shared/lib/seo"; + +type PublicAppMetadataInput = { + locale: Locales; + /** Public page path without locale prefix, e.g. `"/"` or `"/privacy"`. */ + pagePath: string; + title: string; + description: string; + /** When false, emits `noindex, follow` (e.g. user profile pages). Default: indexable. */ + indexable?: boolean; +}; + +/** Indexable SEO for public `app/[lang]/*` routes (canonical = production URL). */ +export function publicAppMetadata({ + locale, + pagePath, + title, + description, + indexable = true, +}: PublicAppMetadataInput): Metadata { + const canonicalUrl = absoluteUrlFromPathname( + localePathForPage(locale, pagePath), + ); + const metaDescription = truncateMetaDescription(description); + + return { + title, + description: metaDescription, + alternates: { canonical: canonicalUrl }, + ...(indexable ? {} : { robots: { index: false, follow: true } }), + openGraph: { + siteName: "dStruct", + title, + type: "website", + url: canonicalUrl, + description: metaDescription, + images: [ + { + url: DEFAULT_OG_IMAGE_URL, + width: 1200, + height: 630, + alt: "dStruct — LeetCode solution visualizer", + }, + ], + }, + twitter: { + card: "summary_large_image", + title, + description: metaDescription, + images: [DEFAULT_OG_IMAGE_URL], + }, + other: { + "twitter:domain": SITE_HOSTNAME, + "twitter:url": canonicalUrl, + }, + }; +} diff --git a/src/app/locale-app/publicPageMetadataFromTranslation.ts b/src/app/locale-app/publicPageMetadataFromTranslation.ts new file mode 100644 index 00000000..cb012c8d --- /dev/null +++ b/src/app/locale-app/publicPageMetadataFromTranslation.ts @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; + +import type { Locales, Translation } from "#/i18n/i18n-types"; +import { importLocaleAsync } from "#/i18n/i18n-util.async"; + +import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; + +type PublicPageCopy = { + title: string; + description: string; +}; + +/** Server-only SEO metadata for `app/[lang]/*` using locale dictionaries. */ +export async function publicPageMetadataFromTranslation( + locale: Locales, + pagePath: string, + pickCopy: (translation: Translation) => PublicPageCopy, + options?: { indexable?: boolean }, +): Promise { + const translation = await importLocaleAsync(locale); + const { title, description } = pickCopy(translation); + + return publicAppMetadata({ + locale, + pagePath, + title, + description, + indexable: options?.indexable, + }); +} diff --git a/src/proxy.ts b/src/proxy.ts index 55db6fd9..e7caade7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -14,16 +14,22 @@ function withLocaleHeader(request: NextRequest, locale: string): Headers { return requestHeaders; } +function localeFromPathname(pathname: string): string | null { + const segment = pathname.split("/").filter(Boolean)[0]; + if (segment && localeSet.has(segment)) { + return segment; + } + return null; +} + /** * Next.js 16+ request proxy (replaces `middleware.ts`). * * - Serves `/api/config` from Edge Config. - * - Sets {@link APP_ROUTER_LOCALE_HEADER} for direct App marketing visits - * (`/internal-marketing/[locale]` Instant Nav pilot). + * - Sets {@link APP_ROUTER_LOCALE_HEADER} for App Router locale paths + * (`/internal-marketing/[locale]/*` pilot and `/[lang]/*` L1 shell). * - * Public `/` and bare `/{locale}` stay on Pages `pages/index` until locale - * routing leaves `next.config` `i18n` (middleware rewrites into App routes - * 404 for non-default locales under Pages i18n). + * Public `/` and unprefixed `/privacy` stay on Pages until locale migration L2/L3. */ export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -42,6 +48,13 @@ export async function proxy(request: NextRequest) { } } + const locale = localeFromPathname(pathname); + if (locale) { + return NextResponse.next({ + request: { headers: withLocaleHeader(request, locale) }, + }); + } + return NextResponse.next(); } @@ -52,5 +65,13 @@ export const config = { source: "/internal-marketing/:path*", locale: false, }, + { + source: "/:locale", + locale: false, + }, + { + source: "/:locale/:path*", + locale: false, + }, ], }; diff --git a/src/scripts/previewSmoke.ts b/src/scripts/previewSmoke.ts index ece941a2..0050255a 100644 --- a/src/scripts/previewSmoke.ts +++ b/src/scripts/previewSmoke.ts @@ -77,6 +77,12 @@ const pagePaths = [ "/internal-marketing/en/playground", "/internal-marketing/en/playground/invert-binary-tree", "/internal-marketing/en/profile/smoke-user", + "/en", + "/en/privacy", + "/en/daily", + "/en/playground", + "/de/playground/invert-binary-tree", + "/en/profile/smoke-user", ]; const apiChecks: Array<{ path: string; matchedPath: string }> = [ diff --git a/src/shared/lib/__tests__/playgroundRoute.test.ts b/src/shared/lib/__tests__/playgroundRoute.test.ts index e1869a55..c431f532 100644 --- a/src/shared/lib/__tests__/playgroundRoute.test.ts +++ b/src/shared/lib/__tests__/playgroundRoute.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + appLocalePlaygroundBasePath, buildPlaygroundPath, internalMarketingPlaygroundBasePath, parsePlaygroundPathname, @@ -42,10 +43,25 @@ describe("playgroundRoute", () => { }); }); + it("parses app/[lang] playground paths", () => { + const basePath = appLocalePlaygroundBasePath("en"); + expect(parsePlaygroundPathname("/en/playground")).toEqual({ + basePath, + slug: [], + }); + expect( + parsePlaygroundPathname("/de/playground/invert-binary-tree"), + ).toEqual({ + basePath: appLocalePlaygroundBasePath("de"), + slug: ["invert-binary-tree"], + }); + }); + it("returns null for non-playground paths", () => { expect(parsePlaygroundPathname("/")).toBeNull(); expect(parsePlaygroundPathname("/privacy")).toBeNull(); expect(parsePlaygroundPathname("/internal-marketing/en")).toBeNull(); + expect(parsePlaygroundPathname("/not-a-locale/playground")).toBeNull(); }); it("builds paths from base + slug segments", () => { diff --git a/src/shared/lib/playgroundRoute.ts b/src/shared/lib/playgroundRoute.ts index 04bf210c..c1c0b198 100644 --- a/src/shared/lib/playgroundRoute.ts +++ b/src/shared/lib/playgroundRoute.ts @@ -1,8 +1,12 @@ +import { locales } from "#/i18n/i18n-util"; + /** Public Pages Router playground prefix (canonical URLs). */ export const PLAYGROUND_PUBLIC_BASE_PATH = "/playground"; const INTERNAL_MARKETING_PREFIX = "/internal-marketing"; +const localeCodeSet = new Set(locales); + export type ParsedPlaygroundRoute = { basePath: string; slug: string[]; @@ -13,8 +17,14 @@ export function internalMarketingPlaygroundBasePath(locale: string): string { return `${INTERNAL_MARKETING_PREFIX}/${locale}/playground`; } +/** App Router public base: `/{lang}/playground` (L1 locale migration). */ +export function appLocalePlaygroundBasePath(lang: string): string { + return `/${lang}/playground`; +} + /** - * Parses `/playground/...` or `/internal-marketing/{locale}/playground/...`. + * Parses `/playground/...`, `/internal-marketing/{locale}/playground/...`, + * or `/{lang}/playground/...` when `lang` is a known locale. */ export function parsePlaygroundPathname( pathname: string, @@ -48,6 +58,19 @@ export function parsePlaygroundPathname( }; } + const appLocaleMatch = pathOnly.match(/^\/([^/]+)\/playground(?:\/(.*))?$/); + if (appLocaleMatch && localeCodeSet.has(appLocaleMatch[1] ?? "")) { + const lang = appLocaleMatch[1] ?? ""; + const slugPart = appLocaleMatch[2]; + const slug = slugPart + ? slugPart.split("/").filter((segment) => segment.length > 0) + : []; + return { + basePath: appLocalePlaygroundBasePath(lang), + slug, + }; + } + return null; } diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index d8999594..dae7a13d 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -12,12 +12,13 @@ - [x] Tried `next@16.3.0` — local OK; Vercel preview `/api/*` → `/en/404` (2026-08, pre-adapter fix) - [x] Re-tried on PR #165 preview (2026-08-09) — **same regression** (`x-matched-path: /en/404`) - [x] **Stay on `next@16.2.12`** until Vercel + Pages `i18n` API routing is fixed -- [ ] Preview smoke green on 16.3.x before enabling Instant Nav flags -- [ ] Retrying **`next@16.3.2`** (PR branch) — local lint/test/build OK; **Vercel preview smoke + e2e** is merge gate +- [x] Preview smoke green on 16.3.x before enabling Instant Nav flags (merged PR #171 — `next@16.3.2`) +- [x] **`next@16.3.2`** on main — Vercel preview smoke + e2e passed merge gate ## Phase 1b — Locale migration (see `Locale-Migration-Design.md`) -- [ ] `app/[lang]/` public routes (mirror internal-marketing pilots) +- [x] `app/[lang]/` public routes (mirror internal-marketing pilots) — PR branch `cursor/locale-migration-l1-8f0a` +- [ ] Rewrites/redirects from Pages to App (L2) - [ ] Remove `i18n` from `next.config.mjs` (after L3–L4 in locale design) ## Phase 2 — App Router pilot