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
5 changes: 5 additions & 0 deletions .changeset/examples-markdown-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pmndrs/docs': patch
---

Pass the example gallery through as published rather than rendering it here. `pmndrs/examples` now writes the documents at build time and links each one from its page with `rel="alternate"`, so `examples://index` and `get_example` hand on the same text an agent would get from the open web — one rendering instead of two that could drift. The agents page documents both tools.
32 changes: 31 additions & 1 deletion docs/agents/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ One endpoint for all libraries, over streamable HTTP: `https://docs.pmnd.rs/api/
| resource | `docs://pmndrs/manifest` | which libraries are served, and how to query them |
| resource | `docs://{lib}/index` | that library's pages, one `{path} - {title}` per line |
| tool | `get_page_content` | the markdown of a single page, given a `lib` + a `path` from the index |
| resource | `examples://index` | the [example gallery](https://pmndrs.github.io/examples), one line per demo |
| tool | `get_example` | one demo in full, given a `name` from that index |

A typical round-trip — *"how do I use TypeScript with Zustand?"*:

Expand All @@ -44,6 +46,34 @@ call get_page_content(lib="zustand", path="/learn/guides/beginner-typescript")

Two pages transferred instead of the whole site.

### Examples

The docs say what an API takes. Most three.js questions are *how is this put together*, and the [gallery](https://pmndrs.github.io/examples) has 167 demos that answer it — so the same endpoint serves them, on the same read-the-index-then-fetch shape.

```txt
read examples://index
→ [...] caustics · #transmission [...]
diamond-refraction · Diamon refraction material · +postprocessing,leva · #refraction,bvh

call get_example(name="caustics")
→ the demo: its source, its live URL, its asset attribution, and the exact
dependency versions the code is written against
```

An index line drops whatever the demo does not carry, so it costs what it is worth. `+` lists only what a demo uses on top of `@react-three/fiber` and `@react-three/drei`, which all of them use. A trailing `~23k` is what `get_example` will cost in tokens — only eight demos carry one, and its absence means the demo is small enough to open without weighing it.

An example is a snapshot, not a spec: it pins its own versions, `get_example` reports them, and an answer that turns on a current signature still belongs in the docs above.

> [!NOTE]
>
> These documents are published, not rendered here — `pmndrs/examples` writes them at build time and every example page links its own:
>
> ```html
> <link rel="alternate" type="text/markdown" href="/examples/catalog/caustics.md" />
> ```
>
> So an agent that lands on an example page, or is handed its URL, reads the same text without going through MCP at all.

> [!TIP]
>
> [`pmndrs/claude-code-plugin`](https://github.com/pmndrs/claude-code-plugin) supports it natively
Expand All @@ -65,7 +95,7 @@ In the web UI it opens: **Add Servers** › **Add manually**, transport `streama

### Getting your library served

Only libraries publishing a `/llms-full.txt` dump are served — the ones badged `MCP` on [docs.pmnd.rs](https://docs.pmnd.rs). To join them:
Only libraries publishing a `/llms-full.txt` dump are served — the ones badged `MCP` on [docs.pmnd.rs](https://docs.pmnd.rs). The examples gallery is not one of them; it publishes its own catalog, and the two resources above are wired to it directly. To join the libraries:

1. **Publish the dump.** If your site is already built with this generator, bump it to a version shipping `/llms-full.txt` and redeploy. Otherwise, either migrate to this generator, or emit that file yourself with the same XML shape (`<page path="…" title="…">…</page>`).

Expand Down
80 changes: 26 additions & 54 deletions src/app/api/[transport]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,61 +41,36 @@ const llmsFullHandlers = Object.values(libs)
return http.get(`${origin}/llms-full.txt`, () => HttpResponse.text(mockLlmsFullTxt))
})

// The examples catalog, as pmndrs/examples publishes it -- an index to pick from
// and one file per example. Two entries is enough to tell "served the index" from
// "served an example"; `src/utils/examples.test.ts` covers the rendering itself.
const mockExampleIndex = {
site: 'https://pmndrs.github.io/examples',
count: 2,
examples: [
{
name: 'caustics',
title: 'Caustics',
description: '',
tags: ['transmission'],
authors: ['Paul Henschel'],
libraries: ['@react-three/drei', '@react-three/fiber'],
source: 'https://codesandbox.io/s/szj6p7',
demo: 'https://pmndrs.github.io/examples/examples/caustics',
thumbnail: 'https://pmndrs.github.io/examples/caustics/thumbnail.webp',
bytes: 4_000,
},
{
name: 'arkanoid',
title: 'Arkanoid',
description: 'Simple arkanoid implementation using cannon physics.',
tags: ['physics', 'game'],
authors: ['Paul Henschel'],
libraries: ['@react-three/fiber', '@react-three/cannon'],
source: 'https://codesandbox.io/s/arkanoid',
demo: 'https://pmndrs.github.io/examples/examples/arkanoid',
thumbnail: 'https://pmndrs.github.io/examples/arkanoid/thumbnail.webp',
bytes: 90_000,
},
],
}

const mockExample = {
...mockExampleIndex.examples[0],
repository: 'https://github.com/pmndrs/examples/tree/main/examples/caustics',
install: 'npx degit pmndrs/examples/examples/caustics',
dependencies: { '@react-three/drei': '10.7.8' },
files: [{ path: 'src/App.tsx', content: 'const caustics = true' }],
binaries: ['src/glass-transformed.glb'],
oversized: [],
assets: [],
}
// The examples catalog, as pmndrs/examples publishes it: markdown, already
// rendered, one document per example plus the index. This server passes them on
// untouched, so the fixtures are text and the assertions are about routing --
// the rendering itself is tested where it is produced.
const mockExampleIndex = `aquarium · #transmission
arkanoid · Simple arkanoid implementation using cannon physics. · +cannon · #physics,game · ~23k
`

const mockExample = `# Caustics

Demo: https://pmndrs.github.io/examples/examples/caustics
Dependencies: @react-three/drei@10.7.8

## src/App.tsx

\`\`\`tsx
const caustics = true
\`\`\`
`

// Setup MSW server
const server = setupServer(
...llmsFullHandlers,

http.get('https://pmndrs.github.io/examples/catalog/index.json', () =>
HttpResponse.json(mockExampleIndex),
http.get('https://pmndrs.github.io/examples/catalog/index.md', () =>
HttpResponse.text(mockExampleIndex),
),

http.get('https://pmndrs.github.io/examples/catalog/caustics.json', () =>
HttpResponse.json(mockExample),
http.get('https://pmndrs.github.io/examples/catalog/caustics.md', () =>
HttpResponse.text(mockExample),
),

// Hosts the standalone fetch-and-parse tests below call directly
Expand Down Expand Up @@ -558,15 +533,14 @@ Content with &lt;special&gt; characters &amp; symbols.
it('serves the whole gallery as one line per example', async () => {
const body = await call('resources/read', { uri: 'examples://index' })

// caustics' fixture is under the size threshold and arkanoid's is over it
expect(body).toContain('caustics · #transmission')
expect(body).toContain('aquarium · #transmission')
expect(body).toContain(
'arkanoid · Simple arkanoid implementation using cannon physics. · +cannon · #physics,game · ~23k',
)
expect(body).not.toContain('MCP server error')
})

it('serves one example with its source', async () => {
it('passes one example through as published', async () => {
const body = await call('tools/call', {
name: 'get_example',
arguments: { name: 'caustics' },
Expand All @@ -575,8 +549,6 @@ Content with &lt;special&gt; characters &amp; symbols.
expect(body).toContain('# Caustics')
expect(body).toContain('const caustics = true')
expect(body).toContain('Dependencies: @react-three/drei@10.7.8')
// Named, not inlined -- a reader that needs the model knows where it is
expect(body).toContain('src/glass-transformed.glb')
expect(body).not.toContain('MCP server error')
})

Expand All @@ -594,7 +566,7 @@ Content with &lt;special&gt; characters &amp; symbols.

it('errors, rather than serving an empty gallery, when the catalog is missing', async () => {
server.use(
http.get('https://pmndrs.github.io/examples/catalog/index.json', () => {
http.get('https://pmndrs.github.io/examples/catalog/index.md', () => {
return new HttpResponse('Not Found', { status: 404 })
}),
)
Expand Down
34 changes: 13 additions & 21 deletions src/app/api/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,7 @@ import { headers } from 'next/headers'
import { revalidateTag } from 'next/cache'
import { libs, type SUPPORTED_LIBRARY_NAMES } from '@/app/page'
import packageJson from '@/package.json' with { type: 'json' }
import {
assertExampleName,
catalogUrl,
renderExample,
renderIndex,
type Example,
type ExampleIndex,
} from '@/utils/examples'
import { assertExampleName, catalogUrl } from '@/utils/examples'

// Extract entries and library names as constants for efficiency
// Only support libraries whose site actually publishes a /llms-full.txt dump -- see
Expand All @@ -31,11 +24,12 @@ async function baseUrl() {
}

/**
* One file out of the examples catalog. Cached and tagged like the docs dumps,
* except the catalog is already split per example, so a request pulls the ~20 kB
* that was asked for rather than slicing it out of a bundle.
* One document out of the examples catalog, as pmndrs/examples published it.
* Cached and tagged like the docs dumps, except the catalog is already split per
* example and already rendered, so a request pulls the few kB that was asked for
* and passes it straight on.
*/
async function fetchCatalog<T>(file: string): Promise<T> {
async function fetchCatalog(file: string): Promise<string> {
const response = await fetch(catalogUrl(file), {
next: { revalidate: 300, tags: ['examples-catalog'] },
})
Expand All @@ -44,7 +38,7 @@ async function fetchCatalog<T>(file: string): Promise<T> {
if (!response.ok) {
throw new Error(`Failed to fetch ${catalogUrl(file)}: ${response.statusText}`)
}
return response.json() as Promise<T>
return response.text()
}

const handler = createMcpHandler(
Expand Down Expand Up @@ -197,8 +191,10 @@ Always handle errors gracefully and consider alternative approaches when a speci
SSE transport would need a Redis instance to relay messages, which this deployment
does not have, so \`/api/sse\` is not usable.
- Documentation is parsed from XML-tagged full-text dumps (\`/llms-full.txt\`)
- Examples come from the JSON catalog the gallery publishes at
\`https://pmndrs.github.io/examples/catalog/\`, already split one file per example
- Examples are passed through from the catalog the gallery publishes at
\`https://pmndrs.github.io/examples/catalog/\`, one already-rendered document per
example. They are public: every example page links its own with
\`rel="alternate"\`, so the same text is reachable without this server

### Security
- CSS selector injection protection via \`.filter()\` instead of direct selectors
Expand Down Expand Up @@ -329,13 +325,11 @@ Always handle errors gracefully and consider alternative approaches when a speci
mimeType: 'text/plain',
},
async () => {
const index = await fetchCatalog<ExampleIndex>('index')

return {
contents: [
{
uri: 'examples://index',
text: renderIndex(index),
text: await fetchCatalog('index'),
},
],
}
Expand Down Expand Up @@ -420,13 +414,11 @@ Always handle errors gracefully and consider alternative approaches when a speci
try {
// The catalog is one file per example, so `name` reaches a URL. Keep it
// to the shape every published example has rather than trusting it.
const example = await fetchCatalog<Example>(assertExampleName(name))

return {
content: [
{
type: 'text',
text: renderExample(example),
text: await fetchCatalog(assertExampleName(name)),
},
],
}
Expand Down
Loading
Loading