diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 06e6fa4c..b7c48b98 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}/api/markdown/home/${locale}`, + }, + }, + }; } export default async function Home() { 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 new file mode 100644 index 00000000..d0c79bda --- /dev/null +++ b/app/api/markdown/home/[[...locale]]/route.ts @@ -0,0 +1,115 @@ +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 + .array(z.enum(SUPPORTED_LOCALES)) + .max(1) + .optional(); + +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 parsedLocale = LOCALE_PARAMS_SCHEMA.safeParse((await params).locale); + if (!parsedLocale.success) { + return new Response("Not Found\n", { + headers: { + "Content-Type": `${MIME_TYPES.text}; charset=utf-8`, + }, + status: 404, + }); + } + + const locale = parsedLocale.data?.[0] ?? DEFAULT_LOCALE; + const markdown = HOME_MARKDOWN[locale]; + + return new Response(markdown, { + headers: { + "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 new file mode 100644 index 00000000..2e79ce0f --- /dev/null +++ b/app/api/markdown/sitemap/route.ts @@ -0,0 +1,46 @@ +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"; + +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 +`; + +const MARKDOWN_SITEMAP = buildMarkdownSitemap(getDefaultLocale(), getLocales()); + +export function GET(): Response { + return new Response(MARKDOWN_SITEMAP, { + headers: { + "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.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 new file mode 100644 index 00000000..7688ebde --- /dev/null +++ b/lib/common/accept.test.ts @@ -0,0 +1,124 @@ +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("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( + "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..ac10e009 --- /dev/null +++ b/lib/common/accept.ts @@ -0,0 +1,139 @@ +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: T, + defaultType: T[number] +): T[number] | 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: T[number] | 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.quality > bestMatch.quality || + (entry.quality === bestMatch.quality && + 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/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 new file mode 100644 index 00000000..43d393a9 --- /dev/null +++ b/lib/marketing/site-map.ts @@ -0,0 +1,139 @@ +import { getLocalizedUrl } from "@/lib/marketing/url"; + +export interface PublicStaticRoute { + description: string; + path: `/${string}`; + priority: number; + title: string; +} + +export interface PublicSitemapEntry { + alternates: Readonly>; + changeFrequency: "weekly"; + lastModified: Date; + priority: number; + url: string; +} + +export interface PublicSitemapRoute extends PublicStaticRoute { + alternates: Readonly>; + url: 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 buildPublicSitemapRoutes( + defaultLocale: string, + locales: readonly string[] +): PublicSitemapRoute[] { + return PUBLIC_STATIC_ROUTES.map((route) => ({ + ...route, + alternates: Object.fromEntries( + locales.map((locale) => [ + locale, + getLocalizedUrl(locale, route.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 { + 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/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 297c292a..bd83a0bb 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,9 +1,143 @@ +import { appendVaryAccept, negotiateContentType } from "@/lib/common/accept"; +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"; -export default createNextMiddleware(); +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; +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) { + const pathname = request.nextUrl.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. + const isRscRequest = + isHomepage && + request.headers + .get("accept") + ?.toLowerCase() + .includes("text/x-component"); + + if ((isHomepage || isSitemap) && !isRscRequest) { + const response = resolveDocumentRepresentation( + request, + isSitemap, + homepageLocale + ); + if (response) { + return response; + } + } + + const response = await gtMiddleware(request); + if (isHomepage) { + appendVaryAccept(response.headers); + } + + return response; +} + +function resolveDocumentRepresentation( + request: NextRequest, + isSitemap: boolean, + homepageLocale: SupportedLocale | null +): 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/${homepageLocale ?? DEFAULT_LOCALE}`; + + 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 ? SITEMAP_MEDIA_TYPES : HOMEPAGE_MEDIA_TYPES + ).join(", "); + + return new Response(`Not Acceptable\n\nAvailable: ${availableTypes}\n`, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + Vary: "Accept", + }, + status: 406, + }); +} + +function getHomepageLocale(pathname: string): SupportedLocale | null { + const normalizedPathname = normalizeHomepagePathname(pathname); + if (normalizedPathname === "/") { + return DEFAULT_LOCALE; + } + + const locale = normalizedPathname.slice(1); + return ( + SUPPORTED_LOCALES.find( + (supportedLocale) => locale === supportedLocale + ) ?? null + ); +} + +function normalizeHomepagePathname(pathname: string): string { + return pathname.length > 1 && pathname.endsWith("/") + ? pathname.slice(0, -1) + : pathname; +} export const config = { matcher: [ + "/sitemap.xml", "/((?!api/|mcp(?:/|$)|static/|_next/|_vercel/|.well-known/workflow/|[^/]+\\.[^/]+$).*)", ], };