@krapwoo/ds-viewer 0.10.0 → 0.12.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 +95 -1
- package/dist/cli/doctor.js +182 -3
- package/dist/cli/render.js +93 -0
- package/dist/cli/staticPage.js +129 -0
- package/dist/native/catalog/catalogNavigation.js +62 -3
- package/dist/native/catalog/comparison.js +14 -3
- package/dist/native/catalog/pageApi.js +408 -0
- package/dist/native/catalog/tokens.js +7 -0
- package/native/catalog/CatalogShell.tsx +206 -25
- package/native/catalog/CatalogSidebar.tsx +57 -3
- package/native/catalog/FamilyPage.tsx +1188 -0
- package/native/catalog/SectionBlock.tsx +57 -13
- package/native/catalog/catalogNavigation.ts +89 -3
- package/native/catalog/comparison.ts +17 -6
- package/native/catalog/familyModel.ts +192 -0
- package/native/catalog/index.ts +5 -2
- package/native/catalog/overlayViewport.ts +30 -10
- package/native/catalog/pageApi.ts +357 -15
- package/native/catalog/tokens.ts +7 -0
- package/native/catalog/types.ts +106 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -445,6 +445,88 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
|
|
|
445
445
|
declared in another component's folder, such as a shared `IconName`, is that component's
|
|
446
446
|
options, so a Banner page never needs one example per icon.
|
|
447
447
|
|
|
448
|
+
### Family pages
|
|
449
|
+
|
|
450
|
+
A page can document a family of related declarations — a view and its internal parts, or a
|
|
451
|
+
pattern and the pieces it's built from — on one page instead of scattering them across separate
|
|
452
|
+
catalog pages, each member getting its own address and search entry. Give it `members`, `whole`
|
|
453
|
+
and `sections` instead of `component`/`whenToUse`/`a11y`/specimens, which live on the members (the
|
|
454
|
+
whole is a member too):
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
export default defineCatalogPage({
|
|
458
|
+
id: 'Toast', group: 'Patterns', description: 'How a Toast is assembled from a Banner.',
|
|
459
|
+
whole: 'Toast',
|
|
460
|
+
sections: [
|
|
461
|
+
{ label: 'Parts', kind: 'blocks', inWhole: true, members: ['ToastBanner'] },
|
|
462
|
+
{ label: 'Related', kind: 'blocks', inWhole: false, members: ['ToastStandaloneBanner'] },
|
|
463
|
+
],
|
|
464
|
+
members: [
|
|
465
|
+
{ id: 'Toast', source: 'components/Toast/Toast.tsx:1', fidelity: 'documentation-only', relationship: 'Main pattern', description: '...' },
|
|
466
|
+
{
|
|
467
|
+
id: 'ToastBanner', component: 'Banner', parent: 'Toast',
|
|
468
|
+
source: 'components/Banner/Banner.tsx:28', fidelity: 'replicated', relationship: 'Anatomy', description: '...',
|
|
469
|
+
whenToUse: '...', a11y: '...', variants: { items: [/* ... */] },
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
id: 'ToastStandaloneBanner', source: 'components/Banner/Banner.tsx:28', fidelity: 'documentation-only', relationship: 'Related',
|
|
473
|
+
related: 'Wraps ToastBanner directly when a notice should stay until dismissed.', description: '...', aliases: ['BannerStandalone'],
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
- **`whole`** is a member id, and must equal the page's own `id`. It renders first, with no header
|
|
480
|
+
of its own (the page title already names it) and no number.
|
|
481
|
+
- **`sections`** (`{ label, kind: 'blocks' | 'list', inWhole, members }`) group the rest: `blocks`
|
|
482
|
+
renders each member as its own full block (specimens and all); `list` renders one card of folded
|
|
483
|
+
rows, each a toggle that expands in place. `inWhole` sections render under the page's one
|
|
484
|
+
"Parts" label; the rest render under its one "Related" label — the only two section labels the
|
|
485
|
+
page ever shows; each section's own `label` stays in the data (for the anatomy legend and your
|
|
486
|
+
own reference) but is never rendered. Numbering follows each member's own `parent` (below), not
|
|
487
|
+
this ordering.
|
|
488
|
+
- **`parent`** nests a member under another (e.g. a subview under its view). A top-level part (an
|
|
489
|
+
`inWhole` member whose `parent` is the whole, or who has no `parent` at all) is numbered `1`,
|
|
490
|
+
`2`, `3`… in the order it's first listed across `sections`; a child takes its parent's number
|
|
491
|
+
plus `.1`, `.2`, … the same way. A Related member (outside every `inWhole` section) gets no
|
|
492
|
+
number at all.
|
|
493
|
+
- **`anatomy`** (page-level) is the first box after the page head, labeled “Anatomy” like the
|
|
494
|
+
specimen grids: `{ picture, groups }`, where each group is
|
|
495
|
+
`{ label, items: [{ member, description } | { other, description }] }`. A `member` item names one
|
|
496
|
+
of this page's own members (not the whole) and is numbered to match its Part; an `other` item
|
|
497
|
+
names a component documented elsewhere, shows an unnumbered ↗ pin, and links only when the name
|
|
498
|
+
resolves to a real catalog address. With six items or fewer the legend sits beside `picture`;
|
|
499
|
+
with more it goes below it in up to three columns, each group placed in the currently shortest
|
|
500
|
+
column so the columns end nearly level (a group never splits). Mark `picture` with
|
|
501
|
+
`<AnatomyPin member="ThatMemberId" />` (its number and style come from the page, so `picture`
|
|
502
|
+
never hardcodes one) or `<AnatomyPin other="ThatComponent" />`.
|
|
503
|
+
- **`axis`** on a `variants` or `states` slot names the one property that grid varies (e.g.
|
|
504
|
+
`'Expression'`) and shows as the grid's label. Family pages hide the generic block labels, so
|
|
505
|
+
give each grid an `axis` there.
|
|
506
|
+
- **`fidelity`** (`'replicated' | 'approximated' | 'documentation-only'`) shows as a small badge.
|
|
507
|
+
Use `'documentation-only'` for a member with no rendered specimen (no native counterpart to show,
|
|
508
|
+
or nothing meaningfully different to render).
|
|
509
|
+
- **`related`** is a short sentence for a member outside the whole (e.g. "Wraps X directly …");
|
|
510
|
+
another member's name inside it becomes a link to that member's own address. **`aliases`** (on a
|
|
511
|
+
member, or on the page itself) resolve old/retired ids to the right address, for both routing and
|
|
512
|
+
search — on the page itself, this only takes effect when the page also has `members`; it's
|
|
513
|
+
accepted but unused on a normal (single-component) page.
|
|
514
|
+
- **`composedOf`** on a member — same shape and meaning as the page-level field above — shows as
|
|
515
|
+
one "Uses …" line inside the member's own disclosure, after any notes. A name links when it
|
|
516
|
+
resolves to a real catalog address (a page, another member, or an alias — in this family or any
|
|
517
|
+
other), otherwise it shows in code; an unknown name logs a `[Catalog]` warning the same way the
|
|
518
|
+
page-level field's does.
|
|
519
|
+
- **`notShown`** (page-level) is a short "Not shown in the browser: …" line under the page head,
|
|
520
|
+
for behavior a web catalog can't demonstrate (a timer, a native gesture). A member's own
|
|
521
|
+
`notShown` works the same way, under its specimens. Like `aliases`, the page-level field only
|
|
522
|
+
takes effect on a page with `members`.
|
|
523
|
+
- **`apiLabel`** (default `'API'`) renames the disclosure's reference-excerpt part for a host app
|
|
524
|
+
that generates one (e.g. `'Swift API'`) — never hardcode a specific language's wording. Also
|
|
525
|
+
only takes effect on a page with `members`.
|
|
526
|
+
- Every component-bound member (one with `component` set) wants a real `whenToUse`, a real `a11y`,
|
|
527
|
+
and at least one specimen, the same as a normal page's component — `doctor` checks each one.
|
|
528
|
+
See `viewer-host/viewer-pages/FamilyPage.catalog.tsx` in this repo for a complete, worked example.
|
|
529
|
+
|
|
448
530
|
### Token pages
|
|
449
531
|
|
|
450
532
|
A token page shows each token at its real value with its role. Pick the layout by the token's
|
|
@@ -613,10 +695,22 @@ and adds what only a real render shows:
|
|
|
613
695
|
cells are not visited at all. Usually fixed with a wider `specimenSize`, by stacking the blocks so
|
|
614
696
|
the example gets the full row, or by dropping `itemsFill` from a small instance that should sit
|
|
615
697
|
centered.
|
|
698
|
+
- `page-head-overlap` (error): something is painting on top of a page's own heading. This is the
|
|
699
|
+
one check measured at **800x600** — Puppeteer's default viewport, and so the size a smoke check
|
|
700
|
+
judges your catalog at — because the overlap it catches does not appear at 1280 at all: a page
|
|
701
|
+
title that is a single unbroken identifier (a long Swift type name, say) has no break
|
|
702
|
+
opportunity for the browser to use, so it keeps its full intrinsic width and paints past its own
|
|
703
|
+
column into the previous/next buttons. It measures every leaf element carrying text, clips each
|
|
704
|
+
rect to any ancestor that scrolls or clips (so something scrolled out of view is not reported as
|
|
705
|
+
covering anything), and reports a pair only when it overlaps the heading by more than 1px in
|
|
706
|
+
both directions, so a shared edge between neighbours is not a finding. Only the heading is
|
|
707
|
+
checked: specimens legitimately stack text, and reporting those would bury the one overlap that
|
|
708
|
+
is always a defect.
|
|
616
709
|
- `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
|
|
617
710
|
|
|
618
711
|
It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
|
|
619
|
-
install a browser. It takes about two minutes for 50 pages
|
|
712
|
+
install a browser. It takes about two minutes for 50 pages — plus a moment per page for the
|
|
713
|
+
narrow-viewport heading pass — and works with `--json` (progress
|
|
620
714
|
goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
|
|
621
715
|
|
|
622
716
|
It starts its own catalog on the first free port from 5181. `--port <n>` pins it instead, which
|
package/dist/cli/doctor.js
CHANGED
|
@@ -7,6 +7,7 @@ import { importedNames, readStaticPages } from "./staticPage.js";
|
|
|
7
7
|
import { resolveGlob } from "./glob.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
9
|
import { gridColumnLimit } from "../native/catalog/comparison.js";
|
|
10
|
+
import { validateComposedOf, validateFamily } from "../native/catalog/pageApi.js";
|
|
10
11
|
function issue(fields) {
|
|
11
12
|
return fields;
|
|
12
13
|
}
|
|
@@ -18,9 +19,14 @@ function pageRef(page) {
|
|
|
18
19
|
* own file (design §2; the viewer already applies this same default via `page.component ??
|
|
19
20
|
* page.id`, and the generated page index already defaults a missing `id` to the file stem). A
|
|
20
21
|
* 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
|
+
* folders resolved from `config.components`, so a standalone page's own folder is never a member.
|
|
23
|
+
* A family page (non-empty `members`) is also never touched — same exemption the runtime applies
|
|
24
|
+
* at `pageApi.ts`'s `buildCatalogSections` (`page.members && page.members.length > 0` skips the
|
|
25
|
+
* `page.component ?? page.id` fallback entirely): a family's own guidance/examples always live on
|
|
26
|
+
* its members, never on the page itself, so a documentation-only whole (its file stem naming no
|
|
27
|
+
* real component export) must never be assigned one here either. */
|
|
22
28
|
export function applyComponentDefaults(pages, componentFolders) {
|
|
23
|
-
return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file))
|
|
29
|
+
return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file)) && !(page.members && page.members.length > 0)
|
|
24
30
|
? { ...page, component: resolvePageId(page, page.file) }
|
|
25
31
|
: page);
|
|
26
32
|
}
|
|
@@ -59,12 +65,22 @@ function pageHasExamples(page) {
|
|
|
59
65
|
}
|
|
60
66
|
function checkComponentNoExamples(pages, components) {
|
|
61
67
|
const byComponent = new Map();
|
|
68
|
+
// Plan V3: "a component documented only as a member counts as documented" — any family member
|
|
69
|
+
// naming a component (regardless of which page, or whether that specific member has its own
|
|
70
|
+
// examples — a member with none gets its own `component-no-examples` from `checkFamilyMembers`
|
|
71
|
+
// instead) is enough to clear the component-wide "no catalog page at all" half of this rule.
|
|
72
|
+
const documentedByMember = new Set();
|
|
62
73
|
for (const page of pages) {
|
|
63
74
|
if (page.component)
|
|
64
75
|
byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
|
|
76
|
+
for (const member of page.members ?? [])
|
|
77
|
+
if (member.component)
|
|
78
|
+
documentedByMember.add(member.component);
|
|
65
79
|
}
|
|
66
80
|
return components
|
|
67
81
|
.filter((c) => {
|
|
82
|
+
if (documentedByMember.has(c.name))
|
|
83
|
+
return false;
|
|
68
84
|
const componentPages = byComponent.get(c.name) ?? [];
|
|
69
85
|
// A not-statically-checkable page is given the benefit of the doubt here — it might have
|
|
70
86
|
// real examples this reader simply couldn't read (Global Constraint: never a partial read;
|
|
@@ -83,6 +99,152 @@ function checkComponentNoExamples(pages, components) {
|
|
|
83
99
|
});
|
|
84
100
|
});
|
|
85
101
|
}
|
|
102
|
+
/** Plan V3: the same two advisories `checkComponentPages` gives a page-level `component` —
|
|
103
|
+
* missing guidance/accessibility, and no examples — evaluated per component-bound member of a
|
|
104
|
+
* family page instead, with the member id in the message. A documentation-only member (no
|
|
105
|
+
* `component`) is exempt from all three. V2c item 9: a member whose `component` no longer
|
|
106
|
+
* resolves to a real export gets its own `component-export-removed` error here (below), naming
|
|
107
|
+
* the member, instead of being silently exempted. */
|
|
108
|
+
function checkFamilyMembers(pages, components) {
|
|
109
|
+
const componentsByName = new Map(components.map((c) => [c.name, c]));
|
|
110
|
+
const issues = [];
|
|
111
|
+
for (const page of pages) {
|
|
112
|
+
// V2c item 6: the same "is this really a family page" predicate the runtime uses
|
|
113
|
+
// (`pageApi.ts`'s `page.members && page.members.length > 0`) — an empty `members` array is a
|
|
114
|
+
// normal, single-component page, never a family.
|
|
115
|
+
if (!page.members || page.members.length === 0 || !page.checkable)
|
|
116
|
+
continue; // never a partial read — see checkGridAxisKeys's own note.
|
|
117
|
+
const ref = pageRef(page);
|
|
118
|
+
// Members in a `list` section render as folded rows without specimens, so asking them for
|
|
119
|
+
// examples would contradict `family-invalid`'s list-section rule.
|
|
120
|
+
const listMemberIds = new Set((page.sections ?? []).filter((s) => s.kind === 'list').flatMap((s) => s.members));
|
|
121
|
+
for (const member of page.members) {
|
|
122
|
+
if (!member.component)
|
|
123
|
+
continue; // documentation-only — exempt.
|
|
124
|
+
// V2c item 9: a member naming a component that no longer exists — reported here rather than
|
|
125
|
+
// silently exempted from every advisory (its props would also silently vanish at runtime).
|
|
126
|
+
if (!componentsByName.has(member.component)) {
|
|
127
|
+
issues.push(issue({
|
|
128
|
+
id: 'component-export-removed', severity: 'error', ...ref,
|
|
129
|
+
message: `Member "${member.id}" documents "${member.component}", which no longer exists as a component export.`,
|
|
130
|
+
fix: `Delete this member, or update its "component" to the export's new name.`,
|
|
131
|
+
}));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!member.hasWhenToUse) {
|
|
135
|
+
issues.push(issue({
|
|
136
|
+
id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
|
|
137
|
+
message: `Member "${member.id}": no "whenToUse" guidance documented.`,
|
|
138
|
+
fix: `Add a one-sentence whenToUse note for "${member.id}", or omit it only when there genuinely is none.`,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
if (!member.hasA11y) {
|
|
142
|
+
issues.push(issue({
|
|
143
|
+
id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
|
|
144
|
+
message: `Member "${member.id}": no "a11y" notes documented.`,
|
|
145
|
+
fix: `Add a one-sentence a11y note for "${member.id}", grounded in its real behavior.`,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
if (!member.hasExamples && !listMemberIds.has(member.id)) {
|
|
149
|
+
issues.push(issue({
|
|
150
|
+
id: 'component-no-examples', severity: 'warning', ...ref,
|
|
151
|
+
message: `Member "${member.id}" ("${member.component}") has no variants, states, or comparison — it will show no specimen.`,
|
|
152
|
+
fix: `Add variants/states/comparison to the "${member.id}" member.`,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return issues;
|
|
158
|
+
}
|
|
159
|
+
/** Plan V3's `family-invalid`: reuses `native/catalog/pageApi.ts`'s own `validateFamily` —
|
|
160
|
+
* the exact same rule `pageApi.ts`'s `buildCatalogSections` runs at runtime — against every
|
|
161
|
+
* checkable family page, with the same catalog-wide "every other page/member id" contract that
|
|
162
|
+
* function already takes. N3: `StaticPage.sections` already carries each section's real
|
|
163
|
+
* `label`/`kind`, so `validateFamily`'s messages name the real section (never an empty label),
|
|
164
|
+
* and its N6 list-section rule can tell a `list` section from a `blocks` one. */
|
|
165
|
+
function checkFamilyValidity(pages) {
|
|
166
|
+
// V2c item 6: `members.length > 0`, the same predicate the runtime uses — an empty array is not
|
|
167
|
+
// a family page.
|
|
168
|
+
const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
|
|
169
|
+
const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
|
|
170
|
+
const familyIdsByPageId = new Map();
|
|
171
|
+
for (const page of familyPages) {
|
|
172
|
+
const id = resolvePageId(page, page.file);
|
|
173
|
+
const ids = new Set();
|
|
174
|
+
for (const member of page.members ?? []) {
|
|
175
|
+
ids.add(member.id);
|
|
176
|
+
for (const alias of member.aliases ?? [])
|
|
177
|
+
ids.add(alias);
|
|
178
|
+
}
|
|
179
|
+
for (const alias of page.aliases ?? [])
|
|
180
|
+
ids.add(alias);
|
|
181
|
+
familyIdsByPageId.set(id, ids);
|
|
182
|
+
}
|
|
183
|
+
const issues = [];
|
|
184
|
+
for (const page of familyPages) {
|
|
185
|
+
const id = resolvePageId(page, page.file);
|
|
186
|
+
const otherPageIds = new Set([...allPageIds].filter((pageId) => pageId !== id));
|
|
187
|
+
const otherMemberIds = new Set();
|
|
188
|
+
for (const [pageId, ids] of familyIdsByPageId)
|
|
189
|
+
if (pageId !== id)
|
|
190
|
+
for (const memberId of ids)
|
|
191
|
+
otherMemberIds.add(memberId);
|
|
192
|
+
const pageLike = {
|
|
193
|
+
id,
|
|
194
|
+
whole: page.whole,
|
|
195
|
+
// `inWhole` is never read by `validateFamily` — only `label`/`kind`/`members` matter here.
|
|
196
|
+
sections: page.sections?.map((s) => ({ ...s, inWhole: true })),
|
|
197
|
+
members: (page.members ?? []).map((m) => ({ id: m.id, parent: m.parent, aliases: m.aliases, hasSpecimens: m.hasExamples })),
|
|
198
|
+
aliases: page.aliases,
|
|
199
|
+
};
|
|
200
|
+
for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds)) {
|
|
201
|
+
issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message, fix: 'Fix the family\'s members/whole/sections to satisfy the rule above.' }));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
206
|
+
/** V2d item 5: resolves each family member's own `composedOf` the same way `buildCatalogSections`
|
|
207
|
+
* resolves it at runtime (`native/catalog/pageApi.ts`'s own `validateComposedOf`, imported
|
|
208
|
+
* directly — the subpath-import boundary that once made importing that file from `dist/cli/`
|
|
209
|
+
* unsafe is gone), against the same known-name universe `checkFamilyValidity` already builds for
|
|
210
|
+
* member ids — every real component name, every page id, and every member id across the whole
|
|
211
|
+
* catalog — so an unknown name is reported here too, not only ever silently warned in a browser
|
|
212
|
+
* console (`npm run check:catalog`). A member whose `composedOf` wasn't itself literally readable
|
|
213
|
+
* (`undefined`) has nothing to check — the same "never a partial read" leniency the page-level
|
|
214
|
+
* field already gets. Always a warning, the same severity the runtime's own console warning
|
|
215
|
+
* carries, never an error. N8: also accepts a member alias and a page-level alias — the same
|
|
216
|
+
* universe `FamilyPage`'s own link resolution (`aliasTargets`) already accepts. */
|
|
217
|
+
function checkMemberComposedOf(pages, components) {
|
|
218
|
+
const knownNames = new Set(components.map((c) => c.name));
|
|
219
|
+
for (const page of pages)
|
|
220
|
+
knownNames.add(resolvePageId(page, page.file));
|
|
221
|
+
for (const page of pages)
|
|
222
|
+
for (const member of page.members ?? [])
|
|
223
|
+
knownNames.add(member.id);
|
|
224
|
+
for (const page of pages)
|
|
225
|
+
for (const member of page.members ?? [])
|
|
226
|
+
for (const alias of member.aliases ?? [])
|
|
227
|
+
knownNames.add(alias);
|
|
228
|
+
for (const page of pages)
|
|
229
|
+
for (const alias of page.aliases ?? [])
|
|
230
|
+
knownNames.add(alias);
|
|
231
|
+
const issues = [];
|
|
232
|
+
for (const page of pages) {
|
|
233
|
+
if (!page.members || page.members.length === 0 || !page.checkable)
|
|
234
|
+
continue; // V2c item 6's own predicate.
|
|
235
|
+
const ref = pageRef(page);
|
|
236
|
+
for (const member of page.members) {
|
|
237
|
+
for (const message of validateComposedOf(member.composedOf, knownNames)) {
|
|
238
|
+
issues.push(issue({
|
|
239
|
+
id: 'composed-of-unknown', severity: 'warning', ...ref,
|
|
240
|
+
message: `Member "${member.id}": ${message}`,
|
|
241
|
+
fix: 'Correct the name, or add { component: \'...\', role: \'...\', relationship: \'...\' } for a component/page/member that really does exist.',
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return issues;
|
|
247
|
+
}
|
|
86
248
|
function checkDuplicatePageId(pages) {
|
|
87
249
|
const byId = new Map();
|
|
88
250
|
for (const page of pages) {
|
|
@@ -225,7 +387,14 @@ function importsModal(file) {
|
|
|
225
387
|
}
|
|
226
388
|
/** `overlay-without-device-frame`: a page whose component opens a React Native `Modal` (which
|
|
227
389
|
* portals to the document body on the web, covering the whole catalog) but whose page imports no
|
|
228
|
-
* device frame. A page whose imports couldn't be read is skipped, never assumed unframed.
|
|
390
|
+
* device frame. A page whose imports couldn't be read is skipped, never assumed unframed.
|
|
391
|
+
* V2c item 3 (CLI side): deliberately exempts a family member's own `component` — this check
|
|
392
|
+
* keys off `page.component`/`page.viewerImports` (the page file's own top-level imports), neither
|
|
393
|
+
* of which a family page sets; a member naming a Modal-opening component would need its OWN
|
|
394
|
+
* device-frame-import evidence, which `cli/staticPage.ts`'s member reader doesn't currently carry.
|
|
395
|
+
* Left as a gap rather than guessed at — a family member wrapping a Modal in a device frame is
|
|
396
|
+
* rare enough (and the resulting miss only an advisory, never an error) that this is the smallest
|
|
397
|
+
* honest fix here. */
|
|
229
398
|
export function checkOverlaysFramed(pages, components, projectRoot) {
|
|
230
399
|
const byName = new Map(components.map((c) => [c.name, c]));
|
|
231
400
|
const issues = [];
|
|
@@ -342,6 +511,13 @@ function checkComponentPages(pages, components) {
|
|
|
342
511
|
for (const page of pages) {
|
|
343
512
|
if (!page.component)
|
|
344
513
|
continue;
|
|
514
|
+
// Plan V3: a family page's own guidance always lives on its members, never on the page
|
|
515
|
+
// itself — even if `applyComponentDefaults` (or, in principle, the author) ends up giving it
|
|
516
|
+
// a page-level `component` matching a real export, this page-level advisory must still defer
|
|
517
|
+
// entirely to `checkFamilyMembers`'s own per-member one. V2c item 6: `members.length > 0`,
|
|
518
|
+
// the same predicate the runtime uses — an empty array is not a family page.
|
|
519
|
+
if (page.members && page.members.length > 0)
|
|
520
|
+
continue;
|
|
345
521
|
if (!page.checkable)
|
|
346
522
|
continue; // never a partial read — see checkGridAxisKeys's own note.
|
|
347
523
|
const component = componentsByName.get(page.component);
|
|
@@ -519,6 +695,9 @@ export function collectIssues(pages, components) {
|
|
|
519
695
|
...checkMissingComposition(pages, components),
|
|
520
696
|
...checkMissingWorkingPreview(pages, components),
|
|
521
697
|
...checkPreviewOnlyPages(pages),
|
|
698
|
+
...checkFamilyMembers(pages, components),
|
|
699
|
+
...checkFamilyValidity(pages),
|
|
700
|
+
...checkMemberComposedOf(pages, components),
|
|
522
701
|
];
|
|
523
702
|
}
|
|
524
703
|
/** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
|
package/dist/cli/render.js
CHANGED
|
@@ -121,6 +121,83 @@ export function identicalVariantIssues(page, pairs) {
|
|
|
121
121
|
fix: "Make the difference visible — a wider specimenSize when the cell is too narrow to show it, or the prop the name claims — or drop one of the two examples.",
|
|
122
122
|
}));
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* The width the heading check measures at. 800x600 is Puppeteer's own default viewport, which is
|
|
126
|
+
* what a consumer's smoke check uses unless it says otherwise, so it is the width their catalog is
|
|
127
|
+
* judged at. Every other check here runs at 1280x800, where this class of overlap does not appear
|
|
128
|
+
* at all — which is exactly why issue #51 reached a release.
|
|
129
|
+
*/
|
|
130
|
+
export const NARROW_VIEWPORT = { width: 800, height: 600 };
|
|
131
|
+
/**
|
|
132
|
+
* Runs in the browser: finds anything painting on top of the page's own heading.
|
|
133
|
+
*
|
|
134
|
+
* The page head puts the title in a `flex: 1` column beside a non-shrinking pager row. A title
|
|
135
|
+
* that is one unbroken identifier — a Swift type name, say — has no space for the browser's
|
|
136
|
+
* default line breaking to use, so without an explicit break opportunity it keeps its full
|
|
137
|
+
* intrinsic width and paints straight through the column into the pager (issue #51:
|
|
138
|
+
* `TranscriptMediaUnavailableChip` covered the `←` button by 14x22 at an 800px window). The
|
|
139
|
+
* reader sees the title and the control drawn over each other, and the title is the page's own
|
|
140
|
+
* heading, so it is the one thing on the page that must stay readable.
|
|
141
|
+
*
|
|
142
|
+
* Measured the way a consumer's own smoke check measures it: every leaf element that carries
|
|
143
|
+
* text, its rect clipped to each ancestor that handles its own overflow (so a scrolled-away rect
|
|
144
|
+
* is not reported as covering anything), and a pair counted only when it overlaps by more than
|
|
145
|
+
* 1px in BOTH directions — a shared edge or a single-pixel seam between neighbours is not an
|
|
146
|
+
* overlap.
|
|
147
|
+
*
|
|
148
|
+
* Only the heading is checked, not every pair on the page. Host specimens legitimately stack text
|
|
149
|
+
* — a badge over a thumbnail, a caption over a surface — and reporting those would bury the one
|
|
150
|
+
* overlap that is always a defect. The heading's own descendants are skipped for the same reason:
|
|
151
|
+
* a heading's `<Text>` child sits inside its `<View>` by construction.
|
|
152
|
+
*/
|
|
153
|
+
export const HEAD_OVERLAP_SCRIPT = `(() => {
|
|
154
|
+
const doc = document;
|
|
155
|
+
const CONTAINS = /^(auto|scroll|hidden|clip)$/;
|
|
156
|
+
const heading = doc.querySelector('[role="heading"]');
|
|
157
|
+
if (!heading) return [];
|
|
158
|
+
const clip = (el) => {
|
|
159
|
+
const r = el.getBoundingClientRect();
|
|
160
|
+
let x1 = r.left, y1 = r.top, x2 = r.right, y2 = r.bottom;
|
|
161
|
+
for (let n = el.parentElement; n; n = n.parentElement) {
|
|
162
|
+
const cs = getComputedStyle(n);
|
|
163
|
+
if (CONTAINS.test(cs.overflowX) || CONTAINS.test(cs.overflowY)) {
|
|
164
|
+
const b = n.getBoundingClientRect();
|
|
165
|
+
if (b.left > x1) x1 = b.left;
|
|
166
|
+
if (b.top > y1) y1 = b.top;
|
|
167
|
+
if (b.right < x2) x2 = b.right;
|
|
168
|
+
if (b.bottom < y2) y2 = b.bottom;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { x1: x1, y1: y1, x2: x2, y2: y2 };
|
|
172
|
+
};
|
|
173
|
+
const head = clip(heading);
|
|
174
|
+
if (head.x2 - head.x1 <= 0 || head.y2 - head.y1 <= 0) return [];
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const el of doc.querySelectorAll('*')) {
|
|
177
|
+
if (el.children.length) continue;
|
|
178
|
+
if (el === heading || heading.contains(el)) continue;
|
|
179
|
+
const text = (el.textContent || '').trim();
|
|
180
|
+
if (!text) continue;
|
|
181
|
+
const r = el.getBoundingClientRect();
|
|
182
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
183
|
+
const b = clip(el);
|
|
184
|
+
if (b.x2 - b.x1 <= 0 || b.y2 - b.y1 <= 0) continue;
|
|
185
|
+
const w = Math.min(head.x2, b.x2) - Math.max(head.x1, b.x1);
|
|
186
|
+
const h = Math.min(head.y2, b.y2) - Math.max(head.y1, b.y1);
|
|
187
|
+
if (w > 1 && h > 1) out.push({ other: text.slice(0, 40), w: Math.round(w), h: Math.round(h) });
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
})()`;
|
|
191
|
+
export function headOverlapIssues(ref, overlaps) {
|
|
192
|
+
return overlaps.map((o) => ({
|
|
193
|
+
id: 'page-head-overlap',
|
|
194
|
+
severity: 'error',
|
|
195
|
+
page: ref.id,
|
|
196
|
+
...(ref.file ? { file: ref.file } : {}),
|
|
197
|
+
message: `At ${NARROW_VIEWPORT.width}px the page heading is covered by "${o.other}" over ${o.w}x${o.h}px.`,
|
|
198
|
+
fix: 'Let the heading wrap or truncate inside its own column, or move or clip whatever paints over it.',
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
124
201
|
/**
|
|
125
202
|
* Runs in the browser: finds specimen content that paints outside its cell and cannot be reached.
|
|
126
203
|
*
|
|
@@ -213,6 +290,8 @@ const DEV_START_TIMEOUT_MS = 120_000;
|
|
|
213
290
|
const FIRST_LOAD_TIMEOUT_MS = 300_000;
|
|
214
291
|
const PAGE_TIMEOUT_MS = 120_000;
|
|
215
292
|
const SETTLE_MS = 900;
|
|
293
|
+
/** After a viewport change, long enough for react-native-web to reflow before anything is measured. */
|
|
294
|
+
const RESIZE_SETTLE_MS = 250;
|
|
216
295
|
/** Stops `dev` and everything it started (Expo/Metro): the whole process tree on Windows, the
|
|
217
296
|
* whole process group elsewhere (Expo stops through dev's own signal handler). Releases the pipes
|
|
218
297
|
* so this process can exit. `os` is injectable for tests. */
|
|
@@ -436,6 +515,20 @@ export async function renderCheck(projectRoot, options = {}) {
|
|
|
436
515
|
}
|
|
437
516
|
if (Array.isArray(clipped))
|
|
438
517
|
issues.push(...clippedSpecimenIssues({ id, file: options.files?.get(id) }, clipped));
|
|
518
|
+
// Last, because it is the only check that changes the viewport: the overlap it guards
|
|
519
|
+
// appears only at a narrow width, and every check above is calibrated at 1280. Advisory
|
|
520
|
+
// like the two passes above — a failing script must not cost this page its other findings.
|
|
521
|
+
let headOverlaps;
|
|
522
|
+
try {
|
|
523
|
+
await page.setViewport({ width: NARROW_VIEWPORT.width, height: NARROW_VIEWPORT.height });
|
|
524
|
+
await new Promise((r) => setTimeout(r, RESIZE_SETTLE_MS));
|
|
525
|
+
headOverlaps = (await page.evaluate(HEAD_OVERLAP_SCRIPT));
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
headOverlaps = undefined;
|
|
529
|
+
}
|
|
530
|
+
if (Array.isArray(headOverlaps))
|
|
531
|
+
issues.push(...headOverlapIssues({ id, file: options.files?.get(id) }, headOverlaps));
|
|
439
532
|
await page.close();
|
|
440
533
|
}
|
|
441
534
|
catch (error) {
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -200,8 +200,137 @@ function readStaticPage(file) {
|
|
|
200
200
|
page.internalProps = names;
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
+
const membersProp = findProp(pageObject, 'members');
|
|
204
|
+
if (membersProp) {
|
|
205
|
+
const members = readFamilyMembers(membersProp.initializer, consts, gridNames, sourceFile);
|
|
206
|
+
if (!members)
|
|
207
|
+
page.checkable = false;
|
|
208
|
+
else
|
|
209
|
+
page.members = members;
|
|
210
|
+
}
|
|
211
|
+
const wholeProp = findProp(pageObject, 'whole');
|
|
212
|
+
if (wholeProp) {
|
|
213
|
+
const whole = stringLiteral(resolve(wholeProp.initializer, consts, new Set()));
|
|
214
|
+
if (whole === undefined)
|
|
215
|
+
page.checkable = false;
|
|
216
|
+
else
|
|
217
|
+
page.whole = whole;
|
|
218
|
+
}
|
|
219
|
+
const sectionsProp = findProp(pageObject, 'sections');
|
|
220
|
+
if (sectionsProp) {
|
|
221
|
+
const sectionsRead = readSections(sectionsProp.initializer, consts);
|
|
222
|
+
if (!sectionsRead)
|
|
223
|
+
page.checkable = false;
|
|
224
|
+
else
|
|
225
|
+
page.sections = sectionsRead;
|
|
226
|
+
}
|
|
227
|
+
const aliasesProp = findProp(pageObject, 'aliases');
|
|
228
|
+
if (aliasesProp) {
|
|
229
|
+
const aliases = readStringLiteralArray(aliasesProp.initializer, consts);
|
|
230
|
+
if (!aliases)
|
|
231
|
+
page.checkable = false;
|
|
232
|
+
else
|
|
233
|
+
page.aliases = aliases;
|
|
234
|
+
}
|
|
203
235
|
return page;
|
|
204
236
|
}
|
|
237
|
+
/** A literal array of string literals (or same-file `const` references to one) — undefined for
|
|
238
|
+
* anything else, including an array containing even one non-literal element. */
|
|
239
|
+
function readStringLiteralArray(node, consts) {
|
|
240
|
+
const resolved = resolve(node, consts, new Set());
|
|
241
|
+
if (!ts.isArrayLiteralExpression(resolved))
|
|
242
|
+
return undefined;
|
|
243
|
+
const items = [];
|
|
244
|
+
for (const element of resolved.elements) {
|
|
245
|
+
const value = stringLiteral(resolve(element, consts, new Set()));
|
|
246
|
+
if (value === undefined)
|
|
247
|
+
return undefined;
|
|
248
|
+
items.push(value);
|
|
249
|
+
}
|
|
250
|
+
return items;
|
|
251
|
+
}
|
|
252
|
+
/** Plan V3 / V2c item 5: a literal array of `CatalogMember`-shaped object literals, down to just
|
|
253
|
+
* the five fields `cli/doctor.ts` needs (`StaticMember`). Undefined for anything that isn't
|
|
254
|
+
* itself a literal array, or whose element isn't an object literal with a literal `id` — the
|
|
255
|
+
* same "never a partial read" contract every other array reader here already follows.
|
|
256
|
+
* `hasExamples` reuses the same item-reading helpers (`readSlotItems`/`readComparison`) the
|
|
257
|
+
* page level uses, rather than a presence-only check — a member authored with `variants: {
|
|
258
|
+
* items: [] }` has no real specimen to show, the same way a page-level empty list doesn't.
|
|
259
|
+
* N1: a member's own `variants`/`states`/`comparison` follows the exact same "never a partial
|
|
260
|
+
* read" contract the page level already does (`readStaticPage`'s own `page.checkable = false`
|
|
261
|
+
* for the same three fields) — a non-literal slot or an unreadable comparison makes the whole
|
|
262
|
+
* members array (and so the whole page) not checkable, rather than silently reading as "no
|
|
263
|
+
* specimen" and firing a confidently false `component-no-examples`. */
|
|
264
|
+
function readFamilyMembers(node, consts, gridNames, sourceFile) {
|
|
265
|
+
const resolved = resolve(node, consts, new Set());
|
|
266
|
+
if (!ts.isArrayLiteralExpression(resolved))
|
|
267
|
+
return undefined;
|
|
268
|
+
const members = [];
|
|
269
|
+
for (const element of resolved.elements) {
|
|
270
|
+
const resolvedElement = resolve(element, consts, new Set());
|
|
271
|
+
if (!ts.isObjectLiteralExpression(resolvedElement))
|
|
272
|
+
return undefined;
|
|
273
|
+
const id = propertyStringLiteral(resolvedElement, 'id', consts);
|
|
274
|
+
if (id === undefined)
|
|
275
|
+
return undefined;
|
|
276
|
+
const component = propertyStringLiteral(resolvedElement, 'component', consts);
|
|
277
|
+
const parent = propertyStringLiteral(resolvedElement, 'parent', consts);
|
|
278
|
+
const aliasesProp = findProp(resolvedElement, 'aliases');
|
|
279
|
+
const aliases = aliasesProp ? readStringLiteralArray(aliasesProp.initializer, consts) : undefined;
|
|
280
|
+
const composedOfProp = findProp(resolvedElement, 'composedOf');
|
|
281
|
+
const composedOf = composedOfProp ? readComposedOf(composedOfProp.initializer, consts) : undefined;
|
|
282
|
+
const variants = readSlotItems(findProp(resolvedElement, 'variants'), consts, component, sourceFile);
|
|
283
|
+
if (!variants.checkable)
|
|
284
|
+
return undefined;
|
|
285
|
+
const states = readSlotItems(findProp(resolvedElement, 'states'), consts, component, sourceFile);
|
|
286
|
+
if (!states.checkable)
|
|
287
|
+
return undefined;
|
|
288
|
+
const comparisonProp = findProp(resolvedElement, 'comparison');
|
|
289
|
+
let comparison;
|
|
290
|
+
if (comparisonProp) {
|
|
291
|
+
comparison = readComparison(comparisonProp.initializer, consts, gridNames);
|
|
292
|
+
if (!comparison)
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
members.push({
|
|
296
|
+
id,
|
|
297
|
+
component,
|
|
298
|
+
parent,
|
|
299
|
+
aliases,
|
|
300
|
+
composedOf,
|
|
301
|
+
hasWhenToUse: findProp(resolvedElement, 'whenToUse') !== undefined,
|
|
302
|
+
hasA11y: findProp(resolvedElement, 'a11y') !== undefined,
|
|
303
|
+
hasExamples: Boolean(variants.items?.length) || Boolean(states.items?.length) || Boolean(comparison),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return members;
|
|
307
|
+
}
|
|
308
|
+
/** Plan V3 / N3: `CatalogPageInput.sections`, read down to `label`/`kind`/`members` — undefined
|
|
309
|
+
* (not checkable) for anything that isn't itself a literal array, or whose element isn't an
|
|
310
|
+
* object literal with a literal `label`, a literal `kind` of `'blocks'`/`'list'`, and a literal
|
|
311
|
+
* `members` array. */
|
|
312
|
+
function readSections(node, consts) {
|
|
313
|
+
const resolved = resolve(node, consts, new Set());
|
|
314
|
+
if (!ts.isArrayLiteralExpression(resolved))
|
|
315
|
+
return undefined;
|
|
316
|
+
const sections = [];
|
|
317
|
+
for (const element of resolved.elements) {
|
|
318
|
+
const resolvedElement = resolve(element, consts, new Set());
|
|
319
|
+
if (!ts.isObjectLiteralExpression(resolvedElement))
|
|
320
|
+
return undefined;
|
|
321
|
+
const label = propertyStringLiteral(resolvedElement, 'label', consts);
|
|
322
|
+
const kindProp = findProp(resolvedElement, 'kind');
|
|
323
|
+
const kind = kindProp ? stringLiteral(resolve(kindProp.initializer, consts, new Set())) : undefined;
|
|
324
|
+
const membersProp = findProp(resolvedElement, 'members');
|
|
325
|
+
if (label === undefined || (kind !== 'blocks' && kind !== 'list') || !membersProp)
|
|
326
|
+
return undefined;
|
|
327
|
+
const members = readStringLiteralArray(membersProp.initializer, consts);
|
|
328
|
+
if (!members)
|
|
329
|
+
return undefined;
|
|
330
|
+
sections.push({ label, kind, members });
|
|
331
|
+
}
|
|
332
|
+
return sections;
|
|
333
|
+
}
|
|
205
334
|
/** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
|
|
206
335
|
* (design §4: role/relationship are authored prose `doctor` never needs for its missing-
|
|
207
336
|
* composition comparison). Undefined — not an empty array — for anything that isn't itself a
|