@krapwoo/ds-viewer 0.1.1 → 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 +182 -78
- package/dist/cli/agentsFile.js +31 -0
- package/dist/cli/config.js +26 -1
- package/dist/cli/dev.js +50 -5
- package/dist/cli/doctor.js +492 -0
- package/dist/cli/explain.js +151 -0
- package/dist/cli/githubAction.js +68 -0
- package/dist/cli/glob.js +8 -0
- package/dist/cli/init.js +152 -18
- package/dist/cli/main.js +115 -4
- package/dist/cli/pageIndex.js +27 -1
- package/dist/cli/props.js +170 -21
- package/dist/cli/staticPage.js +356 -0
- package/dist/cli/sync.js +28 -5
- package/dist/cli/workspace.js +49 -21
- 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/CatalogShell.tsx +6 -1
- package/native/catalog/CatalogSidebar.tsx +98 -5
- package/native/catalog/ComparisonGrid.tsx +9 -7
- package/native/catalog/SpacingScaleGallery.tsx +1 -1
- package/native/catalog/TokenRow.tsx +3 -2
- package/native/catalog/catalogNavigation.ts +1 -1
- package/native/catalog/comparison.ts +63 -5
- package/native/catalog/index.ts +8 -6
- package/native/catalog/logoLayout.ts +12 -0
- package/native/catalog/pageApi.ts +21 -3
- package/native/catalog/tokens.ts +5 -0
- package/native/catalog/types.ts +9 -6
- package/package.json +12 -6
- package/starter-kit/WHEN_TO_USE.md +124 -0
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
- package/starter-kit/components/AnimatedChevron/index.ts +2 -0
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +54 -0
- package/starter-kit/components/Avatar/Avatar.tsx +74 -0
- package/starter-kit/components/Avatar/index.ts +2 -0
- package/starter-kit/components/Badge/Badge.catalog.tsx +88 -0
- package/starter-kit/components/Badge/Badge.tsx +56 -0
- package/starter-kit/components/Badge/Badge.types.ts +18 -0
- package/starter-kit/components/Badge/index.ts +2 -0
- package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
- package/starter-kit/components/Banner/Banner.tsx +257 -0
- package/starter-kit/components/Banner/Banner.types.ts +38 -0
- package/starter-kit/components/Banner/index.ts +2 -0
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
- package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
- package/starter-kit/components/BottomSheet/index.ts +2 -0
- package/starter-kit/components/Button/Button.catalog.tsx +99 -0
- package/starter-kit/components/Button/Button.tsx +173 -0
- package/starter-kit/components/Button/Button.types.ts +52 -0
- package/starter-kit/components/Button/index.ts +2 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
- package/starter-kit/components/ButtonGroup/index.ts +2 -0
- package/starter-kit/components/Card/Card.catalog.tsx +68 -0
- package/starter-kit/components/Card/Card.tsx +133 -0
- package/starter-kit/components/Card/index.ts +2 -0
- package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
- package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
- package/starter-kit/components/Checkbox/index.ts +2 -0
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
- package/starter-kit/components/Dialog/Dialog.tsx +82 -0
- package/starter-kit/components/Dialog/index.ts +2 -0
- package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
- package/starter-kit/components/Divider/Divider.tsx +20 -0
- package/starter-kit/components/Divider/index.ts +2 -0
- package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
- package/starter-kit/components/Dock/Dock.tsx +58 -0
- package/starter-kit/components/Dock/index.ts +2 -0
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
- package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
- package/starter-kit/components/Dropdown/index.ts +2 -0
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
- package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
- package/starter-kit/components/EmptyState/index.ts +2 -0
- package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
- package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
- package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
- package/starter-kit/components/FieldContainer/index.ts +2 -0
- package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
- package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
- package/starter-kit/components/InputClearButton/index.ts +2 -0
- package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
- package/starter-kit/components/InputField/InputField.tsx +418 -0
- package/starter-kit/components/InputField/index.ts +2 -0
- package/starter-kit/components/List/List.catalog.tsx +30 -0
- package/starter-kit/components/List/List.tsx +36 -0
- package/starter-kit/components/List/index.ts +2 -0
- package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
- package/starter-kit/components/ListItem/ListItem.tsx +165 -0
- package/starter-kit/components/ListItem/index.ts +2 -0
- package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
- package/starter-kit/components/Loading/Loading.tsx +121 -0
- package/starter-kit/components/Loading/index.ts +2 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +79 -0
- package/starter-kit/components/Pill/Pill.tsx +181 -0
- package/starter-kit/components/Pill/Pill.types.ts +30 -0
- package/starter-kit/components/Pill/index.ts +2 -0
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
- package/starter-kit/components/PillRow/PillRow.tsx +140 -0
- package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
- package/starter-kit/components/PillRow/index.ts +2 -0
- package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
- package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
- package/starter-kit/components/ProgressDots/index.ts +2 -0
- package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
- package/starter-kit/components/Radio/Radio.tsx +114 -0
- package/starter-kit/components/Radio/index.ts +2 -0
- package/starter-kit/components/Radio/usePressScale.ts +33 -0
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
- package/starter-kit/components/SearchField/SearchField.tsx +98 -0
- package/starter-kit/components/SearchField/index.ts +2 -0
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
- package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
- package/starter-kit/components/SectionHeader/index.ts +2 -0
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
- package/starter-kit/components/SegmentedToggle/index.ts +2 -0
- package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
- package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
- package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
- package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
- package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
- package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
- package/starter-kit/components/Shimmer/index.ts +2 -0
- package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
- package/starter-kit/components/Surface/Surface.tsx +45 -0
- package/starter-kit/components/Surface/index.ts +2 -0
- package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
- package/starter-kit/components/Switch/Switch.tsx +129 -0
- package/starter-kit/components/Switch/index.ts +2 -0
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
- package/starter-kit/components/TextArea/TextArea.tsx +99 -0
- package/starter-kit/components/TextArea/index.ts +2 -0
- package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
- package/starter-kit/components/Toast/Toast.tsx +144 -0
- package/starter-kit/components/Toast/index.ts +2 -0
- package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
- package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
- package/starter-kit/components/Tooltip/index.ts +2 -0
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
- package/starter-kit/components/TopNav/TopNav.tsx +77 -0
- package/starter-kit/components/TopNav/index.ts +2 -0
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
- package/starter-kit/components/UnderlineTabs/index.ts +2 -0
- package/starter-kit/components/index.ts +36 -0
- package/starter-kit/components/statusColors.ts +27 -0
- package/starter-kit/icons/Icon.native.tsx +90 -0
- package/starter-kit/icons/index.ts +10 -0
- package/starter-kit/icons/paths.ts +623 -0
- package/starter-kit/icons/types.ts +69 -0
- package/starter-kit/pages/Colors.catalog.tsx +55 -0
- package/starter-kit/pages/Font.catalog.tsx +45 -0
- package/starter-kit/pages/Icons.catalog.tsx +51 -0
- package/starter-kit/pages/Manifest.catalog.tsx +24 -0
- package/starter-kit/pages/Motion.catalog.tsx +101 -0
- package/starter-kit/pages/Radius.catalog.tsx +35 -0
- package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
- package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
- package/starter-kit/pages/Shadow.catalog.tsx +34 -0
- package/starter-kit/pages/Spacing.catalog.tsx +12 -0
- package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
- package/starter-kit/pages/Typography.catalog.tsx +17 -0
- package/starter-kit/tokens/index.ts +50 -0
- package/starter-kit/tokens/motion.ts +90 -0
- package/starter-kit/tokens/palette.ts +111 -0
- package/starter-kit/tokens/scales.ts +106 -0
- package/starter-kit/tokens/semantic.ts +98 -0
- package/starter-kit/tokens/shadow.ts +36 -0
- package/starter-kit/tokens/typography.ts +72 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readStaticPages } from "./staticPage.js";
|
|
3
|
+
import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
|
|
4
|
+
import { resolveGlob } from "./glob.js";
|
|
5
|
+
import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
|
|
6
|
+
import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
|
|
7
|
+
function placeholderItem(item) {
|
|
8
|
+
return { key: item.key, name: item.name, node: null, group: item.group };
|
|
9
|
+
}
|
|
10
|
+
/** A throwaway `SectionDef` built from a `StaticPage` (Task 3) — every specimen's `node` is `null`
|
|
11
|
+
* (design §4: specimen nodes are never evaluated), since none of the layout functions below ever
|
|
12
|
+
* look at it; only the counts/keys/sizes they do look at are carried over. Every field Task 3
|
|
13
|
+
* reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill` —
|
|
14
|
+
* an earlier draft dropped these four, so `explain` reported different geometry than the real
|
|
15
|
+
* viewer for any page that set one of them (25 kit pages set `itemsFill`; every `kit-host`
|
|
16
|
+
* Viewer page sets `hide`). */
|
|
17
|
+
function toSectionDef(page) {
|
|
18
|
+
const comparison = page.comparison && {
|
|
19
|
+
rowLabel: page.comparison.rowLabel ?? '',
|
|
20
|
+
columnLabel: page.comparison.columnLabel ?? '',
|
|
21
|
+
rows: page.comparison.rows.prop !== undefined ? { prop: page.comparison.rows.prop, items: page.comparison.rows.items } : page.comparison.rows.items,
|
|
22
|
+
columns: page.comparison.columns.prop !== undefined ? { prop: page.comparison.columns.prop, items: page.comparison.columns.items } : page.comparison.columns.items,
|
|
23
|
+
cells: [],
|
|
24
|
+
size: page.comparison.size,
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
id: resolvePageId(page, page.file),
|
|
28
|
+
description: '',
|
|
29
|
+
path: page.file,
|
|
30
|
+
tokenGallery: page.tokenGallery,
|
|
31
|
+
fullWidthLabel: page.fullWidthLabel,
|
|
32
|
+
specimenSize: page.specimenSize,
|
|
33
|
+
previewWidths: page.previewWidths,
|
|
34
|
+
variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, items: page.variantsItems.map(placeholderItem) } : undefined,
|
|
35
|
+
states: page.statesItems ? { itemsFill: page.statesItemsFill, items: page.statesItems.map(placeholderItem) } : undefined,
|
|
36
|
+
comparison,
|
|
37
|
+
render: page.hasRender ? () => null : undefined,
|
|
38
|
+
hide: (page.hideVariants || page.hideStates) ? { variants: page.hideVariants, states: page.hideStates } : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function explainBlock(block) {
|
|
42
|
+
if (block.kind === 'grid') {
|
|
43
|
+
const rows = axisItems(block.comparison.rows).length;
|
|
44
|
+
const columns = axisItems(block.comparison.columns).length;
|
|
45
|
+
const limit = gridColumnLimit(block.size);
|
|
46
|
+
return {
|
|
47
|
+
kind: 'grid', title: block.title,
|
|
48
|
+
reason: `${rows} rows × ${columns} columns (${block.size}); ${columns} of ${limit} max ${block.size} columns used — ` +
|
|
49
|
+
`${columns <= limit ? 'fits' : 'does NOT fit'} a 1280px laptop.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (block.kind === 'list') {
|
|
53
|
+
const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size);
|
|
54
|
+
return {
|
|
55
|
+
kind: 'list', title: block.title,
|
|
56
|
+
reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
|
|
57
|
+
(geometry.fillers > 0 ? ` (${geometry.fillers} filler${geometry.fillers === 1 ? '' : 's'})` : '') + '.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (block.kind === 'grouped') {
|
|
61
|
+
return { kind: 'grouped', title: block.title, reason: `${block.groups.length} variant row(s), each with its own configurations.` };
|
|
62
|
+
}
|
|
63
|
+
if (block.kind === 'preview') {
|
|
64
|
+
const widths = block.widths === 'full' ? 'full width' : `${block.widths.join('/')}px`;
|
|
65
|
+
return { kind: 'preview', title: block.title, reason: `render() content at ${widths}.` };
|
|
66
|
+
}
|
|
67
|
+
return { kind: 'empty', title: block.title, reason: block.message };
|
|
68
|
+
}
|
|
69
|
+
/** Mirrors `native/catalog/SectionBlock.tsx`'s own `blockWidth` helper: a list block's width is its
|
|
70
|
+
* own natural, content-hugging width (`listGeometry`'s `containerWidth`) — the real viewer only
|
|
71
|
+
* ever moves a *second* block beside a *first* one when that first block doesn't already fill the
|
|
72
|
+
* whole row, so `choosePlacement` only reads `first.width` (never `second.width`) — but it's
|
|
73
|
+
* computed uniformly here for either role, since setting it on `second` is simply unused, not wrong. */
|
|
74
|
+
function toPlacementBlock(block, height, available) {
|
|
75
|
+
if (block.kind === 'list') {
|
|
76
|
+
return { kind: 'list', height, itemCount: block.items.length, size: block.size, width: listGeometry(block.items.length, available, block.size).containerWidth };
|
|
77
|
+
}
|
|
78
|
+
return { kind: block.kind, height };
|
|
79
|
+
}
|
|
80
|
+
/** Finds `pageId` among every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, builds
|
|
81
|
+
* its throwaway `SectionDef`, and asks the viewer's own pure layout functions for each block's
|
|
82
|
+
* decision — undefined when no page resolves to that id. */
|
|
83
|
+
export function explainPage(config, pageId, options = {}) {
|
|
84
|
+
// Same exclusion `cli/doctor.ts`'s `runDoctor` applies (mirroring `sync`) — an excluded folder's
|
|
85
|
+
// page should never resolve here either.
|
|
86
|
+
const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
|
|
87
|
+
const componentFolders = [
|
|
88
|
+
...new Set(config.components
|
|
89
|
+
.flatMap((pattern) => resolveGlob(config.projectRoot, pattern))
|
|
90
|
+
.filter((file) => !excluded.has(file))
|
|
91
|
+
.map((file) => path.dirname(file))),
|
|
92
|
+
];
|
|
93
|
+
const pageFiles = discoverAllPageFiles({ componentFolders, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
|
|
94
|
+
const page = readStaticPages(pageFiles).find((p) => resolvePageId(p, p.file) === pageId);
|
|
95
|
+
if (!page)
|
|
96
|
+
return undefined;
|
|
97
|
+
// Important finding 4 (Fable's review): a page whose layout data isn't a literal (or that has a
|
|
98
|
+
// syntax error) must never be explained from partial/empty data — design §4 requires `explain`
|
|
99
|
+
// to never drift from what the viewer actually renders.
|
|
100
|
+
if (!page.checkable)
|
|
101
|
+
return { pageId, notCheckable: true, blocks: [] };
|
|
102
|
+
const def = toSectionDef(page);
|
|
103
|
+
const blocks = presentationBlocks(def, { defaultPreviewWidths: [402] });
|
|
104
|
+
const explained = blocks.map(explainBlock);
|
|
105
|
+
let placement;
|
|
106
|
+
if (blocks.length === 2) {
|
|
107
|
+
if (options.heights) {
|
|
108
|
+
const [firstHeight, secondHeight] = options.heights;
|
|
109
|
+
const decision = choosePlacement({
|
|
110
|
+
available: MATRIX_LAYOUT.laptopContentWidth,
|
|
111
|
+
gap: CATALOG_LAYOUT.blockGap,
|
|
112
|
+
first: toPlacementBlock(blocks[0], firstHeight, MATRIX_LAYOUT.laptopContentWidth),
|
|
113
|
+
second: toPlacementBlock(blocks[1], secondHeight, MATRIX_LAYOUT.laptopContentWidth),
|
|
114
|
+
});
|
|
115
|
+
placement = {
|
|
116
|
+
decision,
|
|
117
|
+
reason: `With heights ${firstHeight}px/${secondHeight}px on a 1280px laptop (${MATRIX_LAYOUT.laptopContentWidth}px content width): ${decision}.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
placement = {
|
|
122
|
+
decision: 'decided-in-viewer',
|
|
123
|
+
reason: "Side-by-side placement depends on each block's measured height — decided in the viewer at runtime. Pass --heights <first>,<second> to check it here.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { pageId, blocks: explained, placement };
|
|
128
|
+
}
|
|
129
|
+
export function formatExplain(result) {
|
|
130
|
+
if (result.notCheckable) {
|
|
131
|
+
return `${result.pageId}\n\nPage not statically checkable — some of its layout data is not a literal doctor can read.`;
|
|
132
|
+
}
|
|
133
|
+
const lines = [result.pageId, ''];
|
|
134
|
+
for (const block of result.blocks) {
|
|
135
|
+
lines.push(block.title);
|
|
136
|
+
lines.push(` ${block.reason}`);
|
|
137
|
+
lines.push('');
|
|
138
|
+
}
|
|
139
|
+
if (result.placement) {
|
|
140
|
+
lines.push('Placement');
|
|
141
|
+
lines.push(` ${result.placement.reason}`);
|
|
142
|
+
}
|
|
143
|
+
return lines.join('\n').replace(/\n+$/, '');
|
|
144
|
+
}
|
|
145
|
+
/** `"80,300"` → `[80, 300]`; undefined for anything else (not exactly two positive numbers). */
|
|
146
|
+
export function parseHeights(raw) {
|
|
147
|
+
const parts = raw.split(',').map((part) => Number(part.trim()));
|
|
148
|
+
if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n) || n <= 0))
|
|
149
|
+
return undefined;
|
|
150
|
+
return [parts[0], parts[1]];
|
|
151
|
+
}
|