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
2 changes: 2 additions & 0 deletions .changeset/twin-generator-suggestion-jsdoc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ apps/www/build
apps/storybook/debug-storybook.log
CLAUDE.md
.claude/settings.local.json
registry
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/components/suggestion/suggestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <Field>
* <Label>Velg en destinasjon</Label>
* <Suggestion>
* <Suggestion.Input />
* <Suggestion.Clear />
* <Suggestion.List>
* <Suggestion.Empty>Tomt</Suggestion.Empty>
* <Suggestion.Option value='Oslo'>Oslo</Suggestion.Option>
* </Suggestion.List>
* </Suggestion>
* </Field>
*/
export const Suggestion = forwardRef<DSSuggestionElement, SuggestionProps>(
function Suggestion(
{
Expand Down
36 changes: 36 additions & 0 deletions scripts/TWINS.md
Original file line number Diff line number Diff line change
@@ -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.
185 changes: 185 additions & 0 deletions scripts/generate-twins.mjs
Original file line number Diff line number Diff line change
@@ -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 <dir>] # 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}`);
Loading