Skip to content
Open
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
13 changes: 11 additions & 2 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,7 @@ export async function generateMetadata({
}): Promise<Metadata> {
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."
),
Expand All @@ -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}`,
},
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},
};
}

export default async function Home() {
Expand Down
37 changes: 37 additions & 0 deletions app/api/markdown/home/[[...locale]]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";

import { GET } from "./route";

function getMarkdown(locale?: string[]): Promise<Response> {
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);
}
});
});
115 changes: 115 additions & 0 deletions app/api/markdown/home/[[...locale]]/route.ts
Original file line number Diff line number Diff line change
@@ -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<SupportedLocale, string>;

export async function GET(
_request: Request,
{ params }: { params: Promise<{ locale?: string[] }> }
): Promise<Response> {
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",
},
});
}
46 changes: 46 additions & 0 deletions app/api/markdown/sitemap/route.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
17 changes: 17 additions & 0 deletions app/api/sitemap/route.test.ts
Original file line number Diff line number Diff line change
@@ -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(
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'
);
});
});
21 changes: 21 additions & 0 deletions app/api/sitemap/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MIME_TYPES } from "@/lib/common/constants";
import { MARKETING_CACHE_CONTROL_HEADER } from "@/lib/marketing/constants";
import {
buildPublicSitemapEntries,
renderSitemapXml,
} from "@/lib/marketing/site-map";
import { getDefaultLocale, getLocales } from "gt-next";

export function GET(): Response {
const sitemap = renderSitemapXml(
buildPublicSitemapEntries(getDefaultLocale(), getLocales())
);

return new Response(sitemap, {
headers: {
"Cache-Control": MARKETING_CACHE_CONTROL_HEADER,
"Content-Type": `${MIME_TYPES.textXml}; charset=utf-8`,
Vary: "Accept",
},
});
}
7 changes: 1 addition & 6 deletions app/metadata.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
Expand Down
52 changes: 7 additions & 45 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,12 @@
import { BASE_URL } from "@/lib/common/constants";
import { normalizeURL } from "@/lib/common/url";
import { buildPublicSitemapEntries } 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();

return PUBLIC_STATIC_ROUTES.map((entry) => ({
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 },
})
);
}
Loading