Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ Next.js 16 (App Router) · React 19 · Tailwind CSS v4 · TypeScript · pnpm. No
## Commands

```bash
pnpm install # required — husky hooks and lint-staged invoke `pnpm exec`
pnpm install # required — husky hooks shell out to `pnpm exec` (lint-staged, commitlint)
pnpm dev # dev server on :3000
pnpm build # static export → ./out (chains check:out + check:og-image)
pnpm start # serve a prior `next build` output (not the static export)
pnpm start # unusable here — `next start` errors under `output: "export"`;
# preview the export with `npx serve@latest out`
pnpm test # vitest run — `lib/blog.test.ts` for a pure-module test,
# `components/ui/MethodRow.test.ts` for a component one
pnpm typecheck # tsc --noEmit (CI) — `next build` only checks what it bundles
Expand All @@ -30,27 +31,29 @@ pnpm check:og-image # postbuild social-card guard over ./out (runs inside `pnp
## Layout

- `app/` — App Router entry (`layout.tsx`, `page.tsx`, `globals.css`), the `blog/` and `blog/[slug]/` routes, the `legal/[doc]` route, the `robots.txt/route.ts` / `sitemap.xml/route.ts` / `sitemap-pages.xml/route.ts` / `llms.txt/route.ts` / `llms-full.txt/route.ts` handlers, and the file-convention assets (`favicon.ico`, `icon.svg`, `apple-icon.png`, `opengraph-image.{png,alt.txt}`).
- `components/site/` — page sections composed by `app/page.tsx` (Hero, Compare, Method, Faq, Contact, …) plus chrome (`Chrome`, `Footer`, `ScrollReveal`, …). Section components are named after their section `id` (e.g. `Contact.tsx` for `id="contact"`); `Hero` is the idiomatic exception for the top `id="intro"` section.
- `components/ui/` — low-level primitives (Frame, SectionHeader, Prose, Figure, …).
- `lib/` — shared helpers (`links.ts`, `blog.ts`, `faq.ts`, `legal.ts`, `jsonld.tsx`, `schema.ts`, …). `lib/schema.ts` holds the site-level JSON-LD nodes; the per-post `BlogPosting` node is `blogPostingNode` in `lib/blog.ts` because it derives from a post rather than from the site — `blogNode` nests it and `app/blog/[slug]/page.tsx` emits it top-level, so they cannot diverge under the same `@id`.
- `components/site/` — page sections composed by `app/page.tsx` (Hero, Compare, Method, Faq, Contact, …) plus chrome (`Chrome`, `Footer`, `MobileMenu`, `ScrollReveal`, …) and the live panels (`HeroTerminal`, `FleetStatus`). Section components are named after their section `id` (e.g. `Contact.tsx` for `id="contact"`); `Hero` is the idiomatic exception for the top `id="intro"` section.
- `components/ui/` — low-level primitives (Frame, SectionHeader, Prose, Figure, PanelFigure, …). `PanelFigure` exists so a tool panel's caption sits outside its `aria-hidden` panel; its test and `check-out.mjs` both hold that.
- `lib/` — shared helpers (`links.ts`, `blog.ts`, `faq.ts`, `legal.ts`, `jsonld.tsx`, `schema.ts`, `metadata.ts`, `scroll.ts`, …). `lib/schema.ts` holds the site-level JSON-LD nodes; the per-post `BlogPosting` node is `blogPostingNode` in `lib/blog.ts` because it derives from a post rather than from the site — `blogNode` nests it and `app/blog/[slug]/page.tsx` emits it top-level, so they cannot diverge under the same `@id`.
- `lib/copy.ts` — the site's prose, as plain data. New or edited homepage copy goes here, not inline in a component: the comparison table maps over `COMPARE_ROWS` and `app/llms-full.txt/route.ts` serialises the same strings as markdown, so copy inlined in JSX silently drifts from the mirror. This is not hypothetical — the hero and method stat strips were inline `<Figure>` attributes restated by hand in the mirror, and had already drifted. Values as well as sentences: `HERO_FIGURES`, `STACK`, `PANEL_CAPTIONS`. Render prose through `highlightBrand` (`components/ui/brand.tsx`) wherever the sentence names the product.
- `lib/stats.ts` — the live testnet figures behind the hero terminal and the Contact fleet panel. The page stays a static export: both panels render a loading state into the HTML and fill in after mount from `https://data.decdn.org/stats-421614.json`, which the `decdn/stats` indexer rewrites every five minutes (schema: `worker/src/stats.ts` there). Shape check, formatting and view models are pure and unit-tested; `lib/use-stats.ts` is the one-fetch-per-page client hook. The CSP in `public/_headers` allowlists the origin under `connect-src` — change both together.
- `test-utils/` — test-only helpers (`react-tree.ts` walks the element tree a server component returns). Nothing under `app/` or `components/` imports from here.
- `scripts/` — `check-out.mjs`, the postbuild assertions `pnpm build` runs against `./out`.
- `content/blog/` — MDX posts loaded by `lib/blog.ts` and rendered via `app/blog/[slug]/page.tsx`.
- `content/legal/` — MDX for the legal pages (`privacy`, `terms`, `disclaimer`) loaded by `lib/legal.ts` and rendered via `app/legal/[doc]/page.tsx`.
- `scripts/` — build-time guards run against the export, not shipped with it (`check-out.mjs` for advertised URLs and dotted routes, `check-og-image.mjs` for social cards; both chained off `pnpm build`). Node built-ins only, no dependencies; `scripts/*.test.ts` spawns them against fixture exports.
- `scripts/` — build-time guards run against the export, not shipped with it (`check-out.mjs` for advertised URLs, dotted routes and the `PANEL_CAPTIONS` strings in the built homepage; `check-og-image.mjs` for social cards; both chained off `pnpm build`). Node built-ins only, no dependencies; `scripts/check-og-image.test.ts` spawns its script against fixture exports (`check-out.mjs` has no test of its own).
- `docs/` — Mintlify source for `docs.decdn.org` (separate build pipeline, not part of the static export).
- Path alias `@/*` → project root (e.g. `@/lib/links`, not `@/src/...`).

