From 950a362b016a3ae5daec29c39f36dfc6a334f05d Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Wed, 12 Aug 2026 11:53:14 +0200 Subject: [PATCH] Pass the example gallery through as published Rendering the gallery here made this server the only way to read it. An agent handed an example's URL, or landing on the page from a search, got HTML and no way to the same text -- and pmndrs/examples is a static export, so it could not render one on demand either. It now writes those documents at build time and links each from its page with rel="alternate". So examples://index and get_example fetch and pass on, and the ~470 lines of rendering that lived here are gone: one rendering, reachable two ways, instead of two that could drift. What stays is the part that belongs to a server taking a name from a model -- that name reaches a URL only if it is the shape a published example has. The agents page now documents both tools. Co-Authored-By: Claude Opus 5 --- .changeset/examples-markdown-passthrough.md | 5 + docs/agents/introduction.mdx | 32 ++- src/app/api/[transport]/route.test.ts | 80 +++---- src/app/api/[transport]/route.ts | 34 ++- src/utils/examples.test.ts | 225 +++----------------- src/utils/examples.ts | 214 ++----------------- 6 files changed, 117 insertions(+), 473 deletions(-) create mode 100644 .changeset/examples-markdown-passthrough.md diff --git a/.changeset/examples-markdown-passthrough.md b/.changeset/examples-markdown-passthrough.md new file mode 100644 index 00000000..833b39fe --- /dev/null +++ b/.changeset/examples-markdown-passthrough.md @@ -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. diff --git a/docs/agents/introduction.mdx b/docs/agents/introduction.mdx index 4400068c..e6baa196 100644 --- a/docs/agents/introduction.mdx +++ b/docs/agents/introduction.mdx @@ -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?"*: @@ -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 +> +> ``` +> +> 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 @@ -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 (``). diff --git a/src/app/api/[transport]/route.test.ts b/src/app/api/[transport]/route.test.ts index f94d00f1..76d2693a 100644 --- a/src/app/api/[transport]/route.test.ts +++ b/src/app/api/[transport]/route.test.ts @@ -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 @@ -558,15 +533,14 @@ Content with <special> characters & 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' }, @@ -575,8 +549,6 @@ Content with <special> characters & 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') }) @@ -594,7 +566,7 @@ Content with <special> characters & 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 }) }), ) diff --git a/src/app/api/[transport]/route.ts b/src/app/api/[transport]/route.ts index 5de2a66d..4f5e3f7e 100644 --- a/src/app/api/[transport]/route.ts +++ b/src/app/api/[transport]/route.ts @@ -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 @@ -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(file: string): Promise { +async function fetchCatalog(file: string): Promise { const response = await fetch(catalogUrl(file), { next: { revalidate: 300, tags: ['examples-catalog'] }, }) @@ -44,7 +38,7 @@ async function fetchCatalog(file: string): Promise { if (!response.ok) { throw new Error(`Failed to fetch ${catalogUrl(file)}: ${response.statusText}`) } - return response.json() as Promise + return response.text() } const handler = createMcpHandler( @@ -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 @@ -329,13 +325,11 @@ Always handle errors gracefully and consider alternative approaches when a speci mimeType: 'text/plain', }, async () => { - const index = await fetchCatalog('index') - return { contents: [ { uri: 'examples://index', - text: renderIndex(index), + text: await fetchCatalog('index'), }, ], } @@ -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(assertExampleName(name)) - return { content: [ { type: 'text', - text: renderExample(example), + text: await fetchCatalog(assertExampleName(name)), }, ], } diff --git a/src/utils/examples.test.ts b/src/utils/examples.test.ts index 7f076e09..1ea6c80f 100644 --- a/src/utils/examples.test.ts +++ b/src/utils/examples.test.ts @@ -1,218 +1,45 @@ import { describe, it, expect } from 'vitest' -import { - assertExampleName, - catalogUrl, - renderExample, - renderIndex, - summaryLine, - type Example, - type ExampleSummary, -} from './examples' +import { assertExampleName, catalogUrl } from './examples' -const summary = (over: Partial = {}): ExampleSummary => ({ - name: 'caustics', - title: 'Caustics', - description: '', - tags: [], - 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, - ...over, -}) - -const example = (over: Partial = {}): Example => ({ - ...summary(), - repository: 'https://github.com/pmndrs/examples/tree/main/examples/caustics', - install: 'npx degit pmndrs/examples/examples/caustics', - dependencies: { '@react-three/drei': '10.7.8', three: '0.165.0' }, - files: [{ path: 'src/App.tsx', content: 'export default function App() {}' }], - binaries: [], - oversized: [], - assets: [], - ...over, -}) - -describe('summaryLine', () => { - it('drops a title that is just the prettified name', () => { - expect(summaryLine(summary())).toBe('caustics') - }) - - it('keeps a title that carries something the name cannot', () => { - expect( - summaryLine(summary({ name: 'bounds-and-makedefault', title: 'Bounds and makeDefault' })), - ).toBe('bounds-and-makedefault (Bounds and makeDefault)') - }) - - it('omits fiber and drei, which every example uses', () => { - const line = summaryLine( - summary({ - libraries: ['@react-three/fiber', '@react-three/drei', '@react-three/cannon', 'zustand'], - }), - ) - - expect(line).toBe('caustics · +cannon,zustand') - }) - - it('collapses the react-spring entry points into one name', () => { - const line = summaryLine( - summary({ libraries: ['@react-spring/three', '@react-spring/web', '@react-spring/core'] }), - ) - - expect(line).toBe('caustics · +react-spring') - }) - - it('assembles description, libraries and tags in that order', () => { - const line = summaryLine( - summary({ - name: 'arkanoid', - title: 'Arkanoid', - description: 'Simple arkanoid implementation using cannon physics.', - libraries: ['@react-three/fiber', '@react-three/cannon'], - tags: ['physics', 'game'], - }), - ) - - expect(line).toBe( - 'arkanoid · Simple arkanoid implementation using cannon physics. · +cannon · #physics,game', - ) - }) - - it('flattens a description that wraps', () => { - const line = summaryLine(summary({ description: 'One idea,\n spread over lines.' })) - - expect(line).toBe('caustics · One idea, spread over lines.') - }) - - it('says nothing about size for an example that is cheap to open', () => { - // The marker has to stay rare to mean anything: its absence is the signal - // that a reader can open two of these without thinking about the budget. - expect(summaryLine(summary({ bytes: 24 * 1024 }))).toBe('caustics') - }) - - it('marks an example large enough that opening it is a decision', () => { - expect(summaryLine(summary({ bytes: 90_048 }))).toBe('caustics · ~23k') - }) - - it('puts the size last, after everything that helps choose', () => { - const line = summaryLine(summary({ description: 'A shield.', tags: ['shader'], bytes: 90_048 })) - - expect(line).toBe('caustics · A shield. · #shader · ~23k') - }) -}) - -describe('renderIndex', () => { - it('is one line per example', () => { - const text = renderIndex({ - site: 'https://pmndrs.github.io/examples', - count: 2, - examples: [summary(), summary({ name: 'aquarium', title: 'Aquarium', tags: ['water'] })], - }) - - expect(text).toBe('caustics\naquarium · #water') - }) -}) +/** + * The rendering these used to cover now lives in pmndrs/examples, which + * publishes the documents this server passes on (`test/render.test.ts` there). + * What is left is the part that belongs to a server taking a name from a model: + * that name reaches a URL only if it is the shape a published example has. + */ describe('assertExampleName', () => { it('passes the shape every published example has', () => { expect(assertExampleName('gltfjsx-400kb-drone')).toBe('gltfjsx-400kb-drone') }) - it.each(['../../etc/passwd', 'Caustics', 'a b', 'caustics/../index', '', 'caustics?x=1'])( - 'rejects %j before it can reach a URL', - (name) => { - expect(() => assertExampleName(name)).toThrow(/Not an example name/) - }, - ) + it.each([ + '../../etc/passwd', + 'caustics/../index', + 'caustics?x=1', + 'caustics#fragment', + 'Caustics', + 'a b', + '', + ])('rejects %j before it can reach a URL', (name) => { + expect(() => assertExampleName(name)).toThrow(/Not an example name/) + }) it('rejects "index", which is the one name that collides with the catalog itself', () => { - // Legal kebab-case, same directory: it would fetch the index and then be - // rendered as an example with no title and no files. + // Legal kebab-case, same directory: it would serve the index of the whole + // gallery under the guise of a single example. expect(() => assertExampleName('index')).toThrow(/Not an example name/) }) - - it('puts a name where the catalog publishes it', () => { - expect(catalogUrl('caustics')).toBe('https://pmndrs.github.io/examples/catalog/caustics.json') - }) }) -describe('renderExample', () => { - it('leads with the title and the facts a reader needs', () => { - const text = renderExample(example({ description: 'Glass, and what it does to light.' })) - - expect(text).toContain('# Caustics') - expect(text).toContain('Glass, and what it does to light.') - expect(text).toContain('Demo: https://pmndrs.github.io/examples/examples/caustics') - expect(text).toContain('Scaffold: npx degit pmndrs/examples/examples/caustics') - expect(text).toContain('Dependencies: @react-three/drei@10.7.8, three@0.165.0') - }) - - it('omits the facts an example does not carry', () => { - const text = renderExample(example({ authors: [], tags: [] })) - - expect(text).not.toContain('Authors:') - expect(text).not.toContain('Tags:') - }) - - it('fences each file under its own path, tagged by extension', () => { - const text = renderExample( - example({ - files: [ - { path: 'src/App.tsx', content: 'const a = 1' }, - { path: 'src/styles.css', content: 'body { margin: 0 }' }, - ], - }), - ) - - expect(text).toContain('## src/App.tsx\n\n```tsx\nconst a = 1\n```') - expect(text).toContain('## src/styles.css\n\n```css\nbody { margin: 0 }\n```') - }) - - it('opens a longer fence than the backticks inside the file', () => { - // A file whose comments quote code would otherwise close the block early and - // hand the reader half a file plus whatever followed it as prose. - const text = renderExample( - example({ files: [{ path: 'src/App.tsx', content: '// ```tsx\nconst a = 1' }] }), - ) - - expect(text).toContain('````tsx\n// ```tsx\nconst a = 1\n````') - }) - - it('names the binaries rather than pretending they are not there', () => { - const text = renderExample(example({ binaries: ['src/glass-transformed.glb'] })) - - expect(text).toContain('src/glass-transformed.glb') +describe('catalogUrl', () => { + it('points at the markdown the gallery publishes, not the JSON beside it', () => { + expect(catalogUrl('caustics')).toBe('https://pmndrs.github.io/examples/catalog/caustics.md') }) - it('says which files were skipped on size, and how big they are', () => { - const text = renderExample( - example({ oversized: [{ path: 'src/realism-effects/v2.js', bytes: 256656 }] }), - ) - - expect(text).toContain( - 'Too large to inline, in the repository: src/realism-effects/v2.js (251 kB)', - ) - }) - - it('carries asset attribution through', () => { - const text = renderExample( - example({ - assets: [ - { - name: 'Fruit Cake Slice', - creator: 'matousekfoto', - license: 'CC-BY-4.0', - source: 'https://sketchfab.com/3d-models/fruit-cake-slice', - }, - ], - }), - ) - - expect(text).toContain('## Asset attribution') - expect(text).toContain( - '- Fruit Cake Slice — by matousekfoto — CC-BY-4.0 (https://sketchfab.com/3d-models/fruit-cake-slice)', + it('follows a local build when one is given', () => { + expect(catalogUrl('index', 'http://localhost:3001')).toBe( + 'http://localhost:3001/catalog/index.md', ) }) }) diff --git a/src/utils/examples.ts b/src/utils/examples.ts index e79a5e1d..d004af51 100644 --- a/src/utils/examples.ts +++ b/src/utils/examples.ts @@ -1,19 +1,21 @@ /** - * Reads the catalog that pmndrs/examples publishes alongside its website - * (`bin/build-catalog.mjs` there), so the MCP server can serve the example - * gallery next to the docs. + * The examples gallery, as served by this MCP server. * - * The docs are page dumps parsed out of one `llms-full.txt` per library; the - * examples are not. They arrive as JSON, already split into an index and one - * file per example, so nothing here has to parse or slice a bundle -- it only - * has to render the pieces as the text an agent reads. + * There is very little here on purpose. pmndrs/examples publishes the documents + * an agent reads -- `/catalog/index.md` and `/catalog/.md` -- already + * rendered, because they are published for the open web too: every example page + * points at its markdown with `rel="alternate"`, and a static host cannot render + * on demand. So this server hands them on verbatim rather than building a second + * rendering that would drift from the first. + * + * That leaves one job worth doing here: making sure a name from a model reaches + * a URL only if it is the shape a published example has. */ /** - * Where the gallery lives. Overridable so the MCP server can be developed against - * a local `pnpm build` of pmndrs/examples -- the catalog only exists once that - * repo has built, and pointing at production while changing its shape tests the - * old shape. + * Where the gallery lives. Overridable so the MCP server can be developed + * against a local `pnpm build` of pmndrs/examples -- the catalog only exists + * once that repo has built. */ export const EXAMPLES_URL = process.env.EXAMPLES_URL || 'https://pmndrs.github.io/examples' @@ -21,75 +23,13 @@ export const EXAMPLES_URL = process.env.EXAMPLES_URL || 'https://pmndrs.github.i const EXAMPLE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ /** - * `index` is legal kebab-case and sits in the same directory, so it would fetch - * the index and then be rendered as an example that has no title and no files. + * `index` is legal kebab-case and sits in the same directory, so it would serve + * the index of the whole gallery under the guise of one example. */ const RESERVED_NAME = 'index' -export interface ExampleSummary { - name: string - title: string - description: string - tags: string[] - authors: string[] - publishedAt?: string - notes?: string - libraries: string[] - source: string - demo: string - thumbnail: string - bytes: number -} - -export interface ExampleIndex { - site: string - count: number - examples: ExampleSummary[] -} - -export interface Example extends ExampleSummary { - repository: string - install: string - dependencies: Record - files: { path: string; content: string }[] - binaries: string[] - oversized: { path: string; bytes: number }[] - assets: { - name: string - files?: string[] - creator?: string - source?: string - license?: string - licenseUrl?: string - modified?: boolean - notes?: string - }[] -} - -/** - * Every example depends on these, so spelling them out on 167 index lines says - * nothing. The per-example view lists the real dependencies, versions included. - */ -const IMPLIED_LIBRARIES = new Set(['@react-three/fiber', '@react-three/drei']) - -const SHORT_LIBRARY_NAMES: Record = { - '@react-spring/core': 'react-spring', - '@react-spring/three': 'react-spring', - '@react-spring/web': 'react-spring', - '@use-gesture/react': 'use-gesture', -} - -const shortenLibrary = (library: string) => - SHORT_LIBRARY_NAMES[library] ?? library.replace(/^@react-three\//, '') - -const titleCase = (name: string) => - name - .split('-') - .map((word) => word[0].toUpperCase() + word.slice(1)) - .join(' ') - export function catalogUrl(file: string, base = EXAMPLES_URL) { - return `${base}/catalog/${file}.json` + return `${base}/catalog/${file}.md` } export function assertExampleName(name: string) { @@ -98,125 +38,3 @@ export function assertExampleName(name: string) { } return name } - -/** - * Where an example stops being cheap. The median is 5 kB and nine tenths are - * under 15 kB, so reading three of them costs less than this index does -- the - * size is worth saying only for the handful where it changes the decision. - * Eight examples are over this line; two of them are most of the distance. - */ -const LARGE_BYTES = 24 * 1024 - -/** - * One index line. Everything past the name is optional and omitted when the - * example does not carry it, so an entry costs what it is worth -- and a line - * with no size marker is one you can open without thinking about it: - * - * aquarium · #transmission - * arkanoid · Simple arkanoid implementation using cannon physics. · +cannon,zustand · #physics,game - * bounds-and-makedefault (Bounds and makeDefault) · #bounds - * flow-shield · Interactive energy shield. · +postprocessing,leva · #shader · ~23k - */ -export function summaryLine({ name, title, description, libraries, tags, bytes }: ExampleSummary) { - // A title that is just the prettified directory name is noise -- but not - // always: `makeDefault`, `GLTF`, `Bruno Simon's` only exist in the title. - const head = title && title !== titleCase(name) ? `${name} (${title})` : name - - const extras = [ - ...new Set(libraries.filter((l) => !IMPLIED_LIBRARIES.has(l)).map(shortenLibrary)), - ] - - return [ - head, - description.trim().replace(/\s+/g, ' '), - extras.length ? `+${extras.join(',')}` : '', - tags.length ? `#${tags.join(',')}` : '', - bytes > LARGE_BYTES ? `~${Math.round(bytes / 4000)}k` : '', - ] - .filter(Boolean) - .join(' · ') -} - -export function renderIndex({ examples }: ExampleIndex) { - return examples.map(summaryLine).join('\n') -} - -const FENCE_LANGUAGES: Record = { - '.ts': 'ts', - '.tsx': 'tsx', - '.js': 'js', - '.jsx': 'jsx', - '.mjs': 'js', - '.cjs': 'js', - '.css': 'css', - '.json': 'json', - '.glsl': 'glsl', - '.vert': 'glsl', - '.frag': 'glsl', -} - -/** A fence long enough to survive whatever backticks the file itself contains. */ -function fence(content: string) { - const longest = Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) - return '`'.repeat(Math.max(3, longest + 1)) -} - -function attribution(assets: Example['assets']) { - return assets - .map((asset) => { - const parts = [asset.name] - if (asset.creator) parts.push(`by ${asset.creator}`) - if (asset.license) parts.push(asset.license) - if (asset.modified) parts.push('modified') - const line = `- ${parts.join(' — ')}` - return asset.source || asset.licenseUrl - ? `${line} (${asset.source ?? asset.licenseUrl})` - : line - }) - .join('\n') -} - -/** - * The whole example as one document: what it is, what it pins, then its source. - * Versions are part of the answer -- the code is written against the `three` and - * drei that sit next to it, and reading it without them invites an API that - * moved. - */ -export function renderExample(example: Example) { - const facts = [ - `Demo: ${example.demo}`, - `Source: ${example.repository}`, - `Scaffold: ${example.install}`, - example.publishedAt && `Published: ${example.publishedAt}`, - example.authors.length && `Authors: ${example.authors.join(', ')}`, - example.tags.length && `Tags: ${example.tags.join(', ')}`, - `Ported from: ${example.source}`, - `Dependencies: ${Object.entries(example.dependencies) - .map(([name, version]) => `${name}@${version}`) - .join(', ')}`, - example.binaries.length && - `Binary files, in the repository but not below: ${example.binaries.join(', ')}`, - // Vendored bundles, font atlases, gltfjsx dumps. Named rather than hidden: - // a reader that finds an unexplained import wants to know it was skipped on - // size, not wonder whether the example is broken. - example.oversized.length && - `Too large to inline, in the repository: ${example.oversized - .map(({ path, bytes }) => `${path} (${Math.round(bytes / 1024)} kB)`) - .join(', ')}`, - ].filter(Boolean) - - const sections = [ - `# ${example.title}`, - example.description.trim(), - facts.join('\n'), - example.notes?.trim(), - example.assets.length && `## Asset attribution\n\n${attribution(example.assets)}`, - ...example.files.map((file) => { - const language = FENCE_LANGUAGES[file.path.slice(file.path.lastIndexOf('.'))] ?? '' - const marks = fence(file.content) - return `## ${file.path}\n\n${marks}${language}\n${file.content.trimEnd()}\n${marks}` - }), - ].filter(Boolean) - - return sections.join('\n\n') -}