From a8c5927b04738ec723f502e28e51cfc7ff7b68e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 22:05:48 +0000 Subject: [PATCH 1/2] Add agent discovery surfaces without inventing product APIs Serve Markdown for Accept: text/markdown, recover from real 404s, and publish llms.txt plus contact and privacy pages from existing editorial channels. Co-authored-by: ben <0thernet@users.noreply.github.com> --- app/AGENTS.md | 6 +- app/about/page.test.tsx | 2 + app/about/page.tsx | 8 +- app/analytics.test.ts | 2 + app/analytics.ts | 2 + app/contact/page.test.tsx | 26 +++ app/contact/page.tsx | 94 ++++++++++ app/layout.tsx | 7 +- app/llms.txt/route.test.ts | 14 ++ app/llms.txt/route.ts | 12 ++ app/metadata.test.ts | 2 + app/not-found.tsx | 19 +- app/privacy/page.test.tsx | 25 +++ app/privacy/page.tsx | 84 +++++++++ app/runtime-surfaces.test.tsx | 5 + app/seo.test.ts | 11 ++ app/seo.ts | 14 ++ app/site-copy.ts | 88 +++++++++ app/site-footer.tsx | 2 + app/sitemap.ts | 6 + lib/AGENTS.md | 2 + lib/accept.test.ts | 103 +++++++++++ lib/accept.ts | 144 +++++++++++++++ lib/llms-txt.ts | 38 ++++ lib/page-markdown.test.ts | 64 +++++++ lib/page-markdown.ts | 329 ++++++++++++++++++++++++++++++++++ proxy.ts | 56 ++++++ 27 files changed, 1156 insertions(+), 9 deletions(-) create mode 100644 app/contact/page.test.tsx create mode 100644 app/contact/page.tsx create mode 100644 app/llms.txt/route.test.ts create mode 100644 app/llms.txt/route.ts create mode 100644 app/privacy/page.test.tsx create mode 100644 app/privacy/page.tsx create mode 100644 app/site-copy.ts create mode 100644 lib/accept.test.ts create mode 100644 lib/accept.ts create mode 100644 lib/llms-txt.ts create mode 100644 lib/page-markdown.test.ts create mode 100644 lib/page-markdown.ts create mode 100644 proxy.ts diff --git a/app/AGENTS.md b/app/AGENTS.md index 2775daf..ccc51b3 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -3,8 +3,10 @@ - `page.tsx` – the canonical unified Stripe history timeline. - `history/` – category pages, the appearances projection, annual-volume and valuation pages, and shared timeline rendering. - `data/` – the crawlable history and research dataset index. -- `about/` – sourcing, review, independence, corrections, and privacy. -- `site.ts`, `site-header.tsx`, and `site-footer.tsx` – canonical identity and shared page chrome. +- `about/`, `contact/`, and `privacy/` – sourcing, review, independence, corrections, public contact channels, and privacy. +- `llms.txt/` – the agent index with when-to-use guidance. +- Root `proxy.ts` – Accept negotiation that serves Markdown for the same public URLs. +- `site.ts`, `site-copy.ts`, `site-header.tsx`, and `site-footer.tsx` – canonical identity, shared editorial copy, and shared page chrome. - `analytics.ts`, `posthog.ts`, and `posthog-analytics.tsx` – the finite public-route analytics contract, strict PostHog boundary, and client provider. - `layout.tsx`, `globals.css`, and `support/` – the document, appearance, structured-data, and portable styling boundaries. - `robots.ts`, `sitemap.ts`, `manifest.ts`, and `opengraph-image.tsx` – public discovery and sharing surfaces. diff --git a/app/about/page.test.tsx b/app/about/page.test.tsx index 069b63b..961042d 100644 --- a/app/about/page.test.tsx +++ b/app/about/page.test.tsx @@ -27,6 +27,8 @@ describe("stripedex.com about page", () => { expect(html).toContain('aria-label="Appearance: System"'); expect(html).toContain('href="https://hraness.com/"'); expect(html).toContain('href="https://github.com/hraness/stripedex"'); + expect(html).toContain('href="/contact"'); + expect(html).toContain('href="/privacy"'); expect(html).not.toContain("Atom feed"); expect(html).not.toContain("news summaries"); expect(html).toContain('type="application/ld+json"'); diff --git a/app/about/page.tsx b/app/about/page.tsx index fc2b5d1..3e6445d 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -87,7 +87,9 @@ export default function AboutPage() { Published and maintained by Hraness. To suggest a correction, add a source, or improve the project, open an issue or contribution in the{" "} - Stripedex repository. + Stripedex repository. The same + public channels are listed on the{" "} + contact page.

Privacy

@@ -105,6 +107,10 @@ export default function AboutPage() { no user accounts or authentication. Requests are still subject to the ordinary logs and security controls of the hosting provider.

+

+ The dedicated privacy page repeats this + policy for agents and other readers who look for /privacy. +

diff --git a/app/analytics.test.ts b/app/analytics.test.ts index 0dd4861..d6d4424 100644 --- a/app/analytics.test.ts +++ b/app/analytics.test.ts @@ -30,6 +30,8 @@ describe("Stripedex analytics routes", () => { expect(JSON.stringify(PUBLIC_ANALYTICS_PATHS)).toBe(JSON.stringify([ "/", "/about", + "/contact", + "/privacy", "/data", "/history/payment-volume", "/history/valuation", diff --git a/app/analytics.ts b/app/analytics.ts index 278f99a..719b667 100644 --- a/app/analytics.ts +++ b/app/analytics.ts @@ -9,6 +9,8 @@ const CANONICAL_ORIGIN = `https://${CANONICAL_DOMAIN}`; const STATIC_ROUTES = [ ["/", "history_timeline"], ["/about", "about"], + ["/contact", "contact"], + ["/privacy", "privacy"], ["/data", "data_index"], ["/history/payment-volume", "payment_volume"], ["/history/valuation", "valuation"], diff --git a/app/contact/page.test.tsx b/app/contact/page.test.tsx new file mode 100644 index 0000000..1dd7985 --- /dev/null +++ b/app/contact/page.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import ContactPage, { metadata } from "./page"; + +function visibleText(html: string): string { + return html.replace(//gu, " ").replace(/<[^>]+>/gu, " ").replace(/\s+/gu, " ").trim(); +} + +describe("stripedex.com contact page", () => { + test("lists the existing public correction and security channels", () => { + const html = renderToStaticMarkup(); + expect(metadata).toMatchObject({ + alternates: { canonical: "/contact" }, + title: "Contact", + }); + expect(html).toContain("

Contact stripedex.com

"); + expect(html).toContain("https://github.com/hraness/stripedex/issues"); + expect(html).toContain("private vulnerability reporting"); + expect(html).toContain("not affiliated with, endorsed by, or operated by"); + expect(html).toContain("There is no reader account, contact form, or product inbox"); + expect(html).not.toContain("@stripedex.com"); + expect(html).toContain('aria-label="Appearance: System"'); + expect(visibleText(html).length).toBeGreaterThan(500); + }); +}); diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..b2a40af --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,94 @@ +import { JsonLdScript } from "@hraness/web-discovery/json-ld"; +import type { Metadata } from "next"; +import Link from "next/link"; + +import { breadcrumbJsonLd } from "../seo"; +import { SiteFooter } from "../site-footer"; +import { SiteHeader } from "../site-header"; +import { + contactDescription, + contactSocialTitle, + contactTitle, + independenceSentence, +} from "../site-copy"; +import { + GITHUB_REPOSITORY_URL, + HRANESS_URL, + site, + socialMetadata, +} from "../site"; + +export const dynamic = "force-static"; + +export const metadata: Metadata = { + title: contactTitle, + description: contactDescription, + alternates: { canonical: "/contact" }, + ...socialMetadata(contactSocialTitle, contactDescription, "/contact", { + alt: `Contact channels for the independent Stripe company history at ${site.domain}`, + }), +}; + +export default function ContactPage() { + return ( +
+ + + +
+
+

Contact {site.domain}

+ public channels +
+

Corrections and sources

+

+ Use public GitHub issues for ordinary historical corrections, missing + events, stronger sources, and focused software improvements. Include + the event date, a concise factual claim, its category, the proposed + confidence and status, and at least one source URL. Prefer primary + sources. If a claim was only proposed or reported, keep that + uncertainty in the record. +

+

+ Open those reports in the{" "} + + Stripedex issue tracker + . +

+

Security

+

+ Report suspected vulnerabilities through GitHub's private + vulnerability reporting for this repository. Do not include sensitive + details in a public issue. +

+

Publisher

+

+ There is no reader account, contact form, or product inbox on{" "} + {site.domain}. The project does not process payments, issue API keys, + or operate a Stripe integration. {independenceSentence} +

+

+ Published and maintained by Hraness. The + complete sourced records and website code are in the{" "} + Stripedex repository. Read{" "} + about for editorial method and{" "} + privacy for analytics limits. +

+
+ +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 3041e26..816fe7e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,7 +9,7 @@ import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; import "./globals.css"; import { PostHogAnalytics } from "./posthog-analytics"; -import { websiteJsonLd } from "./seo"; +import { siteOrganizationJsonLd, websiteJsonLd } from "./seo"; import { SITE_ORIGIN, site } from "./site"; export const metadata: Metadata = { @@ -49,7 +49,10 @@ export default function RootLayout({ children }: Readonly<{ children: ReactNode return ( - + { + test("serves the agent index as plain text", async () => { + const response = await GET(); + const body = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(body).toContain("## When to use this"); + expect(body).toContain("# Stripedex"); + }); +}); diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts new file mode 100644 index 0000000..f0feffa --- /dev/null +++ b/app/llms.txt/route.ts @@ -0,0 +1,12 @@ +import { llmsTxt } from "@/lib/llms-txt"; + +export const dynamic = "force-static"; + +export async function GET() { + return new Response(await llmsTxt(), { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=0, must-revalidate", + }, + }); +} diff --git a/app/metadata.test.ts b/app/metadata.test.ts index ab4b283..d7fe3f6 100644 --- a/app/metadata.test.ts +++ b/app/metadata.test.ts @@ -37,6 +37,8 @@ describe("stripedex.com public identity", () => { `${SITE_ORIGIN}/history/payment-volume`, `${SITE_ORIGIN}/history/valuation`, `${SITE_ORIGIN}/about`, + `${SITE_ORIGIN}/contact`, + `${SITE_ORIGIN}/privacy`, `${SITE_ORIGIN}/data`, ...timelineCategoryIds.map((id) => `${SITE_ORIGIN}/history/${id}`), ])); diff --git a/app/not-found.tsx b/app/not-found.tsx index 1ec37d0..7ba78cd 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -4,9 +4,11 @@ import Link from "next/link"; import { SiteFooter } from "./site-footer"; import { SiteHeader } from "./site-header"; - -const notFoundTitle = "Page not found"; -const notFoundDescription = "The requested Stripe history page does not exist."; +import { + notFoundDescription, + notFoundTitle, + recoveryLinks, +} from "./site-copy"; export const metadata: Metadata = { title: notFoundTitle, @@ -21,7 +23,16 @@ export default function NotFound() {

{notFoundTitle}

{notFoundDescription}

-

Browse Stripe company history

+

Continue from:

+
    + {recoveryLinks.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
diff --git a/app/privacy/page.test.tsx b/app/privacy/page.test.tsx new file mode 100644 index 0000000..e7a7e5a --- /dev/null +++ b/app/privacy/page.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import PrivacyPage, { metadata } from "./page"; + +function visibleText(html: string): string { + return html.replace(//gu, " ").replace(/<[^>]+>/gu, " ").replace(/\s+/gu, " ").trim(); +} + +describe("stripedex.com privacy page", () => { + test("publishes the existing analytics policy at /privacy", () => { + const html = renderToStaticMarkup(); + expect(metadata).toMatchObject({ + alternates: { canonical: "/privacy" }, + title: "Privacy", + }); + expect(html).toContain('

Privacy

'); + expect(html).toContain("anonymous, cookieless pageview events for public pages"); + expect(html).toContain("does not save an analytics cookie or identifier"); + expect(html).toContain("no user accounts or authentication"); + expect(html).toContain('href="/contact"'); + expect(html).toContain('aria-label="Appearance: System"'); + expect(visibleText(html).length).toBeGreaterThan(500); + }); +}); diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..de51f2c --- /dev/null +++ b/app/privacy/page.tsx @@ -0,0 +1,84 @@ +import { JsonLdScript } from "@hraness/web-discovery/json-ld"; +import type { Metadata } from "next"; +import Link from "next/link"; + +import { breadcrumbJsonLd } from "../seo"; +import { SiteFooter } from "../site-footer"; +import { SiteHeader } from "../site-header"; +import { + privacyDescription, + privacySocialTitle, + privacyTitle, +} from "../site-copy"; +import { GITHUB_REPOSITORY_URL, site, socialMetadata } from "../site"; + +export const dynamic = "force-static"; + +export const metadata: Metadata = { + title: privacyTitle, + description: privacyDescription, + alternates: { canonical: "/privacy" }, + ...socialMetadata(privacySocialTitle, privacyDescription, "/privacy", { + alt: `Privacy practices for the independent Stripe company history at ${site.domain}`, + }), +}; + +export default function PrivacyPage() { + return ( +
+ + + +
+
+

{privacyTitle}

+ public pages +
+

+ The site sends anonymous, cookieless pageview events for public pages + to PostHog. Each event contains the normalized public page path, its + page category, a site identifier, an analytics schema version, and + PostHog's cookieless marker. It excludes query strings, URL + fragments, referrer properties, account data, and user content. The + browser does not save an analytics cookie or identifier. +

+

+ The site does not use autocapture, session replay, heatmaps, surveys, + feature flags, performance monitoring, or user profiles, and it has + no user accounts or authentication. Requests are still subject to the + ordinary logs and security controls of the hosting provider. +

+

+ {site.domain} does not sell personal data, does not run advertising + pixels, and does not keep a reader profile. Appearance preferences + stay in the browser. Machine-readable copies of the public pages are + available as Markdown when a client sends{" "} + Accept: text/markdown, and the authored YAML records + remain downloadable from the{" "} + dataset index. +

+

+ Questions about this policy belong on the{" "} + contact page or in the{" "} + Stripedex repository. The broader + sourcing and independence statement lives on the{" "} + about page. +

+
+ +
+ ); +} diff --git a/app/runtime-surfaces.test.tsx b/app/runtime-surfaces.test.tsx index 903fb19..bdc11c4 100644 --- a/app/runtime-surfaces.test.tsx +++ b/app/runtime-surfaces.test.tsx @@ -42,6 +42,11 @@ describe("standalone runtime surfaces", () => { expect(loading).toContain('role="status"'); expect(notFound).toContain("Page not found"); expect(notFound).toContain('href="/"'); + expect(notFound).toContain('href="/llms.txt"'); + expect(notFound).toContain('href="/sitemap.xml"'); + expect(notFound).toContain('href="/about"'); + expect(notFound).toContain('href="/contact"'); + expect(notFound).toContain('href="/privacy"'); expect(loading.match(/data-presentation="menu"/gu)).toHaveLength(1); expect(notFound.match(/data-presentation="menu"/gu)).toHaveLength(1); expect(notFound).toContain('aria-label="hraness"'); diff --git a/app/seo.test.ts b/app/seo.test.ts index af3b4df..9570d17 100644 --- a/app/seo.test.ts +++ b/app/seo.test.ts @@ -8,6 +8,7 @@ import { breadcrumbJsonLd, historyCollectionJsonLd, historyDatasetJsonLd, + siteOrganizationJsonLd, websiteJsonLd, } from "./seo"; @@ -31,6 +32,16 @@ describe("stripedex.com structured discovery", () => { about: { "@type": "Organization", name: "Stripe" }, publisher: { name: "Hraness" }, }); + expect(siteOrganizationJsonLd()).toMatchObject({ + "@type": "Organization", + "@id": "https://stripedex.com/#organization", + name: "Stripedex", + url: "https://stripedex.com/", + sameAs: ["https://github.com/hraness/stripedex"], + parentOrganization: { name: "Hraness", url: "https://hraness.com/" }, + }); + expect(siteOrganizationJsonLd()).not.toHaveProperty("address"); + expect(siteOrganizationJsonLd()).not.toHaveProperty("contactPoint"); }); test("describes the open YAML records as a truthful dataset", async () => { diff --git a/app/seo.ts b/app/seo.ts index 1d07be0..912b026 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -24,6 +24,20 @@ const publisherJsonLd = { sameAs: ["https://github.com/hraness"], } as const; +export function siteOrganizationJsonLd() { + return { + "@context": "https://schema.org", + "@type": "Organization", + "@id": `${SITE_ORIGIN}/#organization`, + name: site.name, + alternateName: site.domain, + description: site.description, + url: `${SITE_ORIGIN}/`, + sameAs: [GITHUB_REPOSITORY_URL], + parentOrganization: publisherJsonLd, + } as const; +} + export function websiteJsonLd() { return { "@context": "https://schema.org", diff --git a/app/site-copy.ts b/app/site-copy.ts new file mode 100644 index 0000000..e861ba5 --- /dev/null +++ b/app/site-copy.ts @@ -0,0 +1,88 @@ +import { GITHUB_REPOSITORY_URL, HRANESS_URL, SITE_ORIGIN, site } from "./site"; + +export const notFoundTitle = "Page not found"; +export const notFoundDescription = "The requested Stripe history page does not exist."; + +export const aboutTitle = "About"; +export const aboutSocialTitle = `About ${site.domain}`; +export const aboutDescription = + `How ${site.domain} selects, summarizes, sources, reviews, corrects, and measures its independent Stripe company history.`; + +export const privacyTitle = "Privacy"; +export const privacySocialTitle = `Privacy | ${site.domain}`; +export const privacyDescription = + `How ${site.domain} handles analytics, cookies, accounts, and hosting logs for the independent Stripe company history.`; + +export const contactTitle = "Contact"; +export const contactSocialTitle = `Contact ${site.domain}`; +export const contactDescription = + `How to send a correction, source, or security report for the independent Stripe company history at ${site.domain}.`; + +export const paymentVolumeTitle = "Stripe Payment and Total Volume by Year"; +export const paymentVolumeDescription = + "Stripe annual volume history: payment volume from 2021 through 2024 and total volume for 2025, with source-linked disclosures from $640 billion+ to $1.9 trillion."; + +export const dataTitle = "Stripe Company History Dataset"; + +export const independenceSentence = + `${site.domain} is not affiliated with, endorsed by, or operated by Stripe, Inc. Stripe names and trademarks belong to their respective owners.`; + +export const recoveryLinks = [ + { href: `${SITE_ORIGIN}/`, label: "Stripe company history" }, + { href: `${SITE_ORIGIN}/llms.txt`, label: "Agent index (llms.txt)" }, + { href: `${SITE_ORIGIN}/sitemap.xml`, label: "Sitemap" }, + { href: `${SITE_ORIGIN}/about`, label: "About" }, + { href: `${SITE_ORIGIN}/data`, label: "Open history and research data" }, + { href: `${SITE_ORIGIN}/contact`, label: "Contact" }, + { href: `${SITE_ORIGIN}/privacy`, label: "Privacy" }, +] as const; + +export const aboutSections = [ + { + heading: "Stripe company history", + paragraphs: [ + `${site.domain} is an independent, sourced guide to Stripe. It publishes a reverse-chronological company timeline covering acquisitions, products, leadership, funding, valuation, expansion, offices, publishing projects, founder side projects and aesthetics programs, early history, annual volume, and reviewed long-form appearances by Stripe founders and senior leaders.`, + ], + }, + { + heading: "Sources and review", + paragraphs: [ + "History entries link to primary sources or strong contemporaneous reporting. Editorial review checks chronology, source support, category placement, and duplicate claims, and preserves uncertainty when a transaction or event was only proposed or reported.", + ], + }, + { + heading: "Independence and corrections", + paragraphs: [ + `${independenceSentence} Corrections are made in the underlying sourced records so the timeline and its focused category views stay aligned.`, + ], + }, + { + heading: "Publisher and contributions", + paragraphs: [ + `Published and maintained by [Hraness](${HRANESS_URL}). To suggest a correction, add a source, or improve the project, open an issue or contribution in the [Stripedex repository](${GITHUB_REPOSITORY_URL}). Use the [contact page](${SITE_ORIGIN}/contact) for the same public channels.`, + ], + }, +] as const; + +export const privacyParagraphs = [ + `The site sends anonymous, cookieless pageview events for public pages to PostHog. Each event contains the normalized public page path, its page category, a site identifier, an analytics schema version, and PostHog's cookieless marker. It excludes query strings, URL fragments, referrer properties, account data, and user content. The browser does not save an analytics cookie or identifier.`, + `The site does not use autocapture, session replay, heatmaps, surveys, feature flags, performance monitoring, or user profiles, and it has no user accounts or authentication. Requests are still subject to the ordinary logs and security controls of the hosting provider.`, + `${site.domain} does not sell personal data, does not run advertising pixels, and does not keep a reader profile. Appearance preferences stay in the browser. Machine-readable copies of the public pages are available as Markdown when a client sends \`Accept: text/markdown\`, and the authored YAML records remain downloadable from the [dataset index](${SITE_ORIGIN}/data).`, + `Questions about this policy belong on the [contact page](${SITE_ORIGIN}/contact) or in the [Stripedex repository](${GITHUB_REPOSITORY_URL}). The broader sourcing and independence statement lives on the [about page](${SITE_ORIGIN}/about).`, +] as const; + +export const contactParagraphs = [ + `Use public GitHub issues for ordinary historical corrections, missing events, stronger sources, and focused software improvements. Include the event date, a concise factual claim, its category, the proposed confidence and status, and at least one source URL. Prefer primary sources. If a claim was only proposed or reported, keep that uncertainty in the record.`, + `Report suspected vulnerabilities through GitHub's private vulnerability reporting for this repository. Do not include sensitive details in a public issue.`, + `There is no reader account, contact form, or product inbox on ${site.domain}. The project does not process payments, issue API keys, or operate a Stripe integration. ${independenceSentence}`, + `Published and maintained by [Hraness](${HRANESS_URL}). The complete sourced records and website code are in the [Stripedex repository](${GITHUB_REPOSITORY_URL}). Read [about](${SITE_ORIGIN}/about) for editorial method and [privacy](${SITE_ORIGIN}/privacy) for analytics limits.`, +] as const; + +export const paymentVolumeIntro = + "Stripe reported annual payment volume from 2021 through 2024 and switched to “total volume” for its 2025 figure."; + +export const paymentVolumeMethod = + "Years refer to the calendar year measured, not the later disclosure date. Values preserve Stripe’s published wording and qualifiers. The 2021 and 2022 figures are lower bounds, and Stripe calls the 2025 figure “total volume.” Missing years are not inferred from rounded growth rates."; + +export const dataIntro = + "These reviewable YAML files power the public timeline and valuation record. History entries preserve chronology, category, summary, confidence, and status when applicable; the research files preserve canonical source identities, valuation observations, leadership appearances, collection scope, and review runs."; diff --git a/app/site-footer.tsx b/app/site-footer.tsx index 6cfe977..c181f85 100644 --- a/app/site-footer.tsx +++ b/app/site-footer.tsx @@ -9,6 +9,8 @@ export function SiteFooter() { diff --git a/app/sitemap.ts b/app/sitemap.ts index 14fa1d7..c543f2c 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -19,6 +19,12 @@ export default async function sitemap(): Promise { { url: `${SITE_ORIGIN}/about`, }, + { + url: `${SITE_ORIGIN}/contact`, + }, + { + url: `${SITE_ORIGIN}/privacy`, + }, { url: `${SITE_ORIGIN}/data`, }, diff --git a/lib/AGENTS.md b/lib/AGENTS.md index 6c62a4b..3d33344 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -4,6 +4,8 @@ - `research-schema.ts` and `research-source-identity.ts` – strict provenance, valuation, appearance, collection, and run contracts with stable source identity. - `automated-publication-schema.ts` – reviewed model policy and hash-only publication attestation contracts. - `content.ts` – deterministic loading, source resolution, validation, categorization, chronology, annual-volume extraction, and valuation selection. +- `accept.ts` – Accept parsing and markdown negotiation decisions. +- `page-markdown.ts` and `llms-txt.ts` – Markdown representations of existing public pages and the agent index. - `*.test.ts` – schema, ordering, uniqueness, and source-provenance regressions. # Guidelines diff --git a/lib/accept.test.ts b/lib/accept.test.ts new file mode 100644 index 0000000..af6d417 --- /dev/null +++ b/lib/accept.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; + +import { + appendVaryAccept, + decideRepresentation, + isNextRscRequest, + markdownSiblingPath, + preferredType, + shouldSkipNegotiation, +} from "./accept"; + +describe("acceptmarkdown.com Accept parsing", () => { + test("defaults to HTML when Accept is missing or empty", () => { + expect(preferredType(null)).toBe("text/html"); + expect(preferredType("")).toBe("text/html"); + expect(preferredType(" ")).toBe("text/html"); + }); + + test("honors q-values and most-specific matching ranges", () => { + expect(preferredType("text/markdown")).toBe("text/markdown"); + expect(preferredType("text/markdown, text/html, */*")).toBe("text/markdown"); + expect(preferredType("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")) + .toBe("text/html"); + expect(preferredType("text/html;q=0.1, text/markdown;q=0.9")).toBe("text/markdown"); + expect(preferredType("text/html;q=0, */*;q=1")).toBe("text/markdown"); + expect(preferredType("text/*;q=0.8, text/markdown;q=0.2")).toBe("text/html"); + }); + + test("returns null when every produced type is rejected", () => { + expect(preferredType("application/pdf")).toBeNull(); + expect(preferredType("text/html;q=0, text/markdown;q=0, */*;q=0")).toBeNull(); + }); +}); + +describe("agent representation negotiation", () => { + test("skips Next.js RSC navigations, non-GET methods, and machine files", () => { + expect(isNextRscRequest(new Headers({ rsc: "1" }))).toBe(true); + expect(isNextRscRequest(new Headers({ "next-router-state-tree": "%5B%5D" }))).toBe(true); + expect(isNextRscRequest(new Headers({ accept: "text/markdown" }))).toBe(false); + expect(shouldSkipNegotiation("/history/acquisitions.yml")).toBe(true); + expect(shouldSkipNegotiation("/research/sources.yml")).toBe(true); + expect(shouldSkipNegotiation("/sitemap.xml")).toBe(true); + expect(shouldSkipNegotiation("/llms.txt")).toBe(true); + expect(shouldSkipNegotiation("/about.md")).toBe(false); + expect(shouldSkipNegotiation("/about")).toBe(false); + expect(decideRepresentation({ + accept: "text/markdown", + method: "POST", + pathname: "/about", + rsc: false, + })).toEqual({ kind: "passthrough" }); + expect(decideRepresentation({ + accept: "text/markdown", + method: "GET", + pathname: "/about", + rsc: true, + })).toEqual({ kind: "passthrough" }); + }); + + test("selects markdown, HTML, .md siblings, and 406 without inventing an API", () => { + expect(markdownSiblingPath("/about.md")).toBe("/about"); + expect(markdownSiblingPath("/.md")).toBe("/"); + expect(markdownSiblingPath("/about")).toBeNull(); + expect(decideRepresentation({ + accept: "text/markdown", + method: "GET", + pathname: "/about", + rsc: false, + })).toEqual({ kind: "markdown", pathname: "/about" }); + expect(decideRepresentation({ + accept: "text/html", + method: "GET", + pathname: "/about.md", + rsc: false, + })).toEqual({ kind: "markdown", pathname: "/about" }); + expect(decideRepresentation({ + accept: "text/html,application/xhtml+xml,*/*;q=0.8", + method: "GET", + pathname: "/", + rsc: false, + })).toEqual({ kind: "html" }); + expect(decideRepresentation({ + accept: "application/pdf", + method: "GET", + pathname: "/", + rsc: false, + })).toEqual({ kind: "not_acceptable" }); + }); + + test("appends Accept to Vary without duplicating it", () => { + const empty = new Headers(); + appendVaryAccept(empty); + expect(empty.get("Vary")).toBe("Accept"); + + const existing = new Headers({ + Vary: "rsc, next-router-state-tree", + }); + appendVaryAccept(existing); + expect(existing.get("Vary")).toBe("rsc, next-router-state-tree, Accept"); + appendVaryAccept(existing); + expect(existing.get("Vary")).toBe("rsc, next-router-state-tree, Accept"); + }); +}); diff --git a/lib/accept.ts b/lib/accept.ts new file mode 100644 index 0000000..b3e1732 --- /dev/null +++ b/lib/accept.ts @@ -0,0 +1,144 @@ +export const PRODUCED_MEDIA_TYPES = ["text/html", "text/markdown"] as const; + +export type ProducedMediaType = (typeof PRODUCED_MEDIA_TYPES)[number]; + +interface AcceptEntry { + readonly type: string; + readonly q: number; + readonly specificity: number; +} + +function parseAccept(header: string): readonly AcceptEntry[] { + return header.split(",").flatMap((raw) => { + const parts = raw.trim().split(";").map((part) => part.trim()); + const type = parts[0]?.toLowerCase(); + if (type === undefined || type === "") return []; + let q = 1; + for (const param of parts.slice(1)) { + const [name, value] = param.split("=").map((part) => part.trim()); + if (name !== "q" || value === undefined) continue; + const parsed = Number(value); + if (!Number.isNaN(parsed)) q = Math.max(0, Math.min(1, parsed)); + } + return [{ + q, + specificity: type === "*/*" ? 0 : type.endsWith("/*") ? 1 : 2, + type, + }]; + }); +} + +function matches(entry: AcceptEntry, candidate: string): boolean { + if (entry.type === "*/*") return true; + if (entry.type.endsWith("/*")) return candidate.startsWith(entry.type.slice(0, -1)); + return entry.type === candidate; +} + +export function preferredType(header: string | null): ProducedMediaType | null { + if (header === null || header.trim() === "") return PRODUCED_MEDIA_TYPES[0]; + const entries = parseAccept(header); + if (entries.length === 0) return PRODUCED_MEDIA_TYPES[0]; + + let bestType: ProducedMediaType | null = null; + let bestQ = -1; + let bestPosition = Number.POSITIVE_INFINITY; + + for (const candidate of PRODUCED_MEDIA_TYPES) { + let matched: AcceptEntry | null = null; + let matchedPosition = Number.POSITIVE_INFINITY; + for (const [idx, entry] of entries.entries()) { + if (!matches(entry, candidate)) continue; + if ( + matched === null + || entry.specificity > matched.specificity + || (entry.specificity === matched.specificity && idx < matchedPosition) + ) { + matched = entry; + matchedPosition = idx; + } + } + if (matched === null || matched.q <= 0) continue; + if (matched.q > bestQ || (matched.q === bestQ && matchedPosition < bestPosition)) { + bestQ = matched.q; + bestPosition = matchedPosition; + bestType = candidate; + } + } + + return bestType; +} + +export function appendVaryAccept(headers: Headers): void { + const existing = headers.get("Vary"); + if (existing === null || existing.trim() === "") { + headers.set("Vary", "Accept"); + return; + } + const tokens = existing.split(",").map((token) => token.trim().toLowerCase()); + if (!tokens.includes("accept")) { + headers.set("Vary", `${existing}, Accept`); + } +} + +export function isNextRscRequest(headers: Headers): boolean { + return headers.has("rsc") || headers.has("next-router-state-tree"); +} + +export function markdownSiblingPath(pathname: string): string | null { + if (!pathname.endsWith(".md")) return null; + const withoutSuffix = pathname.slice(0, -3); + return withoutSuffix === "" ? "/" : withoutSuffix; +} + +const SKIP_NEGOTIATION_PREFIXES = [ + "/_next/", + "/_vercel/", + "/research/", +] as const; + +const SKIP_NEGOTIATION_PATHS = new Set([ + "/favicon.ico", + "/llms.txt", + "/manifest.webmanifest", + "/opengraph-image", + "/robots.txt", + "/sitemap.xml", +]); + +export function shouldSkipNegotiation(pathname: string): boolean { + if (SKIP_NEGOTIATION_PATHS.has(pathname)) return true; + if (SKIP_NEGOTIATION_PREFIXES.some((prefix) => pathname.startsWith(prefix))) { + return true; + } + if (pathname.endsWith(".md")) return false; + const lastSegment = pathname.split("/").at(-1) ?? ""; + return lastSegment.includes("."); +} + +export type NegotiationDecision = + | { readonly kind: "html" } + | { readonly kind: "markdown"; readonly pathname: string } + | { readonly kind: "not_acceptable" } + | { readonly kind: "passthrough" }; + +export function decideRepresentation(input: Readonly<{ + accept: string | null; + method: string; + pathname: string; + rsc: boolean; +}>): NegotiationDecision { + if (input.method !== "GET" && input.method !== "HEAD") return { kind: "passthrough" }; + if (input.rsc || shouldSkipNegotiation(input.pathname)) return { kind: "passthrough" }; + + const sibling = markdownSiblingPath(input.pathname); + if (sibling !== null) return { kind: "markdown", pathname: sibling }; + + const chosen = preferredType(input.accept); + if (chosen === "text/markdown") { + return { kind: "markdown", pathname: input.pathname }; + } + if (chosen === null && input.accept !== null && input.accept.trim() !== "") { + return { kind: "not_acceptable" }; + } + return { kind: "html" }; +} diff --git a/lib/llms-txt.ts b/lib/llms-txt.ts new file mode 100644 index 0000000..32250f6 --- /dev/null +++ b/lib/llms-txt.ts @@ -0,0 +1,38 @@ +import { loadHistory } from "./content"; +import { GITHUB_REPOSITORY_URL, SITE_ORIGIN, site } from "@/app/site"; +import { independenceSentence } from "@/app/site-copy"; + +export async function llmsTxt(): Promise { + const history = await loadHistory(); + const categoryLinks = history.categories.map((category) => { + const count = history.events.filter(({ categoryId }) => categoryId === category.id).length; + return `- [Stripe ${category.label.toLocaleLowerCase("en-US")} history](${SITE_ORIGIN}/history/${category.id}): ${count} sourced events. ${category.description}`; + }); + + return [ + `# ${site.name}`, + `> ${site.description}`, + `${site.domain} publishes an independent, open-source Stripe company history as server-rendered pages and reviewable YAML. ${independenceSentence}`, + "", + "## When to use this", + "Use Stripedex when you need a sourced chronology of Stripe as a company: acquisitions, product launches, funding, private-company valuation, disclosed annual volume, leadership appearances, expansion, offices, publishing, or early history. Prefer a category page or its YAML download when the question is one topic. Fetch this file first, then the Markdown representation of a page by sending `Accept: text/markdown` to the same URL, or by appending `.md`.", + "", + "Do not use Stripedex for Stripe product APIs, payments, billing, Connect, Atlas, OAuth, webhooks, MCP, official documentation, account data, or anything that requires Stripe to speak. This site does not process payments, create accounts, or endorse Stripe.", + "", + "## Pages", + `- [Stripe company history](${SITE_ORIGIN}/): Complete reverse-chronological timeline and topic index`, + `- [About](${SITE_ORIGIN}/about): Sourcing, review, independence, and corrections`, + `- [Contact](${SITE_ORIGIN}/contact): Public correction and security-reporting channels`, + `- [Privacy](${SITE_ORIGIN}/privacy): Analytics, cookies, and hosting limits`, + `- [Open history and research data](${SITE_ORIGIN}/data): YAML downloads and provenance files`, + `- [Annual payment and total volume](${SITE_ORIGIN}/history/payment-volume): ${history.annualVolumes.length} disclosed years`, + `- [Private-company valuation history](${SITE_ORIGIN}/history/valuation): ${history.valuations.length} sourced observations`, + ...categoryLinks, + "", + "## Optional", + `- [Sitemap](${SITE_ORIGIN}/sitemap.xml): Canonical HTML URLs`, + `- [Robots](${SITE_ORIGIN}/robots.txt): Crawl policy`, + `- [Stripedex repository](${GITHUB_REPOSITORY_URL}): Website code and authored YAML`, + "", + ].join("\n"); +} diff --git a/lib/page-markdown.test.ts b/lib/page-markdown.test.ts new file mode 100644 index 0000000..8d0d519 --- /dev/null +++ b/lib/page-markdown.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { loadHistory } from "./content"; + +import { MARKDOWN_CONTENT_TYPE, markdownForPath, notFoundMarkdown } from "./page-markdown"; +import { llmsTxt } from "./llms-txt"; + +function visibleText(markdown: string): string { + return markdown.replace(/[#>*`\[\]()]/gu, " ").replace(/\s+/gu, " ").trim(); +} + +describe("agent markdown representations", () => { + test("returns recovery markdown for unknown paths", async () => { + const missing = await markdownForPath("/some-path-that-does-not-exist"); + expect(missing.status).toBe(404); + expect(missing.body).toBe(notFoundMarkdown()); + expect(missing.body).toContain("# Page not found"); + expect(missing.body).toContain("https://stripedex.com/llms.txt"); + expect(missing.body).toContain("https://stripedex.com/sitemap.xml"); + expect(missing.body).toContain("https://stripedex.com/about"); + expect(MARKDOWN_CONTENT_TYPE).toBe("text/markdown; charset=utf-8"); + }); + + test("renders the homepage as an index instead of the full HTML timeline", async () => { + const history = await loadHistory(); + const page = await markdownForPath("/"); + expect(page.status).toBe(200); + expect(page.body).toContain(`# Stripe Company History: ${history.events.length} Sourced Events`); + expect(page.body).toContain("not affiliated with, endorsed by, or operated by"); + expect(page.body).toContain("https://stripedex.com/history/acquisitions"); + expect(page.body).not.toContain(history.events[0]?.title ?? "missing-event"); + }); + + test("renders category, volume, about, contact, and privacy pages from the same records", async () => { + const history = await loadHistory(); + const acquisitions = history.events.find(({ categoryId }) => categoryId === "acquisitions"); + const category = await markdownForPath("/history/acquisitions"); + expect(category.status).toBe(200); + expect(category.body).toContain(acquisitions?.title ?? "missing-acquisition"); + expect(category.body).toContain("Sources:"); + + const volume = await markdownForPath("/history/payment-volume"); + expect(volume.body).toContain("2025"); + expect(volume.body).toContain("total volume"); + + const about = await markdownForPath("/about"); + expect(about.body).toContain("founder side projects and aesthetics programs"); + expect(visibleText((await markdownForPath("/privacy")).body).length).toBeGreaterThan(500); + expect(visibleText((await markdownForPath("/contact")).body).length).toBeGreaterThan(500); + }); +}); + +describe("llms.txt", () => { + test("names when to use Stripedex and when not to", async () => { + const body = await llmsTxt(); + expect(body.startsWith("# Stripedex\n> ")).toBe(true); + expect(body).toContain("## When to use this"); + expect(body).toContain("Do not use Stripedex for Stripe product APIs"); + expect(body).toContain("https://stripedex.com/about"); + expect(body).toContain("https://stripedex.com/data"); + expect(body).toContain("https://stripedex.com/history/appearances"); + expect(body).not.toContain("openapi"); + expect(body).not.toContain("MCP server"); + }); +}); diff --git a/lib/page-markdown.ts b/lib/page-markdown.ts new file mode 100644 index 0000000..9a6c5cd --- /dev/null +++ b/lib/page-markdown.ts @@ -0,0 +1,329 @@ +import { + aboutDescription, + aboutSections, + aboutSocialTitle, + contactDescription, + contactParagraphs, + contactTitle, + dataIntro, + dataTitle, + independenceSentence, + notFoundDescription, + notFoundTitle, + paymentVolumeDescription, + paymentVolumeIntro, + paymentVolumeMethod, + paymentVolumeTitle, + privacyDescription, + privacyParagraphs, + privacyTitle, + recoveryLinks, +} from "@/app/site-copy"; +import { GITHUB_REPOSITORY_URL, SITE_ORIGIN, site } from "@/app/site"; +import { + loadHistory, + type CategorizedHistoryEvent, + type HistoryCollection, +} from "./content"; +import { timelineCategoryIds, type TimelineCategoryId } from "./history-schema"; +import { llmsTxt } from "./llms-txt"; + +export const MARKDOWN_CONTENT_TYPE = "text/markdown; charset=utf-8"; +export const NOT_ACCEPTABLE_BODY = + "Not Acceptable\n\nAvailable: text/html, text/markdown\n"; + +export interface MarkdownDocument { + readonly body: string; + readonly status: 200 | 404; +} + +const KNOWN_STATIC_PATHS = new Set([ + "/", + "/about", + "/contact", + "/data", + "/history", + "/llms.txt", + "/privacy", + "/history/payment-volume", + "/history/valuation", +]); + +function normalizePathname(pathname: string): string { + if (pathname === "" || pathname === "/") return "/"; + return pathname.length > 1 ? pathname.replace(/\/+$/u, "") : pathname; +} + +function heading(title: string, description: string): string { + return `# ${title}\n\n> ${description}\n`; +} + +function linkList( + items: readonly Readonly<{ href: string; label: string; note?: string }>[], +): string { + return items.map((item) => ( + item.note === undefined + ? `- [${item.label}](${item.href})` + : `- [${item.label}](${item.href}): ${item.note}` + )).join("\n"); +} + +function eventMarkdown(event: CategorizedHistoryEvent): string { + const facts = [ + event.amount === undefined ? undefined : `amount: ${event.amount.display}`, + ...(event.metrics ?? []).map((metric) => ( + metric.context === undefined + ? `${metric.label}: ${metric.value}` + : `${metric.label}: ${metric.value} · ${metric.context}` + )), + ...(event.details ?? []).map((detail) => `${detail.label}: ${detail.value}`), + ].filter((value): value is string => value !== undefined); + const sources = event.sources.map((source) => `[${source.publisher}](${source.url})`).join(" · "); + const status = [ + event.status, + event.confidence === "confirmed" ? undefined : event.confidence, + ].filter((value): value is string => value !== undefined).join(" · "); + return [ + `### ${event.title}`, + "", + status === "" ? event.date : `${event.date} · ${status}`, + "", + event.summary, + ...(facts.length === 0 ? [] : ["", facts.join(" \n")]), + "", + `Sources: ${sources}`, + "", + ].join("\n"); +} + +function historyIndexMarkdown(history: HistoryCollection): string { + const categoryLinks = history.categories.map((category) => { + const count = history.events.filter(({ categoryId }) => categoryId === category.id).length; + return { + href: `${SITE_ORIGIN}/history/${category.id}`, + label: `Stripe ${category.label.toLocaleLowerCase("en-US")} history`, + note: `${count} sourced events. ${category.description}`, + }; + }); + return [ + heading( + `${site.historyTitle}: ${history.events.length} Sourced Events`, + site.description, + ), + independenceSentence, + "", + `This Markdown index covers the same ${history.events.length} sourced events as the HTML timeline. Category, annual-volume, and valuation pages repeat those records in a narrower view.`, + "", + "## Browse by topic", + "", + linkList([ + ...categoryLinks, + { + href: `${SITE_ORIGIN}/history/payment-volume`, + label: "Annual payment and total volume", + note: `${history.annualVolumes.length} disclosed years`, + }, + { + href: `${SITE_ORIGIN}/history/valuation`, + label: "Private-company valuation history", + note: `${history.valuations.length} sourced observations`, + }, + { + href: `${SITE_ORIGIN}/data`, + label: "Open history and research data", + note: "Downloadable YAML with source provenance", + }, + ]), + "", + "## Agent index", + "", + linkList(recoveryLinks.filter(({ href }) => href !== `${SITE_ORIGIN}/`)), + "", + ].join("\n"); +} + +function aboutMarkdown(): string { + return [ + heading(aboutSocialTitle, aboutDescription), + ...aboutSections.flatMap((section) => [ + `## ${section.heading}`, + "", + ...section.paragraphs, + "", + ]), + "## Privacy", + "", + ...privacyParagraphs.slice(0, 2), + "", + `The dedicated [privacy page](${SITE_ORIGIN}/privacy) repeats this policy.`, + "", + ].join("\n"); +} + +function privacyMarkdown(): string { + return [ + heading(`${privacyTitle} | ${site.domain}`, privacyDescription), + ...privacyParagraphs, + "", + ].join("\n"); +} + +function contactMarkdown(): string { + return [ + heading(`${contactTitle} ${site.domain}`, contactDescription), + ...contactParagraphs, + "", + ].join("\n"); +} + +function dataMarkdown(history: HistoryCollection): string { + return [ + heading(dataTitle, site.datasetDescription), + dataIntro, + "", + `The dataset and website code are available under the MIT License in the [Stripedex repository](${GITHUB_REPOSITORY_URL}).`, + "", + "## History files", + "", + linkList(history.categories.map((category) => ({ + href: category.id === "appearances" + ? `${SITE_ORIGIN}/research/appearances.yml` + : `${SITE_ORIGIN}/history/${category.id}.yml`, + label: `${category.label} YAML`, + note: category.description, + }))), + "", + "## Research files", + "", + linkList([ + { + href: `${SITE_ORIGIN}/research/sources.yml`, + label: "Source catalog YAML", + note: `${history.sources.length} canonical sources`, + }, + { + href: `${SITE_ORIGIN}/research/valuations.yml`, + label: "Valuation observations YAML", + note: `${history.valuations.length} observations`, + }, + { + href: `${SITE_ORIGIN}/research/collections.yml`, + label: "Research collections YAML", + }, + { + href: `${SITE_ORIGIN}/research/runs.yml`, + label: "Research run ledger YAML", + }, + ]), + "", + ].join("\n"); +} + +function categoryMarkdown( + history: HistoryCollection, + categoryId: TimelineCategoryId, +): string | null { + const category = history.categories.find(({ id }) => id === categoryId); + if (category === undefined) return null; + const events = history.events.filter(({ categoryId: id }) => id === categoryId); + return [ + heading( + `Stripe ${category.label} Timeline: ${events.length} Sourced Events`, + category.description, + ), + ...events.flatMap((event) => [eventMarkdown(event)]), + ].join("\n"); +} + +function paymentVolumeMarkdown(history: HistoryCollection): string { + const rows = history.annualVolumes.map((point) => { + const event = history.events.find(({ id }) => id === point.eventId); + const kind = point.kind === "total-volume" ? "total volume" : "payment volume"; + const sources = event?.sources.map((source) => `[${source.publisher}](${source.url})`).join(" · "); + return `- ${point.calendarYear}: ${point.display} ${kind}${sources === undefined ? "" : `. Sources: ${sources}`}`; + }); + return [ + heading(paymentVolumeTitle, paymentVolumeDescription), + paymentVolumeIntro, + "", + "## Disclosures", + "", + ...rows, + "", + "## Method", + "", + paymentVolumeMethod, + "", + ].join("\n"); +} + +function valuationMarkdown(history: HistoryCollection): string { + return [ + heading( + "Stripe private-company valuation history", + "Sourced private-company valuation observations for Stripe, with status, basis, and linked evidence.", + ), + ...history.valuations.map((observation) => { + const sources = observation.sources.map((source) => `[${source.title}](${source.url})`).join(" · "); + return [ + `### ${observation.valuation.display}`, + "", + `${observation.effective_date} · ${observation.status} · ${observation.valuation.basis}`, + "", + `Sources: ${sources}`, + "", + ].join("\n"); + }), + ].join("\n"); +} + +export function notFoundMarkdown(): string { + return [ + heading(notFoundTitle, notFoundDescription), + "Continue from:", + "", + linkList(recoveryLinks), + "", + ].join("\n"); +} + +export async function markdownForPath(pathname: string): Promise { + const path = normalizePathname(pathname); + if (path === "/llms.txt") { + return { body: await llmsTxt(), status: 200 }; + } + + const history = await loadHistory(); + if (path === "/" || path === "/history") { + return { body: historyIndexMarkdown(history), status: 200 }; + } + if (path === "/about") return { body: aboutMarkdown(), status: 200 }; + if (path === "/privacy") return { body: privacyMarkdown(), status: 200 }; + if (path === "/contact") return { body: contactMarkdown(), status: 200 }; + if (path === "/data") return { body: dataMarkdown(history), status: 200 }; + if (path === "/history/payment-volume") { + return { body: paymentVolumeMarkdown(history), status: 200 }; + } + if (path === "/history/valuation") { + return { body: valuationMarkdown(history), status: 200 }; + } + if (path.startsWith("/history/")) { + const categoryId = path.slice("/history/".length); + if (timelineCategoryIds.includes(categoryId as TimelineCategoryId)) { + const body = categoryMarkdown(history, categoryId as TimelineCategoryId); + if (body !== null) return { body, status: 200 }; + } + } + + if (!KNOWN_STATIC_PATHS.has(path) && !path.startsWith("/history/")) { + return { body: notFoundMarkdown(), status: 404 }; + } + return { body: notFoundMarkdown(), status: 404 }; +} + +export function markdownHeaders(): Headers { + return new Headers({ + "Content-Type": MARKDOWN_CONTENT_TYPE, + Vary: "Accept", + }); +} diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..d840f23 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,56 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { + appendVaryAccept, + decideRepresentation, + isNextRscRequest, +} from "./lib/accept"; +import { + MARKDOWN_CONTENT_TYPE, + NOT_ACCEPTABLE_BODY, + markdownForPath, +} from "./lib/page-markdown"; + +export async function proxy(request: NextRequest) { + const decision = decideRepresentation({ + accept: request.headers.get("accept"), + method: request.method, + pathname: request.nextUrl.pathname, + rsc: isNextRscRequest(request.headers), + }); + + if (decision.kind === "passthrough") { + return NextResponse.next(); + } + + if (decision.kind === "not_acceptable") { + return new NextResponse(NOT_ACCEPTABLE_BODY, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + Vary: "Accept", + }, + status: 406, + }); + } + + if (decision.kind === "markdown") { + const document = await markdownForPath(decision.pathname); + return new NextResponse(document.body, { + headers: { + "Content-Type": MARKDOWN_CONTENT_TYPE, + Vary: "Accept", + }, + status: document.status, + }); + } + + const response = NextResponse.next(); + appendVaryAccept(response.headers); + return response; +} + +export const config = { + matcher: [ + "/((?!_next/|_vercel/).*)", + ], +}; From 262a9d3df77dff0e34889ee2301ec8757eef1d13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 22:08:49 +0000 Subject: [PATCH 2/2] Declare Vary: Accept on HTML routes Keep caches from mixing HTML and Markdown representations after acceptmarkdown.com negotiation. Co-authored-by: ben <0thernet@users.noreply.github.com> --- app/metadata.test.ts | 8 ++++++++ next.config.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/app/metadata.test.ts b/app/metadata.test.ts index d7fe3f6..7915bbf 100644 --- a/app/metadata.test.ts +++ b/app/metadata.test.ts @@ -101,6 +101,14 @@ describe("stripedex.com public identity", () => { headers: [{ key: "X-Robots-Tag", value: "noindex, follow" }], source: "/research/:path*", }); + expect(await nextConfig.headers?.()).toContainEqual({ + headers: [{ key: "Vary", value: "Accept" }], + source: "/", + }); + expect(await nextConfig.headers?.()).toContainEqual({ + headers: [{ key: "Vary", value: "Accept" }], + source: "/:path*", + }); }); test("redirects former and www hosts directly to the canonical origin", async () => { diff --git a/next.config.ts b/next.config.ts index d9a9644..18377a0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,6 +10,8 @@ const nextConfig: NextConfig = { return [ { headers: noindexHeaders, source: "/history/:category.yml" }, { headers: noindexHeaders, source: "/research/:path*" }, + { headers: [{ key: "Vary", value: "Accept" }], source: "/" }, + { headers: [{ key: "Vary", value: "Accept" }], source: "/:path*" }, ]; }, reactStrictMode: true,