@krapwoo/ds-viewer 0.2.0 → 0.3.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 +82 -0
- package/dist/cli/agentsFile.js +31 -0
- package/dist/cli/doctor.js +492 -0
- package/dist/cli/explain.js +151 -0
- package/dist/cli/githubAction.js +68 -0
- package/dist/cli/init.js +6 -2
- package/dist/cli/main.js +49 -0
- package/dist/cli/pageIndex.js +27 -1
- package/dist/cli/props.js +4 -3
- package/dist/cli/staticPage.js +356 -0
- package/dist/cli/sync.js +11 -4
- package/dist/native/catalog/comparison.js +283 -0
- package/dist/native/catalog/tokens.js +119 -0
- package/dist/native/catalog/types.js +1 -0
- package/native/catalog/ComparisonGrid.tsx +9 -7
- package/native/catalog/SpacingScaleGallery.tsx +1 -1
- package/native/catalog/catalogNavigation.ts +1 -1
- package/native/catalog/comparison.ts +63 -5
- package/native/catalog/index.ts +2 -1
- package/native/catalog/pageApi.ts +11 -2
- package/native/catalog/types.ts +9 -6
- package/package.json +5 -1
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +4 -22
- package/starter-kit/components/Badge/Badge.catalog.tsx +15 -23
- package/starter-kit/components/Banner/Banner.tsx +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.tsx +2 -2
- package/starter-kit/components/Button/Button.catalog.tsx +15 -29
- package/starter-kit/components/Dialog/Dialog.tsx +2 -2
- package/starter-kit/components/Pill/Pill.catalog.tsx +12 -26
package/README.md
CHANGED
|
@@ -61,6 +61,8 @@ Either way, then:
|
|
|
61
61
|
```bash
|
|
62
62
|
npm run ds-viewer dev # opens the catalog in a browser, re-syncing on every file change
|
|
63
63
|
npm run ds-viewer sync # regenerates component/token/page data without starting a server
|
|
64
|
+
npm run ds-viewer doctor # checks every page for drift and coverage gaps (--json, --ci)
|
|
65
|
+
npm run ds-viewer explain <Page> # prints why a page's specimens are laid out the way they are
|
|
64
66
|
```
|
|
65
67
|
|
|
66
68
|
`ds-viewer.config.ts` fields: `name`, `logo` (optional — a path to an image asset, shown in the
|
|
@@ -204,6 +206,86 @@ data, not a component — see `ColorsGallery`/`SpacingGallery`/`TypographyGaller
|
|
|
204
206
|
output full width under a "Tokens" label and shows only a Quick reference card with the source
|
|
205
207
|
path.
|
|
206
208
|
|
|
209
|
+
## Keeping the catalog current
|
|
210
|
+
|
|
211
|
+
### Binding examples to props
|
|
212
|
+
|
|
213
|
+
A grid's row or column axis can name the real prop it demonstrates:
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
import { defineCatalogPage, grid } from '@krapwoo/ds-viewer';
|
|
217
|
+
|
|
218
|
+
comparison: grid(
|
|
219
|
+
'Variant', 'State',
|
|
220
|
+
{ prop: 'variant', items: [
|
|
221
|
+
{ key: 'primary', label: 'Primary' },
|
|
222
|
+
{ key: 'ghost', label: 'Ghost' },
|
|
223
|
+
] },
|
|
224
|
+
[{ key: 'default', label: 'Default' }, { key: 'disabled', label: 'Disabled' }],
|
|
225
|
+
(row, column) => <Button variant={row} disabled={column === 'disabled'} onPress={() => {}} />,
|
|
226
|
+
),
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Only a prop typed as a string-literal union (2 or more options) can be bound this way; each item's
|
|
230
|
+
`key` must then be a real option of that prop. An axis that doesn't name a single real prop (e.g.
|
|
231
|
+
"which of three optional props is set," or a continuous `number`) stays a plain array of
|
|
232
|
+
`{ key, label }` — just as valid, and never flagged by `doctor`. `variants`/`states` list items use
|
|
233
|
+
the existing `props` tag (`{ key: 'ghost', props: { variant: 'ghost' }, node: ... }`) the same way.
|
|
234
|
+
Binding is what lets `doctor` tell you precisely when a renamed or removed option leaves a stale
|
|
235
|
+
example behind, instead of you finding out by reading the diff.
|
|
236
|
+
|
|
237
|
+
### `npx ds-viewer doctor`
|
|
238
|
+
|
|
239
|
+
Reads every page with the TypeScript compiler — it never imports a page file or runs app code.
|
|
240
|
+
Human output is grouped by page, each issue with a one-line fix:
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
Button
|
|
244
|
+
[error] bound-option-removed: The row "outline" is bound to "variant", which no longer has that option.
|
|
245
|
+
Fix: Delete this row, or update its key to a current option of "variant".
|
|
246
|
+
|
|
247
|
+
0 errors, 1 warning — 38 components (37 with examples), 2 unbound examples.
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- `--json` prints the same report as stable, versioned data: `{ version: 1, summary: { errors, warnings, components, withExamples, unboundExamples }, update: null, issues: [{ id, severity, page?, file?, line?, message, fix }] }`. `update` is always `null` in this release (filled in starting 0.4's update check); `file` is always relative to the project root. `line` is populated for a `page-parse-error` found by typechecking a page file (see below), and always `undefined` for every other issue id — threading a real position through every rule is future work.
|
|
251
|
+
- `--ci` exits 1 only when an error was found — a warning alone never fails CI, and `--ci` never makes a network call.
|
|
252
|
+
- `doctor.strict: true` in `ds-viewer.config.ts` promotes every warning to an error, for both the summary counts and `--ci`'s exit code.
|
|
253
|
+
- A page whose layout data (`specimenSize`, `group`, grid `rows`/`columns`, list items' `key`/`name`/`props`/`group`, `propNotes` keys) isn't a literal — written as something other than a literal value, array, object, or a same-file `const` reference — gets one "page not statically checkable" warning. A page file that fails to parse *or typecheck* is `page-parse-error` instead: every page file is also run through the TypeScript type checker (the same way a component file already is), so a genuine syntax or type error in a page is reported with its line.
|
|
254
|
+
|
|
255
|
+
## Known gaps
|
|
256
|
+
|
|
257
|
+
- The viewer's own dev-mode console warnings only cover `duplicate-page-id` (design §4's "the same rules appear as development warnings naming the page" — scoped to that one rule in 0.3). Bound-axis drift (`bound-option-removed`, `bound-axis-prop-removed`) has no viewer-side warning yet; `npx ds-viewer doctor` (and its `--ci` workflow) is the only place that catches it today.
|
|
258
|
+
|
|
259
|
+
### `npx ds-viewer explain <Page>`
|
|
260
|
+
|
|
261
|
+
Prints each layout decision with its reason, using the exact same pure functions the catalog itself
|
|
262
|
+
renders with:
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
Button
|
|
266
|
+
Grid (Variant × State)
|
|
267
|
+
5 rows × 3 columns (regular); 3 of 3 max regular columns used — fits a 1280px laptop.
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Side-by-side placement depends on a real measured height, so by default `explain` reports it as
|
|
271
|
+
"decided in the viewer." Pass `--heights <firstBlockPx>,<secondBlockPx>` to check that decision here
|
|
272
|
+
too, assuming a 1280px laptop (952px content width) the same way `doctor`'s own column-limit check
|
|
273
|
+
does. `--json` is available on `explain` as well.
|
|
274
|
+
|
|
275
|
+
### The `AGENTS.md` section and GitHub Action
|
|
276
|
+
|
|
277
|
+
`npx @krapwoo/ds-viewer init` (either path) appends a short section to `AGENTS.md` between
|
|
278
|
+
`<!-- ds-viewer:start v1 -->`/`<!-- ds-viewer:end -->` markers — creating the file if it doesn't
|
|
279
|
+
exist — telling an AI working on the project to update a page alongside its component, bind
|
|
280
|
+
examples to props, never invent grid cells or groups, and run `doctor`/`explain` before finishing.
|
|
281
|
+
It also writes `.github/workflows/ds-viewer.yml`, a SHA-pinned Action that installs dependencies
|
|
282
|
+
(picking `npm ci`, `pnpm install --frozen-lockfile`, or `yarn install --immutable` from whichever
|
|
283
|
+
lockfile the project has at that moment) and then runs `npx ds-viewer doctor --ci` on every pull
|
|
284
|
+
request. Both are written once; re-running `init` adds
|
|
285
|
+
either one only if it's missing (an `AGENTS.md` without the start marker counts as missing the
|
|
286
|
+
section, even if the file already has other content) and never touches anything outside its own
|
|
287
|
+
markers or its own file.
|
|
288
|
+
|
|
207
289
|
## What's included
|
|
208
290
|
|
|
209
291
|
Tokens · Icons (45) · and generic components:
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const START_MARKER = '<!-- ds-viewer:start v1 -->';
|
|
4
|
+
const END_MARKER = '<!-- ds-viewer:end -->';
|
|
5
|
+
// Design §4's four numbered lines, verbatim — no heading added: the design names only these four
|
|
6
|
+
// lines as the section's content.
|
|
7
|
+
const SECTION_BODY = [
|
|
8
|
+
"1. When you add or change a component's props or a token, update its \`*.catalog.tsx\` in the same change.",
|
|
9
|
+
"2. Bind examples to props (\`prop\` on grid axes, \`props\` on list items) so drift is detected.",
|
|
10
|
+
'3. Never invent grid cells or groups; author only combinations that exist.',
|
|
11
|
+
"4. Run \`npx ds-viewer doctor\` before finishing and fix every error. Use \`npx ds-viewer explain <Page>\` to check layout; see the README's page-authoring guide.",
|
|
12
|
+
].join('\n');
|
|
13
|
+
/** Design §4 "AGENTS.md section", §1 Ownership, §2 step 3. Appends between the exact markers,
|
|
14
|
+
* creating the file when absent — never touches a byte outside them, and never runs at all once
|
|
15
|
+
* the start marker is already present, even if the rest of the file looks nothing like what this
|
|
16
|
+
* function would have written (design: "An AGENTS.md without the markers counts as missing the
|
|
17
|
+
* section" — the converse is also true: a file WITH the marker is never rewritten in 0.3). */
|
|
18
|
+
export function ensureAgentsFileSection(projectRoot) {
|
|
19
|
+
const agentsPath = path.join(projectRoot, 'AGENTS.md');
|
|
20
|
+
const existing = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : '';
|
|
21
|
+
if (existing.includes(START_MARKER))
|
|
22
|
+
return [];
|
|
23
|
+
// Minor finding (Fable's review): a CRLF `AGENTS.md` must not end up with mixed line endings
|
|
24
|
+
// after the append — the separator and the whole section below use whichever newline the
|
|
25
|
+
// existing file already uses.
|
|
26
|
+
const newline = existing.includes('\r\n') ? '\r\n' : '\n';
|
|
27
|
+
const separator = existing === '' ? '' : existing.endsWith(`${newline}${newline}`) ? '' : existing.endsWith(newline) ? newline : `${newline}${newline}`;
|
|
28
|
+
const body = newline === '\n' ? SECTION_BODY : SECTION_BODY.replace(/\n/g, newline);
|
|
29
|
+
writeFileSync(agentsPath, `${existing}${separator}${START_MARKER}${newline}${body}${newline}${END_MARKER}${newline}`);
|
|
30
|
+
return [agentsPath];
|
|
31
|
+
}
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
|
|
5
|
+
import { readHostTsconfigOptions } from "./props.js";
|
|
6
|
+
import { readStaticPages } from "./staticPage.js";
|
|
7
|
+
import { resolveGlob } from "./glob.js";
|
|
8
|
+
import { sync } from "./sync.js";
|
|
9
|
+
import { gridColumnLimit } from "../native/catalog/comparison.js";
|
|
10
|
+
function issue(fields) {
|
|
11
|
+
return fields;
|
|
12
|
+
}
|
|
13
|
+
function pageRef(page) {
|
|
14
|
+
return { page: resolvePageId(page, page.file), file: page.file };
|
|
15
|
+
}
|
|
16
|
+
/** Errata (controller, binding): a page discovered from a component folder (a `components` glob's
|
|
17
|
+
* folder, not a `pages` glob) with no explicit `component:` documents the component named by its
|
|
18
|
+
* own file (design §2; the viewer already applies this same default via `page.component ??
|
|
19
|
+
* page.id`, and the generated page index already defaults a missing `id` to the file stem). A
|
|
20
|
+
* page from a standalone `pages` glob is never touched — `componentFolders` only ever contains
|
|
21
|
+
* folders resolved from `config.components`, so a standalone page's own folder is never a member. */
|
|
22
|
+
export function applyComponentDefaults(pages, componentFolders) {
|
|
23
|
+
return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file))
|
|
24
|
+
? { ...page, component: resolvePageId(page, page.file) }
|
|
25
|
+
: page);
|
|
26
|
+
}
|
|
27
|
+
function checkParseErrors(pages) {
|
|
28
|
+
return pages
|
|
29
|
+
.filter((p) => Boolean(p.parseError))
|
|
30
|
+
.map((p) => issue({ id: 'page-parse-error', severity: 'error', file: p.file, message: `Failed to parse: ${p.parseError}`, fix: 'Fix the syntax error, then rerun doctor.' }));
|
|
31
|
+
}
|
|
32
|
+
function checkNotCheckable(pages) {
|
|
33
|
+
return pages
|
|
34
|
+
.filter((p) => !p.checkable && !p.parseError)
|
|
35
|
+
.map((p) => issue({
|
|
36
|
+
id: 'page-not-checkable', severity: 'warning', ...pageRef(p),
|
|
37
|
+
message: 'Page not statically checkable — some of its layout data is not a literal doctor can read.',
|
|
38
|
+
fix: 'Write rows/columns/items/propNotes as literal arrays/objects (or a same-file const), not a function call.',
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
function checkComponentExportRemoved(pages, components) {
|
|
42
|
+
const names = new Set(components.map((c) => c.name));
|
|
43
|
+
return pages
|
|
44
|
+
.filter((p) => p.component && !names.has(p.component))
|
|
45
|
+
.map((p) => issue({
|
|
46
|
+
id: 'component-export-removed', severity: 'error', ...pageRef(p),
|
|
47
|
+
message: `Documents "${p.component}", which no longer exists as a component export.`,
|
|
48
|
+
fix: 'Delete this page, or update "component" to the export\'s new name.',
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
/** True when a page documents at least one example by any mechanism design §4 recognizes — a
|
|
52
|
+
* grid, a `variants` item, a `states` item, or `render`. A page can be "fully covered" by
|
|
53
|
+
* `checkComponentExportRemoved`'s standard (it names a real component) and still have none of
|
|
54
|
+
* these — e.g. `init`'s own "Needs examples" draft, which sets only `component`/`group`/
|
|
55
|
+
* `description` — in which case the viewer shows the exact same "No examples documented." an
|
|
56
|
+
* undocumented component would. */
|
|
57
|
+
function pageHasExamples(page) {
|
|
58
|
+
return Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true;
|
|
59
|
+
}
|
|
60
|
+
function checkComponentNoExamples(pages, components) {
|
|
61
|
+
const byComponent = new Map();
|
|
62
|
+
for (const page of pages) {
|
|
63
|
+
if (page.component)
|
|
64
|
+
byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
|
|
65
|
+
}
|
|
66
|
+
return components
|
|
67
|
+
.filter((c) => {
|
|
68
|
+
const componentPages = byComponent.get(c.name) ?? [];
|
|
69
|
+
// A not-statically-checkable page is given the benefit of the doubt here — it might have
|
|
70
|
+
// real examples this reader simply couldn't read (Global Constraint: never a partial read;
|
|
71
|
+
// its own `page-not-checkable` warning already covers it). Only "every page is checkable
|
|
72
|
+
// and genuinely empty" (or "no page at all") fires this rule.
|
|
73
|
+
return componentPages.length === 0 || componentPages.every((p) => p.checkable && !pageHasExamples(p));
|
|
74
|
+
})
|
|
75
|
+
.map((c) => {
|
|
76
|
+
const hasPage = (byComponent.get(c.name) ?? []).length > 0;
|
|
77
|
+
return issue({
|
|
78
|
+
id: 'component-no-examples', severity: 'warning', page: c.name, file: c.file,
|
|
79
|
+
message: hasPage
|
|
80
|
+
? `"${c.name}" has a catalog page with no variants, states, comparison, or render — it will show "No examples documented."`
|
|
81
|
+
: `"${c.name}" has no catalog page — it will show "No examples documented."`,
|
|
82
|
+
fix: hasPage ? `Add variants/states/comparison, or render, to ${c.name}'s catalog page.` : `Add ${c.name}.catalog.tsx beside ${c.name}'s own file.`,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function checkDuplicatePageId(pages) {
|
|
87
|
+
const byId = new Map();
|
|
88
|
+
for (const page of pages) {
|
|
89
|
+
const id = resolvePageId(page, page.file);
|
|
90
|
+
byId.set(id, [...(byId.get(id) ?? []), page]);
|
|
91
|
+
}
|
|
92
|
+
const issues = [];
|
|
93
|
+
for (const [id, group] of byId) {
|
|
94
|
+
if (group.length < 2)
|
|
95
|
+
continue;
|
|
96
|
+
for (const page of group) {
|
|
97
|
+
const others = group.filter((p) => p !== page).map((p) => p.file).join(', ');
|
|
98
|
+
issues.push(issue({
|
|
99
|
+
id: 'duplicate-page-id', severity: 'error', page: id, file: page.file,
|
|
100
|
+
message: `Page id "${id}" is also used by ${others}.`,
|
|
101
|
+
fix: 'Give one of these pages its own "id".',
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return issues;
|
|
106
|
+
}
|
|
107
|
+
function checkGridAxisKeys(pages) {
|
|
108
|
+
const issues = [];
|
|
109
|
+
for (const page of pages) {
|
|
110
|
+
// Global Constraints: "never a partial read" — a not-checkable page gets only
|
|
111
|
+
// `page-not-checkable` (`checkNotCheckable` above); a *different* field on it happening to be
|
|
112
|
+
// literal (e.g. its grid, while its `propNotes` wasn't) is never grounds for a second opinion.
|
|
113
|
+
if (!page.checkable || !page.comparison)
|
|
114
|
+
continue;
|
|
115
|
+
for (const [axisName, axis] of [['row', page.comparison.rows], ['column', page.comparison.columns]]) {
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
for (const item of axis.items) {
|
|
118
|
+
if (item.key === '') {
|
|
119
|
+
issues.push(issue({ id: 'grid-axis-key-invalid', severity: 'error', ...pageRef(page), message: `A ${axisName} has an empty key.`, fix: `Give every ${axisName} a non-empty, unique key.` }));
|
|
120
|
+
}
|
|
121
|
+
else if (seen.has(item.key)) {
|
|
122
|
+
issues.push(issue({ id: 'grid-axis-key-invalid', severity: 'error', ...pageRef(page), message: `Duplicate ${axisName} key "${item.key}".`, fix: `Give each ${axisName} its own key.` }));
|
|
123
|
+
}
|
|
124
|
+
seen.add(item.key);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return issues;
|
|
129
|
+
}
|
|
130
|
+
function checkGridColumnLimit(pages) {
|
|
131
|
+
const issues = [];
|
|
132
|
+
for (const page of pages) {
|
|
133
|
+
if (!page.checkable || !page.comparison)
|
|
134
|
+
continue; // same "never a partial read" reasoning as checkGridAxisKeys above.
|
|
135
|
+
const size = (page.comparison.size ?? page.specimenSize ?? 'regular');
|
|
136
|
+
const limit = gridColumnLimit(size);
|
|
137
|
+
const count = page.comparison.columns.items.length;
|
|
138
|
+
if (count > limit) {
|
|
139
|
+
issues.push(issue({
|
|
140
|
+
id: 'grid-column-limit', severity: 'error', ...pageRef(page),
|
|
141
|
+
message: `Grid has ${count} columns; at most ${limit} ${size} columns fit a 1280px laptop.`,
|
|
142
|
+
fix: 'Swap the axes, use two lists, or pick a smaller specimenSize.',
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return issues;
|
|
147
|
+
}
|
|
148
|
+
/** Everything that needs a page's own matching `ComponentRecord` to check: `propNotes`, bound
|
|
149
|
+
* axes, tagged list items, coverage, grouped-state matching, and guidance/a11y presence. One
|
|
150
|
+
* function because every one of these checks shares the same per-page `component`/`propsByName`
|
|
151
|
+
* lookup — splitting it further would only duplicate that lookup, not clarify anything. */
|
|
152
|
+
function checkComponentPages(pages, components) {
|
|
153
|
+
const componentsByName = new Map(components.map((c) => [c.name, c]));
|
|
154
|
+
const issues = [];
|
|
155
|
+
for (const page of pages) {
|
|
156
|
+
if (!page.component)
|
|
157
|
+
continue;
|
|
158
|
+
if (!page.checkable)
|
|
159
|
+
continue; // never a partial read — see checkGridAxisKeys's own note.
|
|
160
|
+
const component = componentsByName.get(page.component);
|
|
161
|
+
if (!component)
|
|
162
|
+
continue; // already reported by checkComponentExportRemoved
|
|
163
|
+
const ref = pageRef(page);
|
|
164
|
+
const propsByName = new Map(component.props.map((p) => [p.name, p]));
|
|
165
|
+
for (const key of page.propNoteKeys ?? []) {
|
|
166
|
+
if (!propsByName.has(key)) {
|
|
167
|
+
issues.push(issue({
|
|
168
|
+
id: 'propnotes-prop-removed', severity: 'error', ...ref,
|
|
169
|
+
message: `propNotes names "${key}", which is no longer a prop of ${component.name}.`,
|
|
170
|
+
fix: `Delete the "${key}" entry from propNotes, or rename it to the prop's new name.`,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (page.comparison) {
|
|
175
|
+
for (const [axisName, axis] of [['row', page.comparison.rows], ['column', page.comparison.columns]]) {
|
|
176
|
+
if (axis.prop === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
const prop = propsByName.get(axis.prop);
|
|
179
|
+
if (!prop) {
|
|
180
|
+
issues.push(issue({
|
|
181
|
+
id: 'bound-axis-prop-removed', severity: 'error', ...ref,
|
|
182
|
+
message: `The ${axisName} axis is bound to "${axis.prop}", which is no longer a prop of ${component.name}.`,
|
|
183
|
+
fix: 'Update "prop" to the prop\'s new name, or unbind this axis.',
|
|
184
|
+
}));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!prop.options) {
|
|
188
|
+
issues.push(issue({
|
|
189
|
+
id: 'bound-axis-not-union', severity: 'error', ...ref,
|
|
190
|
+
message: `The ${axisName} axis is bound to "${axis.prop}", which is not a string-literal-union prop.`,
|
|
191
|
+
fix: 'Only a prop with a fixed set of string options can be bound; unbind this axis.',
|
|
192
|
+
}));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
for (const item of axis.items) {
|
|
196
|
+
if (!prop.options.includes(item.key)) {
|
|
197
|
+
issues.push(issue({
|
|
198
|
+
id: 'bound-option-removed', severity: 'error', ...ref,
|
|
199
|
+
message: `The ${axisName} "${item.key}" is bound to "${axis.prop}", which no longer has that option.`,
|
|
200
|
+
fix: `Delete this ${axisName}, or update its key to a current option of "${axis.prop}".`,
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const listItems = [...(page.variantsItems ?? []), ...(page.statesItems ?? [])];
|
|
207
|
+
for (const item of listItems) {
|
|
208
|
+
for (const [propName, value] of Object.entries(item.props ?? {})) {
|
|
209
|
+
if (typeof value !== 'string')
|
|
210
|
+
continue;
|
|
211
|
+
const prop = propsByName.get(propName);
|
|
212
|
+
if (!prop) {
|
|
213
|
+
// A name missing from `props` is only real drift when the component has nothing
|
|
214
|
+
// inherited to explain it — `component.props` deliberately omits a node_modules-declared
|
|
215
|
+
// prop (cli/props.ts:200), so e.g. `autoCapitalize` on a `TextInput`-extending component
|
|
216
|
+
// is expected to be absent here, not a removed prop (design: `inheritedFrom` summarises
|
|
217
|
+
// those instead of listing them).
|
|
218
|
+
if (component.inheritedFrom.length > 0)
|
|
219
|
+
continue;
|
|
220
|
+
// The tagged prop *name* itself no longer exists — arguably the same drift as
|
|
221
|
+
// `bound-axis-prop-removed`, but a tagged list item (unlike a bound axis) has no
|
|
222
|
+
// "prop" field of its own to report that id against, so it's reported here instead.
|
|
223
|
+
// Previously silently skipped (`!prop?.options` was true for both "prop missing" and
|
|
224
|
+
// "prop exists but isn't a union"), masking exactly the drift this rule exists to catch.
|
|
225
|
+
issues.push(issue({
|
|
226
|
+
id: 'bound-option-removed', severity: 'error', ...ref,
|
|
227
|
+
message: `"${item.name}" is tagged props.${propName} = "${value}", but "${propName}" is no longer a prop of ${component.name}.`,
|
|
228
|
+
fix: 'Delete the tag, or update it to a current prop name.',
|
|
229
|
+
}));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!prop.options || prop.options.includes(value))
|
|
233
|
+
continue;
|
|
234
|
+
issues.push(issue({
|
|
235
|
+
id: 'bound-option-removed', severity: 'error', ...ref,
|
|
236
|
+
message: `"${item.name}" is tagged props.${propName} = "${value}", which is no longer an option of "${propName}".`,
|
|
237
|
+
fix: 'Update the tagged value, or delete this item if the option no longer exists.',
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const prop of component.props) {
|
|
242
|
+
if (!prop.options)
|
|
243
|
+
continue;
|
|
244
|
+
const covered = new Set();
|
|
245
|
+
if (page.comparison) {
|
|
246
|
+
for (const axis of [page.comparison.rows, page.comparison.columns]) {
|
|
247
|
+
if (axis.prop === prop.name)
|
|
248
|
+
for (const item of axis.items)
|
|
249
|
+
covered.add(item.key);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const item of listItems) {
|
|
253
|
+
const value = item.props?.[prop.name];
|
|
254
|
+
if (typeof value === 'string')
|
|
255
|
+
covered.add(value);
|
|
256
|
+
}
|
|
257
|
+
for (const option of prop.options.filter((o) => !covered.has(o))) {
|
|
258
|
+
issues.push(issue({
|
|
259
|
+
id: 'option-not-covered', severity: 'warning', ...ref,
|
|
260
|
+
message: `"${prop.name}" option "${option}" has no bound or tagged example.`,
|
|
261
|
+
fix: `Bind a grid axis to "${prop.name}", or add { props: { ${prop.name}: '${option}' } } to a variants/states item.`,
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const variantKeys = new Set((page.variantsItems ?? []).map((i) => i.key));
|
|
266
|
+
for (const item of page.statesItems ?? []) {
|
|
267
|
+
if (item.group !== undefined && !variantKeys.has(item.group)) {
|
|
268
|
+
issues.push(issue({
|
|
269
|
+
id: 'group-unmatched', severity: 'warning', ...ref,
|
|
270
|
+
message: `"${item.name}" names group "${item.group}", which matches no variant key; it will show under "Other configurations".`,
|
|
271
|
+
fix: 'Fix the group value, or remove it if this item isn\'t tied to a specific variant.',
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Design §4 names this one rule, "missing guidance or accessibility notes" — one id, and (per
|
|
276
|
+
// the controller) both halves: a missing `whenToUse` and a missing `a11y` each get their own
|
|
277
|
+
// message under `missing-guidance-or-a11y`, since the viewer renders a visible placeholder
|
|
278
|
+
// ("No usage guidance documented."/"No accessibility notes documented.") for either gap, not
|
|
279
|
+
// just the second. Never invented content — both messages only ever point the author at
|
|
280
|
+
// writing their own, real sentence.
|
|
281
|
+
if (!page.tokenGallery && page.hasWhenToUse === false) {
|
|
282
|
+
issues.push(issue({
|
|
283
|
+
id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
|
|
284
|
+
message: 'No "whenToUse" guidance documented.',
|
|
285
|
+
fix: 'Add a one-sentence whenToUse note disambiguating this component from its closest look-alike(s), or omit it only when there genuinely is none.',
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
if (!page.tokenGallery && page.hasA11y === false && !page.hidesAccessibility) {
|
|
289
|
+
issues.push(issue({
|
|
290
|
+
id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
|
|
291
|
+
message: 'No "a11y" notes documented.',
|
|
292
|
+
fix: 'Add a one-sentence a11y note, grounded in the component\'s real behavior — even "no explicit handling beyond defaults" is better than leaving it blank.',
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return issues;
|
|
297
|
+
}
|
|
298
|
+
/** Every page-scoped issue, from every rule above. */
|
|
299
|
+
export function collectIssues(pages, components) {
|
|
300
|
+
return [
|
|
301
|
+
...checkParseErrors(pages),
|
|
302
|
+
...checkNotCheckable(pages),
|
|
303
|
+
...checkComponentExportRemoved(pages, components),
|
|
304
|
+
...checkComponentNoExamples(pages, components),
|
|
305
|
+
...checkDuplicatePageId(pages),
|
|
306
|
+
...checkGridAxisKeys(pages),
|
|
307
|
+
...checkGridColumnLimit(pages),
|
|
308
|
+
...checkComponentPages(pages, components),
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
/** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
|
|
312
|
+
* project with no token modules at all. */
|
|
313
|
+
export function collectGlobalIssues(input) {
|
|
314
|
+
const issues = input.syncWarnings.map((warning) => issue({ id: 'props-unreadable', severity: 'warning', message: warning, fix: 'Fix the file so sync can read it, or exclude it from the components/tokens globs.' }));
|
|
315
|
+
if (input.tokenFileCount === 0) {
|
|
316
|
+
issues.push(issue({ id: 'no-token-modules', severity: 'warning', message: 'No token modules are configured.', fix: 'Add a tokens glob to ds-viewer.config.ts.' }));
|
|
317
|
+
}
|
|
318
|
+
return issues;
|
|
319
|
+
}
|
|
320
|
+
/** Design §4's other half of "fails to parse or typecheck" — the half `cli/staticPage.ts` (Task 3)
|
|
321
|
+
* deliberately never attempts, since it has no type checker and no module resolution at all.
|
|
322
|
+
* This builds exactly one `ts.Program` over every page file, using the identical compiler-host
|
|
323
|
+
* construction `cli/props.ts`'s `createComponentReader` already uses for component files (same
|
|
324
|
+
* `compilerOptions` shape, same `fallbackPaths` mechanism for a page outside the project root —
|
|
325
|
+
* `runDoctor`, Task 5, computes `fallbackPaths` the same way `cli/sync.ts` already does for
|
|
326
|
+
* component files), and reports every syntactic *and* semantic diagnostic as `page-parse-error`,
|
|
327
|
+
* with a real `line` (unlike every other rule in this file — see this task's own resolved-scope
|
|
328
|
+
* note above). Errata (controller, binding): a page that already has a `parseError` from
|
|
329
|
+
* `cli/staticPage.ts`'s own syntactic reader is skipped here — its syntax error was already
|
|
330
|
+
* reported once by `checkParseErrors`, and this program would otherwise report it again (its own
|
|
331
|
+
* syntactic diagnostics are a superset of what `ts.transpileModule` already found). A spike
|
|
332
|
+
* against the real kit (`spikes/page-typecheck/` in this plan's spike folder) found exactly one
|
|
333
|
+
* real, previously-uncaught diagnostic, fixed in Task 2. */
|
|
334
|
+
export function checkPageTypeErrors(pages, options = {}) {
|
|
335
|
+
const checkablePages = pages.filter((p) => !p.parseError);
|
|
336
|
+
const pageFiles = checkablePages.map((p) => p.file);
|
|
337
|
+
if (pageFiles.length === 0)
|
|
338
|
+
return [];
|
|
339
|
+
const byFile = new Map(checkablePages.map((p) => [p.file, p]));
|
|
340
|
+
// Important finding 2 (Fable's review): the host project's own tsconfig decides every option —
|
|
341
|
+
// its ambient declarations (expo-env.d.ts's `*.png`/`*.svg` modules, a project's own
|
|
342
|
+
// declarations.d.ts) and its `strict`/`lib`/etc. settings — not a fixed set copied from
|
|
343
|
+
// `createComponentReader` (where a mismatch only degrades prop reading silently; here it would
|
|
344
|
+
// produce CI-failing errors the host's own `tsc` would never raise). Only `noEmit`,
|
|
345
|
+
// `skipLibCheck`, and the merged `paths` are forced on top.
|
|
346
|
+
const host = readHostTsconfigOptions(pageFiles[0]);
|
|
347
|
+
const compilerOptions = {
|
|
348
|
+
...host.options,
|
|
349
|
+
noEmit: true,
|
|
350
|
+
skipLibCheck: true,
|
|
351
|
+
paths: { ...options.fallbackPaths, ...host.paths },
|
|
352
|
+
};
|
|
353
|
+
const program = ts.createProgram([...pageFiles, ...host.declarationFiles], compilerOptions);
|
|
354
|
+
const issues = [];
|
|
355
|
+
for (const file of pageFiles) {
|
|
356
|
+
const sourceFile = program.getSourceFile(file);
|
|
357
|
+
if (!sourceFile)
|
|
358
|
+
continue;
|
|
359
|
+
const diagnostics = [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)];
|
|
360
|
+
const page = byFile.get(file);
|
|
361
|
+
const ref = page ? pageRef(page) : { file };
|
|
362
|
+
for (const d of diagnostics) {
|
|
363
|
+
const line = d.start !== undefined ? sourceFile.getLineAndCharacterOfPosition(d.start).line + 1 : undefined;
|
|
364
|
+
issues.push(issue({
|
|
365
|
+
id: 'page-parse-error', severity: 'error', ...ref, line,
|
|
366
|
+
message: `Failed to typecheck: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`,
|
|
367
|
+
fix: 'Fix the error, then rerun doctor.',
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return issues;
|
|
372
|
+
}
|
|
373
|
+
/** "With examples" means what `checkComponentNoExamples` (Task 4) checks the *absence* of: at
|
|
374
|
+
* least one checkable page with a real example, not merely "has a page" (an `init`-drafted
|
|
375
|
+
* "Needs examples" page has one, with none). */
|
|
376
|
+
function countWithExamples(pages, components) {
|
|
377
|
+
const byComponent = new Map();
|
|
378
|
+
for (const page of pages) {
|
|
379
|
+
if (page.component)
|
|
380
|
+
byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
|
|
381
|
+
}
|
|
382
|
+
return components.filter((c) => (byComponent.get(c.name) ?? []).some((p) => p.checkable && pageHasExamples(p))).length;
|
|
383
|
+
}
|
|
384
|
+
function countUnboundExamples(pages) {
|
|
385
|
+
let count = 0;
|
|
386
|
+
for (const page of pages) {
|
|
387
|
+
if (page.comparison) {
|
|
388
|
+
if (page.comparison.rows.prop === undefined)
|
|
389
|
+
count += page.comparison.rows.items.length;
|
|
390
|
+
if (page.comparison.columns.prop === undefined)
|
|
391
|
+
count += page.comparison.columns.items.length;
|
|
392
|
+
}
|
|
393
|
+
for (const item of [...(page.variantsItems ?? []), ...(page.statesItems ?? [])]) {
|
|
394
|
+
if (!item.props || Object.keys(item.props).length === 0)
|
|
395
|
+
count += 1;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return count;
|
|
399
|
+
}
|
|
400
|
+
/** Runs `sync`, reads every page `cli/staticPage.ts` can find (`discoverAllPageFiles` —
|
|
401
|
+
* deliberately wider than the viewer's own page list, so a renamed/removed component's orphaned
|
|
402
|
+
* page is still seen), applies the component-folder default (Errata, `applyComponentDefaults`
|
|
403
|
+
* above), and applies every rule from Task 4. `config.doctor.strict` promotes every warning to an
|
|
404
|
+
* error for both the summary counts and the exit code (design §2 Config: "`strict: true` promotes
|
|
405
|
+
* `doctor` warnings to errors"). */
|
|
406
|
+
export function runDoctor(config) {
|
|
407
|
+
const syncResult = sync(config);
|
|
408
|
+
const components = JSON.parse(readFileSync(path.join(syncResult.generatedDir, 'components.json'), 'utf8'));
|
|
409
|
+
// Same exclusion `sync` already applies to `componentEntryFiles` (cli/sync.ts:29-30) — without
|
|
410
|
+
// it, an excluded folder's own `*.catalog.tsx` is still discovered below (`discoverAllPageFiles`)
|
|
411
|
+
// even though its component never makes it into `components.json`, firing a false
|
|
412
|
+
// `component-export-removed`.
|
|
413
|
+
const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
|
|
414
|
+
const componentFolders = [
|
|
415
|
+
...new Set(config.components
|
|
416
|
+
.flatMap((pattern) => resolveGlob(config.projectRoot, pattern))
|
|
417
|
+
.filter((file) => !excluded.has(file))
|
|
418
|
+
.map((file) => path.dirname(file))),
|
|
419
|
+
];
|
|
420
|
+
const tokenFileCount = config.tokens.flatMap((pattern) => resolveGlob(config.projectRoot, pattern)).length;
|
|
421
|
+
const pageFiles = discoverAllPageFiles({ componentFolders, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
|
|
422
|
+
const pages = applyComponentDefaults(readStaticPages(pageFiles), new Set(componentFolders));
|
|
423
|
+
// Same "a page outside the project root needs a fallback paths entry" rule `cli/sync.ts` already
|
|
424
|
+
// applies to component entry files (e.g. `kit-host`'s own component pages, which physically live
|
|
425
|
+
// in the sibling `../starter-kit/`) — mirrored here for page files, since `checkPageTypeErrors`
|
|
426
|
+
// below needs it for the identical reason `createComponentReader` does. `@krapwoo/ds-viewer`
|
|
427
|
+
// itself resolves from `../starter-kit` pages through Node's own self-name resolution (the repo
|
|
428
|
+
// root `package.json`'s `name` + `exports`), not this fallback — never a `starter-kit/package.json`.
|
|
429
|
+
const hasPageOutsideProjectRoot = pageFiles.some((file) => !file.startsWith(`${config.projectRoot}${path.sep}`));
|
|
430
|
+
const fallbackPaths = hasPageOutsideProjectRoot
|
|
431
|
+
? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
|
|
432
|
+
: undefined;
|
|
433
|
+
let issues = [
|
|
434
|
+
...collectIssues(pages, components),
|
|
435
|
+
...checkPageTypeErrors(pages, { fallbackPaths }),
|
|
436
|
+
...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
|
|
437
|
+
];
|
|
438
|
+
// Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
|
|
439
|
+
// (`discoverAllPageFiles`) and `ComponentRecord.file` is cwd-relative (`cli/props.ts`) — cwd
|
|
440
|
+
// equals `config.projectRoot` for every real CLI invocation, so resolving against one and
|
|
441
|
+
// re-relativizing against the other normalizes both to the same, documented shape.
|
|
442
|
+
issues = issues.map((i) => (i.file ? { ...i, file: path.relative(config.projectRoot, path.resolve(config.projectRoot, i.file)) } : i));
|
|
443
|
+
if (config.doctor?.strict)
|
|
444
|
+
issues = issues.map((i) => (i.severity === 'warning' ? { ...i, severity: 'error' } : i));
|
|
445
|
+
const summary = {
|
|
446
|
+
errors: issues.filter((i) => i.severity === 'error').length,
|
|
447
|
+
warnings: issues.filter((i) => i.severity === 'warning').length,
|
|
448
|
+
components: components.length,
|
|
449
|
+
withExamples: countWithExamples(pages, components),
|
|
450
|
+
unboundExamples: countUnboundExamples(pages),
|
|
451
|
+
};
|
|
452
|
+
return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0 };
|
|
453
|
+
}
|
|
454
|
+
export function toDoctorJson(result) {
|
|
455
|
+
const { version, summary, update, issues } = result;
|
|
456
|
+
return { version, summary, update, issues };
|
|
457
|
+
}
|
|
458
|
+
/** Human report (design §4 "Output"): grouped by page, each issue with its one-line fix; issues
|
|
459
|
+
* with no `page` (`props-unreadable`, `no-token-modules`) are listed under "General". Ends with
|
|
460
|
+
* the same counts `--json`'s `summary` carries. */
|
|
461
|
+
export function formatHuman(result) {
|
|
462
|
+
const byPage = new Map();
|
|
463
|
+
const general = [];
|
|
464
|
+
for (const issue of result.issues) {
|
|
465
|
+
if (issue.page)
|
|
466
|
+
byPage.set(issue.page, [...(byPage.get(issue.page) ?? []), issue]);
|
|
467
|
+
else
|
|
468
|
+
general.push(issue);
|
|
469
|
+
}
|
|
470
|
+
const lines = [];
|
|
471
|
+
const renderIssue = (issue) => {
|
|
472
|
+
const location = issue.file ? ` (${issue.file}${issue.line ? `:${issue.line}` : ''})` : '';
|
|
473
|
+
lines.push(` [${issue.severity}] ${issue.id}: ${issue.message}${location}`);
|
|
474
|
+
lines.push(` Fix: ${issue.fix}`);
|
|
475
|
+
};
|
|
476
|
+
for (const [page, issues] of [...byPage].sort(([a], [b]) => a.localeCompare(b))) {
|
|
477
|
+
lines.push(page);
|
|
478
|
+
for (const issue of issues)
|
|
479
|
+
renderIssue(issue);
|
|
480
|
+
lines.push('');
|
|
481
|
+
}
|
|
482
|
+
if (general.length > 0) {
|
|
483
|
+
lines.push('General');
|
|
484
|
+
for (const issue of general)
|
|
485
|
+
renderIssue(issue);
|
|
486
|
+
lines.push('');
|
|
487
|
+
}
|
|
488
|
+
const { errors, warnings, components, withExamples, unboundExamples } = result.summary;
|
|
489
|
+
lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} — ` +
|
|
490
|
+
`${components} components (${withExamples} with examples), ${unboundExamples} unbound examples.`);
|
|
491
|
+
return lines.join('\n');
|
|
492
|
+
}
|