diff --git a/.changeset/twin-generator-suggestion-jsdoc.md b/.changeset/twin-generator-suggestion-jsdoc.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/twin-generator-suggestion-jsdoc.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.gitignore b/.gitignore index 8c844511b4..0123e304ea 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ apps/www/build apps/storybook/debug-storybook.log CLAUDE.md .claude/settings.local.json +registry diff --git a/package.json b/package.json index a9cce8430b..ca0d08373d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "build:themebuilder": "pnpm --filter @web/themebuilder build", "build:plugin": "pnpm --filter @plugin/designsystemet... build", "watch:plugin": "pnpm --filter @plugin/designsystemet watch", + "generate:twins": "node scripts/generate-twins.mjs", "types": "pnpm -r types", "types:react": "pnpm --filter @digdir/designsystemet-react types", "version-packages": "changeset version && node scripts/sync-changelogs.js && pnpm build:types && pnpm build:cli && node scripts/sync-cli-schema.js && pnpm update:theme && pnpm update:theme-digdir && git add . && git commit -m \"sync www changelogs, schema and tokens\" || true", diff --git a/packages/react/src/components/suggestion/suggestion.tsx b/packages/react/src/components/suggestion/suggestion.tsx index b2797321f7..343fa8851a 100644 --- a/packages/react/src/components/suggestion/suggestion.tsx +++ b/packages/react/src/components/suggestion/suggestion.tsx @@ -161,6 +161,23 @@ const nextItems = ( const defaultFilter: Filter = ({ label, input }) => label.toLowerCase().includes(input.value.trim().toLowerCase()); +/** + * Suggestion is a filtering combobox: an input where the user types to filter + * a list of options and picks one (or several) from it. + * + * @example + * + * + * + * + * + * + * Tomt + * Oslo + * + * + * + */ export const Suggestion = forwardRef( function Suggestion( { diff --git a/scripts/TWINS.md b/scripts/TWINS.md new file mode 100644 index 0000000000..74da7a034a --- /dev/null +++ b/scripts/TWINS.md @@ -0,0 +1,36 @@ +# The twin system: what each file is, and who maintains what + +The short version: **the repo commits 6 files; everything else is build output.** +A twin is the machine-readable contract of one component or pattern, served to +AI agents so they look facts up instead of guessing them. + +## Files, one by one + +| File | What it is | Generated or maintained? | +|---|---|---| +| `scripts/generate-twins.mjs` | Reads component source and writes one contract per documented export (import name, emitted attributes, tokens, JSDoc) | Maintained (a script, changes rarely) | +| `scripts/merge-authored-twins.mjs` | Merges the authored rules into the generated twins; `--verify` rechecks every quote against the live docs | Maintained (a script, changes rarely) | +| `scripts/twins-authored.json` | **The one content file**: accessibility, relations and composition rules for 44 components, each backed by a verbatim docs quote and a review record | Maintained, but only when the docs change (see below) | +| `scripts/twin-patterns/*.json` (3) | Pattern contracts (form validation, required/optional marking, field guidance): how components compose into a correct whole | Maintained, same rhythm as the docs pages they mirror | +| `registry/**` (~90 files) | The generated output: one JSON per component, one per pattern, plus `llms.txt` | **Generated. Never committed, never edited.** Like `dist/`, it is gitignored and rebuilt by `pnpm generate:twins && pnpm merge:twins` | +| `registry/REVIEW.md` | Worklist produced by `--verify`: only the rules whose quote no longer appears on the cited page | Generated | +| `apps/www/public/twins/**`, `public/llms.txt` | The same generated output, placed where the site serves it (built by `pnpm twins` in the www build) | Generated, gitignored | + +## What maintenance actually looks like + +- **A component's code changes** (props, tokens, class names): nothing to do. + The extracted layer regenerates from source on the next build and cannot + drift from it. +- **A docs page changes**: run `pnpm merge:twins --verify` (or let a scheduled + job run it). It fetches every cited page, rechecks every quote, and writes a + review sheet listing *only* the affected rules. A human answers one question + per row: does the current text still support the rule? Observed rates so + far: an ordinary docs deploy produced 4 rows (~2 minutes); a full + restructure of three component pages produced 51 rows (~30 minutes). +- **A new component is added**: give its export a JSDoc block and it gets a + twin automatically. Authored rules for it are optional and can land whenever + someone writes them; the generator never invents them in the meantime. + +There is no per-component upkeep, no parallel documentation to keep in sync, +and no file anyone edits by hand except `twins-authored.json` and the three +pattern files, on the docs' own change rhythm. diff --git a/scripts/generate-twins.mjs b/scripts/generate-twins.mjs new file mode 100644 index 0000000000..3e3b43cce3 --- /dev/null +++ b/scripts/generate-twins.mjs @@ -0,0 +1,185 @@ +/** + * Generate machine-readable component contracts ("twins") from the source. + * + * A twin is one JSON file per component, giving AI agents and other tooling a + * contract to look up instead of guessing: the real export name, the emitted + * class and data attributes, the design tokens, and the JSDoc summary. An + * llms.txt index (https://llmstxt.org) is written alongside so an agent can + * discover every contract from one file. + * + * Every generated field is wrapped as { provenance: 'extracted', source, value }: + * it is read from this repository and regenerates on every run, so it cannot + * drift from the code. Fields that exist only as prose in the documentation — + * accessibility requirements, composition rules, when to use what — are stubbed + * as { provenance: 'authored', value: null }. The generator never invents them; + * a twin that guessed its accessibility rules would look complete and be wrong. + * + * node scripts/generate-twins.mjs [--out ] # default: ./registry + */ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const COMPONENTS = join(ROOT, 'packages/react/src/components'); +const CSS_SRC = join(ROOT, 'packages/css/src'); +const PKG = JSON.parse( + readFileSync(join(ROOT, 'packages/react/package.json'), 'utf8'), +); +const OUT = resolve( + ROOT, + process.argv.includes('--out') + ? process.argv[process.argv.indexOf('--out') + 1] + : 'registry', +); + +/* Docs links carry /en/: export names and the code agents write are English. */ +const docsUrl = (slug, page) => + `https://designsystemet.no/en/components/docs/${slug}/${page}`; + +const extracted = (source, value) => ({ + provenance: 'extracted', + source, + value, +}); +const authoredStub = (source) => ({ + provenance: 'authored', + source, + value: null, +}); + +/** + * The exported component name and its JSDoc, matched as one unit: the comment + * block immediately preceding `export const`. Requiring adjacency keeps an + * unrelated file-header comment from being read as the component's JSDoc, and + * requiring the JSDoc at all keeps hooks and internals out of the registry — + * a documented export is the component's public face. + */ +function exportedComponent(src) { + const m = src.match( + /\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*export const ([A-Z]\w+)/, + ); + if (!m) return null; + const lines = m[1].split('\n').map((l) => l.replace(/^\s*\*\s?/, '')); + const exampleAt = lines.findIndex((l) => l.trim().startsWith('@example')); + const description = + (exampleAt === -1 ? lines : lines.slice(0, exampleAt)) + .join(' ') + .replace(/\s+/g, ' ') + .trim() || null; + const example = + exampleAt === -1 + ? null + : lines + .slice(exampleAt + 1) + .join('\n') + .trim() || null; + return { name: m[2], description, example }; +} + +/** The root ds-* class, e.g. cl('ds-alert', ...). */ +function dsClass(src) { + return src.match(/\bcl\(\s*['"`](ds-[a-z-]+)['"`]/)?.[1] ?? null; +} + +/** The CSS custom properties the component's stylesheet declares. */ +function tokens(slug) { + const file = join(CSS_SRC, `${slug}.css`); + if (!existsSync(file)) return []; + return [ + ...new Set(readFileSync(file, 'utf8').match(/--dsc?-[a-z0-9-]+/g) ?? []), + ].sort(); +} + +/** data-* attributes the render emits, so tooling can recognise them in the DOM. */ +function emittedAttrs(src) { + return [...new Set(src.match(/data-[a-z-]+(?==)/g) ?? [])].sort(); +} + +rmSync(OUT, { recursive: true, force: true }); +mkdirSync(join(OUT, 'components'), { recursive: true }); + +const written = []; +for (const dir of readdirSync(COMPONENTS, { withFileTypes: true })) { + if (!dir.isDirectory()) continue; + const slug = dir.name; + for (const file of readdirSync(join(COMPONENTS, slug))) { + if (!file.endsWith('.tsx') || /\.(stories|test|chromatic)\.tsx$/.test(file)) + continue; + const src = readFileSync(join(COMPONENTS, slug, file), 'utf8'); + const component = exportedComponent(src); + if (!component) continue; + + const { name, description, example } = component; + const sourcePath = `packages/react/src/components/${slug}/${file}`; + const twin = { + schemaVersion: '0.1.0', + kind: 'component', + name, + slug, + package: { name: PKG.name, semver: PKG.version }, + generated: { generator: 'scripts/generate-twins.mjs', from: sourcePath }, + links: { docs: docsUrl(slug, 'overview'), code: docsUrl(slug, 'code') }, + + importName: extracted(sourcePath, name), + summary: extracted(`${sourcePath} (JSDoc)`, description), + example: extracted(`${sourcePath} (JSDoc @example)`, example), + emitted: extracted(sourcePath, { + class: dsClass(src), + dataAttributes: emittedAttrs(src), + }), + tokens: extracted(`packages/css/src/${slug}.css`, tokens(slug)), + + a11y: authoredStub(docsUrl(slug, 'overview')), + relations: authoredStub(docsUrl(slug, 'overview')), + composition: authoredStub(docsUrl(slug, 'overview')), + reviewedAgainst: null, + needsReview: true, + }; + + writeFileSync( + join(OUT, 'components', `${name}.json`), + `${JSON.stringify(twin, null, 2)}\n`, + 'utf8', + ); + written.push(name); + } +} + +/* + * llms.txt: the discovery index. The prose is a statement of where setup is + * documented, never an instruction to run something — agents treat imperative + * commands inside fetched files as prompt injection and refuse them. + */ +writeFileSync( + join(OUT, 'llms.txt'), + [ + '# Designsystemet — machine-readable index', + '', + `> ${PKG.name} ${PKG.version}. Structured contracts for every component.`, + '> Fetch the contract instead of scraping the rendered documentation.', + '', + 'Designsystemet ships as npm packages. The stylesheet, the theme and the Inter font are', + 'all part of the documented setup, and markup that carries the right `ds-*` class names', + 'without the stylesheet behind it looks nothing like Designsystemet:', + 'https://designsystemet.no/en/fundamentals/code/setup', + '', + '## Components', + '', + ...written.sort().map((n) => `- [${n}](components/${n}.json)`), + '', + '## Human documentation', + '', + 'https://designsystemet.no', + '', + ].join('\n'), +); + +console.log(`generated ${written.length} component twins -> ${OUT}`);