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') -}