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
4 changes: 2 additions & 2 deletions e2e/app-locale-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
16 changes: 16 additions & 0 deletions e2e/helpers/dismissCookieBanner.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
54 changes: 54 additions & 0 deletions e2e/locale-migration-l2.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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('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/",
);
});

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",
);
});
});
10 changes: 8 additions & 2 deletions e2e/pilot-routes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from "@playwright/test";

import { dismissCookieBannerIfVisible } from "./helpers/dismissCookieBanner";

/**
* Smoke tests for `/internal-marketing/[locale]/*` App Router pilots.
*
Expand Down Expand Up @@ -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()]);
});
});
1 change: 1 addition & 0 deletions next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
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.
29 changes: 0 additions & 29 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -84,11 +60,6 @@ const config = {
"three",
"zod",
],
i18n: {
locales: i18nLocales,
defaultLocale: "en",
localeDetection: false,
},
images: {
remotePatterns: [
{
Expand Down
21 changes: 21 additions & 0 deletions src/app/(default-locale)/daily/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <DailyPageView />;
}
14 changes: 14 additions & 0 deletions src/app/(default-locale)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 <LocaleAppLayout localeParam={baseLocale}>{children}</LocaleAppLayout>;
}
18 changes: 18 additions & 0 deletions src/app/(default-locale)/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <MarketingHomeView />;
}
40 changes: 40 additions & 0 deletions src/app/(default-locale)/playground/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 = () => <SplitPanelsLayoutSkeleton />;

export default function DefaultLocalePlaygroundPage() {
return (
<Suspense fallback={<PlaygroundFallback />}>
<PlaygroundPageView />
</Suspense>
);
}
21 changes: 21 additions & 0 deletions src/app/(default-locale)/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
return publicPageMetadataFromTranslation(
baseLocale,
"/privacy",
(translation) => ({
title: `${translation.PRIVACY_PAGE_TITLE} — dStruct`,
description: translation.PRIVACY_INTRO,
}),
);
}

export default function DefaultLocalePrivacyPage() {
return <PrivacyPageView />;
}
42 changes: 42 additions & 0 deletions src/app/(default-locale)/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <ProfilePageView />;
}
3 changes: 1 addition & 2 deletions src/app/[lang]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MarketingHomeView />;
Expand Down
5 changes: 1 addition & 4 deletions src/app/internal-marketing/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MarketingHomeView />;
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export default async function RootLayout({
return (
<html lang={locale} dir={htmlDir} className={fontVariableClassNames}>
<head>
{/* Parity with `pages/_document.tsx` — Dark Reader must see this literal empty meta. */}
<meta name="darkreader-lock" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
Expand Down
3 changes: 1 addition & 2 deletions src/features/homePage/ui/MarketingHomeView.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
14 changes: 14 additions & 0 deletions src/i18n/__tests__/localeMigrationRouting.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
16 changes: 16 additions & 0 deletions src/i18n/localeMigrationRouting.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading