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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"build:plugin": "pnpm --filter @plugin/designsystemet... build",
"watch:plugin": "pnpm --filter @plugin/designsystemet watch",
"generate:twins": "node scripts/generate-twins.mjs",
"merge:twins": "node scripts/merge-authored-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
53 changes: 53 additions & 0 deletions scripts/generate-twins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,38 @@ function emittedAttrs(src) {
return [...new Set(src.match(/data-[a-z-]+(?==)/g) ?? [])].sort();
}

/*
* Pattern twins have no code source: they describe how components compose into
* a correct whole (e.g. form validation) and are maintained as data in
* scripts/twin-patterns/. The generator copies them into the registry and
* inverts their component lists, so each component twin also says which
* patterns govern it and which components it usually appears with.
*/
const PATTERNS_DIR = join(ROOT, 'scripts/twin-patterns');
const patternFiles = existsSync(PATTERNS_DIR)
? readdirSync(PATTERNS_DIR).filter((f) => f.endsWith('.json'))
: [];
const patterns = patternFiles.map((f) =>
JSON.parse(readFileSync(join(PATTERNS_DIR, f), 'utf8')),
);

function patternsFor(slug) {
const governing = [];
const companions = new Set();
for (const p of patterns) {
const refs = (p.components?.value ?? [])
.map((c) => c.ref ?? c.name)
.filter(Boolean);
if (!refs.includes(slug)) continue;
governing.push({ slug: p.slug, name: p.name, docs: p.links?.docs ?? null });
for (const other of refs) if (other !== slug) companions.add(other);
}
return { governing, companions: [...companions].sort() };
}

rmSync(OUT, { recursive: true, force: true });
mkdirSync(join(OUT, 'components'), { recursive: true });
mkdirSync(join(OUT, 'patterns'), { recursive: true });

const written = [];
for (const dir of readdirSync(COMPONENTS, { withFileTypes: true })) {
Expand Down Expand Up @@ -136,6 +166,14 @@ for (const dir of readdirSync(COMPONENTS, { withFileTypes: true })) {
dataAttributes: emittedAttrs(src),
}),
tokens: extracted(`packages/css/src/${slug}.css`, tokens(slug)),
patterns: extracted(
'scripts/twin-patterns/*.json (inverted)',
patternsFor(slug).governing,
),
usedWith: extracted(
'scripts/twin-patterns/*.json (inverted)',
patternsFor(slug).companions,
),

a11y: authoredStub(docsUrl(slug, 'overview')),
relations: authoredStub(docsUrl(slug, 'overview')),
Expand All @@ -153,6 +191,14 @@ for (const dir of readdirSync(COMPONENTS, { withFileTypes: true })) {
}
}

for (const p of patterns) {
writeFileSync(
join(OUT, 'patterns', `${p.slug}.json`),
`${JSON.stringify(p, null, 2)}\n`,
'utf8',
);
}

