From 144f8de1696834ef089949bc7621a998411b7174 Mon Sep 17 00:00:00 2001 From: gsmt <37253958+rortan134@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:35:09 +0200 Subject: [PATCH 1/4] feat(marketing): add agent-friendly page representations --- app/[locale]/page.tsx | 13 +- app/api/markdown/home/[[...locale]]/route.ts | 97 +++++++++++++ app/api/markdown/sitemap/route.ts | 43 ++++++ app/sitemap.ts | 32 +---- lib/common/accept.test.ts | 108 +++++++++++++++ lib/common/accept.ts | 137 +++++++++++++++++++ lib/marketing/site-map.ts | 64 +++++++++ proxy.ts | 98 ++++++++++++- 8 files changed, 561 insertions(+), 31 deletions(-) create mode 100644 app/api/markdown/home/[[...locale]]/route.ts create mode 100644 app/api/markdown/sitemap/route.ts create mode 100644 lib/common/accept.test.ts create mode 100644 lib/common/accept.ts create mode 100644 lib/marketing/site-map.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 06e6fa4c..4e7d58c6 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -134,8 +134,7 @@ export async function generateMetadata({ }): Promise { const { locale } = await params; const gt = await getGT(); - - return buildPageMetadata({ + const metadata = buildPageMetadata({ description: gt( "The AI bookmark manager for busy people. View, manage, and organize bookmarks across platforms." ), @@ -153,6 +152,16 @@ export async function generateMetadata({ absolute: `Cache | ${gt("Unify your bookmarks across every platform")}`, }, }); + + return { + ...metadata, + alternates: { + ...metadata.alternates, + types: { + "text/markdown": `${BASE_URL}/llms.txt`, + }, + }, + }; } export default async function Home() { diff --git a/app/api/markdown/home/[[...locale]]/route.ts b/app/api/markdown/home/[[...locale]]/route.ts new file mode 100644 index 00000000..97a7ff5b --- /dev/null +++ b/app/api/markdown/home/[[...locale]]/route.ts @@ -0,0 +1,97 @@ +import { BASE_URL } from "@/lib/common/constants"; +import type { SupportedLocale } from "@/lib/common/constants"; + +const HOME_MARKDOWN = { + "en-US": `# Cache + +> The AI bookmark manager for busy people. Collect, organize, and rediscover everything you've saved across platforms. + +## What Cache does + +Cache brings your saved content into a single, searchable, actionable library. + +- **Curate a personal library** — Search saved links, notes, recipes, lessons, and ideas when you need them. +- **Import everything you've already saved** — Sync bookmarks from Chrome, Instagram, TikTok, YouTube, X/Twitter, GitHub, Pinterest, and Google Photos. +- **Build useful collections** — Organize items into named collections, add priorities, and share collections publicly. +- **Make saving a habit** — Use automations and review workflows to bring important content back before it gets buried. +- **Search and read quickly** — Use full-text search, command shortcuts, and distraction-free Quick Look reading. +- **Keep the useful, drop the stale** — Smart Collections help separate actionable material from inspiration, duplicates, and broken links. + +## AI features + +- AI-powered smart collections organize new items automatically. +- AI summaries help you synthesize collections and sections. +- Cache's AI agent can help you find and brainstorm from your saved knowledge. + +## Agent access + +Cache exposes a Model Context Protocol (MCP) server so AI agents can read and write your library securely. The full setup and tool reference is available at [llms.txt](${BASE_URL}/llms.txt). + +## Pricing + +Cache has a free tier. Pro is available from $8/month with unlimited bookmarks, unlimited AI quota, and priority support. + +## Links + +- [Open Cache](${BASE_URL}) +- [Install the Chrome extension](https://chromewebstore.google.com/detail/fibhdcjlclheehonialdpealhemmoikn) +- [GitHub repository](https://github.com/rortan134/cache-app) +- [Full agent context](${BASE_URL}/llms.txt) +`, + "es-ES": `# Cache + +> El gestor de marcadores con IA para personas ocupadas. Recopila, organiza y vuelve a descubrir todo lo que has guardado en distintas plataformas. + +## Qué hace Cache + +Cache reúne todo tu contenido guardado en una biblioteca única, consultable y útil. + +- **Crea una biblioteca personal** — Busca enlaces, notas, recetas, lecciones e ideas guardadas cuando las necesites. +- **Importa todo lo que ya has guardado** — Sincroniza marcadores de Chrome, Instagram, TikTok, YouTube, X/Twitter, GitHub, Pinterest y Google Photos. +- **Crea colecciones útiles** — Organiza elementos en colecciones, añade prioridades y comparte colecciones públicamente. +- **Convierte el guardado en un hábito** — Usa automatizaciones y revisiones para recuperar contenido importante antes de que se pierda. +- **Busca y lee rápidamente** — Usa la búsqueda de texto completo, los atajos de comandos y la lectura sin distracciones de Quick Look. +- **Conserva lo útil y elimina lo obsoleto** — Las Colecciones inteligentes ayudan a separar lo accionable de la inspiración, los duplicados y los enlaces rotos. + +## Funciones de IA + +- Las Colecciones inteligentes con IA organizan automáticamente los elementos nuevos. +- Los resúmenes con IA ayudan a sintetizar colecciones y secciones. +- El agente de IA de Cache puede ayudarte a encontrar ideas y generar lluvias de ideas a partir de tu conocimiento guardado. + +## Acceso para agentes + +Cache ofrece un servidor de Model Context Protocol (MCP) para que los agentes de IA puedan leer y escribir en tu biblioteca de forma segura. La configuración completa y la referencia de herramientas están disponibles en [llms.txt](${BASE_URL}/llms.txt). + +## Precios + +Cache tiene un nivel gratuito. Pro está disponible desde 8 $/mes e incluye marcadores ilimitados, cuota de IA ilimitada y soporte prioritario. + +## Enlaces + +- [Abrir Cache](${BASE_URL}) +- [Instalar la extensión de Chrome](https://chromewebstore.google.com/detail/fibhdcjlclheehonialdpealhemmoikn) +- [Repositorio en GitHub](https://github.com/rortan134/cache-app) +- [Contexto completo para agentes](${BASE_URL}/llms.txt) +`, +} satisfies Record; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ locale?: string[] }> } +): Promise { + const { locale } = await params; + const markdown = + locale?.[0] === "es-ES" + ? HOME_MARKDOWN["es-ES"] + : HOME_MARKDOWN["en-US"]; + + return new Response(markdown, { + headers: { + "Cache-Control": + "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800", + "Content-Type": "text/markdown; charset=utf-8", + Vary: "Accept", + }, + }); +} diff --git a/app/api/markdown/sitemap/route.ts b/app/api/markdown/sitemap/route.ts new file mode 100644 index 00000000..7a6f6774 --- /dev/null +++ b/app/api/markdown/sitemap/route.ts @@ -0,0 +1,43 @@ +import { getDefaultLocale, getLocales } from "gt-next"; + +import { + getLocalizedUrl, + PUBLIC_STATIC_ROUTES, +} from "@/lib/marketing/site-map"; + +const MARKDOWN_SITEMAP_HEADER = `# Cache sitemap + +Public pages and agent resources for Cache. The canonical page URLs below return Markdown when requested with \`Accept: text/markdown\`. + +## Public pages +`; + +export function GET(): Response { + const defaultLocale = getDefaultLocale(); + const locales = getLocales(); + const pages = PUBLIC_STATIC_ROUTES.map((route) => { + const defaultUrl = getLocalizedUrl(defaultLocale, route.path); + const alternateLocales = locales + .filter((locale) => locale !== defaultLocale) + .map( + (locale) => + ` - [${route.title} (${locale})](${getLocalizedUrl(locale, route.path)})` + ); + + return [ + `- [${route.title}](${defaultUrl}) — ${route.description}`, + ...alternateLocales, + ].join("\n"); + }).join("\n"); + + const sitemap = `${MARKDOWN_SITEMAP_HEADER}\n${pages}\n\n## Agent resources\n\n- [Full agent context](/llms.txt)\n- [MCP server endpoint](/mcp)\n`; + + return new Response(sitemap, { + headers: { + "Cache-Control": + "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800", + "Content-Type": "text/markdown; charset=utf-8", + Vary: "Accept", + }, + }); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 6adf4d48..c51fc7a8 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,34 +1,10 @@ -import { BASE_URL } from "@/lib/common/constants"; -import { normalizeURL } from "@/lib/common/url"; +import { + getLocalizedUrl, + PUBLIC_STATIC_ROUTES, +} from "@/lib/marketing/site-map"; import { getDefaultLocale, getLocales } from "gt-next"; import type { MetadataRoute } from "next"; -interface SitemapRoute { - path: `/${string}`; - priority: number; -} - -/** - * Public static routes that do not require authentication. - * Authenticated-only routes (e.g. /library) are intentionally excluded - * because they redirect anonymous users and should not be indexed. - */ -const PUBLIC_STATIC_ROUTES = [ - { path: "/", priority: 1 }, - { path: "/library", priority: 0.85 }, - { path: "/changelog", priority: 0.7 }, - { path: "/security", priority: 0.7 }, - { path: "/legal", priority: 0.7 }, - { path: "/legal/terms-of-service", priority: 0.7 }, - { path: "/legal/privacy-policy", priority: 0.7 }, -] satisfies SitemapRoute[]; - -function getLocalizedUrl(locale: string, path: SitemapRoute["path"]) { - return normalizeURL( - path === "/" ? `${BASE_URL}/${locale}` : `${BASE_URL}/${locale}${path}` - ); -} - export default function sitemap(): MetadataRoute.Sitemap { const locales = getLocales(); const defaultLocale = getDefaultLocale(); diff --git a/lib/common/accept.test.ts b/lib/common/accept.test.ts new file mode 100644 index 00000000..e750cdf7 --- /dev/null +++ b/lib/common/accept.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; + +import { appendVaryAccept, negotiateContentType } from "@/lib/common/accept"; + +const SUPPORTED_TYPES = ["text/html", "text/markdown"] as const; + +describe("negotiateContentType", () => { + test("prefers markdown when it is listed first", () => { + expect( + negotiateContentType( + "text/markdown, text/html;q=0.8, */*;q=0.1", + SUPPORTED_TYPES, + "text/html" + ) + ).toBe("text/markdown"); + }); + + test("uses the highest quality value", () => { + expect( + negotiateContentType( + "text/html;q=0.9, text/markdown;q=0.5", + SUPPORTED_TYPES, + "text/html" + ) + ).toBe("text/html"); + }); + + test("respects a specific zero-quality rejection over a wildcard", () => { + expect( + negotiateContentType( + "text/markdown;q=0, */*;q=1", + SUPPORTED_TYPES, + "text/html" + ) + ).toBe("text/html"); + }); + + test("returns null when every supported type is rejected", () => { + expect( + negotiateContentType( + "text/markdown;q=0, text/html;q=0", + SUPPORTED_TYPES, + "text/html" + ) + ).toBeNull(); + }); + + test("defaults to HTML when Accept is missing or unrestricted", () => { + expect(negotiateContentType(null, SUPPORTED_TYPES, "text/html")).toBe( + "text/html" + ); + expect(negotiateContentType("*/*", SUPPORTED_TYPES, "text/html")).toBe( + "text/html" + ); + }); + + test("returns null for an unsupported media type", () => { + expect( + negotiateContentType( + "application/json", + SUPPORTED_TYPES, + "text/html" + ) + ).toBeNull(); + }); + + test("negotiates XML as the sitemap default representation", () => { + expect( + negotiateContentType( + "application/xml", + ["application/xml", "text/markdown"], + "application/xml" + ) + ).toBe("application/xml"); + expect( + negotiateContentType( + "text/xml", + ["application/xml", "text/xml", "text/markdown"], + "application/xml" + ) + ).toBe("text/xml"); + expect( + negotiateContentType( + "*/*", + ["application/xml", "text/markdown"], + "application/xml" + ) + ).toBe("application/xml"); + }); +}); + +describe("appendVaryAccept", () => { + test("preserves existing values and avoids duplicates", () => { + const headers = new Headers({ Vary: "RSC, Accept" }); + + appendVaryAccept(headers); + + expect(headers.get("Vary")).toBe("RSC, Accept"); + }); + + test("adds Accept when it is missing", () => { + const headers = new Headers({ Vary: "RSC" }); + + appendVaryAccept(headers); + + expect(headers.get("Vary")).toBe("RSC, Accept"); + }); +}); diff --git a/lib/common/accept.ts b/lib/common/accept.ts new file mode 100644 index 00000000..16b95b3d --- /dev/null +++ b/lib/common/accept.ts @@ -0,0 +1,137 @@ +interface AcceptEntry { + position: number; + quality: number; + specificity: number; + type: string; +} + +/** + * Selects the best representation from a server's supported media types. + * Specific Accept ranges take precedence over wildcards for each candidate, + * even when the wildcard has a higher quality value. + */ +export function negotiateContentType( + acceptHeader: string | null, + supportedTypes: readonly string[], + defaultType: string +): string | null { + if (!acceptHeader) { + return defaultType; + } + + const entries = parseAcceptHeader(acceptHeader); + if (entries.length === 0) { + return defaultType; + } + + let bestPosition = Number.POSITIVE_INFINITY; + let bestQuality = -1; + let bestType: string | null = null; + + for (const supportedType of supportedTypes) { + const normalizedSupportedType = supportedType.toLowerCase(); + let bestMatch: AcceptEntry | null = null; + + for (const entry of entries) { + if (!matchesMediaType(entry.type, normalizedSupportedType)) { + continue; + } + + if ( + bestMatch === null || + entry.specificity > bestMatch.specificity || + (entry.specificity === bestMatch.specificity && + entry.position < bestMatch.position) + ) { + bestMatch = entry; + } + } + + if (bestMatch === null || bestMatch.quality <= 0) { + continue; + } + + if ( + bestMatch.quality > bestQuality || + (bestMatch.quality === bestQuality && + bestMatch.position < bestPosition) + ) { + bestQuality = bestMatch.quality; + bestPosition = bestMatch.position; + bestType = supportedType; + } + } + + return bestType; +} + +/** Adds `Accept` to Vary without dropping values set by another layer. */ +export function appendVaryAccept(headers: Headers): void { + const existing = headers.get("Vary"); + if (!existing) { + headers.set("Vary", "Accept"); + return; + } + + const values = existing.split(",").map((value) => value.trim()); + if (!values.some((value) => value.toLowerCase() === "accept")) { + headers.set("Vary", `${existing}, Accept`); + } +} + +function parseAcceptHeader(header: string): AcceptEntry[] { + return header + .split(",") + .map((raw, position) => { + const parts = raw.trim().split(";"); + const type = parts[0]?.trim().toLowerCase() ?? ""; + let quality = 1; + + for (const parameter of parts.slice(1)) { + const separatorIndex = parameter.indexOf("="); + if (separatorIndex === -1) { + continue; + } + + const name = parameter.slice(0, separatorIndex).trim(); + if (name.toLowerCase() !== "q") { + continue; + } + + const parsedQuality = Number( + parameter.slice(separatorIndex + 1).trim() + ); + if (!Number.isNaN(parsedQuality)) { + quality = Math.max(0, Math.min(1, parsedQuality)); + } + } + + return { + position, + quality, + specificity: getSpecificity(type), + type, + }; + }) + .filter((entry) => entry.type.length > 0); +} + +function getSpecificity(type: string): number { + if (type === "*/*") { + return 0; + } + + return type.endsWith("/*") ? 1 : 2; +} + +function matchesMediaType(range: string, candidate: string): boolean { + if (range === "*/*") { + return true; + } + + if (range.endsWith("/*")) { + return candidate.startsWith(range.slice(0, -1)); + } + + return range === candidate; +} diff --git a/lib/marketing/site-map.ts b/lib/marketing/site-map.ts new file mode 100644 index 00000000..168fae9c --- /dev/null +++ b/lib/marketing/site-map.ts @@ -0,0 +1,64 @@ +import { BASE_URL } from "@/lib/common/constants"; +import { normalizeURL } from "@/lib/common/url"; + +export interface PublicStaticRoute { + description: string; + path: `/${string}`; + priority: number; + title: string; +} + +export const PUBLIC_STATIC_ROUTES = [ + { + description: + "The AI bookmark manager for busy people. View, manage, and organize bookmarks across platforms.", + path: "/", + priority: 1, + title: "Cache — the AI bookmark manager", + }, + { + description: "Browse and search your saved bookmarks and notes.", + path: "/library", + priority: 0.85, + title: "Library", + }, + { + description: "The latest product updates from Cache.", + path: "/changelog", + priority: 0.7, + title: "Changelog", + }, + { + description: "Security information and documentation for Cache.", + path: "/security", + priority: 0.7, + title: "Security", + }, + { + description: "Legal documents and policies for Cache.", + path: "/legal", + priority: 0.7, + title: "Legal", + }, + { + description: "Cache terms of service.", + path: "/legal/terms-of-service", + priority: 0.7, + title: "Terms of Service", + }, + { + description: "Cache privacy policy.", + path: "/legal/privacy-policy", + priority: 0.7, + title: "Privacy Policy", + }, +] satisfies readonly PublicStaticRoute[]; + +export function getLocalizedUrl( + locale: string, + path: PublicStaticRoute["path"] +): string { + return normalizeURL( + path === "/" ? `${BASE_URL}/${locale}` : `${BASE_URL}/${locale}${path}` + ); +} diff --git a/proxy.ts b/proxy.ts index 297c292a..a5af6f02 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,9 +1,105 @@ +import { appendVaryAccept, negotiateContentType } from "@/lib/common/accept"; +import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "@/lib/common/constants"; import { createNextMiddleware } from "gt-next/middleware"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; -export default createNextMiddleware(); +const HTML_MEDIA_TYPE = "text/html"; +const MARKDOWN_MEDIA_TYPE = "text/markdown"; +const XML_MEDIA_TYPE = "application/xml"; +const MARKDOWN_ROUTE = "/api/markdown"; +const HOMEPAGE_MEDIA_TYPES = [HTML_MEDIA_TYPE, MARKDOWN_MEDIA_TYPE] as const; +const SITEMAP_MEDIA_TYPES = [XML_MEDIA_TYPE, MARKDOWN_MEDIA_TYPE] as const; +const gtMiddleware = createNextMiddleware(); + +export default async function proxy(request: NextRequest) { + const pathname = request.nextUrl.pathname; + const isHomepage = isHomepagePath(pathname); + const isSitemap = pathname === "/sitemap.xml"; + // RSC navigations use a private representation and must reach the page + // renderer instead of being treated as document negotiation. + const isRscRequest = + isHomepage && + request.headers + .get("accept") + ?.toLowerCase() + .includes("text/x-component"); + + if ((isHomepage || isSitemap) && !isRscRequest) { + const defaultMediaType = isSitemap ? XML_MEDIA_TYPE : HTML_MEDIA_TYPE; + const preferredType = negotiateContentType( + request.headers.get("accept"), + isSitemap ? SITEMAP_MEDIA_TYPES : HOMEPAGE_MEDIA_TYPES, + defaultMediaType + ); + + if (preferredType === null) { + return createNotAcceptableResponse(isSitemap); + } + + if (preferredType === MARKDOWN_MEDIA_TYPE) { + const url = request.nextUrl.clone(); + url.pathname = isSitemap + ? `${MARKDOWN_ROUTE}/sitemap` + : `${MARKDOWN_ROUTE}/home/${getHomepageLocale(pathname)}`; + + const response = NextResponse.rewrite(url); + appendVaryAccept(response.headers); + return response; + } + + if (isSitemap) { + const response = NextResponse.next(); + appendVaryAccept(response.headers); + return response; + } + } + + const response = await gtMiddleware(request); + if (isHomepage) { + appendVaryAccept(response.headers); + } + + return response; +} + +function createNotAcceptableResponse(isSitemap: boolean): Response { + const availableTypes = isSitemap + ? "application/xml, text/markdown" + : "text/html, text/markdown"; + + return new Response(`Not Acceptable\n\nAvailable: ${availableTypes}\n`, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + Vary: "Accept", + }, + status: 406, + }); +} + +function isHomepagePath(pathname: string): boolean { + if (pathname === "/") { + return true; + } + + const locale = pathname.slice(1); + return SUPPORTED_LOCALES.some( + (supportedLocale) => locale === supportedLocale + ); +} + +function getHomepageLocale(pathname: string): string { + const locale = pathname.slice(1); + return ( + SUPPORTED_LOCALES.find( + (supportedLocale) => locale === supportedLocale + ) ?? DEFAULT_LOCALE + ); +} export const config = { matcher: [ + "/sitemap.xml", "/((?!api/|mcp(?:/|$)|static/|_next/|_vercel/|.well-known/workflow/|[^/]+\\.[^/]+$).*)", ], }; From c417167a2a8837702fb9ea5292b961a9c5c943f2 Mon Sep 17 00:00:00 2001 From: gsmt <37253958+rortan134@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:48:17 +0200 Subject: [PATCH 2/4] fix(marketing): address agent-friendly page review feedback --- app/[locale]/page.tsx | 2 +- .../markdown/home/[[...locale]]/route.test.ts | 37 +++++++ app/api/markdown/home/[[...locale]]/route.ts | 27 ++++-- app/api/sitemap/route.test.ts | 17 ++++ app/api/sitemap/route.ts | 20 ++++ app/sitemap.ts | 28 ++---- lib/common/accept.test.ts | 16 ++++ lib/common/accept.ts | 4 +- lib/marketing/site-map.ts | 67 +++++++++++++ proxy.ts | 96 +++++++++++++------ 10 files changed, 255 insertions(+), 59 deletions(-) create mode 100644 app/api/markdown/home/[[...locale]]/route.test.ts create mode 100644 app/api/sitemap/route.test.ts create mode 100644 app/api/sitemap/route.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 4e7d58c6..b7c48b98 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -158,7 +158,7 @@ export async function generateMetadata({ alternates: { ...metadata.alternates, types: { - "text/markdown": `${BASE_URL}/llms.txt`, + "text/markdown": `${BASE_URL}/api/markdown/home/${locale}`, }, }, }; diff --git a/app/api/markdown/home/[[...locale]]/route.test.ts b/app/api/markdown/home/[[...locale]]/route.test.ts new file mode 100644 index 00000000..d2435f8f --- /dev/null +++ b/app/api/markdown/home/[[...locale]]/route.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { GET } from "./route"; + +function getMarkdown(locale?: string[]): Promise { + return GET(new Request("https://www.cachd.app/api/markdown/home"), { + params: Promise.resolve({ locale }), + }); +} + +describe("GET /api/markdown/home", () => { + test("defaults to English when no locale is provided", async () => { + const response = await getMarkdown(); + + expect(response.status).toBe(200); + expect(await response.text()).toContain( + "The AI bookmark manager for busy people" + ); + }); + + test("returns the requested supported locale", async () => { + const response = await getMarkdown(["es-ES"]); + + expect(response.status).toBe(200); + expect(await response.text()).toContain( + "El gestor de marcadores con IA" + ); + }); + + test("rejects unsupported and extra locale segments", async () => { + for (const locale of [["fr-FR"], ["en-US", "extra"]]) { + const response = await getMarkdown(locale); + + expect(response.status).toBe(404); + } + }); +}); diff --git a/app/api/markdown/home/[[...locale]]/route.ts b/app/api/markdown/home/[[...locale]]/route.ts index 97a7ff5b..a7f9ae50 100644 --- a/app/api/markdown/home/[[...locale]]/route.ts +++ b/app/api/markdown/home/[[...locale]]/route.ts @@ -1,5 +1,15 @@ import { BASE_URL } from "@/lib/common/constants"; -import type { SupportedLocale } from "@/lib/common/constants"; +import { + DEFAULT_LOCALE, + SUPPORTED_LOCALES, + type SupportedLocale, +} from "@/lib/common/constants"; +import { z } from "zod"; + +const LOCALE_PARAMS_SCHEMA = z + .array(z.enum(SUPPORTED_LOCALES)) + .max(1) + .optional(); const HOME_MARKDOWN = { "en-US": `# Cache @@ -80,11 +90,16 @@ export async function GET( _request: Request, { params }: { params: Promise<{ locale?: string[] }> } ): Promise { - const { locale } = await params; - const markdown = - locale?.[0] === "es-ES" - ? HOME_MARKDOWN["es-ES"] - : HOME_MARKDOWN["en-US"]; + const parsedLocale = LOCALE_PARAMS_SCHEMA.safeParse((await params).locale); + if (!parsedLocale.success) { + return new Response("Not Found\n", { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + status: 404, + }); + } + + const locale = parsedLocale.data?.[0] ?? DEFAULT_LOCALE; + const markdown = HOME_MARKDOWN[locale]; return new Response(markdown, { headers: { diff --git a/app/api/sitemap/route.test.ts b/app/api/sitemap/route.test.ts new file mode 100644 index 00000000..f2650e79 --- /dev/null +++ b/app/api/sitemap/route.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; + +import { GET } from "./route"; + +describe("GET /api/sitemap", () => { + test("returns a text/xml sitemap", async () => { + const response = GET(); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "text/xml; charset=utf-8" + ); + expect(await response.text()).toContain( + ' ({ - alternates: { - languages: Object.fromEntries( - locales.map((locale) => [ - locale, - getLocalizedUrl(locale, entry.path), - ]) - ), - }, - changeFrequency: "weekly", - lastModified: new Date(), - priority: entry.priority, - url: getLocalizedUrl(defaultLocale, entry.path), - })); + return buildPublicSitemapEntries(getDefaultLocale(), getLocales()).map( + ({ alternates, ...entry }) => ({ + ...entry, + alternates: { languages: alternates }, + }) + ); } diff --git a/lib/common/accept.test.ts b/lib/common/accept.test.ts index e750cdf7..7688ebde 100644 --- a/lib/common/accept.test.ts +++ b/lib/common/accept.test.ts @@ -25,6 +25,22 @@ describe("negotiateContentType", () => { ).toBe("text/html"); }); + test("matches media types case-insensitively", () => { + expect( + negotiateContentType("TEXT/MARKDOWN", SUPPORTED_TYPES, "text/html") + ).toBe("text/markdown"); + }); + + test("uses the highest quality for duplicate media ranges", () => { + expect( + negotiateContentType( + "text/html;q=0.2, text/html;q=0.9, text/markdown;q=0.5", + SUPPORTED_TYPES, + "text/html" + ) + ).toBe("text/html"); + }); + test("respects a specific zero-quality rejection over a wildcard", () => { expect( negotiateContentType( diff --git a/lib/common/accept.ts b/lib/common/accept.ts index 16b95b3d..762cf1f5 100644 --- a/lib/common/accept.ts +++ b/lib/common/accept.ts @@ -41,7 +41,9 @@ export function negotiateContentType( bestMatch === null || entry.specificity > bestMatch.specificity || (entry.specificity === bestMatch.specificity && - entry.position < bestMatch.position) + (entry.quality > bestMatch.quality || + (entry.quality === bestMatch.quality && + entry.position < bestMatch.position))) ) { bestMatch = entry; } diff --git a/lib/marketing/site-map.ts b/lib/marketing/site-map.ts index 168fae9c..17bf8b36 100644 --- a/lib/marketing/site-map.ts +++ b/lib/marketing/site-map.ts @@ -8,6 +8,14 @@ export interface PublicStaticRoute { title: string; } +export interface PublicSitemapEntry { + alternates: Readonly>; + changeFrequency: "weekly"; + lastModified: Date; + priority: number; + url: string; +} + export const PUBLIC_STATIC_ROUTES = [ { description: @@ -62,3 +70,62 @@ export function getLocalizedUrl( path === "/" ? `${BASE_URL}/${locale}` : `${BASE_URL}/${locale}${path}` ); } + +export function buildPublicSitemapEntries( + defaultLocale: string, + locales: readonly string[] +): PublicSitemapEntry[] { + const lastModified = new Date(); + + return PUBLIC_STATIC_ROUTES.map((entry) => ({ + alternates: Object.fromEntries( + locales.map((locale) => [ + locale, + getLocalizedUrl(locale, entry.path), + ]) + ), + changeFrequency: "weekly" as const, + lastModified, + priority: entry.priority, + url: getLocalizedUrl(defaultLocale, entry.path), + })); +} + +export function renderSitemapXml( + entries: readonly PublicSitemapEntry[] +): string { + return [ + '', + '', + ...entries.map((entry) => + [ + " ", + ` ${escapeXml(entry.url)}`, + ` ${entry.lastModified.toISOString()}`, + ` ${entry.changeFrequency}`, + ` ${entry.priority}`, + ...Object.entries(entry.alternates).map( + ([locale, url]) => + ` ` + ), + " ", + ].join("\n") + ), + "", + "", + ].join("\n"); +} + +function escapeXml(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => + ({ + "'": "'", + '"': """, + "&": "&", + "<": "<", + ">": ">", + })[character] ?? character + ); +} diff --git a/proxy.ts b/proxy.ts index a5af6f02..b83d2d8e 100644 --- a/proxy.ts +++ b/proxy.ts @@ -7,9 +7,15 @@ import type { NextRequest } from "next/server"; const HTML_MEDIA_TYPE = "text/html"; const MARKDOWN_MEDIA_TYPE = "text/markdown"; const XML_MEDIA_TYPE = "application/xml"; +const TEXT_XML_MEDIA_TYPE = "text/xml"; const MARKDOWN_ROUTE = "/api/markdown"; +const TEXT_XML_SITEMAP_ROUTE = "/api/sitemap"; const HOMEPAGE_MEDIA_TYPES = [HTML_MEDIA_TYPE, MARKDOWN_MEDIA_TYPE] as const; -const SITEMAP_MEDIA_TYPES = [XML_MEDIA_TYPE, MARKDOWN_MEDIA_TYPE] as const; +const SITEMAP_MEDIA_TYPES = [ + XML_MEDIA_TYPE, + TEXT_XML_MEDIA_TYPE, + MARKDOWN_MEDIA_TYPE, +] as const; const gtMiddleware = createNextMiddleware(); export default async function proxy(request: NextRequest) { @@ -26,31 +32,8 @@ export default async function proxy(request: NextRequest) { .includes("text/x-component"); if ((isHomepage || isSitemap) && !isRscRequest) { - const defaultMediaType = isSitemap ? XML_MEDIA_TYPE : HTML_MEDIA_TYPE; - const preferredType = negotiateContentType( - request.headers.get("accept"), - isSitemap ? SITEMAP_MEDIA_TYPES : HOMEPAGE_MEDIA_TYPES, - defaultMediaType - ); - - if (preferredType === null) { - return createNotAcceptableResponse(isSitemap); - } - - if (preferredType === MARKDOWN_MEDIA_TYPE) { - const url = request.nextUrl.clone(); - url.pathname = isSitemap - ? `${MARKDOWN_ROUTE}/sitemap` - : `${MARKDOWN_ROUTE}/home/${getHomepageLocale(pathname)}`; - - const response = NextResponse.rewrite(url); - appendVaryAccept(response.headers); - return response; - } - - if (isSitemap) { - const response = NextResponse.next(); - appendVaryAccept(response.headers); + const response = resolveDocumentRepresentation(request, isSitemap); + if (response) { return response; } } @@ -63,9 +46,55 @@ export default async function proxy(request: NextRequest) { return response; } +function resolveDocumentRepresentation( + request: NextRequest, + isSitemap: boolean +): Response | null { + const defaultMediaType = isSitemap ? XML_MEDIA_TYPE : HTML_MEDIA_TYPE; + const preferredType = negotiateContentType( + request.headers.get("accept"), + isSitemap ? SITEMAP_MEDIA_TYPES : HOMEPAGE_MEDIA_TYPES, + defaultMediaType + ); + + if (preferredType === null) { + return createNotAcceptableResponse(isSitemap); + } + + if (preferredType === MARKDOWN_MEDIA_TYPE) { + const url = request.nextUrl.clone(); + url.pathname = isSitemap + ? `${MARKDOWN_ROUTE}/sitemap` + : `${MARKDOWN_ROUTE}/home/${getHomepageLocale( + request.nextUrl.pathname + )}`; + + const response = NextResponse.rewrite(url); + appendVaryAccept(response.headers); + return response; + } + + if (preferredType === TEXT_XML_MEDIA_TYPE) { + const url = request.nextUrl.clone(); + url.pathname = TEXT_XML_SITEMAP_ROUTE; + + const response = NextResponse.rewrite(url); + appendVaryAccept(response.headers); + return response; + } + + if (isSitemap) { + const response = NextResponse.next(); + appendVaryAccept(response.headers); + return response; + } + + return null; +} + function createNotAcceptableResponse(isSitemap: boolean): Response { const availableTypes = isSitemap - ? "application/xml, text/markdown" + ? "application/xml, text/xml, text/markdown" : "text/html, text/markdown"; return new Response(`Not Acceptable\n\nAvailable: ${availableTypes}\n`, { @@ -78,18 +107,19 @@ function createNotAcceptableResponse(isSitemap: boolean): Response { } function isHomepagePath(pathname: string): boolean { - if (pathname === "/") { + const normalizedPathname = normalizeHomepagePathname(pathname); + if (normalizedPathname === "/") { return true; } - const locale = pathname.slice(1); + const locale = normalizedPathname.slice(1); return SUPPORTED_LOCALES.some( (supportedLocale) => locale === supportedLocale ); } function getHomepageLocale(pathname: string): string { - const locale = pathname.slice(1); + const locale = normalizeHomepagePathname(pathname).slice(1); return ( SUPPORTED_LOCALES.find( (supportedLocale) => locale === supportedLocale @@ -97,6 +127,12 @@ function getHomepageLocale(pathname: string): string { ); } +function normalizeHomepagePathname(pathname: string): string { + return pathname.length > 1 && pathname.endsWith("/") + ? pathname.slice(0, -1) + : pathname; +} + export const config = { matcher: [ "/sitemap.xml", From 6bf80ac3ba8c5d840fc74bb05cdc2bcfbc1a072b Mon Sep 17 00:00:00 2001 From: gsmt <37253958+rortan134@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:04:53 +0200 Subject: [PATCH 3/4] Update proxy.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- proxy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/proxy.ts b/proxy.ts index b83d2d8e..a03ff4c3 100644 --- a/proxy.ts +++ b/proxy.ts @@ -93,9 +93,9 @@ function resolveDocumentRepresentation( } function createNotAcceptableResponse(isSitemap: boolean): Response { - const availableTypes = isSitemap - ? "application/xml, text/xml, text/markdown" - : "text/html, text/markdown"; + const availableTypes = ( + isSitemap ? SITEMAP_MEDIA_TYPES : HOMEPAGE_MEDIA_TYPES + ).join(", "); return new Response(`Not Acceptable\n\nAvailable: ${availableTypes}\n`, { headers: { From a4e588182bc42ab34cb4fe5811f7d449a9051fa0 Mon Sep 17 00:00:00 2001 From: gsmt <37253958+rortan134@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:59:36 +0200 Subject: [PATCH 4/4] refactor(marketing): simplify agent-friendly page routes --- app/api/markdown/home/[[...locale]]/route.ts | 13 +++-- app/api/markdown/sitemap/route.ts | 59 ++++++++++---------- app/api/sitemap/route.ts | 7 ++- app/metadata.ts | 7 +-- lib/common/accept.ts | 10 ++-- lib/common/constants.ts | 2 + lib/marketing/constants.ts | 2 + lib/marketing/site-map.ts | 50 ++++++++++------- lib/marketing/url.ts | 7 +++ proxy.ts | 44 ++++++++------- 10 files changed, 112 insertions(+), 89 deletions(-) create mode 100644 lib/marketing/constants.ts create mode 100644 lib/marketing/url.ts diff --git a/app/api/markdown/home/[[...locale]]/route.ts b/app/api/markdown/home/[[...locale]]/route.ts index a7f9ae50..d0c79bda 100644 --- a/app/api/markdown/home/[[...locale]]/route.ts +++ b/app/api/markdown/home/[[...locale]]/route.ts @@ -1,9 +1,11 @@ -import { BASE_URL } from "@/lib/common/constants"; import { + BASE_URL, DEFAULT_LOCALE, + MIME_TYPES, SUPPORTED_LOCALES, type SupportedLocale, } from "@/lib/common/constants"; +import { MARKETING_CACHE_CONTROL_HEADER } from "@/lib/marketing/constants"; import { z } from "zod"; const LOCALE_PARAMS_SCHEMA = z @@ -93,7 +95,9 @@ export async function GET( const parsedLocale = LOCALE_PARAMS_SCHEMA.safeParse((await params).locale); if (!parsedLocale.success) { return new Response("Not Found\n", { - headers: { "Content-Type": "text/plain; charset=utf-8" }, + headers: { + "Content-Type": `${MIME_TYPES.text}; charset=utf-8`, + }, status: 404, }); } @@ -103,9 +107,8 @@ export async function GET( return new Response(markdown, { headers: { - "Cache-Control": - "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800", - "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": MARKETING_CACHE_CONTROL_HEADER, + "Content-Type": `${MIME_TYPES.markdown}; charset=utf-8`, Vary: "Accept", }, }); diff --git a/app/api/markdown/sitemap/route.ts b/app/api/markdown/sitemap/route.ts index 7a6f6774..2e79ce0f 100644 --- a/app/api/markdown/sitemap/route.ts +++ b/app/api/markdown/sitemap/route.ts @@ -1,10 +1,8 @@ +import { MIME_TYPES } from "@/lib/common/constants"; +import { MARKETING_CACHE_CONTROL_HEADER } from "@/lib/marketing/constants"; +import { buildPublicSitemapRoutes } from "@/lib/marketing/site-map"; import { getDefaultLocale, getLocales } from "gt-next"; -import { - getLocalizedUrl, - PUBLIC_STATIC_ROUTES, -} from "@/lib/marketing/site-map"; - const MARKDOWN_SITEMAP_HEADER = `# Cache sitemap Public pages and agent resources for Cache. The canonical page URLs below return Markdown when requested with \`Accept: text/markdown\`. @@ -12,32 +10,37 @@ Public pages and agent resources for Cache. The canonical page URLs below return ## Public pages `; +const MARKDOWN_SITEMAP = buildMarkdownSitemap(getDefaultLocale(), getLocales()); + export function GET(): Response { - const defaultLocale = getDefaultLocale(); - const locales = getLocales(); - const pages = PUBLIC_STATIC_ROUTES.map((route) => { - const defaultUrl = getLocalizedUrl(defaultLocale, route.path); - const alternateLocales = locales - .filter((locale) => locale !== defaultLocale) - .map( - (locale) => - ` - [${route.title} (${locale})](${getLocalizedUrl(locale, route.path)})` - ); - - return [ - `- [${route.title}](${defaultUrl}) — ${route.description}`, - ...alternateLocales, - ].join("\n"); - }).join("\n"); - - const sitemap = `${MARKDOWN_SITEMAP_HEADER}\n${pages}\n\n## Agent resources\n\n- [Full agent context](/llms.txt)\n- [MCP server endpoint](/mcp)\n`; - - return new Response(sitemap, { + return new Response(MARKDOWN_SITEMAP, { headers: { - "Cache-Control": - "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800", - "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": MARKETING_CACHE_CONTROL_HEADER, + "Content-Type": `${MIME_TYPES.markdown}; charset=utf-8`, Vary: "Accept", }, }); } + +function buildMarkdownSitemap( + defaultLocale: string, + locales: readonly string[] +): string { + const pages = buildPublicSitemapRoutes(defaultLocale, locales) + .map((route) => { + const alternateLocales = Object.entries(route.alternates) + .filter(([locale]) => locale !== defaultLocale) + .map( + ([locale, url]) => + ` - [${route.title} (${locale})](${url})` + ); + + return [ + `- [${route.title}](${route.url}) — ${route.description}`, + ...alternateLocales, + ].join("\n"); + }) + .join("\n"); + + return `${MARKDOWN_SITEMAP_HEADER}\n${pages}\n\n## Agent resources\n\n- [Full agent context](/llms.txt)\n- [MCP server endpoint](/mcp)\n`; +} diff --git a/app/api/sitemap/route.ts b/app/api/sitemap/route.ts index 78fadbfd..f1c31e6c 100644 --- a/app/api/sitemap/route.ts +++ b/app/api/sitemap/route.ts @@ -1,3 +1,5 @@ +import { MIME_TYPES } from "@/lib/common/constants"; +import { MARKETING_CACHE_CONTROL_HEADER } from "@/lib/marketing/constants"; import { buildPublicSitemapEntries, renderSitemapXml, @@ -11,9 +13,8 @@ export function GET(): Response { return new Response(sitemap, { headers: { - "Cache-Control": - "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800", - "Content-Type": "text/xml; charset=utf-8", + "Cache-Control": MARKETING_CACHE_CONTROL_HEADER, + "Content-Type": `${MIME_TYPES.textXml}; charset=utf-8`, Vary: "Accept", }, }); diff --git a/app/metadata.ts b/app/metadata.ts index 8aaff585..59f0c194 100644 --- a/app/metadata.ts +++ b/app/metadata.ts @@ -1,4 +1,5 @@ import { BASE_URL } from "@/lib/common/constants"; +import { getLocalizedUrl } from "@/lib/marketing/url"; import { getDefaultLocale, getLocales, resolveCanonicalLocale } from "gt-next"; import type { Metadata } from "next"; @@ -68,12 +69,6 @@ export function buildPageMetadata({ }; } -function getLocalizedUrl(locale: string, path: `/${string}`) { - return path === "/" - ? `${BASE_URL}/${locale}` - : `${BASE_URL}/${locale}${path}`; -} - export function buildLocaleAlternates( path: `/${string}`, locale?: string diff --git a/lib/common/accept.ts b/lib/common/accept.ts index 762cf1f5..ac10e009 100644 --- a/lib/common/accept.ts +++ b/lib/common/accept.ts @@ -10,11 +10,11 @@ interface AcceptEntry { * Specific Accept ranges take precedence over wildcards for each candidate, * even when the wildcard has a higher quality value. */ -export function negotiateContentType( +export function negotiateContentType( acceptHeader: string | null, - supportedTypes: readonly string[], - defaultType: string -): string | null { + supportedTypes: T, + defaultType: T[number] +): T[number] | null { if (!acceptHeader) { return defaultType; } @@ -26,7 +26,7 @@ export function negotiateContentType( let bestPosition = Number.POSITIVE_INFINITY; let bestQuality = -1; - let bestType: string | null = null; + let bestType: T[number] | null = null; for (const supportedType of supportedTypes) { const normalizedSupportedType = supportedType.toLowerCase(); diff --git a/lib/common/constants.ts b/lib/common/constants.ts index db934078..fb556157 100644 --- a/lib/common/constants.ts +++ b/lib/common/constants.ts @@ -76,7 +76,9 @@ export const STRING_MIME_TYPES = { csv: "text/csv", html: "text/html", json: "application/json", + markdown: "text/markdown", text: "text/plain", + textXml: "text/xml", xhtml: "application/xhtml+xml", xml: "application/xml", } as const; diff --git a/lib/marketing/constants.ts b/lib/marketing/constants.ts new file mode 100644 index 00000000..86a8827e --- /dev/null +++ b/lib/marketing/constants.ts @@ -0,0 +1,2 @@ +export const MARKETING_CACHE_CONTROL_HEADER = + "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800"; diff --git a/lib/marketing/site-map.ts b/lib/marketing/site-map.ts index 17bf8b36..43d393a9 100644 --- a/lib/marketing/site-map.ts +++ b/lib/marketing/site-map.ts @@ -1,5 +1,4 @@ -import { BASE_URL } from "@/lib/common/constants"; -import { normalizeURL } from "@/lib/common/url"; +import { getLocalizedUrl } from "@/lib/marketing/url"; export interface PublicStaticRoute { description: string; @@ -16,6 +15,11 @@ export interface PublicSitemapEntry { url: string; } +export interface PublicSitemapRoute extends PublicStaticRoute { + alternates: Readonly>; + url: string; +} + export const PUBLIC_STATIC_ROUTES = [ { description: @@ -62,35 +66,39 @@ export const PUBLIC_STATIC_ROUTES = [ }, ] satisfies readonly PublicStaticRoute[]; -export function getLocalizedUrl( - locale: string, - path: PublicStaticRoute["path"] -): string { - return normalizeURL( - path === "/" ? `${BASE_URL}/${locale}` : `${BASE_URL}/${locale}${path}` - ); -} - -export function buildPublicSitemapEntries( +export function buildPublicSitemapRoutes( defaultLocale: string, locales: readonly string[] -): PublicSitemapEntry[] { - const lastModified = new Date(); - - return PUBLIC_STATIC_ROUTES.map((entry) => ({ +): PublicSitemapRoute[] { + return PUBLIC_STATIC_ROUTES.map((route) => ({ + ...route, alternates: Object.fromEntries( locales.map((locale) => [ locale, - getLocalizedUrl(locale, entry.path), + getLocalizedUrl(locale, route.path), ]) ), - changeFrequency: "weekly" as const, - lastModified, - priority: entry.priority, - url: getLocalizedUrl(defaultLocale, entry.path), + url: getLocalizedUrl(defaultLocale, route.path), })); } +export function buildPublicSitemapEntries( + defaultLocale: string, + locales: readonly string[] +): PublicSitemapEntry[] { + const lastModified = new Date(); + + return buildPublicSitemapRoutes(defaultLocale, locales).map( + ({ alternates, priority, url }) => ({ + alternates, + changeFrequency: "weekly" as const, + lastModified, + priority, + url, + }) + ); +} + export function renderSitemapXml( entries: readonly PublicSitemapEntry[] ): string { diff --git a/lib/marketing/url.ts b/lib/marketing/url.ts new file mode 100644 index 00000000..08cd0f0a --- /dev/null +++ b/lib/marketing/url.ts @@ -0,0 +1,7 @@ +import { BASE_URL } from "@/lib/common/constants"; + +export function getLocalizedUrl(locale: string, path: `/${string}`): string { + return path === "/" + ? `${BASE_URL}/${locale}` + : `${BASE_URL}/${locale}${path}`; +} diff --git a/proxy.ts b/proxy.ts index a03ff4c3..bd83a0bb 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,13 +1,18 @@ import { appendVaryAccept, negotiateContentType } from "@/lib/common/accept"; -import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "@/lib/common/constants"; +import { + DEFAULT_LOCALE, + MIME_TYPES, + SUPPORTED_LOCALES, + type SupportedLocale, +} from "@/lib/common/constants"; import { createNextMiddleware } from "gt-next/middleware"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -const HTML_MEDIA_TYPE = "text/html"; -const MARKDOWN_MEDIA_TYPE = "text/markdown"; -const XML_MEDIA_TYPE = "application/xml"; -const TEXT_XML_MEDIA_TYPE = "text/xml"; +const HTML_MEDIA_TYPE = MIME_TYPES.html; +const MARKDOWN_MEDIA_TYPE = MIME_TYPES.markdown; +const XML_MEDIA_TYPE = MIME_TYPES.xml; +const TEXT_XML_MEDIA_TYPE = MIME_TYPES.textXml; const MARKDOWN_ROUTE = "/api/markdown"; const TEXT_XML_SITEMAP_ROUTE = "/api/sitemap"; const HOMEPAGE_MEDIA_TYPES = [HTML_MEDIA_TYPE, MARKDOWN_MEDIA_TYPE] as const; @@ -20,7 +25,8 @@ const gtMiddleware = createNextMiddleware(); export default async function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname; - const isHomepage = isHomepagePath(pathname); + const homepageLocale = getHomepageLocale(pathname); + const isHomepage = homepageLocale !== null; const isSitemap = pathname === "/sitemap.xml"; // RSC navigations use a private representation and must reach the page // renderer instead of being treated as document negotiation. @@ -32,7 +38,11 @@ export default async function proxy(request: NextRequest) { .includes("text/x-component"); if ((isHomepage || isSitemap) && !isRscRequest) { - const response = resolveDocumentRepresentation(request, isSitemap); + const response = resolveDocumentRepresentation( + request, + isSitemap, + homepageLocale + ); if (response) { return response; } @@ -48,7 +58,8 @@ export default async function proxy(request: NextRequest) { function resolveDocumentRepresentation( request: NextRequest, - isSitemap: boolean + isSitemap: boolean, + homepageLocale: SupportedLocale | null ): Response | null { const defaultMediaType = isSitemap ? XML_MEDIA_TYPE : HTML_MEDIA_TYPE; const preferredType = negotiateContentType( @@ -65,9 +76,7 @@ function resolveDocumentRepresentation( const url = request.nextUrl.clone(); url.pathname = isSitemap ? `${MARKDOWN_ROUTE}/sitemap` - : `${MARKDOWN_ROUTE}/home/${getHomepageLocale( - request.nextUrl.pathname - )}`; + : `${MARKDOWN_ROUTE}/home/${homepageLocale ?? DEFAULT_LOCALE}`; const response = NextResponse.rewrite(url); appendVaryAccept(response.headers); @@ -106,24 +115,17 @@ function createNotAcceptableResponse(isSitemap: boolean): Response { }); } -function isHomepagePath(pathname: string): boolean { +function getHomepageLocale(pathname: string): SupportedLocale | null { const normalizedPathname = normalizeHomepagePathname(pathname); if (normalizedPathname === "/") { - return true; + return DEFAULT_LOCALE; } const locale = normalizedPathname.slice(1); - return SUPPORTED_LOCALES.some( - (supportedLocale) => locale === supportedLocale - ); -} - -function getHomepageLocale(pathname: string): string { - const locale = normalizeHomepagePathname(pathname).slice(1); return ( SUPPORTED_LOCALES.find( (supportedLocale) => locale === supportedLocale - ) ?? DEFAULT_LOCALE + ) ?? null ); }