@krapwoo/ds-viewer 0.12.1 → 0.13.1
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 +28 -3
- package/dist/cli/doctor.js +43 -3
- package/dist/cli/staticPage.js +31 -2
- package/dist/native/catalog/pageApi.js +42 -5
- package/native/catalog/FamilyPage.tsx +71 -10
- package/native/catalog/index.ts +1 -1
- package/native/catalog/pageApi.ts +51 -6
- package/native/catalog/types.ts +11 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -167,6 +167,13 @@ npm run viewer:dev # opens viewer-host/, the DS Viewer internals catalog (
|
|
|
167
167
|
|
|
168
168
|
`check:catalog`, `check:doctor` and `check:types` cover both catalogs.
|
|
169
169
|
|
|
170
|
+
Three checks cover three different things, and none substitutes for another: `npm test` covers
|
|
171
|
+
pure logic and source assertions; `npm run test:render` renders catalog components into jsdom
|
|
172
|
+
through react-native-web and covers component structure (which element wraps what); `npm run
|
|
173
|
+
check:catalog` is the only one that sees geometry, because jsdom has no layout engine —
|
|
174
|
+
`getBoundingClientRect()` returns zeros there. A layout or alignment claim must be verified in a
|
|
175
|
+
browser, with `check:catalog`, never in a renderer test.
|
|
176
|
+
|
|
170
177
|
## Contributing
|
|
171
178
|
|
|
172
179
|
Found a bug or an improvement while using ds-viewer in your app? Open an issue or a pull request:
|
|
@@ -459,6 +466,7 @@ export default defineCatalogPage({
|
|
|
459
466
|
whole: 'Toast',
|
|
460
467
|
sections: [
|
|
461
468
|
{ label: 'Parts', kind: 'blocks', inWhole: true, members: ['ToastBanner'] },
|
|
469
|
+
// deprecated shape — don't copy this; name a related component in `relatedPages` instead (below).
|
|
462
470
|
{ label: 'Related', kind: 'blocks', inWhole: false, members: ['ToastStandaloneBanner'] },
|
|
463
471
|
],
|
|
464
472
|
members: [
|
|
@@ -484,7 +492,18 @@ export default defineCatalogPage({
|
|
|
484
492
|
"Parts" label; the rest render under its one "Related" label — the only two section labels the
|
|
485
493
|
page ever shows; each section's own `label` stays in the data (for the anatomy legend and your
|
|
486
494
|
own reference) but is never rendered. Numbering follows each member's own `parent` (below), not
|
|
487
|
-
this ordering.
|
|
495
|
+
this ordering. A non-`inWhole` section is **deprecated**: it still works, but `doctor`'s
|
|
496
|
+
`deprecated-related-section` now warns about it — give the member its own catalog page and name
|
|
497
|
+
it in `relatedPages` instead of documenting it inline here. Under `doctor.strict`, this warning
|
|
498
|
+
becomes an error, so a consumer with existing non-`inWhole` sections sees red CI on upgrade until
|
|
499
|
+
they migrate.
|
|
500
|
+
- **`relatedPages`** (`{ page, note? }[]`) names other catalog pages at the bottom of this page,
|
|
501
|
+
under the same "Related" heading, after any deprecated sections. Each row is the named page's own
|
|
502
|
+
id, linked when it resolves to a real catalog address — a page id, another family's member id, or
|
|
503
|
+
a family page's own alias or one of its members' aliases — and shown as plain text, never a dead link, when it doesn't
|
|
504
|
+
(`doctor` reports an unresolvable or self-naming entry either way), then its `note`, when
|
|
505
|
+
authored — no specimens, disclosure, source line, number, or pin. A page with neither
|
|
506
|
+
`relatedPages` nor a non-`inWhole` section renders no "Related" heading at all.
|
|
488
507
|
- **`parent`** nests a member under another (e.g. a subview under its view). A top-level part (an
|
|
489
508
|
`inWhole` member whose `parent` is the whole, or who has no `parent` at all) is numbered `1`,
|
|
490
509
|
`2`, `3`… in the order it's first listed across `sections`; a child takes its parent's number
|
|
@@ -493,8 +512,10 @@ export default defineCatalogPage({
|
|
|
493
512
|
- **`anatomy`** (page-level) is the first box after the page head, labeled “Anatomy” like the
|
|
494
513
|
specimen grids: `{ picture, groups }`, where each group is
|
|
495
514
|
`{ label, items: [{ member, description } | { other, description }] }`. A `member` item names one
|
|
496
|
-
of this page's own members (not the whole)
|
|
497
|
-
|
|
515
|
+
of this page's own members (not the whole); it shows that member's Part number, or no pin at all
|
|
516
|
+
when the member sits outside every `inWhole` section — such a row reserves no gutter for the pin
|
|
517
|
+
it doesn't have, its name starting flush at the row's own left edge; an `other` item names a
|
|
518
|
+
component documented elsewhere, shows an unnumbered ↗ pin, and links only when the name
|
|
498
519
|
resolves to a real catalog address. With six items or fewer the legend sits beside `picture`;
|
|
499
520
|
with more it goes below it in up to three columns, each group placed in the currently shortest
|
|
500
521
|
column so the columns end nearly level (a group never splits). Mark `picture` with
|
|
@@ -642,6 +663,10 @@ Button
|
|
|
642
663
|
gallery or a whole-page layout.
|
|
643
664
|
- `internal-prop-unknown` — `internalProps` names something that is not a prop of the component,
|
|
644
665
|
so the exemption it was written for is not in effect. Correct the spelling or delete the entry.
|
|
666
|
+
- `deprecated-related-section` — a family page still documents one or more members inline in a
|
|
667
|
+
non-`inWhole` section, naming every one of them so you can act without opening the file. Give
|
|
668
|
+
each its own catalog page and name it in the family's `relatedPages` list instead; the section
|
|
669
|
+
keeps working until you do.
|
|
645
670
|
|
|
646
671
|
The catalog also warns at render time when a page under-declares the width its examples need.
|
|
647
672
|
A `PhoneFrame` given a cell narrower than the 375px device scales the whole phone down, and its
|
package/dist/cli/doctor.js
CHANGED
|
@@ -168,7 +168,12 @@ function checkFamilyValidity(pages) {
|
|
|
168
168
|
const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
|
|
169
169
|
const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
|
|
170
170
|
const familyIdsByPageId = new Map();
|
|
171
|
-
|
|
171
|
+
// I-B (independent review, confirmation pass): built from every page with members, not only
|
|
172
|
+
// checkable ones — the same precedent `checkMemberComposedOf` above already follows for
|
|
173
|
+
// `knownNames` (no `checkable` filter). A non-checkable family page still contributes a working
|
|
174
|
+
// link at runtime (`pageApi.ts`'s `otherMemberIds` has no `checkable` concept either), so
|
|
175
|
+
// excluding it here turned a valid `relatedPages` entry into a false CI-failing error.
|
|
176
|
+
for (const page of pages.filter((p) => p.members && p.members.length > 0)) {
|
|
172
177
|
const id = resolvePageId(page, page.file);
|
|
173
178
|
const ids = new Set();
|
|
174
179
|
for (const member of page.members ?? []) {
|
|
@@ -192,10 +197,19 @@ function checkFamilyValidity(pages) {
|
|
|
192
197
|
const pageLike = {
|
|
193
198
|
id,
|
|
194
199
|
whole: page.whole,
|
|
195
|
-
// `
|
|
196
|
-
|
|
200
|
+
// `validateFamily` does read `inWhole` now (`pageApi.ts:277`, C1's narrowing).
|
|
201
|
+
// `StaticFamilySection.inWhole` is optional (unlike the real `CatalogMemberSection`) only so
|
|
202
|
+
// hand-built test fixtures don't all need one, so this defaults a missing one to `false` —
|
|
203
|
+
// the lenient direction (an unreadable `inWhole` must never manufacture a false "has no
|
|
204
|
+
// number" the day `anatomy` joins the static read) rather than passing `undefined` where
|
|
205
|
+
// `validateFamily`'s own type expects a boolean.
|
|
206
|
+
sections: page.sections?.map((s) => ({ ...s, inWhole: s.inWhole ?? false })),
|
|
197
207
|
members: (page.members ?? []).map((m) => ({ id: m.id, parent: m.parent, aliases: m.aliases, hasSpecimens: m.hasExamples })),
|
|
198
208
|
aliases: page.aliases,
|
|
209
|
+
// I2: passed through so an unresolvable or self-naming `relatedPages` entry is reported here
|
|
210
|
+
// too, the same precedent `checkMemberComposedOf` below already follows for `composedOf` —
|
|
211
|
+
// not only ever silently warned in a browser console (`npm run check:catalog`).
|
|
212
|
+
relatedPages: page.relatedPages,
|
|
199
213
|
};
|
|
200
214
|
for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds)) {
|
|
201
215
|
issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message, fix: 'Fix the family\'s members/whole/sections to satisfy the rule above.' }));
|
|
@@ -203,6 +217,31 @@ function checkFamilyValidity(pages) {
|
|
|
203
217
|
}
|
|
204
218
|
return issues;
|
|
205
219
|
}
|
|
220
|
+
/** Deprecation (`relatedPages`): a family page still using one or more non-`inWhole` sections to
|
|
221
|
+
* document a merely-related component inline, instead of giving it its own page and naming it in
|
|
222
|
+
* the page's `relatedPages` list. One finding per page (never per section), naming every member
|
|
223
|
+
* still parked in a deprecated section so the author can act without opening the file. Always a
|
|
224
|
+
* warning — `inWhole: false` keeps working. */
|
|
225
|
+
function checkDeprecatedRelatedSections(pages) {
|
|
226
|
+
const issues = [];
|
|
227
|
+
for (const page of pages) {
|
|
228
|
+
if (!page.members || page.members.length === 0 || !page.checkable)
|
|
229
|
+
continue; // V2c item 6's own predicate.
|
|
230
|
+
// Strictly `false`, never `!s.inWhole` — a test fixture (or any other caller) that omits
|
|
231
|
+
// `inWhole` entirely means "not testing this", not "deprecated".
|
|
232
|
+
const deprecated = (page.sections ?? []).filter((s) => s.inWhole === false);
|
|
233
|
+
if (deprecated.length === 0)
|
|
234
|
+
continue;
|
|
235
|
+
const memberIds = deprecated.flatMap((s) => s.members);
|
|
236
|
+
const id = resolvePageId(page, page.file);
|
|
237
|
+
issues.push(issue({
|
|
238
|
+
id: 'deprecated-related-section', severity: 'warning', ...pageRef(page),
|
|
239
|
+
message: `Family "${id}" documents ${memberIds.length} member${memberIds.length === 1 ? '' : 's'} (${memberIds.join(', ')}) in a deprecated non-"inWhole" section.`,
|
|
240
|
+
fix: `Give each of these members its own catalog page, drop the non-"inWhole" section, and name its new page in "${id}"'s relatedPages instead.`,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
return issues;
|
|
244
|
+
}
|
|
206
245
|
/** V2d item 5: resolves each family member's own `composedOf` the same way `buildCatalogSections`
|
|
207
246
|
* resolves it at runtime (`native/catalog/pageApi.ts`'s own `validateComposedOf`, imported
|
|
208
247
|
* directly — the subpath-import boundary that once made importing that file from `dist/cli/`
|
|
@@ -698,6 +737,7 @@ export function collectIssues(pages, components) {
|
|
|
698
737
|
...checkFamilyMembers(pages, components),
|
|
699
738
|
...checkFamilyValidity(pages),
|
|
700
739
|
...checkMemberComposedOf(pages, components),
|
|
740
|
+
...checkDeprecatedRelatedSections(pages),
|
|
701
741
|
];
|
|
702
742
|
}
|
|
703
743
|
/** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -232,6 +232,9 @@ function readStaticPage(file) {
|
|
|
232
232
|
else
|
|
233
233
|
page.aliases = aliases;
|
|
234
234
|
}
|
|
235
|
+
const relatedPagesProp = findProp(pageObject, 'relatedPages');
|
|
236
|
+
if (relatedPagesProp)
|
|
237
|
+
page.relatedPages = readRelatedPages(relatedPagesProp.initializer, consts);
|
|
235
238
|
return page;
|
|
236
239
|
}
|
|
237
240
|
/** A literal array of string literals (or same-file `const` references to one) — undefined for
|
|
@@ -308,7 +311,10 @@ function readFamilyMembers(node, consts, gridNames, sourceFile) {
|
|
|
308
311
|
/** Plan V3 / N3: `CatalogPageInput.sections`, read down to `label`/`kind`/`members` — undefined
|
|
309
312
|
* (not checkable) for anything that isn't itself a literal array, or whose element isn't an
|
|
310
313
|
* object literal with a literal `label`, a literal `kind` of `'blocks'`/`'list'`, and a literal
|
|
311
|
-
* `members` array.
|
|
314
|
+
* `members` array. I1: `inWhole` is read too, but — like `composedOf` — a non-literal `inWhole`
|
|
315
|
+
* never costs the section's `label`/`kind`/`members`, so the page stays checkable; it only means
|
|
316
|
+
* `checkDeprecatedRelatedSections` has nothing to check for that section (`StaticFamilySection`'s
|
|
317
|
+
* own `inWhole?` already reflects this: present only when it was itself a literal). */
|
|
312
318
|
function readSections(node, consts) {
|
|
313
319
|
const resolved = resolve(node, consts, new Set());
|
|
314
320
|
if (!ts.isArrayLiteralExpression(resolved))
|
|
@@ -322,12 +328,14 @@ function readSections(node, consts) {
|
|
|
322
328
|
const kindProp = findProp(resolvedElement, 'kind');
|
|
323
329
|
const kind = kindProp ? stringLiteral(resolve(kindProp.initializer, consts, new Set())) : undefined;
|
|
324
330
|
const membersProp = findProp(resolvedElement, 'members');
|
|
331
|
+
const inWholeProp = findProp(resolvedElement, 'inWhole');
|
|
332
|
+
const inWhole = inWholeProp ? literalBooleanOrUnknown(inWholeProp.initializer, consts) : undefined;
|
|
325
333
|
if (label === undefined || (kind !== 'blocks' && kind !== 'list') || !membersProp)
|
|
326
334
|
return undefined;
|
|
327
335
|
const members = readStringLiteralArray(membersProp.initializer, consts);
|
|
328
336
|
if (!members)
|
|
329
337
|
return undefined;
|
|
330
|
-
sections.push({ label, kind, members });
|
|
338
|
+
sections.push({ label, kind, members, inWhole });
|
|
331
339
|
}
|
|
332
340
|
return sections;
|
|
333
341
|
}
|
|
@@ -352,6 +360,27 @@ function readComposedOf(node, consts) {
|
|
|
352
360
|
}
|
|
353
361
|
return entries;
|
|
354
362
|
}
|
|
363
|
+
/** A literal array of `RelatedPage`-shaped object literals, read down to just `page` (design
|
|
364
|
+
* parity with `readComposedOf`: `note` is authored prose `doctor` never needs). Undefined — not
|
|
365
|
+
* an empty array — for anything that isn't itself a literal array, or whose element isn't an
|
|
366
|
+
* object literal with a literal `page`: `doctor` must never mistake "couldn't read this" for
|
|
367
|
+
* "author declared no related pages here". */
|
|
368
|
+
function readRelatedPages(node, consts) {
|
|
369
|
+
const resolved = resolve(node, consts, new Set());
|
|
370
|
+
if (!ts.isArrayLiteralExpression(resolved))
|
|
371
|
+
return undefined;
|
|
372
|
+
const entries = [];
|
|
373
|
+
for (const element of resolved.elements) {
|
|
374
|
+
const resolvedElement = resolve(element, consts, new Set());
|
|
375
|
+
if (!ts.isObjectLiteralExpression(resolvedElement))
|
|
376
|
+
return undefined;
|
|
377
|
+
const page = propertyStringLiteral(resolvedElement, 'page', consts);
|
|
378
|
+
if (page === undefined)
|
|
379
|
+
return undefined;
|
|
380
|
+
entries.push({ page });
|
|
381
|
+
}
|
|
382
|
+
return entries;
|
|
383
|
+
}
|
|
355
384
|
/** Local name -> imported name, for every named import from `moduleSpecifier`. */
|
|
356
385
|
export function importedNames(sourceFile, moduleSpecifier) {
|
|
357
386
|
const names = new Map();
|
|
@@ -130,11 +130,20 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
|
|
|
130
130
|
checkId(alias, false);
|
|
131
131
|
// Slice D1 item 3: an anatomy `member` item must name a real, non-whole member of this page, and
|
|
132
132
|
// each member may be pinned to the picture's legend at most once.
|
|
133
|
-
// D2 item A2: a member item
|
|
134
|
-
//
|
|
135
|
-
// a
|
|
133
|
+
// D2 item A2, narrowed per review C1: a member item is reported only when it sits inside an
|
|
134
|
+
// `inWhole` section but still has no number — i.e. its parent isn't itself numbered. Left
|
|
135
|
+
// unchecked, such a part renders indistinguishably from a Related row and its `AnatomyPin`
|
|
136
|
+
// renders nothing at all. A member outside every `inWhole` section (Related) is legitimately
|
|
137
|
+
// unnumbered by design (APPROVED.md: related members have no number) and must not be reported
|
|
138
|
+
// here.
|
|
136
139
|
if (page.anatomy) {
|
|
137
140
|
const numbered = numberFor(page);
|
|
141
|
+
const inWholeMemberIds = new Set();
|
|
142
|
+
for (const section of page.sections ?? []) {
|
|
143
|
+
if (section.inWhole)
|
|
144
|
+
for (const id of section.members)
|
|
145
|
+
inWholeMemberIds.add(id);
|
|
146
|
+
}
|
|
138
147
|
const seenAnatomyMembers = new Set();
|
|
139
148
|
for (const group of page.anatomy.groups) {
|
|
140
149
|
for (const item of group.items) {
|
|
@@ -153,12 +162,39 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
|
|
|
153
162
|
continue;
|
|
154
163
|
}
|
|
155
164
|
seenAnatomyMembers.add(item.member);
|
|
156
|
-
if (!numbered.has(item.member)) {
|
|
165
|
+
if (inWholeMemberIds.has(item.member) && !numbered.has(item.member)) {
|
|
157
166
|
issues.push(`Family "${page.id}": anatomy item "${item.member}" has no number.`);
|
|
158
167
|
}
|
|
159
168
|
}
|
|
160
169
|
}
|
|
161
170
|
}
|
|
171
|
+
// `relatedPages`: each entry must name a real catalog page, other than this one, and the list
|
|
172
|
+
// must not repeat a page. An unresolvable or self-naming entry still renders (plain text, never
|
|
173
|
+
// a dead link), but is always reported here so `doctor` fails in CI. N8: `allPageIds` ∪
|
|
174
|
+
// `otherMemberIds` is the same universe `FamilyPage`'s own `resolvesAddress` accepts as a
|
|
175
|
+
// working link — page ids, member ids, and both page-level and member aliases — so an entry
|
|
176
|
+
// naming another page's alias or another family's member address is accepted too.
|
|
177
|
+
if (page.relatedPages) {
|
|
178
|
+
const seenRelatedPages = new Set();
|
|
179
|
+
for (const entry of page.relatedPages) {
|
|
180
|
+
if (seenRelatedPages.has(entry.page)) {
|
|
181
|
+
issues.push(`Family "${page.id}": relatedPages lists "${entry.page}" more than once.`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
seenRelatedPages.add(entry.page);
|
|
185
|
+
if (entry.page === page.id) {
|
|
186
|
+
issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" names this page itself.`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (byId.has(entry.page)) {
|
|
190
|
+
issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" is a member of this page, not another page.`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!allPageIds.has(entry.page) && !otherMemberIds.has(entry.page)) {
|
|
194
|
+
issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" is not a catalog page.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
162
198
|
return issues;
|
|
163
199
|
}
|
|
164
200
|
/** Slice D1 item 2: anatomy-first numbering — the only numbering rule for every family page (not
|
|
@@ -334,7 +370,7 @@ pages, components, groupOrder) {
|
|
|
334
370
|
...member,
|
|
335
371
|
hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
|
|
336
372
|
}));
|
|
337
|
-
const issues = validateFamily({ ...page, members, anatomy: page.anatomy }, otherPageIds, otherMemberIds);
|
|
373
|
+
const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
|
|
338
374
|
if (globalThis.__DEV__) {
|
|
339
375
|
for (const issue of issues)
|
|
340
376
|
console.error(`[Catalog] ${issue}`);
|
|
@@ -347,6 +383,7 @@ pages, components, groupOrder) {
|
|
|
347
383
|
aliases: page.aliases,
|
|
348
384
|
apiLabel: page.apiLabel,
|
|
349
385
|
anatomy: page.anatomy,
|
|
386
|
+
relatedPages: page.relatedPages,
|
|
350
387
|
members: page.members.map((member) => {
|
|
351
388
|
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
352
389
|
return {
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type PartTreeEntry,
|
|
27
27
|
type RelatedSegment,
|
|
28
28
|
} from './familyModel';
|
|
29
|
-
import type { CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, SectionDef } from './types';
|
|
29
|
+
import type { CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, RelatedPage, SectionDef } from './types';
|
|
30
30
|
|
|
31
31
|
/** `SectionDef`'s parts `presentationBlocks`/`Blocks` actually read, built from one family member
|
|
32
32
|
* so its own specimens render through the exact same renderers (`VariantGroup`, `ComparisonGrid`,
|
|
@@ -654,12 +654,46 @@ function FamilySectionGroup({
|
|
|
654
654
|
);
|
|
655
655
|
}
|
|
656
656
|
|
|
657
|
+
/** One row of the bottom-of-page `relatedPages` list (field spec, item 3 "Rendering"): the named
|
|
658
|
+
* page's own id as a link when it resolves to a real catalog address, plain text when not — never
|
|
659
|
+
* a dead link — then its note, when authored. No specimens, no disclosure, no source line, no
|
|
660
|
+
* number, no pin. */
|
|
661
|
+
function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean }) {
|
|
662
|
+
const resolved = resolvesAddress(entry.page);
|
|
663
|
+
return (
|
|
664
|
+
<Text style={styles.belowLine}>
|
|
665
|
+
{resolved ? (
|
|
666
|
+
<Text role="link" style={styles.relatedPageName} {...({ href: hashForId(entry.page) } as Record<string, unknown>)}>
|
|
667
|
+
{entry.page}
|
|
668
|
+
</Text>
|
|
669
|
+
) : (
|
|
670
|
+
<Text style={[styles.relatedPageName, styles.relatedPageNameUnresolved]}>{entry.page}</Text>
|
|
671
|
+
)}
|
|
672
|
+
{entry.note ? ` — ${entry.note}` : null}
|
|
673
|
+
</Text>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function RelatedPagesList({ items, resolvesAddress }: { items: readonly RelatedPage[]; resolvesAddress: (name: string) => boolean }) {
|
|
678
|
+
return (
|
|
679
|
+
<View style={styles.relatedPagesList}>
|
|
680
|
+
{items.map((entry, i) => (
|
|
681
|
+
<RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} />
|
|
682
|
+
))}
|
|
683
|
+
</View>
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
657
687
|
/** Slice D1 item 4: the two umbrella labels — "Parts" groups every `inWhole` section, "Related"
|
|
658
|
-
* every other one
|
|
659
|
-
*
|
|
688
|
+
* every other one (plus, on "Related" only, the page's own `relatedPages` list, rendered after
|
|
689
|
+
* every deprecated section — field spec item 3: "render the deprecated sections first and the
|
|
690
|
+
* links after, under the single Related heading"). A divider sits above each, and nothing else
|
|
691
|
+
* renders between the authored sections it covers (item 10: authored `section.label`s stay in the
|
|
692
|
+
* data, never on screen). */
|
|
660
693
|
function FamilyLabeledGroup({
|
|
661
694
|
label,
|
|
662
695
|
sections,
|
|
696
|
+
relatedPages,
|
|
663
697
|
outline,
|
|
664
698
|
byId,
|
|
665
699
|
allMemberIds,
|
|
@@ -672,6 +706,8 @@ function FamilyLabeledGroup({
|
|
|
672
706
|
}: {
|
|
673
707
|
label: 'Parts' | 'Related';
|
|
674
708
|
sections: CatalogMemberSection[];
|
|
709
|
+
/** Only ever set for the "Related" group — "Parts" has no equivalent. */
|
|
710
|
+
relatedPages?: RelatedPage[];
|
|
675
711
|
outline: FamilyDef['outline'];
|
|
676
712
|
byId: ReadonlyMap<string, FamilyMember>;
|
|
677
713
|
allMemberIds: readonly string[];
|
|
@@ -682,7 +718,9 @@ function FamilyLabeledGroup({
|
|
|
682
718
|
onToggleRow: (id: string) => void;
|
|
683
719
|
registerNode: (id: string, node: View | null) => void;
|
|
684
720
|
}) {
|
|
685
|
-
|
|
721
|
+
// Field spec item 3: "a page with no relatedPages and no Related sections renders exactly as it
|
|
722
|
+
// does today — no heading, no divider, nothing."
|
|
723
|
+
if (sections.length === 0 && (!relatedPages || relatedPages.length === 0)) return null;
|
|
686
724
|
return (
|
|
687
725
|
<View style={styles.labeledGroup}>
|
|
688
726
|
{/* Spec §5.2 "Heading semantics": "Parts"/"Related" are aria-level 2 headings — one level
|
|
@@ -711,6 +749,7 @@ function FamilyLabeledGroup({
|
|
|
711
749
|
registerNode={registerNode}
|
|
712
750
|
/>
|
|
713
751
|
))}
|
|
752
|
+
{relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} />}
|
|
714
753
|
</View>
|
|
715
754
|
</View>
|
|
716
755
|
);
|
|
@@ -723,8 +762,10 @@ const FamilyAnatomyContext = createContext<{ byId: ReadonlyMap<string, FamilyMem
|
|
|
723
762
|
|
|
724
763
|
/** Slice D1 item 5: a pin for use inside `anatomy.picture`. `member` names one of this page's
|
|
725
764
|
* members and shows its number (dashed when it has no area of its own), looked up through
|
|
726
|
-
* `FamilyAnatomyContext` — renders nothing for an unknown member, one
|
|
727
|
-
*
|
|
765
|
+
* `FamilyAnatomyContext` — renders nothing for an unknown member, one outside a family page's own
|
|
766
|
+
* anatomy box (no context), or one with no number — the last is a `validateFamily` issue only
|
|
767
|
+
* when the member sits inside an `inWhole` section (D2 item A2, narrowed per review C1); for a
|
|
768
|
+
* member outside every `inWhole` section it's legal, deliberately silent, and reported by nobody.
|
|
728
769
|
* `other` marks a component documented on another page with the unnumbered ↗ pin the legend
|
|
729
770
|
* uses, and always renders it — it needs no context, with or without a family page in scope. */
|
|
730
771
|
export function AnatomyPin({ member, other }: { member?: string; other?: string }) {
|
|
@@ -742,12 +783,23 @@ export function AnatomyPin({ member, other }: { member?: string; other?: string
|
|
|
742
783
|
}
|
|
743
784
|
|
|
744
785
|
/** Slice D1 item 5: one legend row naming a member of this page — a pin (looked up the same way
|
|
745
|
-
* `AnatomyPin` does), its name linked to the member's address, and the row's own description.
|
|
786
|
+
* `AnatomyPin` does), its name linked to the member's address, and the row's own description.
|
|
787
|
+
* Defect fix (APPROVED.md "the legend is not indented"): the pin's 34px gutter is only reserved
|
|
788
|
+
* when there's a pin to show — a member with no number (outside every `inWhole` section) gets no
|
|
789
|
+
* gutter either, so its name starts flush at the row's own left edge instead of under blank
|
|
790
|
+
* space. */
|
|
746
791
|
function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId: string; description: string; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] }) {
|
|
747
792
|
const pin = anatomyPinFor(memberId, byId, outline);
|
|
748
793
|
return (
|
|
749
794
|
<View style={styles.legendRow}>
|
|
750
|
-
|
|
795
|
+
{pin && (
|
|
796
|
+
// `testID` exists only so a renderer test can assert the slot's presence without
|
|
797
|
+
// depending on class names — react-native-web renders it to the DOM as `data-testid`;
|
|
798
|
+
// native ignores it.
|
|
799
|
+
<View style={styles.legendPinSlot} testID="legend-pin-slot">
|
|
800
|
+
<Pin number={pin.number} dashed={pin.dashed} />
|
|
801
|
+
</View>
|
|
802
|
+
)}
|
|
751
803
|
<View style={styles.legendBody}>
|
|
752
804
|
<Text role="link" style={styles.legendName} {...({ href: hashForId(memberId) } as Record<string, unknown>)}>
|
|
753
805
|
{memberId}
|
|
@@ -759,12 +811,16 @@ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId:
|
|
|
759
811
|
}
|
|
760
812
|
|
|
761
813
|
/** Slice D1 item 5: one legend row naming a component documented elsewhere — never numbered; its
|
|
762
|
-
* own name links only when it resolves to a real catalog address.
|
|
814
|
+
* own name links only when it resolves to a real catalog address. The ↗ marker keeps its own
|
|
815
|
+
* gutter: the defect this slice fixes is a gutter reserved for a pin that *isn't there*, and an
|
|
816
|
+
* "other page" row always shows its ↗, so the slot is never blank. Keeping it also keeps
|
|
817
|
+
* APPROVED.md's "the description ... starts at the name's left edge" — pulling the marker inline
|
|
818
|
+
* instead left each description hanging 26px to the left of its own name. */
|
|
763
819
|
function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string; description: string; resolvesAddress: (name: string) => boolean }) {
|
|
764
820
|
const resolved = resolvesAddress(name);
|
|
765
821
|
return (
|
|
766
822
|
<View style={styles.legendRow}>
|
|
767
|
-
<View style={styles.legendPinSlot} {...ARIA_HIDDEN}>
|
|
823
|
+
<View style={styles.legendPinSlot} {...ARIA_HIDDEN} testID="legend-pin-slot">
|
|
768
824
|
<View style={styles.extPin}>
|
|
769
825
|
<Text style={styles.extPinText}>↗</Text>
|
|
770
826
|
</View>
|
|
@@ -1025,6 +1081,7 @@ export function FamilyPage<TId extends string>({
|
|
|
1025
1081
|
<FamilyLabeledGroup
|
|
1026
1082
|
label="Related"
|
|
1027
1083
|
sections={relatedSections}
|
|
1084
|
+
relatedPages={family.relatedPages}
|
|
1028
1085
|
outline={family.outline}
|
|
1029
1086
|
byId={byId}
|
|
1030
1087
|
allMemberIds={allMemberIds}
|
|
@@ -1114,6 +1171,10 @@ const styles = StyleSheet.create({
|
|
|
1114
1171
|
memberSpecimens: { marginTop: CATALOG_SPACE.sm, marginBottom: CATALOG_SPACE.lg - CATALOG_SPACE.sm },
|
|
1115
1172
|
belowLine: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
1116
1173
|
relatedLink: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
|
|
1174
|
+
// relatedPages (field spec item 3 "Rendering"): one row per entry, under the Related heading.
|
|
1175
|
+
relatedPagesList: { gap: CATALOG_SPACE.sm },
|
|
1176
|
+
relatedPageName: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.accent },
|
|
1177
|
+
relatedPageNameUnresolved: { color: CATALOG_COLOR.text },
|
|
1117
1178
|
wholeBelowRow: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xs, marginTop: CATALOG_SPACE.sm, flexWrap: 'wrap' },
|
|
1118
1179
|
wholeName: { ...CATALOG_TEXT.codeSmallStrong, color: CATALOG_COLOR.text },
|
|
1119
1180
|
// V2d item 3: v8's own `.uses` line, inside the disclosure.
|
package/native/catalog/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRe
|
|
|
49
49
|
export type {
|
|
50
50
|
PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
|
|
51
51
|
PreviewWidths, TokenSection, ComposedOfEntry, CatalogMember, CatalogMemberSection, FamilyDef,
|
|
52
|
-
CatalogAnatomy, CatalogAnatomyGroup, CatalogAnatomyItem,
|
|
52
|
+
CatalogAnatomy, CatalogAnatomyGroup, CatalogAnatomyItem, RelatedPage,
|
|
53
53
|
} from './types';
|
|
54
54
|
export { UpdatePanel } from './UpdatePanel';
|
|
55
55
|
export { UPDATE_PAGE_ID } from './catalogNavigation';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
-
import type { CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
|
|
2
|
+
import type { CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, RelatedPage, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
|
|
3
3
|
|
|
4
4
|
/** `SectionDef` without the derived fields (`id`/`path`/`props` come from detection and
|
|
5
5
|
* generated props), plus the three fields design §1 "Page shape" adds. */
|
|
@@ -75,6 +75,13 @@ export interface CatalogPageInput<TId extends string = string> {
|
|
|
75
75
|
* a picture of the whole with numbered pins and a legend. Only takes effect on a page with
|
|
76
76
|
* `members`. */
|
|
77
77
|
anatomy?: CatalogAnatomy;
|
|
78
|
+
/** Other catalog pages worth naming at the bottom of this page, under the "Related" heading,
|
|
79
|
+
* rendered after any deprecated non-`inWhole` sections — a component merely related to the
|
|
80
|
+
* whole owns its own page rather than being documented inline here. Each entry's `page` must
|
|
81
|
+
* resolve to a real catalog page, never this page's own id; `validateFamily` reports both as
|
|
82
|
+
* issues, and an entry that still fails to resolve at runtime renders its name as plain text
|
|
83
|
+
* instead of a dead link. Only takes effect on a page with `members`. */
|
|
84
|
+
relatedPages?: RelatedPage[];
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
export interface CatalogPage<TId extends string = string> extends CatalogPageInput<TId> {
|
|
@@ -158,6 +165,8 @@ export interface FamilyPageLike extends FamilyOutlineInput {
|
|
|
158
165
|
* decoupled from the full `CatalogAnatomy` type (which carries a `picture` React node this pure
|
|
159
166
|
* module never touches). */
|
|
160
167
|
anatomy?: FamilyAnatomyLike;
|
|
168
|
+
/** See `CatalogPageInput.relatedPages`. */
|
|
169
|
+
relatedPages?: RelatedPage[];
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
/** Slice D1 item 3. */
|
|
@@ -256,11 +265,18 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
|
|
|
256
265
|
|
|
257
266
|
// Slice D1 item 3: an anatomy `member` item must name a real, non-whole member of this page, and
|
|
258
267
|
// each member may be pinned to the picture's legend at most once.
|
|
259
|
-
// D2 item A2: a member item
|
|
260
|
-
//
|
|
261
|
-
// a
|
|
268
|
+
// D2 item A2, narrowed per review C1: a member item is reported only when it sits inside an
|
|
269
|
+
// `inWhole` section but still has no number — i.e. its parent isn't itself numbered. Left
|
|
270
|
+
// unchecked, such a part renders indistinguishably from a Related row and its `AnatomyPin`
|
|
271
|
+
// renders nothing at all. A member outside every `inWhole` section (Related) is legitimately
|
|
272
|
+
// unnumbered by design (APPROVED.md: related members have no number) and must not be reported
|
|
273
|
+
// here.
|
|
262
274
|
if (page.anatomy) {
|
|
263
275
|
const numbered = numberFor(page);
|
|
276
|
+
const inWholeMemberIds = new Set<string>();
|
|
277
|
+
for (const section of page.sections ?? []) {
|
|
278
|
+
if (section.inWhole) for (const id of section.members) inWholeMemberIds.add(id);
|
|
279
|
+
}
|
|
264
280
|
const seenAnatomyMembers = new Set<string>();
|
|
265
281
|
for (const group of page.anatomy.groups) {
|
|
266
282
|
for (const item of group.items) {
|
|
@@ -278,13 +294,41 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
|
|
|
278
294
|
continue;
|
|
279
295
|
}
|
|
280
296
|
seenAnatomyMembers.add(item.member);
|
|
281
|
-
if (!numbered.has(item.member)) {
|
|
297
|
+
if (inWholeMemberIds.has(item.member) && !numbered.has(item.member)) {
|
|
282
298
|
issues.push(`Family "${page.id}": anatomy item "${item.member}" has no number.`);
|
|
283
299
|
}
|
|
284
300
|
}
|
|
285
301
|
}
|
|
286
302
|
}
|
|
287
303
|
|
|
304
|
+
// `relatedPages`: each entry must name a real catalog page, other than this one, and the list
|
|
305
|
+
// must not repeat a page. An unresolvable or self-naming entry still renders (plain text, never
|
|
306
|
+
// a dead link), but is always reported here so `doctor` fails in CI. N8: `allPageIds` ∪
|
|
307
|
+
// `otherMemberIds` is the same universe `FamilyPage`'s own `resolvesAddress` accepts as a
|
|
308
|
+
// working link — page ids, member ids, and both page-level and member aliases — so an entry
|
|
309
|
+
// naming another page's alias or another family's member address is accepted too.
|
|
310
|
+
if (page.relatedPages) {
|
|
311
|
+
const seenRelatedPages = new Set<string>();
|
|
312
|
+
for (const entry of page.relatedPages) {
|
|
313
|
+
if (seenRelatedPages.has(entry.page)) {
|
|
314
|
+
issues.push(`Family "${page.id}": relatedPages lists "${entry.page}" more than once.`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
seenRelatedPages.add(entry.page);
|
|
318
|
+
if (entry.page === page.id) {
|
|
319
|
+
issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" names this page itself.`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (byId.has(entry.page)) {
|
|
323
|
+
issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" is a member of this page, not another page.`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (!allPageIds.has(entry.page) && !otherMemberIds.has(entry.page)) {
|
|
327
|
+
issues.push(`Family "${page.id}": relatedPages entry "${entry.page}" is not a catalog page.`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
288
332
|
return issues;
|
|
289
333
|
}
|
|
290
334
|
|
|
@@ -459,7 +503,7 @@ export function buildCatalogSections<TId extends string>(
|
|
|
459
503
|
...member,
|
|
460
504
|
hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
|
|
461
505
|
}));
|
|
462
|
-
const issues = validateFamily({ ...page, members, anatomy: page.anatomy }, otherPageIds, otherMemberIds);
|
|
506
|
+
const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
|
|
463
507
|
if ((globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
464
508
|
for (const issue of issues) console.error(`[Catalog] ${issue}`);
|
|
465
509
|
}
|
|
@@ -471,6 +515,7 @@ export function buildCatalogSections<TId extends string>(
|
|
|
471
515
|
aliases: page.aliases,
|
|
472
516
|
apiLabel: page.apiLabel,
|
|
473
517
|
anatomy: page.anatomy,
|
|
518
|
+
relatedPages: page.relatedPages,
|
|
474
519
|
members: page.members!.map((member) => {
|
|
475
520
|
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
476
521
|
return {
|
package/native/catalog/types.ts
CHANGED
|
@@ -401,6 +401,14 @@ export interface CatalogAnatomy {
|
|
|
401
401
|
groups: CatalogAnatomyGroup[];
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
+
/** One other catalog page worth naming at the bottom of a family page: a component merely
|
|
405
|
+
* related to the whole owns its own page, and the family page links to it rather than
|
|
406
|
+
* documenting it inside. `note` is one short sentence saying why a reader would go there. */
|
|
407
|
+
export interface RelatedPage {
|
|
408
|
+
page: string;
|
|
409
|
+
note?: string;
|
|
410
|
+
}
|
|
411
|
+
|
|
404
412
|
/** A family page's members with resolved props and paths, the whole, sections, and the computed
|
|
405
413
|
* outline — set on `SectionDef.family` by `buildCatalogSections` when a page has `members`. */
|
|
406
414
|
export interface FamilyDef {
|
|
@@ -417,6 +425,9 @@ export interface FamilyDef {
|
|
|
417
425
|
apiLabel?: string;
|
|
418
426
|
/** Slice D1 item 1. See `CatalogPageInput.anatomy`. */
|
|
419
427
|
anatomy?: CatalogAnatomy;
|
|
428
|
+
/** Other catalog pages named at the bottom of this page, under the "Related" heading, after any
|
|
429
|
+
* deprecated non-`inWhole` sections. See `CatalogPageInput.relatedPages`. */
|
|
430
|
+
relatedPages?: RelatedPage[];
|
|
420
431
|
}
|
|
421
432
|
|
|
422
433
|
/** One titled card on a token page (`SectionDef.tokenSections`). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krapwoo/ds-viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A browsable, always-current catalog for an Expo / React Native design system.",
|
|
@@ -55,14 +55,18 @@
|
|
|
55
55
|
"typescript": "^6.0.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
+
"@types/jsdom": "^21.1.7",
|
|
58
59
|
"@types/node": "^22.0.0",
|
|
59
60
|
"@types/react": "^19.0.0",
|
|
61
|
+
"esbuild": "^0.25.12",
|
|
62
|
+
"jsdom": "^26.1.0",
|
|
60
63
|
"puppeteer": "^25.12.0"
|
|
61
64
|
},
|
|
62
65
|
"scripts": {
|
|
63
66
|
"build": "tsc -p tsconfig.json",
|
|
64
67
|
"prepack": "npm run build",
|
|
65
68
|
"test": "node --experimental-strip-types --test 'cli/**/*.test.ts' 'config/**/*.test.ts' 'native/catalog/__tests__/*.test.ts'",
|
|
69
|
+
"test:render": "node --import ./test/register.mjs --test 'test/__tests__/*.test.ts' 'native/catalog/__tests__/*.test.tsx'",
|
|
66
70
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
67
71
|
"kit:dev": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
|
|
68
72
|
"viewer:dev": "npm run build && cd viewer-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
|