roast-my-design-system 7.5.0 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +16 -3
- package/skills/roast-my-design-system/scripts/diagnose/score.mjs +4 -1
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/profiles/index.mjs +23 -2
- package/skills/roast-my-design-system/scripts/profiles/registry.mjs +164 -0
- package/skills/roast-my-design-system/scripts/profiles/shadcn.mjs +3 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.
|
|
1
|
+
<img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.6.0" width="72" alt="roast-my-design-system">
|
|
2
2
|
|
|
3
3
|
# roast-my-design-system
|
|
4
4
|
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
A free CLI tool (and Claude Code skill) that roasts your repo's design system with real data, then generates the rules that keep your AI agent on-system.
|
|
12
12
|
|
|
13
|
+
> **New in 7.6: a fourth repo profile, `registry`.** A repo that publishes a shadcn registry is read as a registry, not as an app that installed shadcn. Recognition: a `registry.json` with typed items (`registry:ui`, `registry:style`, ...) or a route that builds one from a packages folder. Covers shadcn's own source, magicui, kibo-ui and tweakcn. The header states what the repo publishes (components, blocks, styles, demos) and how many variants of the same components it keeps. Scoring is unchanged in this release: a registry is measured with the shadcn tiles against the shadcn slice, and the registry counting rules follow in the next one. `summary.json` gains `kind: "registry"` and a `registry` block. Also in 7.6: the theme file is checked for the variables tweakcn adds to every export, shown as a `tweakcn theme` pill on a match; typefaces declared in a single file are reported as a picker rather than as N typefaces.
|
|
14
|
+
|
|
13
15
|
> **New in 7.5: the kit is read as it is built, and every file left out of a count is named.** A fresh `npx shadcn create` install is told apart from a repo built on one: "The score is the kit's, not yours." A rules file a framework wrote for itself is named as such, and so is the shadcn skill when it is installed. The theme file is found where it is, even when `components.json` points at a file that moved; `button/index.tsx` is a Button. The repaint advice checks whether the theme file holds the variables before telling you to swap to them. And the side panel now lists everything a check leaves out, with the reason and one honest line: your agent reads it anyway. New on that list: the Next.js crash page, an embedded widget's `!important`, and `!important` aimed at a library's own class names.
|
|
14
16
|
|
|
15
17
|
> **New in 7.4: the score says what it measures, and the report has a new shape.** Under the number: "How safely an AI agent can build on this repo without going off-system." A fixed side panel holds the score, the stack, how the repo was read, an index of every section, and what is not yours and not counted. Installed code splits by one test, does it teach an agent a wrong lesson: shadcn's own bracket values are out of the count and named; palette colours inside an installed registry stay in the score and are never prompted; catalogue stock is not a trap. A factory install scores 100. Leftover theme variables become a fix.
|
|
@@ -81,11 +83,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
|
|
|
81
83
|
|
|
82
84
|
The full report for vercel/ai-chatbot, top to bottom, including "What the numbers mean", Claude's read of the scan, embedded right under the verdict:
|
|
83
85
|
|
|
84
|
-

|
|
85
87
|
|
|
86
88
|
The same report in light mode (one file, built-in toggle):
|
|
87
89
|
|
|
88
|
-

