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
2 changes: 1 addition & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,5 @@ knowledge_base:
code_guidelines:
enabled: true
filePatterns:
- "AGENTS.md"
- "CLAUDE.md"
- "CLAUDE.md"
6 changes: 3 additions & 3 deletions AGENTS.md → CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# AGENTS.md - AI Agent Instructions for Pumperly
# CLAUDE.md - AI Agent Instructions for Pumperly

> **PLAN MODE**: Use Plan Mode frequently! Before implementing complex features, multi-step tasks, or making significant changes, switch to Plan Mode to think through the approach, consider edge cases, and outline the implementation strategy.

> **IMPORTANT**: Do NOT update this file unless the user explicitly says to. Only the user can authorize changes to AGENTS.md.
> **IMPORTANT**: Do NOT update this file unless the user explicitly says to. Only the user can authorize changes to CLAUDE.md.

> **SECURITY WARNING**: This repository is PUBLIC at [github.com/GeiserX/pumperly](https://github.com/GeiserX/pumperly). **NEVER commit secrets, API keys, passwords, tokens, or any sensitive data.** All secrets must be stored in:
> - GitHub Secrets (for CI/CD)
Expand Down Expand Up @@ -380,7 +380,7 @@ pumperly/
│ ├── scrapers/{base,cli,spain,france,germany,italy,uk,austria,portugal,slovenia,...}.ts
│ └── types/station.ts
├── docker/Dockerfile
├── AGENTS.md
├── CLAUDE.md
├── ROADMAP.md
├── README.md
└── package.json
Expand Down
9 changes: 1 addition & 8 deletions src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import type { Locale } from "@/lib/i18n";
import { headers } from "next/headers";
import {
OG_TRANSLATIONS,
SUPPORTED_LOCALES,
Expand All @@ -24,13 +23,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {

const og = OG_TRANSLATIONS[locale];

const h = await headers();
const originalPath = h.get("x-pumperly-original-path");
const isRoot = originalPath === "/";

const canonicalUrl = isRoot
? "https://pumperly.com"
: `https://pumperly.com/${locale}`;
const canonicalUrl = `https://pumperly.com/${locale}`;

const alternateLocales = SUPPORTED_LOCALES.filter((l) => l !== locale).map(
(l) => OG_TRANSLATIONS[l].ogLocale,
Expand Down
25 changes: 10 additions & 15 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,18 @@ const BASE = "https://pumperly.com";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();

return [
{
url: BASE,
lastModified: now,
changeFrequency: "daily",
priority: 1.0,
alternates: {
languages: Object.fromEntries(
return SUPPORTED_LOCALES.map((locale) => ({
url: `${BASE}/${locale}`,
lastModified: now,
changeFrequency: "daily" as const,
priority: 1.0,
alternates: {
languages: {
...Object.fromEntries(
SUPPORTED_LOCALES.map((l) => [l, `${BASE}/${l}`]),
),
"x-default": BASE,
},
},
...SUPPORTED_LOCALES.map((locale) => ({
url: `${BASE}/${locale}`,
lastModified: now,
changeFrequency: "daily" as const,
priority: 0.9,
})),
];
}));
}
48 changes: 20 additions & 28 deletions src/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ vi.mock("next/server", () => {
return res;
}

static redirect(url: unknown, status?: number) {
const res = new MockNextResponse();
responses.push({ type: "redirect", args: [url, status] });
return res;
}

static json(data: unknown, init?: unknown) {
return { data, init };
}
Expand Down Expand Up @@ -160,53 +166,40 @@ describe("middleware", () => {
});
});

describe("middleware root rewrite", () => {
describe("middleware root redirect", () => {
beforeEach(() => {
(NextResponse as any)._responses.length = 0;
});

it("sets x-pumperly-original-path header on root path rewrite", () => {
it("redirects root path to detected locale", () => {
const req = makeRequest("/");
middleware(req);

// The rewrite call is captured in _responses
const responses = (NextResponse as any)._responses as Array<{
type: string;
args: unknown[];
}>;
const rewriteCall = responses.find((r) => r.type === "rewrite");
expect(rewriteCall).toBeDefined();

// The second arg is the options with request.headers
const opts = rewriteCall!.args[1] as {
request?: { headers?: Map<string, string> };
};
expect(opts?.request?.headers).toBeDefined();
const headers = opts!.request!.headers!;
expect(headers.get("x-pumperly-original-path")).toBe("/");
expect(headers.get("x-pumperly-locale")).toBeDefined();
const redirectCall = responses.find((r) => r.type === "redirect");
expect(redirectCall).toBeDefined();
expect(redirectCall!.args[1]).toBe(302);
});

it("sets x-pumperly-original-path for non-root paths without locale", () => {
it("redirects non-root paths without locale", () => {
const req = makeRequest("/some/page");
middleware(req);

const responses = (NextResponse as any)._responses as Array<{
type: string;
args: unknown[];
}>;
const rewriteCall = responses.find((r) => r.type === "rewrite");
expect(rewriteCall).toBeDefined();
const redirectCall = responses.find((r) => r.type === "redirect");
expect(redirectCall).toBeDefined();

const opts = rewriteCall!.args[1] as {
request?: { headers?: Map<string, string> };
};
expect(opts!.request!.headers!.get("x-pumperly-original-path")).toBe(
"/some/page",
);
const url = redirectCall!.args[0] as { pathname: string };
expect(url.pathname).toBe("/es/some/page");
});

it("rewrites URL to include detected locale prefix", () => {
it("redirects to detected locale from accept-language", () => {
const req = makeRequest("/", {
headers: { "accept-language": "fr-FR,fr;q=0.9" },
});
Expand All @@ -216,11 +209,10 @@ describe("middleware root rewrite", () => {
type: string;
args: unknown[];
}>;
const rewriteCall = responses.find((r) => r.type === "rewrite");
expect(rewriteCall).toBeDefined();
const redirectCall = responses.find((r) => r.type === "redirect");
expect(redirectCall).toBeDefined();

// First arg is the rewrite URL
const url = rewriteCall!.args[0] as { pathname: string };
const url = redirectCall!.args[0] as { pathname: string };
expect(url.pathname).toBe("/fr/");
});
});
Expand Down
7 changes: 2 additions & 5 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,11 @@ export function middleware(req: NextRequest) {
return res;
}

// Root "/" — rewrite internally to /[detected-locale] (URL stays as /)
// Root "/" or unlocalized path — redirect to /[detected-locale] so search engines see distinct URLs
const locale = detectLocale(req);
const url = req.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-pumperly-locale", locale);
requestHeaders.set("x-pumperly-original-path", pathname);
const res = NextResponse.rewrite(url, { request: { headers: requestHeaders } });
const res = NextResponse.redirect(url, 302);
res.cookies.set("pumperly-locale", locale, { path: "/", maxAge: 60 * 60 * 24 * 365, sameSite: "lax" });
return res;
}
Expand Down
Loading