/*
* llms.txt: the discovery index. The prose is a statement of where setup is
* documented, never an instruction to run something — agents treat imperative
Expand All @@ -171,6 +217,13 @@ writeFileSync(
'without the stylesheet behind it looks nothing like Designsystemet:',
'https://designsystemet.no/en/fundamentals/code/setup',
'',
'## Patterns',
'',
...patterns.map(
(p) =>
`- [${p.name}](patterns/${p.slug}.json)${p.links?.docs ? `: ${p.links.docs}` : ''}`,
),
'',
'## Components',
'',
...written.sort().map((n) => `- [${n}](components/${n}.json)`),
Expand Down
168 changes: 168 additions & 0 deletions scripts/merge-authored-twins.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Merge the authored layer into the generated twins, verifying every quote.
*
* The authored layer (scripts/twins-authored.json) carries the rules that exist
* only as prose on designsystemet.no: accessibility requirements, relations
* ("use Radio instead"), and composition rules. Each rule cites a verbatim
* quote from the documentation. This script fetches the cited pages and checks
* that every quote still appears there, then writes the rules into the twins
* that `generate-twins.mjs` produced.
*
* Two guards keep a wrong rule out of a twin:
* 1. mechanical: a quote that no longer appears on the page marks the rule
* unverified, the component loses its reviewed status, and the rule lands
* on the review sheet (registry/REVIEW.md). This is how documentation
* changes surface within a day instead of silently going stale.
* 2. human: the machine proves the sentence is on the page; it cannot prove
* the rule follows from the sentence. `reviewedAgainst` in the data file
* records the package version a human sign-off was made against.
*
* node scripts/merge-authored-twins.mjs (run after generate-twins.mjs)
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const REGISTRY = join(ROOT, 'registry/components');
const AUTHORED = JSON.parse(
readFileSync(join(ROOT, 'scripts/twins-authored.json'), 'utf8'),
);

const SUBPAGES = ['overview', 'code', 'accessibility'];
const docsUrl = (slug, page) =>
`https://designsystemet.no/en/components/docs/${slug}/${page}`;

const ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
hellip: '…',
ndash: '-',
mdash: '-',
};

/**
* Both the page text and the quotes go through the same normalisation, or a
* quote containing any transformed character could never match: typographic
* quotes become ASCII, markdown/formatting characters are stripped, whitespace
* collapses, and the space that tag-stripping strands before punctuation is
* removed.
*/
function normalise(s) {
return (s ?? '')
.replace(/[’‘]/g, "'")
.replace(/[“”]/g, '"')
.replace(/[`*[\]"]/g, '')
.replace(/\s+/g, ' ')
.replace(/\s+([,.;:!?])/g, '$1')
.trim()
.toLowerCase();
}

async function pageText(url) {
try {
const res = await fetch(url);
if (!res.ok) return '';
const html = (await res.text())
.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&([a-z]+);/g, (m, e) => ENTITIES[e] ?? m);
return normalise(html);
} catch {
return '';
}
}

const reviewRows = [];
let merged = 0;
let reviewed = 0;

for (const [slug, entry] of Object.entries(AUTHORED)) {
const { name, reviewedAgainst, ...fields } = entry;
const twinPath = join(REGISTRY, `${name}.json`);
if (!existsSync(twinPath)) {
console.warn(
` skip ${name}: no generated twin (run generate-twins.mjs first)`,
);
continue;
}

const pages = {};
for (const sub of SUBPAGES) pages[sub] = await pageText(docsUrl(slug, sub));

const twin = JSON.parse(readFileSync(twinPath, 'utf8'));
let allVerified = true;

for (const key of ['a11y', 'relations', 'composition']) {
for (const rule of fields[key] ?? []) {
const quotes = [rule.quote, rule.quote2].filter(Boolean).map(normalise);
const found = SUBPAGES.find((sub) =>
quotes.every((q) => pages[sub].includes(q)),
);
rule.verified = found !== undefined;
rule.sourcePage = found ? docsUrl(slug, found) : null;
if (!rule.verified) {
allVerified = false;
reviewRows.push([
name,
key,
rule.rule ?? `${rule.component}: ${rule.note ?? ''}`,
rule.quote ?? '',
]);
}
}
}

// Stamp after verification, so no field can claim a review the twin loses later.
const method =
allVerified && reviewedAgainst
? `quote-verified against the cited page; human-reviewed against ${reviewedAgainst}`
: 'quote-verified where marked; NOT yet human-reviewed';
for (const key of ['a11y', 'relations', 'composition']) {
if (!fields[key]) continue;
twin[key] = {
provenance: 'authored',
source: docsUrl(slug, 'overview'),
method,
value: fields[key],
};
}

twin.reviewedAgainst = allVerified ? (reviewedAgainst ?? null) : null;
twin.needsReview = !(allVerified && reviewedAgainst);
if (!twin.needsReview) reviewed += 1;
writeFileSync(twinPath, `${JSON.stringify(twin, null, 2)}\n`, 'utf8');
merged += 1;
console.log(` merged ${name}${twin.needsReview ? ' [needs review]' : ''}`);
}

const sheet = [
'# Authored-layer review sheet',
'',
'Rules whose quote no longer verifies against the cited documentation page.',
'For each row, the question is: does the current documentation still support the rule?',
'',
...(reviewRows.length
? [
'| Component | Field | Rule | Quote |',
'|---|---|---|---|',
...reviewRows.map(
(r) =>
`| ${r.map((c) => String(c).replace(/\|/g, '\\|').slice(0, 160)).join(' | ')} |`,
),
]
: [
'Nothing pending: every quote verifies and every component is human-reviewed.',
]),
'',
];
writeFileSync(join(ROOT, 'registry/REVIEW.md'), sheet.join('\n'));

console.log(
`merged ${merged} components: ${reviewed} reviewed, ${reviewRows.length} rules pending review`,
);
72 changes: 72 additions & 0 deletions scripts/twin-patterns/field-guidance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"schemaVersion": "0.1.0",
"kind": "pattern",
"name": "Field guidance — placeholders, descriptions and input purpose",
"slug": "field-guidance",
"package": {
"name": "@digdir/designsystemet-react",
"semver": "1.x"
},
"generated": {
"fromCommit": "n/a — pattern authored from docs",
"at": "2026-08-12T00:00:00Z",
"generator": "authored"
},
"links": {
"docs": "https://designsystemet.no/en/best-practices/content-work/questions"
},
"summary": {
"provenance": "authored",
"source": "https://designsystemet.no/en/best-practices/content-work/questions",
"value": "How a form field tells the user what to enter: guidance before the field, never placeholder-only; descriptions (help text) for difficult content such as expected formats; autocomplete attributes where the field's purpose is predefined."
},
"components": {
"provenance": "authored",
"source": "https://designsystemet.no/en/components/docs/textfield/overview",
"value": [
{
"role": "carries label, description and error in one composition",
"ref": "textfield"
},
{
"role": "links label, description, validation message and counter for assistive technology",
"ref": "field"
}
]
},
"a11y": {
"provenance": "authored",
"source": "https://designsystemet.no/en/best-practices/content-work/questions",
"method": "drafted from the documentation, quote-verified; human review pending",
"value": [
{
"rule": "Avoid guidance text inside the input itself (placeholders); give all relevant information before the user fills in the field",
"wcag": null,
"quote": "Users should not have to try and fail, so give them all relevant information before they choose an option or fill in a field. Avoid guidance text inside the input itself, also known as placeholders. Placeholders disappear when the field is filled in, and may conflict with accessibility requirements."
},
{
"rule": "Autocomplete is required on fields with a predefined purpose",
"wcag": "1.3.5",
"quote": "Autocomplete is required in fields where a predefined purpose exists, as defined in 1.3.5",
"sourcePage": "https://designsystemet.no/en/components/docs/textfield/overview"
}
]
},
"composition": {
"provenance": "authored",
"source": "https://designsystemet.no/en/best-practices/content-work/questions",
"method": "drafted from the documentation, quote-verified; human review pending",
"value": [
{
"rule": "Use a description (help text) to explain difficult content — legal terms, specialist language, expected formats",
"quote": "Sometimes descriptions, also known as help text, are useful for explaining something difficult, such as legal terms or specialist language."
},
{
"rule": "Only use descriptions where needed, to reduce cognitive load",
"quote": "Only use descriptions where they are needed, to reduce cognitive load."
}
]
},
"needsReview": true,
"reviewedAgainst": null
}
Loading
Loading