## Gotchas

- **Static export only.** `next.config.ts` has `output: "export"`. No SSR, ISR, middleware, or Image Optimization API. Route handlers (`app/robots.txt/route.ts`, `app/sitemap.xml/route.ts`, `app/sitemap-pages.xml/route.ts`, `app/llms.txt/route.ts`, `app/llms-full.txt/route.ts`) are allowed only when statically generated at build time (`dynamic = "force-static"`, GET-only). `robots.txt` is a hand-written route handler rather than the Next `app/robots.ts` metadata file so it can emit non-standard directives (`Content-Signal:` per contentsignals.org). A dotted route segment is what makes these land as real files (`out/llms.txt`) rather than `<path>/index.html` despite `trailingSlash: true`.
- **`trailingSlash: true`.** `next.config.ts` emits every route as `<path>/index.html` and canonical/internal links should expect a trailing slash. Cloudflare Pages serves `out/` as-is.
- **Tailwind v4.** `globals.css` uses `@import "tailwindcss"` and `@theme inline { … }`. There is no `tailwind.config.*` — theme tokens live in CSS. Don't reach for v3 directives.
- **Tailwind v4.** `globals.css` uses `@import "tailwindcss"` and `@theme inline { … }`. There is no `tailwind.config.*` — theme tokens live in CSS. Don't reach for v3 directives. Shared classes go in `@layer components` (focus rings in `@layer base`) so a utility beside one can refine it. Use the tokens — `--ease-expo` / `--ease-soft` / `--ease-term`, `--font-sans` / `--font-mono`, `--panel-radius` — rather than literal curves, stacks and radii.
- **Fonts are self-hosted.** `app/layout.tsx` loads Geist and Geist Mono through `next/font/google`, which downloads them at build time, so the CSP's `font-src 'self'` suffices; a font CDN would need a CSP change in `public/_headers`. `--font-mono-system` is the fallback for glyphs the Geist Mono subset lacks (→ ✓ Σ).
- **Conventional commits required.** `commitlint` runs in the `commit-msg` husky hook; non-conforming messages are rejected.
- **`metadataBase` is live.** `lib/links.ts` `site` is the real origin and `INDEXABLE` is `true`. Anything anchored on this origin — OG and canonical (via `metadataBase`); JSON-LD, `app/sitemap.xml/route.ts`, `app/sitemap-pages.xml/route.ts`, `app/robots.txt/route.ts` (via `SITE_URL`) — ships to production. Adding a new non-blog page = append an entry to `app/sitemap-pages.xml/route.ts`; blog posts auto-derive from `content/blog/` and legal pages from the closed `LEGAL_SLUGS` list in `lib/legal.ts`. Flip `INDEXABLE` to mark pages noindex; `robots.txt` and the sitemap remain unchanged by design (see `lib/links.ts` for why). `app/llms.txt/route.ts` and `app/llms-full.txt/route.ts` ship to the same origin and derive their page entries from `listPosts()` / `LEGAL_SLUGS` / `lib/copy.ts`; both are listed in `app/sitemap-pages.xml/route.ts` and advertised via `alternates.types` in `app/layout.tsx` and a `Link:` header in `public/_headers`. Deriving entries is not by itself a guarantee that a URL exists — the sitemap's two llms entries are literals, and the litepaper, press kit and `docs.decdn.org` entries are not routes at all. `scripts/check-out.mjs` is what resolves every advertised same-origin URL against `out/` after a build; `scripts/check-og-image.mjs` does the same for every `og:image` / `twitter:image` in every built page, plus every `sitemap-pages.xml` `<loc>`.
- **`docs/` is a different product.** Mintlify Cloud builds it from `docs/docs.json` and serves it at `docs.decdn.org` — independent of `pnpm build`. The website code must not import from `docs/`; ESLint and the website CI workflow ignore it. Edits to MDX go through the `docs` workflow (Prettier + `markdownlint-cli2` + `mintlify broken-links`).
- **vitest only collects `**/\*.test.ts`.** A `.test.tsx`is silently skipped — the suite stays green and the file never runs. Component tests therefore contain no JSX: call the component as the plain function it is and walk the result with`test-utils/react-tree.ts`(or`React.createElement` for fixtures).
- **`docs/` is a different product.** Mintlify Cloud builds it from `docs/docs.json` and serves it at `docs.decdn.org` — independent of `pnpm build`. The website code must not import from `docs/`; ESLint and the website CI workflow ignore it, but the root vitest config still collects `docs/*.test.ts`. Edits to MDX go through the `docs` workflow (Prettier + `markdownlint-cli2` + `pnpm test` for `docs/canonical-host.test.ts` + `mintlify broken-links`).
- **vitest only collects `.test.ts` files.** The include glob in `vitest.config.ts` is `**/*.test.ts`, so a `.test.tsx` is silently skipped — the suite stays green and the file never runs. Component tests therefore contain no JSX: call the component as the plain function it is and walk the result with `test-utils/react-tree.ts` (or `React.createElement` for fixtures). A client component that calls hooks can't be invoked that way — export its pure render half and test that (`FleetPanel` in `FleetStatus.tsx`); what's left is covered only by `scripts/check-out.mjs` against the built HTML.
- **CI typechecks separately.** `next build` only typechecks what it bundles, so test files are invisible to it — one had a type error on `main`. `pnpm typecheck` is a distinct CI step.
- **CSS custom properties in `style` props.** Known `--var` names are declared in `types/css.d.ts` (module-augmenting React's `CSSProperties`) so call sites can write `style={{ "--reveal-delay": "120ms" }}` without a cast. Add new vars there before using them.
- **Social links have three consumers.** A new entry in `links` (`lib/links.ts`) also belongs in `sameAs` in `lib/schema.ts` (asserted by `lib/schema.test.ts`) and in the contact line of `app/llms-full.txt/route.ts`.
- **ESLint majors are held back.** `.github/dependabot.yml` ignores `eslint` semver-major updates: `eslint-config-next` pulls in an `eslint-plugin-react` that crashes on v10. Lift it when Next ships a compatible plugin.
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,29 @@ This is the source for the deCDN marketing site and blog.

## Stack

Next.js 16 (App Router) · React 19 · Tailwind CSS v4 · TypeScript · pnpm. Static export, deployed to Cloudflare Pages.
Next.js 16 (App Router) · React 19 · Tailwind CSS v4 · TypeScript · pnpm · Node ≥24 (`.nvmrc`). Static export, deployed to Cloudflare Pages.

## Getting started

```bash
pnpm install # required (not npm/yarn) — husky hooks shell out to `pnpm exec`
pnpm dev # dev server on :3000
pnpm build # static export → ./out
pnpm test # vitest run
pnpm typecheck # tsc --noEmit
pnpm lint # eslint (flat config)
pnpm format # prettier --write .
pnpm install # required (not npm/yarn) — husky hooks shell out to `pnpm exec`
pnpm dev # dev server on :3000
pnpm build # static export → ./out, then the check:out + check:og-image guards
pnpm test # vitest run
pnpm typecheck # tsc --noEmit
pnpm lint # eslint (flat config)
pnpm format # prettier --write .
pnpm format:check # prettier --check . (CI)
```

## Project layout

- `app/` — App Router entry (`layout.tsx`, `page.tsx`, `globals.css`) plus the blog routes (`blog/`, `blog/[slug]/`), the `legal/[doc]` route, the sitemap/robots handlers, the `llms.txt` / `llms-full.txt` handlers, and file-convention metadata assets.
- `components/site/` — page sections composed by `app/page.tsx` (Hero, Compare, Method, Faq, Contact, …) plus chrome (`Chrome`, `Footer`, `ScrollReveal`, …).
- `components/ui/` — low-level primitives (Frame, SectionHeader, Prose, Figure, …).
- `lib/` — shared helpers (`links.ts`, `copy.ts`, `blog.ts`, `faq.ts`, `legal.ts`, `jsonld.tsx`, `schema.ts`, …). The site's prose _and_ its displayed values live in `lib/copy.ts` as plain data — the comparison table maps over it and the `llms-full.txt` mirror serialises it, so anything inlined in a component drifts from the mirror.
- `lib/` — shared helpers (`links.ts`, `copy.ts`, `stats.ts`, `blog.ts`, `faq.ts`, `legal.ts`, `jsonld.tsx`, `schema.ts`, …). The site's prose _and_ its displayed values live in `lib/copy.ts` as plain data — the comparison table maps over it and the `llms-full.txt` mirror serialises it, so anything inlined in a component drifts from the mirror. Prose that names the product renders through `highlightBrand` (`components/ui/brand.tsx`).
- `test-utils/` — test-only helpers for walking the element tree a server component returns.
- `scripts/` — `check-out.mjs`, postbuild assertions run against `./out` by `pnpm build`.
- `scripts/` — postbuild guards run against `./out` by `pnpm build`: `check-out.mjs` (advertised URLs, dotted routes, panel captions) and `check-og-image.mjs` (social cards).
- `content/blog/` — MDX posts loaded by `lib/blog.ts` and rendered by `app/blog/[slug]/page.tsx`.
- `content/legal/` — MDX for the legal pages (`privacy`, `terms`, `disclaimer`), loaded by `lib/legal.ts` and rendered by `app/legal/[doc]/page.tsx`.
- `docs/` — Mintlify source for `docs.decdn.org`. Built and deployed independently of `pnpm build`; not part of the static export and not imported from the website code.
Expand All @@ -50,6 +51,7 @@ pnpm format # prettier --write .
- **Conventional commits enforced.** `commitlint` runs in the `commit-msg` husky hook; non-conforming messages are rejected.
- **`metadataBase` is live.** `lib/links.ts` `site` is the real origin and `INDEXABLE` is `true`. Anything anchored on this origin — OG and canonical (via `metadataBase`); JSON-LD and the sitemap/robots emitters (via `SITE_URL`) — ships to production. Adding a non-blog page means appending an entry to `app/sitemap-pages.xml/route.ts`; blog posts are auto-derived from `content/blog/`, while legal pages are driven by the closed `LEGAL_SLUGS` list in `lib/legal.ts` (add both the MDX file under `content/legal/` and a slug there). Flipping `INDEXABLE` flips `<meta name="robots">` only; `robots.txt` and the sitemap stay unconditional by design (rationale in `lib/links.ts`).
- **Machine-readable surfaces.** `/llms.txt` is a curated index of this origin and `/llms-full.txt` is every page as one plain-text document; both derive their page entries from `listPosts()`, `LEGAL_SLUGS`, `lib/faq.ts` and `lib/copy.ts`, are listed in `app/sitemap-pages.xml/route.ts`, and are advertised through `alternates.types` in `app/layout.tsx` and a `Link:` header in `public/_headers`. `scripts/check-out.mjs` resolves every advertised same-origin URL against `out/` after a build. The protocol documentation has its own pair on `docs.decdn.org`, served by Mintlify.
- **Live testnet stats.** The hero terminal and the Contact fleet panel render a loading state into the static HTML and fill in after mount from `https://data.decdn.org/stats-421614.json` (rewritten every five minutes by the `decdn/stats` indexer). The fetch lives in `lib/stats.ts` / `lib/use-stats.ts`; the CSP in `public/_headers` allowlists the origin under `connect-src`, so change `STATS_ORIGIN` and the CSP together.

## Deploy

Expand Down
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://mintlify.com/docs.json",
"theme": "maple",
"name": "deCDN",
"description": "Decentralized CDN — staked nodes cache and serve content over QUIC, paid per-MB via off-chain USDC channels.",
"description": "Decentralized CDN — staked nodes cache and serve content over QUIC, paid per-MB with off-chain vouchers against a shared on-chain USDC pool.",
"colors": {
"primary": "#0f9d6a",
"light": "#1dc889",
Expand Down
2 changes: 1 addition & 1 deletion docs/overview/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ See [Participants](/overview/participants) for a deeper breakdown per role.

- No external origin URL exists — content enters the network through origin-backed nodes whose backends are hidden.
- A node cannot earn without delivering verifiable bytes — hash mismatch voids payment.
- A node cannot join the mesh without bonding — registration enforces a capacity bond (`bond = k × Mbps^α`) scaled to declared bandwidth, rather than a flat minimum.
- A node cannot join the mesh without bonding — registration enforces a capacity bond scaled to declared bandwidth (`k × Mbps^α`) and never below the global `minBond` entry floor.
- A node cannot register an identity it does not control — both keys must sign at registration.
- Safety bounds on all governable parameters are hardcoded — governance cannot set fees to 100% or push the operator revenue share below its floor.
- Being recognized as an origin is gated on-chain — governance vets a publisher wallet, and the vetted publisher seats its own origin operators per namespace. Configuring an origin backend locally without being seated means the node's bytes are served only as cache.
Expand Down
Loading
Loading