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
11 changes: 3 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
<!-- BEGIN:nextjs-agent-rules -->

# 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.

<!-- END:nextjs-agent-rules -->

Expand Down
63 changes: 63 additions & 0 deletions e2e/app-locale-routes.spec.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
});
});
28 changes: 28 additions & 0 deletions src/app/[lang]/daily/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <DailyPageView />;
}
15 changes: 15 additions & 0 deletions src/app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 <LocaleAppLayout localeParam={lang}>{children}</LocaleAppLayout>;
}
32 changes: 32 additions & 0 deletions src/app/[lang]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <MarketingHomeView />;
}
45 changes: 45 additions & 0 deletions src/app/[lang]/playground/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 = () => <SplitPanelsLayoutSkeleton />;

export default function LangPlaygroundPage() {
return (
<Suspense fallback={<PlaygroundFallback />}>
<PlaygroundPageView />
</Suspense>
);
}
32 changes: 32 additions & 0 deletions src/app/[lang]/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <PrivacyPageView />;
}
44 changes: 44 additions & 0 deletions src/app/[lang]/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <ProfilePageView />;
}
22 changes: 6 additions & 16 deletions src/app/internal-marketing/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 (
<AppRootLayoutClient session={session} i18n={i18n} locale={locale}>
{children}
</AppRootLayoutClient>
<LocaleAppLayout localeParam={localeParam}>{children}</LocaleAppLayout>
);
}

export function generateStaticParams(): Array<{ locale: string }> {
return locales.map((locale) => ({ locale }));
}
31 changes: 31 additions & 0 deletions src/app/locale-app/LocaleAppLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AppRootLayoutClient session={session} i18n={i18n} locale={locale}>
{children}
</AppRootLayoutClient>
);
}
43 changes: 43 additions & 0 deletions src/app/locale-app/__tests__/publicAppMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading