Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .claude/skills/describe-example/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ Two fields of one example's `pmndrs.json`, written by reading its source: the on

## Why this is a skill

The gallery publishes an agent-readable surface — `llms.txt`, `/examples/<name>.md`, `/examples/<name>.json`, built by `bin/build-llms.mjs` and served both over the pmndrs docs MCP server and by `rel="alternate"` on every example page. The pipeline is sound. The content it carries is not: **40 of the 170 descriptions are empty**, and the other 130 run to a median of **56 characters** that generally name no technique.
The gallery publishes an agent-readable surface — `llms.txt`, `/examples/<name>.md`, `/examples/<name>.json`, built by `bin/build-llms.mjs` and served both over the pmndrs docs MCP server and by `rel="alternate"` on every example page. The pipeline is sound. The content it carries is not: **39 of the 170 descriptions are empty**, and the rest run to a median of **56 characters** that generally name no technique.

`aquarium` is the case that names the problem. It ships an empty description and the single tag `transmission`, while what actually makes the demo work is a **stencil mask** — `useMask` plus a backside `MeshTransmissionMaterial` — named nowhere. An agent asking "how do I do refractive glass" cannot tell it from the other 169, and having opened it, pays full token price to rediscover the trick by reading 200 lines of TSX.
`aquarium` is the case that names the problem, and the one this skill has been run on. It shipped an empty description and the single tag `transmission`, while what actually makes the demo work is a **stencil mask** — `useMask` plus a backside `MeshTransmissionMaterial` — named nowhere. An agent asking "how do I do refractive glass" cannot tell it from the other 169, and having opened it, pays full token price to rediscover the trick by reading 200 lines of TSX.

Fixing that is 170 acts of judgement. The two contracts below are what keeps those 170 acts consistent, which is why they live in this file rather than in a reviewer's memory.

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Canonical vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-fo

### Domain docs

Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root (created lazily by `/domain-modeling`). See `docs/agents/domain.md`.
Multi-context: one `CONTEXT.md` + `docs/adr/` per workspace package — `apps/website/`, `packages/e2e/`, and one per `examples/<demo>/` — with the root `docs/adr/` holding system-wide decisions (build, CI, dependency policy). The context is resolved by path, and every file is created lazily by `/domain-modeling`. See `docs/agents/domain.md`.

### UI components

Expand Down
13 changes: 13 additions & 0 deletions bin/build-llms.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { explainerOf } from "./lib/explainer.mjs";
import { renderExample, renderIndex } from "./lib/render-llms.mjs";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
Expand Down Expand Up @@ -149,10 +150,22 @@ for (const name of names) {
};
index.push(summary);

// The explanation a reader wants once they have chosen. It lives in the
// example's `README.md` -- where GitHub renders it and `degit` carries it
// into whatever the reader scaffolds -- and is inlined here so the document
// an agent is served is not the one that stops at a single line. It stays out
// of `summary`, and so out of `llms.txt`: that file is read at the start of
// every question, and one line each is what it can afford.
const readmePath = path.join(exampleDirectory, "README.md");
const explainer = fs.existsSync(readmePath)
? explainerOf(fs.readFileSync(readmePath, "utf8"))
: "";

const example = {
...summary,
repository: `https://github.com/pmndrs/examples/tree/main/examples/${name}`,
install: `npx degit pmndrs/examples/examples/${name}`,
...(explainer && { explainer }),
dependencies: packageJson.dependencies ?? {},
files: paths.filter(isInlinable).map((file) => ({
path: file,
Expand Down
11 changes: 5 additions & 6 deletions bin/description-exceptions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,15 @@
export const DESCRIPTION_MAX_LENGTH = 120;

//
// Examples that ship `"description": ""`. Forty of the hundred and seventy, on
// 2026-08-14; the other hundred and thirty run to a median of 56 characters.
// Examples that ship `"description": ""`. Thirty-nine of the hundred and
// seventy; the rest run to a median of 56 characters.
//
// Nothing groups them. They are not the old examples, or the small ones, or
// the ones nobody looks at -- `aquarium`, `caustics` and `portals` are here.
// The field was simply never required to hold anything, so for these forty it
// never got anything.
// the ones nobody looks at -- `caustics` and `portals` are here, and
// `aquarium` was until it became the first entry this list lost. The field was
// simply never required to hold anything, so for these it never got anything.
//
export const UNDESCRIBED = [
"aquarium",
"bloom-hdr-workflow-gltf",
"cards",
"cards-with-border-radius",
Expand Down
79 changes: 79 additions & 0 deletions bin/lib/explainer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* The long-form explainer an example carries in its own `README.md`, and the
* identifiers its prose names.
*
* Two readers share this file. `bin/build-llms.mjs` lifts the explainer out of
* the README and inlines it into `/examples/<name>.md`, so the document agents
* are served carries the explanation rather than a one-line description and 200
* lines of TSX. `bin/validate-pmndrs-metadata.mjs` reads the identifiers back
* out and checks them against the example's source, which is the only thing
* keeping the prose from rotting quietly.
*
* Both are textual, and deliberately so. No markdown parser, no import
* resolution: what the rule needs is the names as written, and a dependency
* here would sit on the critical path of the site build and of `turbo`'s cache.
*/

/**
* The header every example's README opens with -- three badges, the `degit`
* line, the thumbnail -- and nothing else, in all 170 of them. It is generated
* scaffolding, so the explainer goes under it rather than into it: the badges
* and the scaffold command are one block, and prose spliced into the middle
* separates the command from the links that point at the same thing.
*
* The thumbnail is the last line of that block, which makes it the marker. A
* README without one has no explainer to find -- better than guessing, which
* would publish the badges as an explanation the first time the header moves.
*/
const THUMBNAIL = /^!\[[^\]]*\]\(\s*thumbnail[^)]*\)[^\S\n]*$/gm;

/** The prose under the badge header, or `""` when the example has none. */
export function explainerOf(readme) {
const markers = [...readme.matchAll(THUMBNAIL)];
if (markers.length === 0) return "";

const last = markers[markers.length - 1];
return readme.slice(last.index + last[0].length).trim();
}

/**
* A fenced block is quoted code, not prose about this example: it may show a
* reader how to carry the technique into their own scene, where the names are
* theirs and not ours. Exempt, so the rule stays a rule about what the prose
* claims.
*/
const FENCED_BLOCK = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1[^\n]*$/gm;

/** An inline code span, single- or multi-backtick. */
const CODE_SPAN = /(`+)([^`][\s\S]*?)\1/g;

const IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/g;

/**
* Every identifier-shaped word in `text`. Applied to source it is a vocabulary,
* applied to a code span it is what that span claims -- and the check is the
* difference, which is why both sides tokenize the same way. Matching whole
* words rather than substrings is the point: prose naming `Mask` must not pass
* because the source happens to contain `useMask`.
*/
export function identifiersIn(text) {
return new Set(text.match(IDENTIFIER) ?? []);
}

/**
* The identifiers a document puts in backticks. Whole spans are tokenized
* rather than taken verbatim, because a technique carried by a prop is written
* as the prop is written -- `gl={{ stencil: true }}` names `gl` and `stencil`,
* and both are checkable.
*/
export function backtickedIdentifiers(markdown) {
const names = new Set();

for (const [, , span] of markdown
.replace(FENCED_BLOCK, "")
.matchAll(CODE_SPAN)) {
for (const name of identifiersIn(span)) names.add(name);
}

return names;
}
6 changes: 6 additions & 0 deletions bin/lib/render-llms.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export function renderExample(example) {
`# ${example.title}`,
example.description.trim(),
facts.join("\n"),
// The explainer, from the example's own README. Under the facts and above
// the source, because it is what the facts were chosen to introduce and
// what the source is read against -- an agent that stops here should have
// the technique, and one that reads on should know what to look for.
// Absent on most examples, and that is the expected state.
example.explainer?.trim(),
example.assets.length &&
`## Asset attribution\n\n${attribution(example.assets)}`,
...example.files.map((file) => {
Expand Down
114 changes: 104 additions & 10 deletions bin/validate-pmndrs-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import {
OVERLONG,
UNDESCRIBED,
} from "./description-exceptions.mjs";
import {
backtickedIdentifiers,
explainerOf,
identifiersIn,
} from "./lib/explainer.mjs";
import { importedIdentifiers } from "./lib/imports.mjs";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
Expand Down Expand Up @@ -62,29 +67,92 @@ const SOURCE_EXTENSIONS = new Set([
".cjs",
]);

/** Every identifier the example's own `src/` imports, across all of its files. */
function importsOf(exampleDirectory) {
const identifiers = new Set();
/**
* What the prose is checked against, which is wider than what the imports are
* read from: a shader uniform or a class name is as much a thing an explainer
* can name as a drei export.
*/
const TEXT_EXTENSIONS = new Set([
...SOURCE_EXTENSIONS,
".css",
".json",
".glsl",
".vert",
".frag",
]);

/** Every file under the example's `src/`, at any depth, with those extensions. */
function sourceFiles(exampleDirectory, extensions) {
const files = [];

const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) walk(absolute);
else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
for (const identifier of importedIdentifiers(
fs.readFileSync(absolute, "utf8"),
)) {
identifiers.add(identifier);
}
}
else if (extensions.has(path.extname(entry.name))) files.push(absolute);
}
};

walk(path.join(exampleDirectory, "src"));
return files;
}

/** Every identifier the example's own `src/` imports, across all of its files. */
function importsOf(exampleDirectory) {
const identifiers = new Set();

for (const file of sourceFiles(exampleDirectory, SOURCE_EXTENSIONS)) {
for (const identifier of importedIdentifiers(
fs.readFileSync(file, "utf8"),
)) {
identifiers.add(identifier);
}
}

return identifiers;
}

/**
* Every identifier-shaped word anywhere in the example's `src/`, comments and
* strings included. Looser than `importsOf` on purpose -- see the rule below.
*/
function vocabularyOf(exampleDirectory) {
const vocabulary = new Set();

for (const file of sourceFiles(exampleDirectory, TEXT_EXTENSIONS)) {
for (const word of identifiersIn(fs.readFileSync(file, "utf8"))) {
vocabulary.add(word);
}
}

return vocabulary;
}

/**
* The two documents an example can carry prose in: the explainer under the
* badges in `README.md`, and the glossary in `CONTEXT.md`. Both are optional,
* and on most examples neither exists.
*/
function proseOf(exampleDirectory) {
const documents = [];

const readmePath = path.join(exampleDirectory, "README.md");
if (fs.existsSync(readmePath)) {
// Only what is under the badges: the header is generated scaffolding, and
// its `degit` line names the repository, not this example's source.
const explainer = explainerOf(fs.readFileSync(readmePath, "utf8"));
if (explainer) documents.push(["README.md", explainer]);
}

const contextPath = path.join(exampleDirectory, "CONTEXT.md");
if (fs.existsSync(contextPath)) {
documents.push(["CONTEXT.md", fs.readFileSync(contextPath, "utf8")]);
}

return documents;
}

const exampleNames = fs
.readdirSync(examplesDirectory)
.filter((name) =>
Expand Down Expand Up @@ -192,6 +260,32 @@ for (const exampleName of exampleNames) {
}
}

// Prose that explains code goes stale on its own, and the obvious tripwire --
// a hash of the source, stored next to the explainer -- would have fired on
// 161 examples the day a prettier hook landed, training the reflex of
// re-stamping without reading. This one fires on renames instead: every
// identifier the prose puts in backticks has to still appear somewhere in the
// example's `src/`. `useMask` disappears in a drei migration and it breaks on
// precisely the explainers that named it; a formatting sweep never wakes it.
//
// Deliberately looser than the rule on `apis`, because it guards prose rather
// than a data field: an entry in `apis` that is not *imported* has no
// business there, whereas an explainer may name the demo's own components
// (`Aquarium`, `Turtle`) and its props (`stencil` in `gl={{ stencil: true }}`).
// What it gives up is small -- an identifier deleted from the code but
// surviving in a comment still passes.
const prose = proseOf(exampleDirectory);
if (prose.length > 0) {
const vocabulary = vocabularyOf(exampleDirectory);
for (const [file, text] of prose) {
for (const name of backtickedIdentifiers(text)) {
if (!vocabulary.has(name)) {
addError(exampleName, `${file} names \`${name}\`, absent from src/`);
}
}
}
}

if (!Array.isArray(metadata.assets)) {
addError(exampleName, '"assets" must be an array');
} else {
Expand Down
Loading
Loading