@krapwoo/ds-viewer 0.10.0 → 0.11.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/doctor.js +182 -3
- 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 +47 -11
- 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
|
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/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
|
|
@@ -33,15 +33,20 @@ export function neighbors(order, id) {
|
|
|
33
33
|
next: index < order.length - 1 ? order[index + 1] : null,
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
-
/** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed.
|
|
36
|
+
/** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. A page
|
|
37
|
+
* matches on its id, its title, or (spec §5.3) any of its family's member ids or aliases. */
|
|
37
38
|
export function filterGroups(groups, query,
|
|
38
39
|
/** Display titles by id; a page matches on its id or its title. */
|
|
39
|
-
labels
|
|
40
|
+
labels,
|
|
41
|
+
/** Every member id/alias a family page's members use, by page id. */
|
|
42
|
+
memberIds) {
|
|
40
43
|
const q = query.trim().toLowerCase();
|
|
41
44
|
return groups
|
|
42
45
|
.map((group) => ({
|
|
43
46
|
label: group.label,
|
|
44
|
-
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)
|
|
47
|
+
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)
|
|
48
|
+
|| (labels?.get(id)?.toLowerCase().includes(q) ?? false)
|
|
49
|
+
|| (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false)) : group.ids),
|
|
45
50
|
}))
|
|
46
51
|
.filter((group) => group.ids.length > 0);
|
|
47
52
|
}
|
|
@@ -109,6 +114,60 @@ export function resolveActiveFromHash(hash, order) {
|
|
|
109
114
|
return UPDATE_PAGE_ID;
|
|
110
115
|
return idFromHash(hash, order);
|
|
111
116
|
}
|
|
117
|
+
/** Every member id other than the whole, every member alias, and every family page's own
|
|
118
|
+
* (page-level) alias, mapped to where it resolves (spec §5.3 "Old addresses"). Pages without a
|
|
119
|
+
* `family` contribute nothing. */
|
|
120
|
+
export function aliasTargets(sections) {
|
|
121
|
+
const targets = new Map();
|
|
122
|
+
for (const section of sections) {
|
|
123
|
+
const family = section.family;
|
|
124
|
+
if (!family)
|
|
125
|
+
continue;
|
|
126
|
+
for (const member of family.members) {
|
|
127
|
+
if (member.id === family.whole)
|
|
128
|
+
continue;
|
|
129
|
+
targets.set(member.id, { pageId: section.id, memberId: member.id });
|
|
130
|
+
for (const alias of member.aliases ?? [])
|
|
131
|
+
targets.set(alias, { pageId: section.id, memberId: member.id });
|
|
132
|
+
}
|
|
133
|
+
for (const alias of family.aliases ?? [])
|
|
134
|
+
targets.set(alias, { pageId: section.id });
|
|
135
|
+
}
|
|
136
|
+
return targets;
|
|
137
|
+
}
|
|
138
|
+
export function resolveRoute(hash, order, aliases) {
|
|
139
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
140
|
+
if (raw === '')
|
|
141
|
+
return { kind: 'empty' };
|
|
142
|
+
let decoded;
|
|
143
|
+
try {
|
|
144
|
+
decoded = decodeURIComponent(raw);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { kind: 'notFound', raw };
|
|
148
|
+
}
|
|
149
|
+
if (decoded === UPDATE_PAGE_ID)
|
|
150
|
+
return { kind: 'update' };
|
|
151
|
+
if (order.includes(decoded))
|
|
152
|
+
return { kind: 'page', id: decoded };
|
|
153
|
+
const target = aliases.get(decoded);
|
|
154
|
+
if (target) {
|
|
155
|
+
if (target.memberId !== undefined)
|
|
156
|
+
return { kind: 'member', pageId: target.pageId, memberId: target.memberId };
|
|
157
|
+
return { kind: 'page', id: target.pageId };
|
|
158
|
+
}
|
|
159
|
+
return { kind: 'notFound', raw: decoded };
|
|
160
|
+
}
|
|
161
|
+
/** Member search hits (spec §5.3 "Search"): every entry whose member id or alias contains the
|
|
162
|
+
* query, case-insensitively. The label is always the member id — the Swift declaration name. */
|
|
163
|
+
export function searchResults(entries, query) {
|
|
164
|
+
const q = query.trim().toLowerCase();
|
|
165
|
+
if (!q)
|
|
166
|
+
return [];
|
|
167
|
+
return entries
|
|
168
|
+
.filter((entry) => entry.memberId.toLowerCase().includes(q) || (entry.aliases ?? []).some((alias) => alias.toLowerCase().includes(q)))
|
|
169
|
+
.map((entry) => ({ pageId: entry.pageId, memberId: entry.memberId, label: entry.memberId, familyTitle: entry.familyTitle }));
|
|
170
|
+
}
|
|
112
171
|
/** Design §5: "Major: ... dismissible per version; the footer line remains." `dismissedVersion`
|
|
113
172
|
* is whatever a previous dismissal recorded (`CatalogShell`'s own `localStorage` read, Task 14) —
|
|
114
173
|
* any value other than the *current* `update.latest` (including `null`, or an older dismissed
|
|
@@ -257,18 +257,28 @@ export function presentationBlocks(def, options = {}) {
|
|
|
257
257
|
title: `${def.comparison.rowLabel} × ${def.comparison.columnLabel}`,
|
|
258
258
|
size: def.comparison.size ?? def.specimenSize ?? 'regular',
|
|
259
259
|
comparison: def.comparison,
|
|
260
|
+
// A3: `comparison` replaces the Variants list, but `def.variants.axis` (if still set) is
|
|
261
|
+
// the only axis label a grid has to carry onto a family page, which hides generic titles.
|
|
262
|
+
...(def.variants?.axis ? { axis: def.variants.axis } : {}),
|
|
260
263
|
});
|
|
261
264
|
}
|
|
262
265
|
else if (grouped && def.variants) {
|
|
263
266
|
// Grouped rows place several cells beside a row header, so full-width (itemsFill) slots do
|
|
264
267
|
// not force 402px cells here; a cell's own `fill` still stretches its specimen.
|
|
265
|
-
blocks.push({
|
|
268
|
+
blocks.push({
|
|
269
|
+
kind: 'grouped',
|
|
270
|
+
title: 'Variant × configuration',
|
|
271
|
+
size: def.specimenSize ?? 'regular',
|
|
272
|
+
groups: grouped.groups,
|
|
273
|
+
...(def.variants.axis ? { axis: def.variants.axis } : {}),
|
|
274
|
+
});
|
|
266
275
|
states = grouped.leftovers;
|
|
267
276
|
}
|
|
268
277
|
else if (def.variants) {
|
|
269
278
|
blocks.push({
|
|
270
279
|
kind: 'list',
|
|
271
|
-
title: 'Variants',
|
|
280
|
+
title: def.variants.axis ?? 'Variants',
|
|
281
|
+
...(def.variants.axis ? { axis: def.variants.axis } : {}),
|
|
272
282
|
size: slotSize(def, def.variants),
|
|
273
283
|
items: slotItems(def.variants, 'variants', def.specimenSize === 'page'),
|
|
274
284
|
...slotColumnCap(def.variants),
|
|
@@ -282,7 +292,8 @@ export function presentationBlocks(def, options = {}) {
|
|
|
282
292
|
const secondary = blocks.length > 0 && blocks[0].kind !== 'list';
|
|
283
293
|
blocks.push({
|
|
284
294
|
kind: 'list',
|
|
285
|
-
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
295
|
+
title: states.axis ?? (secondary ? 'Other configurations' : 'States / configurations'),
|
|
296
|
+
...(states.axis ? { axis: states.axis } : {}),
|
|
286
297
|
size: slotSize(def, states),
|
|
287
298
|
items: slotItems(states, 'states', def.specimenSize === 'page'),
|
|
288
299
|
...slotColumnCap(states),
|