|
|
89
91
|
|
|
90
92
|
## What makes the numbers trustworthy
|
|
91
93
|
|
|
@@ -235,12 +237,13 @@ After the roast, the skill also offers to write `design-system-rules.md` to disk
|
|
|
235
237
|
|
|
236
238
|
## Live examples
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
7 real roasts of public repos, hosted as-is (the same self-contained HTML the skill generates), spanning React, Stencil and Lit:
|
|
239
241
|
|
|
240
242
|
- **[telekom/scale](https://gregkozakiewicz.github.io/roast-my-design-system/examples/telekom-scale.html)**: Stencil, 95 components read by tag, with Claude's notes embedded
|
|
241
243
|
- **[adobe/spectrum-web-components](https://gregkozakiewicz.github.io/roast-my-design-system/examples/adobe-spectrum.html)**: Lit, the `--spectrum-*` namespace named in the header
|
|
242
244
|
- **[npx shadcn create, fresh](https://gregkozakiewicz.github.io/roast-my-design-system/examples/shadcn-create-fresh.html)**: a factory install with all 61 components, scanned as is and read as fresh
|
|
243
245
|
- **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)**: React on shadcn, with Claude's notes embedded
|
|
246
|
+
- **[magicuidesign/magicui](https://gregkozakiewicz.github.io/roast-my-design-system/examples/magicui.html)**: read as a shadcn registry, what it publishes named under the score
|
|
244
247
|
- **[excalidraw/excalidraw](https://gregkozakiewicz.github.io/roast-my-design-system/examples/excalidraw-excalidraw.html)**
|
|
245
248
|
- **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**
|
|
246
249
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.6.0",
|
|
4
4
|
"mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
|
|
5
5
|
"description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 34 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
|
|
6
6
|
"keywords": [
|
|
@@ -33,6 +33,7 @@ import { WHY } from './why.mjs';
|
|
|
33
33
|
import { parseColor, luminance, isGrey } from '../lib/color.mjs';
|
|
34
34
|
import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scoreBreakdown, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
|
|
35
35
|
import { ownSpacing, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
|
|
36
|
+
import { publishesLine } from '../profiles/registry.mjs';
|
|
36
37
|
|
|
37
38
|
// The benchmark and every judgement made against it live in score.mjs; this
|
|
38
39
|
// file only draws. The same numbers reach summary.json through scoreHarvest.
|
|
@@ -285,7 +286,12 @@ const offenders = h.tokens.offenders ?? [];
|
|
|
285
286
|
const candidates = [];
|
|
286
287
|
const effColors = tokenLed ? colorStrays : colors.length;
|
|
287
288
|
const effGreys = tokenLed ? greyStrays : greys.length;
|
|
288
|
-
|
|
289
|
+
// Every typeface declared in one file is a picker (a theme editor's font
|
|
290
|
+
// list, shadcn's docs site): one choice offered, one in use at a time.
|
|
291
|
+
const fontFiles = new Set(fontFamilies.flatMap((f) => (f.files ?? []).map((x) => x.file)));
|
|
292
|
+
const fontPicker = typefaces.length > 3 && fontFiles.size === 1 ? [...fontFiles][0] : null;
|
|
293
|
+
if (fontPicker) candidates.push({ ratio: 1.05, text: `${typefaces.length} typefaces offered by a picker in one file (${basename(fontPicker)}), one in use at a time` });
|
|
294
|
+
else if (typefaces.length > 3) candidates.push({ ratio: typefaces.length / 3, text: `${typefaces.length} typefaces. Most products use 2 or 3` });
|
|
289
295
|
else if (typefaces.length && fontFamilies.length > 6) candidates.push({ ratio: fontFamilies.length / 6, text: `${typefaces.length} typeface${typefaces.length > 1 ? 's' : ''} declared ${fontFamilies.length} different ways` });
|
|
290
296
|
if (effColors > 24 * 1.25) candidates.push({ ratio: effColors / 24, text: tokenLed
|
|
291
297
|
? `${n(colorStrays)} hardcoded colours outside the token set, your agent will happily copy them at random`
|
|
@@ -607,7 +613,7 @@ function sheetSection() {
|
|
|
607
613
|
if (sheet.shadcnMissing?.length) bits.push(`${sheet.shadcnMissing.length} of the ${SHADCN_ROW_COUNT} current shadcn variables not defined (${sheet.shadcnMissing.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')}${sheet.shadcnMissing.length > 4 ? '…' : ''})${sheet.hslEra ? ', normal for an install from before the chart and sidebar rows existed' : ''}`);
|
|
608
614
|
if (sheet.missingDark?.length) bits.push(`${sheet.missingDark.length} variable${sheet.missingDark.length === 1 ? '' : 's'} with no dark value (${sheet.missingDark.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')})`);
|
|
609
615
|
if (sheet.custom?.length) bits.push(`${sheet.custom.length} custom variable${sheet.custom.length === 1 ? '' : 's'} of your own (${sheet.custom.slice(0, 5).map((r) => `--${esc(r)}`).join(', ')}${sheet.custom.length > 5 ? '…' : ''})${sheet.customMissingDark?.length ? `, ${sheet.customMissingDark.length} of them light only` : ''}${sheet.customUnregistered?.length ? `, ${sheet.customUnregistered.length} never mapped in @theme inline` : ''}`);
|
|
610
|
-
if (sheet.tweakcnPresent >= 10) bits.push(
|
|
616
|
+
if (sheet.tweakcnPresent >= 10) bits.push(`a tweakcn theme: ${sheet.tweakcnPresent} of the rows tweakcn adds to every theme it exports are here (shadows, letter-spacing, spacing), so the theme came from a theme editor or copied its shape, and updates will come from there too`);
|
|
611
617
|
if (sheet.spacingChanged) bits.push(`<b>--spacing is ${esc(sheet.spacing)}</b>, not the default 0.25rem: this resizes every gap in the app at once, which shadcn\'s own changelog says never to do`);
|
|
612
618
|
parts.push(`<div class="receipts">${eyebrow('the theme file, variable by variable')}<p class="sub">${bits.join(' · ')}.</p></div>`);
|
|
613
619
|
} else if (sheet) {
|
|
@@ -1317,6 +1323,12 @@ function shadcnReceipt() {
|
|
|
1317
1323
|
const pages = comps.filter((c) => c.isPage && f.ownFiles.includes(c.file)).length;
|
|
1318
1324
|
return `<div class="excl fresh">It is a fresh shadcn install${k?.style ? ` (style ${esc(k.style)}${k.baseColor ? `, base colour ${esc(k.baseColor)}` : ''}${k.tailwind ? `, Tailwind ${esc(k.tailwind)}` : ''})` : ''}. Nothing of your own yet: ${n(doors)} component${doors === 1 ? '' : 's'} installed, the theme file untouched, ${pages === 1 ? 'one demo page' : `${pages} pages`}. <b>The score is the kit's, not yours.</b> Run this again once you have built a few screens.</div><div class="excl">Evidence: ${receipt}</div>`;
|
|
1319
1325
|
}
|
|
1326
|
+
if (P.isRegistry && P.registry) {
|
|
1327
|
+
const r = P.registry;
|
|
1328
|
+
const what = r.builtFrom ? `publishes ${r.items} component${r.items === 1 ? '' : 's'}, built from its packages folder` : `publishes ${esc(publishesLine(r))}`;
|
|
1329
|
+
const pages = comps.filter((c) => c.isPage).length;
|
|
1330
|
+
return `<div class="excl fresh">Read as a shadcn registry: ${what}${r.variants.length ? `, the same components kept in ${r.variants.length} variants` : ''}.${pages ? ` Its own site: ${n(pages)} page${pages === 1 ? '' : 's'}.` : ''} Scored as a shadcn install for now; the registry rules come next.</div><div class="excl">Evidence: ${receipt}</div>`;
|
|
1331
|
+
}
|
|
1320
1332
|
return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>`;
|
|
1321
1333
|
}
|
|
1322
1334
|
|
|
@@ -1350,7 +1362,7 @@ function sidePanel() {
|
|
|
1350
1362
|
? `<div class="bd">Of which <b>${breakdown.installedPoints} point${breakdown.installedPoints === 1 ? '' : 's'}</b> come from installed code you did not write: ${esc(rp.dirs.map((d) => basename(d)).join(', '))} (${n(rp.files)} file${rp.files === 1 ? '' : 's'}, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}). Your own code alone would score <b>${breakdown.ownScore}</b>. Kept in the score because your agent reads those files like everything else; left out of the fixes because they are not yours to edit.</div>` : '';
|
|
1351
1363
|
const scoreBlock = healthScore !== null
|
|
1352
1364
|
? `<div class="score${noSystemLikely ? ' muted' : ''}">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div>${noSystemLikely ? '<div class="note">little here to score · see the note</div>' : ''}<div class="def">${def}${lift}</div>${bd}</div>` : '';
|
|
1353
|
-
const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>`;
|
|
1365
|
+
const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${(P.shadcn?.sheet?.tweakcnPresent ?? 0) >= 10 ? '<span class="chip">tweakcn theme</span>' : ''}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>`;
|
|
1354
1366
|
const facts = [
|
|
1355
1367
|
shadcnReceipt(),
|
|
1356
1368
|
exclusionsLine(),
|
|
@@ -2027,6 +2039,7 @@ if (summaryPath) {
|
|
|
2027
2039
|
verdict,
|
|
2028
2040
|
role: P.role,
|
|
2029
2041
|
kind: P.kind,
|
|
2042
|
+
...(P.isRegistry && P.registry ? { registry: { source: P.registry.source, builtFrom: P.registry.builtFrom, items: P.registry.items, publishes: P.registry.publishes, variants: P.registry.variants } } : {}),
|
|
2030
2043
|
...(P.isShadcn && P.shadcn ? { shadcn: { confidence: P.confidence, evidence: P.evidence, style: P.shadcn.kit?.style ?? null, baseColor: P.shadcn.kit?.baseColor ?? null, tailwind: P.shadcn.kit?.tailwind ?? null, catalogues: P.uiDirs, registries: P.shadcn.registryDirs ?? [], ...(P.shadcn.registryPaint ? { registryFiles: P.shadcn.registryPaint.files, registryPaletteColours: P.shadcn.registryPaint.tinUses } : {}), ownFiles: P.shadcn.paint?.ownFiles ?? null, fresh: P.shadcn.fresh?.fresh === true } } : {}),
|
|
2031
2044
|
componentsMeasured,
|
|
2032
2045
|
metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
|
|
@@ -45,7 +45,8 @@ export function loadBenchmark() {
|
|
|
45
45
|
* ideals and the reputable-systems line are the same for every kind.
|
|
46
46
|
*/
|
|
47
47
|
export function benchHelpers(bench, kind = 'product') {
|
|
48
|
-
|
|
48
|
+
// registries are compared with the shadcn repos, labelled as such (Greg, 2026-09-13)
|
|
49
|
+
const slice = bench?.slices?.[kind === 'registry' ? 'shadcn' : kind] ?? null;
|
|
49
50
|
if (slice?.stats) bench = { ...bench, stats: { ...(bench?.stats ?? {}), ...slice.stats } };
|
|
50
51
|
const sliceInfo = slice ? { kind, repoCount: slice.repoCount, builtAt: slice.builtAt } : null;
|
|
51
52
|
// where does this value sit among the scanned fleet? ("more colours than 90%")
|
|
@@ -105,6 +106,8 @@ export const PROFILE_TILES = {
|
|
|
105
106
|
['doorOverrides', 'components recoloured from outside per 100 files'],
|
|
106
107
|
],
|
|
107
108
|
};
|
|
109
|
+
// a registry measures what a shadcn repo measures (release (a), 2026-09-13)
|
|
110
|
+
PROFILE_TILES.registry = PROFILE_TILES.shadcn;
|
|
108
111
|
export const tilesFor = (kind) => [...TILES, ...(PROFILE_TILES[kind] ?? [])];
|
|
109
112
|
|
|
110
113
|
/** The tiles, in report order: metric key, the label the report prints. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single version constant for the engine — imported by diagnose (report
|
|
2
2
|
// footer) and rules (generated-by line). This is the bump spot that used to
|
|
3
3
|
// live as a const inside diagnose/index.mjs.
|
|
4
|
-
export const VERSION = '7.
|
|
4
|
+
export const VERSION = '7.6.0';
|
|
5
5
|
// The shape of harvest.json and summary.json. Bumped only when a field is
|
|
6
6
|
// renamed, removed or changes meaning; a new field is not a new schema. A
|
|
7
7
|
// tool comparing two scans compares like with like by this number, not by VERSION.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
3
|
/**
|
|
4
4
|
* Profiles — what kind of repo is this, decided once and read everywhere.
|
|
5
5
|
*
|
|
@@ -22,6 +22,7 @@ import { join } from 'node:path';
|
|
|
22
22
|
* fixture's expected output is byte-identical before and after.
|
|
23
23
|
*/
|
|
24
24
|
import shadcn from './shadcn.mjs';
|
|
25
|
+
import { readRegistry, publishesLine, variantsFromDirs } from './registry.mjs';
|
|
25
26
|
import library from './library.mjs';
|
|
26
27
|
import product from './product.mjs';
|
|
27
28
|
|
|
@@ -60,6 +61,23 @@ export function decideProfile(profile, components, files, root = null) {
|
|
|
60
61
|
profile.kind = picked.kind;
|
|
61
62
|
profile.kindConfidence = decision.confidence;
|
|
62
63
|
profile.kindEvidence = decision.evidence;
|
|
64
|
+
// A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
|
|
65
|
+
// Every count still reads the shadcn facts (release (a): zero score change);
|
|
66
|
+
// the kind, the receipt and the header line say what it is.
|
|
67
|
+
if (picked === shadcn && root) {
|
|
68
|
+
const reg = readRegistry(root, files);
|
|
69
|
+
if (reg) {
|
|
70
|
+
if (!reg.variants.length) reg.variants = variantsFromDirs(reg, profile.uiDirs, files);
|
|
71
|
+
delete reg.itemNames;
|
|
72
|
+
profile.kind = 'registry';
|
|
73
|
+
profile.registry = reg;
|
|
74
|
+
profile.kindEvidence = [
|
|
75
|
+
reg.builtFrom ? `registry built from ${reg.items} packages by ${reg.source}` : `${reg.source} publishes ${publishesLine(reg)}`,
|
|
76
|
+
...(reg.variants.length ? [`the same components kept in ${reg.variants.length} variants (${reg.variants.map((d) => basename(d)).join(', ')})`] : []),
|
|
77
|
+
...decision.evidence,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
63
81
|
|
|
64
82
|
// Measurability. The gate fires on the situation, never a list of known
|
|
65
83
|
// frameworks: a repo with real code volume where the detector found almost
|
|
@@ -90,7 +108,10 @@ export function profileOf(h) {
|
|
|
90
108
|
confidence: p.kindConfidence ?? null,
|
|
91
109
|
evidence: p.kindEvidence ?? [],
|
|
92
110
|
isLibrary: role === 'library',
|
|
93
|
-
|
|
111
|
+
// a registry is read with the shadcn facts, plus what it publishes
|
|
112
|
+
isShadcn: kind === 'shadcn' || kind === 'registry',
|
|
113
|
+
isRegistry: kind === 'registry',
|
|
114
|
+
registry: p.registry ?? null,
|
|
94
115
|
shadcn: p.shadcn ?? null,
|
|
95
116
|
uiDirs: p.uiDirs ?? (p.uiDir ? [p.uiDir] : []),
|
|
96
117
|
// A vendored shadcn catalogue is stock on a shelf, not abandonment.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry: a project that publishes components or themes for other repos
|
|
3
|
+
* to install with the shadcn CLI (shadcn's own source, magicui, kibo-ui,
|
|
4
|
+
* tweakcn). Read as an ordinary shadcn install, the scanner counts its
|
|
5
|
+
* product range as sprawl (shadcn's source: 132 duplicates, one per base
|
|
6
|
+
* library; 29 typefaces from a font picker) and files what it publishes
|
|
7
|
+
* under "installed, not yours". Both wrong: what a registry publishes ships
|
|
8
|
+
* into every repo that installs it.
|
|
9
|
+
*
|
|
10
|
+
* Release (a), 2026-09-13: recognition and the header line only. Every count
|
|
11
|
+
* still reads the shadcn profile, so no score moves. Release (b) brings the
|
|
12
|
+
* counting rules (variants once, published code as own code, the theme
|
|
13
|
+
* check, the demo zone out). Brief: DOCs/proffer-2-notes/docs/registry-profile-brief-2026-09-13.md
|
|
14
|
+
*
|
|
15
|
+
* Recognised by either:
|
|
16
|
+
* 1. a registry.json whose items carry registry:* types (the source file,
|
|
17
|
+
* never the built copies under public/ nor test fixtures or templates)
|
|
18
|
+
* 2. a route that builds the registry from a packages folder
|
|
19
|
+
* (app/r/registry.json/route.ts, kibo-ui)
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
22
|
+
import { join, dirname, basename } from 'node:path';
|
|
23
|
+
|
|
24
|
+
const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
25
|
+
|
|
26
|
+
// registry item types → the words the report uses
|
|
27
|
+
const KINDS = [
|
|
28
|
+
['components', /^registry:(ui|component)$/],
|
|
29
|
+
['blocks', /^registry:block$/],
|
|
30
|
+
['styles', /^registry:(style|theme)$/],
|
|
31
|
+
['demos', /^registry:example$/],
|
|
32
|
+
['other', /^registry:(lib|hook|file|page|internal)$/],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function tally(items) {
|
|
36
|
+
const out = { components: 0, blocks: 0, styles: 0, demos: 0, other: 0 };
|
|
37
|
+
for (const it of items) {
|
|
38
|
+
const t = String(it?.type ?? '');
|
|
39
|
+
const k = KINDS.find(([, re]) => re.test(t));
|
|
40
|
+
if (k) out[k[0]] += 1;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Sibling folders holding the same published component names: the variants of one range. */
|
|
46
|
+
function variantDirs(items, root) {
|
|
47
|
+
const byName = new Map();
|
|
48
|
+
for (const it of items) {
|
|
49
|
+
if (!/^registry:(ui|component)$/.test(String(it?.type ?? ''))) continue;
|
|
50
|
+
for (const f of it.files ?? []) {
|
|
51
|
+
const p = typeof f === 'string' ? f : f?.path;
|
|
52
|
+
if (!p) continue;
|
|
53
|
+
const dir = dirname(p);
|
|
54
|
+
if (!byName.has(it.name)) byName.set(it.name, new Set());
|
|
55
|
+
byName.get(it.name).add(dir);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const dirs = new Map();
|
|
59
|
+
for (const set of byName.values()) if (set.size > 1) for (const d of set) dirs.set(d, (dirs.get(d) ?? 0) + 1);
|
|
60
|
+
return [...dirs.entries()].filter(([, n]) => n >= 5).map(([d]) => d).sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The facts a registry publishes, or null when the repo publishes nothing.
|
|
65
|
+
* @returns {{ source: string, builtFrom: string|null, items: number, publishes, variants: string[] } | null}
|
|
66
|
+
*/
|
|
67
|
+
// The main walk skips docs/, public/ and test folders on purpose (they are
|
|
68
|
+
// not the product's design language). A registry's paperwork lives exactly
|
|
69
|
+
// there (kibo's route under apps/docs, tweakcn's built JSON under public), so
|
|
70
|
+
// recognition takes its own small look: depth-limited, noise skipped.
|
|
71
|
+
const LOOK_SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.turbo', 'test', 'tests', '__tests__', 'fixtures', 'templates']);
|
|
72
|
+
function lookFor(root, maxDepth = 6) {
|
|
73
|
+
const found = { registries: [], routes: [] };
|
|
74
|
+
const walk = (dir, rel, depth) => {
|
|
75
|
+
if (depth > maxDepth) return;
|
|
76
|
+
let es; try { es = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
77
|
+
for (const e of es) {
|
|
78
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
79
|
+
if (e.isDirectory()) {
|
|
80
|
+
if (LOOK_SKIP.has(e.name) || (e.name.startsWith('.') && e.name !== '.')) continue;
|
|
81
|
+
if (e.name === 'registry.json') {
|
|
82
|
+
const route = ['route.ts', 'route.js', 'route.tsx', 'route.mjs'].find((n) => existsSync(join(dir, e.name, n)));
|
|
83
|
+
if (route && basename(dir) === 'r') found.routes.push(`${r}/${route}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
walk(join(dir, e.name), r, depth + 1);
|
|
87
|
+
} else if (e.name === 'registry.json') found.registries.push(r);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
walk(root, '', 0);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readRegistry(root, files) {
|
|
95
|
+
const look = lookFor(root);
|
|
96
|
+
// 1. a registry.json with typed items: the source file first (the biggest
|
|
97
|
+
// wins), else the built copy under public/ (tweakcn generates its registry
|
|
98
|
+
// from a TypeScript file and only the built JSON exists)
|
|
99
|
+
let best = null;
|
|
100
|
+
const consider = (f) => {
|
|
101
|
+
const j = readJSON(join(root, f));
|
|
102
|
+
const items = Array.isArray(j?.items) ? j.items : null;
|
|
103
|
+
if (!items || !items.some((it) => /^registry:/.test(String(it?.type ?? '')))) return;
|
|
104
|
+
if (!best || items.length > best.items.length) best = { source: f, items };
|
|
105
|
+
};
|
|
106
|
+
const candidates = look.registries;
|
|
107
|
+
for (const f of candidates.filter((f) => !/(^|\/)public\//.test(f))) consider(f);
|
|
108
|
+
if (!best) for (const f of candidates.filter((f) => /(^|\/)public\//.test(f))) consider(f);
|
|
109
|
+
if (best) {
|
|
110
|
+
const publishes = tally(best.items);
|
|
111
|
+
const itemNames = best.items.filter((it) => /^registry:(ui|component)$/.test(String(it?.type ?? ''))).map((it) => it.name).filter(Boolean);
|
|
112
|
+
return { source: best.source, builtFrom: null, items: best.items.length, publishes, variants: variantDirs(best.items, root), itemNames };
|
|
113
|
+
}
|
|
114
|
+
// 2. a route that builds the registry from packages/* (kibo-ui)
|
|
115
|
+
const route = look.routes[0] ?? null;
|
|
116
|
+
if (route) {
|
|
117
|
+
const src = readFileSync(join(root, route), 'utf8');
|
|
118
|
+
if (/packages/.test(src)) {
|
|
119
|
+
// the packages folder nearest the route's app, walking up
|
|
120
|
+
let dir = dirname(route);
|
|
121
|
+
let pkgs = null;
|
|
122
|
+
while (dir && dir !== '.') {
|
|
123
|
+
const cand = join(root, dir, 'packages');
|
|
124
|
+
if (existsSync(cand)) { pkgs = cand; break; }
|
|
125
|
+
dir = dirname(dir);
|
|
126
|
+
}
|
|
127
|
+
if (!pkgs && existsSync(join(root, 'packages'))) pkgs = join(root, 'packages');
|
|
128
|
+
if (pkgs) {
|
|
129
|
+
const names = readdirSync(pkgs, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync(join(pkgs, e.name, 'index.tsx'))).map((e) => e.name);
|
|
130
|
+
return { source: route, builtFrom: 'packages', items: names.length, publishes: { components: names.length, blocks: 0, styles: 0, demos: 0, other: 0 }, variants: [] };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Variants found on disk: catalogue folders (the shadcn profile's uiDirs)
|
|
139
|
+
* that each hold most of the published component names. shadcn's own
|
|
140
|
+
* registry.json lists one file per component (new-york-v4), while the same
|
|
141
|
+
* components sit again under bases/aria, bases/base and bases/radix.
|
|
142
|
+
*/
|
|
143
|
+
export function variantsFromDirs(reg, uiDirs, files) {
|
|
144
|
+
const names = new Set();
|
|
145
|
+
// names come from the registry items when it is a file, else from packages
|
|
146
|
+
if (reg.itemNames) for (const nm of reg.itemNames) names.add(nm);
|
|
147
|
+
if (names.size < 5) return [];
|
|
148
|
+
const stem = (f) => { const b = basename(f).replace(/\.[cm]?[jt]sx?$/, ''); return b === 'index' ? basename(dirname(f)) : b; };
|
|
149
|
+
const hits = [];
|
|
150
|
+
for (const d of uiDirs ?? []) {
|
|
151
|
+
const have = new Set((files.code ?? []).filter((f) => f.startsWith(`${d}/`) && /\.[jt]sx$/.test(f)).map(stem));
|
|
152
|
+
const shared = [...names].filter((nm) => have.has(nm)).length;
|
|
153
|
+
if (shared >= Math.max(5, Math.ceil(names.size * 0.5))) hits.push(d);
|
|
154
|
+
}
|
|
155
|
+
return hits.length > 1 ? hits.sort() : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The header sentence: what it publishes, in words. */
|
|
159
|
+
export function publishesLine(r) {
|
|
160
|
+
const parts = [];
|
|
161
|
+
const n = (k, word) => { const v = r.publishes[k]; if (v) parts.push(`${v} ${word}${v === 1 ? '' : 's'}`); };
|
|
162
|
+
n('components', 'component'); n('blocks', 'block'); n('styles', 'style'); n('demos', 'demo');
|
|
163
|
+
return parts.length ? parts.join(', ').replace(/, ([^,]*)$/, ' and $1') : `${r.items} items`;
|
|
164
|
+
}
|
|
@@ -303,6 +303,9 @@ export default {
|
|
|
303
303
|
}
|
|
304
304
|
evidence.push(contract ? `${contract} of shadcn's ${CONTRACT_ROWS.length} theme variables defined` : 'no shadcn theme variables found in any stylesheet');
|
|
305
305
|
if (kit?.style) evidence.push(`style ${kit.style}${kit.baseColor ? `, base colour ${kit.baseColor}` : ''}${kit.tailwind ? `, Tailwind ${kit.tailwind}` : ''}`);
|
|
306
|
+
// ten or more of the rows tweakcn adds to every theme it exports: a
|
|
307
|
+
// fingerprint of a theme editor, not a signature, so "tweakcn-style"
|
|
308
|
+
if ((sheet?.tweakcnPresent ?? 0) >= 10) evidence.push(`tweakcn theme (${sheet.tweakcnPresent} of its rows present)`);
|
|
306
309
|
|
|
307
310
|
// 4. write the facts every consumer reads
|
|
308
311
|
profile.uiDirs = installs.map((i) => i.uiDir).filter(Boolean);
|