From 8bbfcee186006349a322f7cc479fa65dd969f152 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 20:32:02 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(app):=20locale=20migration=20L2=20?= =?UTF-8?q?=E2=80=94=20default-locale=20App=20cutover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add app/(default-locale)/ at unprefixed URLs (/ , /privacy, /daily, /playground, /profile). Remove duplicate Pages marketing routes (Next.js forbids App + Pages on the same path). Drop next.config i18n so app/[lang] serves /de, /fr, etc. Extend proxy locale header for unprefixed paths. Includes L2 e2e smoke, preview-smoke /daily path, and design doc updates. --- e2e/app-locale-routes.spec.ts | 4 +- e2e/locale-migration-l2.spec.ts | 49 +++++++++++++ next-env.d.ts | 1 + next.config.mjs | 29 -------- src/app/(default-locale)/daily/page.tsx | 21 ++++++ src/app/(default-locale)/layout.tsx | 14 ++++ src/app/(default-locale)/page.tsx | 18 +++++ .../playground/[[...slug]]/page.tsx | 40 +++++++++++ src/app/(default-locale)/privacy/page.tsx | 21 ++++++ .../profile/[userId]/page.tsx | 42 +++++++++++ src/app/[lang]/page.tsx | 3 +- src/app/internal-marketing/[locale]/page.tsx | 5 +- .../homePage/ui/MarketingHomeView.tsx | 3 +- .../__tests__/localeMigrationRouting.test.ts | 14 ++++ src/i18n/localeMigrationRouting.ts | 16 +++++ src/pages/daily.tsx | 29 -------- src/pages/index.tsx | 35 ---------- src/pages/playground/[[...slug]].tsx | 69 ------------------- src/pages/privacy.tsx | 27 -------- src/pages/profile/[userId].tsx | 46 ------------- src/proxy.ts | 18 ++++- src/scripts/previewSmoke.ts | 1 + vibe-docs/Instant-Navigations-TODO.md | 11 +-- vibe-docs/Locale-Migration-Design.md | 19 +++-- 24 files changed, 275 insertions(+), 260 deletions(-) create mode 100644 e2e/locale-migration-l2.spec.ts create mode 100644 src/app/(default-locale)/daily/page.tsx create mode 100644 src/app/(default-locale)/layout.tsx create mode 100644 src/app/(default-locale)/page.tsx create mode 100644 src/app/(default-locale)/playground/[[...slug]]/page.tsx create mode 100644 src/app/(default-locale)/privacy/page.tsx create mode 100644 src/app/(default-locale)/profile/[userId]/page.tsx create mode 100644 src/i18n/__tests__/localeMigrationRouting.test.ts create mode 100644 src/i18n/localeMigrationRouting.ts delete mode 100644 src/pages/daily.tsx delete mode 100644 src/pages/index.tsx delete mode 100644 src/pages/playground/[[...slug]].tsx delete mode 100644 src/pages/privacy.tsx delete mode 100644 src/pages/profile/[userId].tsx diff --git a/e2e/app-locale-routes.spec.ts b/e2e/app-locale-routes.spec.ts index bf5b976a..d2b34513 100644 --- a/e2e/app-locale-routes.spec.ts +++ b/e2e/app-locale-routes.spec.ts @@ -3,8 +3,8 @@ 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. + * Explicit `/en/*` URLs redirect to unprefixed canonicals in L2; see + * `e2e/locale-migration-l2.spec.ts` for unprefixed `/` and `/privacy`. */ test.describe("app/[lang] public routes", () => { test("en home is indexable with public canonical", async ({ page }) => { diff --git a/e2e/locale-migration-l2.spec.ts b/e2e/locale-migration-l2.spec.ts new file mode 100644 index 00000000..1eb1d788 --- /dev/null +++ b/e2e/locale-migration-l2.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; + +/** + * Smoke tests for locale migration L2: unprefixed default-locale URLs serve App + * `(default-locale)/*`. Explicit `/en/*` may also serve App `[lang]` with the same canonicals. + */ +test.describe("locale migration L2 public URLs", () => { + test("unprefixed home is indexable with public canonical", async ({ + page, + }) => { + await page.goto("/"); + 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("unprefixed privacy is indexable with public canonical", async ({ + page, + }) => { + await page.goto("/privacy"); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + "https://dstruct.pro/privacy", + ); + }); + + test("unprefixed daily is indexable with public canonical", async ({ + page, + }) => { + await page.goto("/daily"); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + "https://dstruct.pro/daily", + ); + }); + + test("explicit /en/privacy serves unprefixed canonical", async ({ page }) => { + await page.goto("/en/privacy"); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + "https://dstruct.pro/privacy", + ); + }); +}); diff --git a/next-env.d.ts b/next-env.d.ts index 2d5420eb..ca6651b4 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,6 +2,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs index 979345ea..5385b73f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,30 +7,6 @@ void ( !process.env.SKIP_ENV_VALIDATION && (await import("./src/env/server.mjs")) ); -/** Keep in sync with `src/i18n/i18n-util.ts` `locales`. */ -const i18nLocales = [ - "ar", - "be", - "de", - "en", - "es", - "fr", - "hi", - "id", - "it", - "ja", - "ko", - "nl", - "pl", - "pt", - "ru", - "sr", - "tr", - "uk", - "vi", - "zh", -]; - /** @type {import("next").NextConfig} */ const config = { reactStrictMode: true, @@ -84,11 +60,6 @@ const config = { "three", "zod", ], - i18n: { - locales: i18nLocales, - defaultLocale: "en", - localeDetection: false, - }, images: { remotePatterns: [ { diff --git a/src/app/(default-locale)/daily/page.tsx b/src/app/(default-locale)/daily/page.tsx new file mode 100644 index 00000000..0a596730 --- /dev/null +++ b/src/app/(default-locale)/daily/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { DailyPageView } from "#/features/homePage/ui/DailyPageView"; +import { baseLocale } from "#/i18n/i18n-util"; + +import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; + +export async function generateMetadata(): Promise { + return publicPageMetadataFromTranslation( + baseLocale, + "/daily", + (translation) => ({ + title: `${translation.HOME_DAILY_SECTION_TITLE} — dStruct`, + description: `${translation.HOME_DAILY_SECTION_TITLE}. ${translation.HOME_DAILY_SECTION_LEAD}`, + }), + ); +} + +export default function DefaultLocaleDailyPage() { + return ; +} diff --git a/src/app/(default-locale)/layout.tsx b/src/app/(default-locale)/layout.tsx new file mode 100644 index 00000000..32370ecb --- /dev/null +++ b/src/app/(default-locale)/layout.tsx @@ -0,0 +1,14 @@ +import { baseLocale } from "#/i18n/i18n-util"; + +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; + +/** Default-locale (`en`) public App shell at unprefixed URLs (L2). */ +export const dynamic = "force-dynamic"; + +export default async function DefaultLocaleLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(default-locale)/page.tsx b/src/app/(default-locale)/page.tsx new file mode 100644 index 00000000..a9193978 --- /dev/null +++ b/src/app/(default-locale)/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; + +import { MarketingHomeView } from "#/features/homePage/ui/MarketingHomeView"; +import { baseLocale } from "#/i18n/i18n-util"; + +import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; + +export async function generateMetadata(): Promise { + return publicPageMetadataFromTranslation(baseLocale, "/", (translation) => ({ + title: translation.SITE_SEO_TITLE, + description: translation.SITE_SEO_DESCRIPTION, + })); +} + +/** App Router home at `/` (default locale; non-`en` locales use `app/[lang]`). */ +export default function DefaultLocaleHomePage() { + return ; +} diff --git a/src/app/(default-locale)/playground/[[...slug]]/page.tsx b/src/app/(default-locale)/playground/[[...slug]]/page.tsx new file mode 100644 index 00000000..715c049b --- /dev/null +++ b/src/app/(default-locale)/playground/[[...slug]]/page.tsx @@ -0,0 +1,40 @@ +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 { baseLocale } 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<{ slug?: string[] }>; +}): Promise { + const { slug } = await params; + const slugStr = slug?.[0]; + const pagePath = slugStr ? `/playground/${slugStr}` : "/playground"; + const { pageTitle, pageDescription } = await resolvePlaygroundPageSeo( + baseLocale, + slugStr, + ); + + return publicAppMetadata({ + locale: baseLocale, + pagePath, + title: pageTitle, + description: pageDescription, + }); +} + +const PlaygroundFallback: React.FC = () => ; + +export default function DefaultLocalePlaygroundPage() { + return ( + }> + + + ); +} diff --git a/src/app/(default-locale)/privacy/page.tsx b/src/app/(default-locale)/privacy/page.tsx new file mode 100644 index 00000000..c6ce1443 --- /dev/null +++ b/src/app/(default-locale)/privacy/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { PrivacyPageView } from "#/features/privacy/ui/PrivacyPageView"; +import { baseLocale } from "#/i18n/i18n-util"; + +import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; + +export async function generateMetadata(): Promise { + return publicPageMetadataFromTranslation( + baseLocale, + "/privacy", + (translation) => ({ + title: `${translation.PRIVACY_PAGE_TITLE} — dStruct`, + description: translation.PRIVACY_INTRO, + }), + ); +} + +export default function DefaultLocalePrivacyPage() { + return ; +} diff --git a/src/app/(default-locale)/profile/[userId]/page.tsx b/src/app/(default-locale)/profile/[userId]/page.tsx new file mode 100644 index 00000000..105ce27d --- /dev/null +++ b/src/app/(default-locale)/profile/[userId]/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { ProfilePageView } from "#/features/profile/ui/ProfilePageView"; +import { baseLocale } from "#/i18n/i18n-util"; + +import { publicPageMetadataFromTranslation } from "#/app/locale-app/publicPageMetadataFromTranslation"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ userId: string }>; +}): Promise { + const { userId } = await params; + if (!userId.trim()) { + return {}; + } + const pagePath = `/profile/${userId}`; + + return publicPageMetadataFromTranslation( + baseLocale, + pagePath, + (translation) => ({ + title: `${translation.PROFILE} — dStruct`, + description: translation.SITE_SEO_DESCRIPTION, + }), + { indexable: false }, + ); +} + +export default async function DefaultLocaleProfilePage({ + params, +}: { + params: Promise<{ userId: string }>; +}) { + const { userId } = await params; + if (!userId.trim()) { + notFound(); + } + + return ; +} diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index 53ffbe1b..1133e76a 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -24,8 +24,7 @@ export async function generateMetadata({ } /** - * App Router home at `/{lang}` (e.g. `/en`, `/de`). - * Default-locale public `/` remains Pages until locale migration L2/L3. + * App Router home at `/{lang}` (e.g. `/de`). Default-locale `/` uses `(default-locale)`. */ export default function LangHomePage() { return ; diff --git a/src/app/internal-marketing/[locale]/page.tsx b/src/app/internal-marketing/[locale]/page.tsx index 74abadd5..6125ba89 100644 --- a/src/app/internal-marketing/[locale]/page.tsx +++ b/src/app/internal-marketing/[locale]/page.tsx @@ -26,10 +26,7 @@ export async function generateMetadata({ })); } -/** - * Instant Nav pilot (App Router). Public home remains Pages `pages/index` - * until locale routing leaves `next.config` `i18n`. - */ +/** Instant Nav pilot (App Router). Public home is `app/(default-locale)` / `app/[lang]`. */ export default function InternalMarketingHomePage() { return ; } diff --git a/src/features/homePage/ui/MarketingHomeView.tsx b/src/features/homePage/ui/MarketingHomeView.tsx index 7de951bc..30ee42fc 100644 --- a/src/features/homePage/ui/MarketingHomeView.tsx +++ b/src/features/homePage/ui/MarketingHomeView.tsx @@ -1,8 +1,7 @@ "use client"; /** - * Marketing home UI. Public `/` uses Pages `pages/index`; App Router - * `internal-marketing/[locale]` reuses this for the Instant Nav pilot. + * Marketing home UI. Public `/` and `app/[lang]` reuse this; internal-marketing pilot too. */ import { useState } from "react"; diff --git a/src/i18n/__tests__/localeMigrationRouting.test.ts b/src/i18n/__tests__/localeMigrationRouting.test.ts new file mode 100644 index 00000000..fccf84b9 --- /dev/null +++ b/src/i18n/__tests__/localeMigrationRouting.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { isDefaultLocalePublicMarketingPath } from "#/i18n/localeMigrationRouting"; + +describe("localeMigrationRouting", () => { + it("detects default-locale public marketing paths", () => { + expect(isDefaultLocalePublicMarketingPath("/")).toBe(true); + expect(isDefaultLocalePublicMarketingPath("/privacy")).toBe(true); + expect(isDefaultLocalePublicMarketingPath("/playground/foo")).toBe(true); + expect(isDefaultLocalePublicMarketingPath("/profile/u1")).toBe(true); + expect(isDefaultLocalePublicMarketingPath("/de")).toBe(false); + expect(isDefaultLocalePublicMarketingPath("/api/trpc")).toBe(false); + }); +}); diff --git a/src/i18n/localeMigrationRouting.ts b/src/i18n/localeMigrationRouting.ts new file mode 100644 index 00000000..25c2589b --- /dev/null +++ b/src/i18n/localeMigrationRouting.ts @@ -0,0 +1,16 @@ +/** Unprefixed default-locale marketing paths served by App `(default-locale)` (L2). */ +export function isDefaultLocalePublicMarketingPath(pathname: string): boolean { + if (pathname === "/") { + return true; + } + if (pathname === "/privacy" || pathname === "/daily") { + return true; + } + if (pathname === "/playground" || pathname.startsWith("/playground/")) { + return true; + } + if (pathname.startsWith("/profile/")) { + return true; + } + return false; +} diff --git a/src/pages/daily.tsx b/src/pages/daily.tsx deleted file mode 100644 index 157bdea7..00000000 --- a/src/pages/daily.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { InferGetStaticPropsType, NextPage } from "next"; - -import { DailyPageView } from "#/features/homePage/ui/DailyPageView"; -import { getI18nPropsWithCanonical } from "#/i18n/getI18nProps"; -import { useI18nContext } from "#/shared/hooks"; -import { SiteSeoHead } from "#/shared/ui/seo/SiteSeoHead"; - -export const getStaticProps = getI18nPropsWithCanonical("/daily"); - -type DailyProblemPageProps = InferGetStaticPropsType; - -const DailyProblemPage: NextPage = ({ - canonicalUrl, -}) => { - const { LL } = useI18nContext(); - - return ( - <> - - - - ); -}; - -export default DailyProblemPage; diff --git a/src/pages/index.tsx b/src/pages/index.tsx deleted file mode 100644 index a2f274a2..00000000 --- a/src/pages/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { InferGetStaticPropsType, NextPage } from "next"; - -import { MarketingHomeView } from "#/features/homePage/ui/MarketingHomeView"; -import { getI18nPropsWithCanonical } from "#/i18n/getI18nProps"; -import { useI18nContext } from "#/shared/hooks"; -import { SiteSeoHead } from "#/shared/ui/seo/SiteSeoHead"; - -export const getStaticProps = getI18nPropsWithCanonical("/"); - -type DashboardProps = InferGetStaticPropsType; - -/** - * Public marketing home (Pages Router + `i18n`). - * - * App Router `internal-marketing/[locale]` keeps the Instant Nav pilot, but - * Pages `i18n` cannot rewrite bare `/{locale}` into App routes (invoke path - * becomes `/{locale}/internal-marketing/...` → 404). Public cutover waits on - * migrating locale routing off `next.config` `i18n`. - */ -const DashboardPage: NextPage = ({ canonicalUrl }) => { - const { LL } = useI18nContext(); - - return ( - <> - - - - ); -}; - -export default DashboardPage; diff --git a/src/pages/playground/[[...slug]].tsx b/src/pages/playground/[[...slug]].tsx deleted file mode 100644 index 3ceea7b0..00000000 --- a/src/pages/playground/[[...slug]].tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { GetServerSideProps, NextPage } from "next"; - -import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlaygroundPageSeo"; -import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView"; -import { loadI18nServerProps, localeFromContext } from "#/i18n/getI18nProps"; -import { - absoluteUrlFromPathname, - pathnameFromResolvedUrl, -} from "#/shared/lib/seo"; -import { - resolveSsrDeviceType, - setDeviceHintResponseHeaders, -} from "#/shared/lib/ssrDevice"; -import { SiteSeoHead } from "#/shared/ui/seo/SiteSeoHead"; -import type { SsrDeviceType } from "#/themes"; - -type PlaygroundPageProps = { - ssrDeviceType: SsrDeviceType; - canonicalUrl: string; - pageTitle: string; - pageDescription: string; -}; - -const PlaygroundPage: NextPage = ({ - canonicalUrl, - pageTitle, - pageDescription, -}) => ( - <> - - - -); - -export const getServerSideProps: GetServerSideProps< - PlaygroundPageProps -> = async (context) => { - const { req, res, params, resolvedUrl } = context; - const ssrDeviceType = resolveSsrDeviceType(req.headers); - setDeviceHintResponseHeaders(res); - - const slug = params?.slug; - const slugStr = Array.isArray(slug) ? slug[0] : undefined; - const pathOnly = pathnameFromResolvedUrl(resolvedUrl) || "/playground"; - const canonicalUrl = absoluteUrlFromPathname(pathOnly); - - const locale = localeFromContext(context); - const { pageTitle, pageDescription } = await resolvePlaygroundPageSeo( - locale, - slugStr, - ); - const { i18n } = await loadI18nServerProps(context); - - return { - props: { - ssrDeviceType, - canonicalUrl, - pageTitle, - pageDescription, - i18n, - }, - }; -}; - -export default PlaygroundPage; diff --git a/src/pages/privacy.tsx b/src/pages/privacy.tsx deleted file mode 100644 index 2480cf0e..00000000 --- a/src/pages/privacy.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { InferGetStaticPropsType, NextPage } from "next"; - -import { PrivacyPageView } from "#/features/privacy/ui/PrivacyPageView"; -import { getI18nPropsWithCanonical } from "#/i18n/getI18nProps"; -import { useI18nContext } from "#/shared/hooks"; -import { SiteSeoHead } from "#/shared/ui/seo/SiteSeoHead"; - -export const getStaticProps = getI18nPropsWithCanonical("/privacy"); - -type PrivacyPageProps = InferGetStaticPropsType; - -const PrivacyPage: NextPage = ({ canonicalUrl }) => { - const { LL } = useI18nContext(); - - return ( - <> - - - - ); -}; - -export default PrivacyPage; diff --git a/src/pages/profile/[userId].tsx b/src/pages/profile/[userId].tsx deleted file mode 100644 index ddfcd096..00000000 --- a/src/pages/profile/[userId].tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { NextPage } from "next"; - -import { ProfilePageView } from "#/features/profile/ui/ProfilePageView"; -import { withI18nServerSideProps } from "#/i18n/getI18nProps"; -import { useI18nContext } from "#/shared/hooks"; -import { - absoluteUrlFromPathname, - pathnameFromResolvedUrl, -} from "#/shared/lib/seo"; -import { SiteSeoHead } from "#/shared/ui/seo/SiteSeoHead"; - -type ProfilePageProps = { - canonicalUrl: string; -}; - -const ProfilePage: NextPage = ({ canonicalUrl }) => { - const { LL } = useI18nContext(); - - return ( - <> - - - - ); -}; - -export const getServerSideProps = withI18nServerSideProps( - async (ctx) => { - const raw = ctx.params?.userId; - const profileUserId = typeof raw === "string" ? raw : ""; - if (!profileUserId) { - return { notFound: true }; - } - const pathOnly = - pathnameFromResolvedUrl(ctx.resolvedUrl) || `/profile/${profileUserId}`; - const canonicalUrl = absoluteUrlFromPathname(pathOnly); - return { props: { canonicalUrl } }; - }, -); - -export default ProfilePage; diff --git a/src/proxy.ts b/src/proxy.ts index e7caade7..cf10a375 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,7 +1,8 @@ import { getAll } from "@vercel/edge-config"; import { type NextRequest, NextResponse } from "next/server"; -import { locales } from "#/i18n/i18n-util"; +import { baseLocale, locales } from "#/i18n/i18n-util"; +import { isDefaultLocalePublicMarketingPath } from "#/i18n/localeMigrationRouting"; import { APP_ROUTER_LOCALE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; const localeSet = new Set(locales); @@ -27,9 +28,9 @@ function localeFromPathname(pathname: string): string | null { * * - Serves `/api/config` from Edge Config. * - Sets {@link APP_ROUTER_LOCALE_HEADER} for App Router locale paths - * (`/internal-marketing/[locale]/*` pilot and `/[lang]/*` L1 shell). + * (`/internal-marketing/[locale]/*`, `/[lang]/*`, and L2 unprefixed default-locale marketing). * - * Public `/` and unprefixed `/privacy` stay on Pages until locale migration L2/L3. + * Unprefixed `/`, `/privacy`, … are App `(default-locale)` routes (L2). */ export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -48,6 +49,12 @@ export async function proxy(request: NextRequest) { } } + if (isDefaultLocalePublicMarketingPath(pathname)) { + return NextResponse.next({ + request: { headers: withLocaleHeader(request, baseLocale) }, + }); + } + const locale = localeFromPathname(pathname); if (locale) { return NextResponse.next({ @@ -61,6 +68,11 @@ export async function proxy(request: NextRequest) { export const config = { matcher: [ "/api/config", + "/", + "/privacy", + "/daily", + "/playground/:path*", + "/profile/:path*", { source: "/internal-marketing/:path*", locale: false, diff --git a/src/scripts/previewSmoke.ts b/src/scripts/previewSmoke.ts index 0050255a..a0429c83 100644 --- a/src/scripts/previewSmoke.ts +++ b/src/scripts/previewSmoke.ts @@ -69,6 +69,7 @@ const pagePaths = [ "/", "/de", "/privacy", + "/daily", "/playground", "/profile/smoke-user", "/internal-marketing/en", diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index dae7a13d..3e530b4b 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -17,9 +17,10 @@ ## Phase 1b — Locale migration (see `Locale-Migration-Design.md`) -- [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) +- [x] `app/[lang]/` public routes (mirror internal-marketing pilots) — merged #172 +- [x] Rewrites/redirects from Pages to App (L2) — `app/(default-locale)/` at unprefixed URLs +- [x] Remove default-locale Pages marketing (`index`, `privacy`, `daily`, `playground`, `profile`) — L3 partial +- [x] Remove `i18n` from `next.config.mjs` (required once Pages marketing deleted — L2/L4) ## Phase 2 — App Router pilot @@ -27,12 +28,12 @@ - [x] `src/app/layout.tsx` + `internal-marketing/[locale]/` pilot - [x] `MarketingHomeView` shared by Pages home + App pilot - [x] Dual-router shell (`next/compat/router`) so App pilot does not throw -- [x] Public `/` / `/{locale}` stay on Pages `pages/index` (Pages `i18n` cannot rewrite bare locales into App routes) +- [x] Public `/` served from App `(default-locale)` (Pages marketing removed L2/L3) - [x] `proxy.ts`: `/api/config` + locale header for direct `/internal-marketing/*` - [x] App layout metadata (viewport, icons, Material Icons) - [x] `robots: noindex` on pilot routes (`internalMarketingPilotMetadata` + layout default) - [x] Extend pilot to `/internal-marketing/[locale]/privacy` and `/daily` -- [ ] Public cutover of home to App (blocked on removing `next.config` `i18n`) +- [x] Public cutover of home to App (L2 `(default-locale)/`; pilot `/internal-marketing` until L3b) - [ ] `cacheComponents` / `partialPrefetching` (blocked: root `headers()` + need 16.3) - [ ] `unstable_instant` on pilot routes (blocked until `cacheComponents`) - [x] Remove unused `@trpc/next` dependency diff --git a/vibe-docs/Locale-Migration-Design.md b/vibe-docs/Locale-Migration-Design.md index e6e51243..40f6b5aa 100644 --- a/vibe-docs/Locale-Migration-Design.md +++ b/vibe-docs/Locale-Migration-Design.md @@ -63,22 +63,27 @@ Locale list: keep **`src/i18n/i18n-util.ts`** as source of truth; `generateStati **Do not** remove `i18n` from config yet. -### L2 — Rewrites / default locale +### L2 — Default-locale App cutover (no rewrites) -1. `next.config` rewrites: `/` → `/en` App home (or default locale) when ready. -2. Redirect duplicate Pages URLs to App (308) per route. -3. Playwright: public App routes + API smoke on preview. +Pages `i18n` auto-redirects `/en/*` → unprefixed URLs, so **`next.config` rewrites to `/en` loop**. L2 instead adds App route group `(default-locale)/` at unprefixed paths; App Router takes precedence over duplicate Pages files. -### L3 — Remove Pages marketing +1. `app/(default-locale)/` — home, privacy, daily, playground, profile (`baseLocale` layout). +2. Extend `proxy.ts` locale header for unprefixed marketing paths. +3. Playwright: unprefixed URLs + existing `app/[lang]` + API smoke. +4. Pages marketing files stay until L3 (unused for default-locale traffic). -1. Delete `pages/index.tsx`, `pages/privacy.tsx`, `pages/daily.tsx` (or stub redirects). +**Do not** remove `i18n` from config yet. + +### L3 — Remove Pages marketing (default locale done in L2) + +1. ~~Delete `pages/index.tsx`, `pages/privacy.tsx`, `pages/daily.tsx`~~ (done with L2 — Next.js forbids duplicate App/Pages paths). 2. Remove `/internal-marketing/*` App pilot (or 301 → public App). ### L4 — Playground + profile on App only 1. Move public traffic to `app/[lang]/playground` + `profile` (already prototyped). 2. Delete Pages `playground` / `profile` after parity tests. -3. **`i18n` block removed** from `next.config.mjs`. +3. **`i18n` block removed** from `next.config.mjs` (required for `app/[lang]` after Pages marketing deleted). ### L5 — Instant Nav flags From b22836c17433a0777e8e24f06692bdc50eff7333 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 20:51:40 +0000 Subject: [PATCH 2/3] fix(e2e): dismiss cookie banner before pilot footer link click Vercel preview uses a fresh session; the consent overlay covers the footer and ambiguous Privacy policy links caused the pilot footer navigation test to flake. --- e2e/helpers/dismissCookieBanner.ts | 16 ++++++++++++++++ e2e/pilot-routes.spec.ts | 10 ++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 e2e/helpers/dismissCookieBanner.ts diff --git a/e2e/helpers/dismissCookieBanner.ts b/e2e/helpers/dismissCookieBanner.ts new file mode 100644 index 00000000..e3b02111 --- /dev/null +++ b/e2e/helpers/dismissCookieBanner.ts @@ -0,0 +1,16 @@ +import type { Page } from "@playwright/test"; + +/** Dismiss the cookie consent banner when present (fresh preview / CI sessions). */ +export async function dismissCookieBannerIfVisible(page: Page): Promise { + const rejectButton = page.getByRole("button", { + name: /reject non-essential/i, + }); + const isVisible = await rejectButton.isVisible().catch(() => false); + if (!isVisible) { + return; + } + await rejectButton.click(); + await rejectButton + .waitFor({ state: "hidden", timeout: 15_000 }) + .catch(() => undefined); +} diff --git a/e2e/pilot-routes.spec.ts b/e2e/pilot-routes.spec.ts index d62d2fdd..a01c7d10 100644 --- a/e2e/pilot-routes.spec.ts +++ b/e2e/pilot-routes.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from "@playwright/test"; +import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner"; + /** * Smoke tests for `/internal-marketing/[locale]/*` App Router pilots. * @@ -76,7 +78,11 @@ test.describe("internal-marketing pilot routes", () => { test("pilot home footer links to public privacy page", async ({ page }) => { await page.goto("/internal-marketing/en"); - await page.getByRole("link", { name: /privacy policy/i }).click(); - await expect(page).toHaveURL(/\/privacy$/); + await dismissCookieBannerIfVisible(page); + const privacyLink = page + .getByRole("contentinfo") + .getByRole("link", { name: /privacy policy/i }); + await privacyLink.scrollIntoViewIfNeeded(); + await Promise.all([page.waitForURL(/\/privacy$/), privacyLink.click()]); }); }); From 00a014113d2a3596e3fa6e5538949b340b242982 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 21:03:10 +0000 Subject: [PATCH 3/3] fix(app): restore darkreader-lock meta on App Router pages Metadata API omits other tags with empty string values. Render the literal in app/layout.tsx to match _document.tsx; add unit and e2e smoke so it stays on the L2 default-locale routes. --- e2e/locale-migration-l2.spec.ts | 5 +++++ src/app/layout.tsx | 2 ++ .../lib/__tests__/appDocumentMetadata.test.ts | 21 +++++++++++++++++++ src/shared/lib/appDocumentMetadata.ts | 3 ++- 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/shared/lib/__tests__/appDocumentMetadata.test.ts diff --git a/e2e/locale-migration-l2.spec.ts b/e2e/locale-migration-l2.spec.ts index 1eb1d788..c2737d90 100644 --- a/e2e/locale-migration-l2.spec.ts +++ b/e2e/locale-migration-l2.spec.ts @@ -11,6 +11,11 @@ test.describe("locale migration L2 public URLs", () => { await page.goto("/"); await expect(page).toHaveTitle(/dStruct/); await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + await expect(page.locator('meta[name="darkreader-lock"]')).toHaveCount(1); + await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute( + "content", + "#121212", + ); await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( "href", "https://dstruct.pro/", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index baa196a9..b4f50d7b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -33,6 +33,8 @@ export default async function RootLayout({ return ( + {/* Parity with `pages/_document.tsx` — Dark Reader must see this literal empty meta. */} + { + it("sets themeColor for PWA chrome parity with _document", () => { + expect(appDocumentViewport.themeColor).toBe("#121212"); + }); + + it("includes mobile PWA meta via Metadata other (non-empty values)", () => { + expect(appDocumentMetadata.other).toMatchObject({ + "mobile-web-app-capable": "yes", + "apple-mobile-web-app-capable": "yes", + "msapplication-TileColor": "#121212", + }); + expect(appDocumentMetadata.other).not.toHaveProperty("darkreader-lock"); + }); +}); diff --git a/src/shared/lib/appDocumentMetadata.ts b/src/shared/lib/appDocumentMetadata.ts index bd95944a..1ac209cd 100644 --- a/src/shared/lib/appDocumentMetadata.ts +++ b/src/shared/lib/appDocumentMetadata.ts @@ -10,7 +10,8 @@ export const appDocumentViewport: Viewport = { export const appDocumentMetadata: Metadata = { other: { - "darkreader-lock": "", + // `darkreader-lock` is rendered as a literal `` in + // `app/layout.tsx` — Metadata `other` skips empty-string values (see e2e head-tag smoke). "mobile-web-app-capable": "yes", "apple-mobile-web-app-capable": "yes", "apple-mobile-web-app-status-bar-style": "black-translucent",