@krapwoo/ds-viewer 0.14.2 → 0.16.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 +249 -3
- package/config/index.ts +3 -0
- package/dist/cli/config.js +9 -1
- package/dist/cli/doctor.js +452 -19
- package/dist/cli/explain.js +26 -0
- package/dist/cli/staticPage.js +99 -10
- package/dist/cli/tokenDefinition.js +125 -0
- package/dist/native/catalog/catalogNavigation.js +27 -11
- package/dist/native/catalog/pageApi.js +557 -26
- package/dist/native/catalog/tokenDefinition.js +205 -0
- package/native/catalog/CatalogSearchInput.tsx +18 -3
- package/native/catalog/CatalogShell.tsx +318 -46
- package/native/catalog/CatalogSidebar.tsx +37 -1
- package/native/catalog/FamilyPage.tsx +567 -137
- package/native/catalog/GroupPage.tsx +230 -0
- package/native/catalog/JumpRow.tsx +81 -0
- package/native/catalog/ReferenceDetails.tsx +144 -16
- package/native/catalog/SectionBlock.tsx +49 -5
- package/native/catalog/SourceReferencePicker.tsx +180 -0
- package/native/catalog/StructuredTokenPage.tsx +592 -0
- package/native/catalog/Swatch.tsx +29 -2
- package/native/catalog/TokenLayouts.tsx +30 -9
- package/native/catalog/catalogNavigation.ts +30 -10
- package/native/catalog/catalogReferences.ts +212 -0
- package/native/catalog/familyModel.ts +10 -4
- package/native/catalog/index.ts +22 -0
- package/native/catalog/pageApi.ts +598 -23
- package/native/catalog/tokenDefinition.ts +430 -0
- package/native/catalog/types.ts +77 -5
- package/package.json +1 -1
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -0
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -0
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +1 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +1 -0
- package/starter-kit/components/Divider/Divider.catalog.tsx +1 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +1 -0
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +1 -0
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
export function defineCatalogPage(page) {
|
|
2
2
|
return { ...page, __dsViewerPage: true };
|
|
3
3
|
}
|
|
4
|
+
/** Shared by a family member's own "also used by" (legacy) and, #60, an ordinary page's own —
|
|
5
|
+
* dedupes by `component`, keeping the first role, and drops any user in `excludeIds` (a family
|
|
6
|
+
* member excludes its own family's ids; an ordinary page excludes only itself). */
|
|
7
|
+
function dedupeAlsoUsedBy(entries, excludeIds) {
|
|
8
|
+
const deduped = new Map();
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
if (excludeIds.has(entry.component))
|
|
11
|
+
continue;
|
|
12
|
+
if (!deduped.has(entry.component))
|
|
13
|
+
deduped.set(entry.component, entry);
|
|
14
|
+
}
|
|
15
|
+
return [...deduped.values()].map((entry) => ({ component: entry.component, role: entry.role || undefined }));
|
|
16
|
+
}
|
|
4
17
|
function mergePropNotes(component, propNotes) {
|
|
5
18
|
const own = component.props.map((prop) => ({
|
|
6
19
|
name: prop.name,
|
|
@@ -19,6 +32,99 @@ function mergePropNotes(component, propNotes) {
|
|
|
19
32
|
}));
|
|
20
33
|
return [...own, ...inherited];
|
|
21
34
|
}
|
|
35
|
+
// #61: the same canonical-title map `tokenDefinition.ts`'s own `canonicalTokenTitle` holds —
|
|
36
|
+
// duplicated here, deliberately, rather than value-imported: `pageApi.ts` must stay importable
|
|
37
|
+
// from a plain `node --experimental-strip-types` test (`pageApi.test.ts`) with no JSX/bundler
|
|
38
|
+
// loader, which needs a real resolvable `./tokenDefinition.ts` specifier, while the SAME file is
|
|
39
|
+
// also reachable from `kit-host`'s/`viewer-host`'s own "how the app actually resolves this"
|
|
40
|
+
// `moduleResolution: "bundler"` type-check (`npm run check:types`), which rejects a `.ts`-extension
|
|
41
|
+
// *value* import outright (`allowImportingTsExtensions` is off there, and that tsconfig is read-
|
|
42
|
+
// only). A `import type` (erased entirely, so its extension is never resolved at either runtime)
|
|
43
|
+
// stays fine either way — only the three lines of logic below that would otherwise need a real
|
|
44
|
+
// value import are kept local instead. `tokenDefinition.ts`'s own exported `canonicalTokenTitle`/
|
|
45
|
+
// `validateTokenDefinition`/`validateTokenDefinitionUsage` remain the canonical versions for every
|
|
46
|
+
// other consumer (`StructuredTokenPage.tsx`, `cli/doctor.ts`, the public `index.ts` barrel) — only
|
|
47
|
+
// `pageApi.ts` keeps this one local copy, and only for this one reason.
|
|
48
|
+
const TOKEN_KIND_TITLES = {
|
|
49
|
+
colors: 'Colors', typography: 'Typography', spacing: 'Spacing', radius: 'Radius',
|
|
50
|
+
dimensions: 'Dimensions', shadows: 'Shadows', opacity: 'Opacity', motion: 'Motion',
|
|
51
|
+
};
|
|
52
|
+
/** #61: resolves `page.tokenDefinition` into the page's real `title`/`tokenGallery`/`tokenSections`
|
|
53
|
+
* — the only three fields `comparison.ts`'s own `presentationBlocks` (not writable here) reads to
|
|
54
|
+
* place a token-gallery block, so an opted-in page needs zero changes anywhere in that layout
|
|
55
|
+
* pipeline. Each derived `TokenSection`'s own `render` is a trivial `() => null` placeholder, never
|
|
56
|
+
* actually called: `SectionBlock.tsx`'s own `tokenSections` block case renders this page's real
|
|
57
|
+
* `tokenDefinition` entries instead whenever it's set (`StructuredTokenPage.tsx`) — keeping every
|
|
58
|
+
* JSX-producing closure at that existing rendering boundary, never inside this plain-TS module.
|
|
59
|
+
* Conflicts (duplicate section/entry ids, a legacy `render`/`tokenGallery`/`tokenSections`/
|
|
60
|
+
* `members` set alongside it, or an explicit title that disagrees with the kind) are logged to the
|
|
61
|
+
* dev console only — the same "soft" runtime posture `validateComposedOf`/`validateFamily` already
|
|
62
|
+
* use; `cli/doctor.ts`'s own static read of the equivalent rules (`tokenDefinition.ts`'s exported
|
|
63
|
+
* `validateTokenDefinition`/`validateTokenDefinitionUsage`) is what actually fails CI on them.
|
|
64
|
+
* Undefined when the page didn't opt in — every other field keeps the author's own values
|
|
65
|
+
* untouched. */
|
|
66
|
+
function deriveTokenDefinition(page) {
|
|
67
|
+
const def = page.tokenDefinition;
|
|
68
|
+
if (!def)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (globalThis.__DEV__) {
|
|
71
|
+
const issues = [];
|
|
72
|
+
if (page.render !== undefined)
|
|
73
|
+
issues.push('tokenDefinition conflicts with this page\'s own "render" — remove one of them.');
|
|
74
|
+
if (page.tokenGallery === true)
|
|
75
|
+
issues.push('tokenDefinition conflicts with this page\'s own "tokenGallery" — remove one of them.');
|
|
76
|
+
if (page.tokenSections !== undefined && page.tokenSections.length > 0)
|
|
77
|
+
issues.push('tokenDefinition conflicts with this page\'s own "tokenSections" — remove one of them.');
|
|
78
|
+
if (page.members !== undefined && page.members.length > 0)
|
|
79
|
+
issues.push('tokenDefinition conflicts with this page\'s own "members" (a family page) — remove one of them.');
|
|
80
|
+
const canonicalTitle = TOKEN_KIND_TITLES[def.kind];
|
|
81
|
+
if (page.title !== undefined && page.title !== canonicalTitle) {
|
|
82
|
+
issues.push(`tokenDefinition: title "${page.title}" conflicts with kind "${def.kind}" (expected "${canonicalTitle}"). Omit "title" to use it automatically.`);
|
|
83
|
+
}
|
|
84
|
+
const duplicateIds = (ids) => {
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const duplicates = new Set();
|
|
87
|
+
for (const id of ids) {
|
|
88
|
+
if (seen.has(id))
|
|
89
|
+
duplicates.add(id);
|
|
90
|
+
seen.add(id);
|
|
91
|
+
}
|
|
92
|
+
return [...duplicates];
|
|
93
|
+
};
|
|
94
|
+
for (const id of duplicateIds(def.sections.map((s) => s.id)))
|
|
95
|
+
issues.push(`tokenDefinition has more than one section with id "${id}".`);
|
|
96
|
+
for (const section of def.sections) {
|
|
97
|
+
for (const id of duplicateIds(section.entries.map((e) => e.id))) {
|
|
98
|
+
issues.push(`tokenDefinition section "${section.id}" has more than one entry with id "${id}".`);
|
|
99
|
+
}
|
|
100
|
+
for (const entry of section.entries) {
|
|
101
|
+
if (entry.kind !== def.kind)
|
|
102
|
+
issues.push(`tokenDefinition section "${section.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
|
|
103
|
+
}
|
|
104
|
+
// #64 B1: groups inside a category — the same duplicate-id/kind-mismatch checks, scoped to
|
|
105
|
+
// each group's own entries, mirroring `tokenDefinition.ts`'s own `validateTokenDefinition`.
|
|
106
|
+
for (const id of duplicateIds((section.groups ?? []).map((g) => g.id))) {
|
|
107
|
+
issues.push(`tokenDefinition section "${section.id}" has more than one group with id "${id}".`);
|
|
108
|
+
}
|
|
109
|
+
for (const group of section.groups ?? []) {
|
|
110
|
+
for (const id of duplicateIds(group.entries.map((e) => e.id))) {
|
|
111
|
+
issues.push(`tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`);
|
|
112
|
+
}
|
|
113
|
+
for (const entry of group.entries) {
|
|
114
|
+
if (entry.kind !== def.kind)
|
|
115
|
+
issues.push(`tokenDefinition section "${section.id}" group "${group.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const issueMsg of issues)
|
|
120
|
+
console.error(`[Catalog] ${page.id}: ${issueMsg}`);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
title: page.title ?? TOKEN_KIND_TITLES[def.kind],
|
|
124
|
+
tokenGallery: true,
|
|
125
|
+
tokenSections: def.sections.map((section) => ({ id: section.id, title: section.title, desc: section.desc, wide: section.wide, render: () => null })),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
22
128
|
/** Human-readable problems with a page's (or, V2d, a family member's own) `composedOf`, in
|
|
23
129
|
* declaration order: one line per entry whose `component` isn't in `knownNames` (every real
|
|
24
130
|
* component name, documented or not, plus every page id and family member id — the full catalog
|
|
@@ -37,12 +143,21 @@ export function validateComposedOf(composedOf, knownNames) {
|
|
|
37
143
|
* member in exactly one section; every parent resolving within the page with no cycles; member
|
|
38
144
|
* and alias ids unique catalog-wide and never colliding with a page id, except the whole (whose
|
|
39
145
|
* id equals its own page's id). Returns `[]` for a page without `members`. `allPageIds` and
|
|
40
|
-
* `otherMemberIds` are the ids used by every *other* page/member in the catalog.
|
|
41
|
-
|
|
146
|
+
* `otherMemberIds` are the ids used by every *other* page/member in the catalog. #63 A1: a page
|
|
147
|
+
* with neither `whole` nor `sections` is a *group* page, not a family — validated instead by
|
|
148
|
+
* `validateGroup`; this function is a no-op for it (its own "both present" behavior and messages
|
|
149
|
+
* are otherwise unchanged). A page with exactly one of the two stays a family-shape error, as
|
|
150
|
+
* before. #63 A5: `externalPartIds` names every id this family's own `inWhole` sections list that
|
|
151
|
+
* was resolved (catalog-wide, by the caller) to a real, validly-parented group-page member —
|
|
152
|
+
* these are exempted from the "not a member of this page" check below; omit it (the default, `[]`)
|
|
153
|
+
* to keep every existing call site's behavior byte-for-byte unchanged. */
|
|
154
|
+
export function validateFamily(page, allPageIds, otherMemberIds, externalPartIds = new Set()) {
|
|
42
155
|
const { members } = page;
|
|
43
156
|
if (!members || members.length === 0)
|
|
44
157
|
return [];
|
|
45
158
|
const issues = [];
|
|
159
|
+
if (!page.whole && !page.sections)
|
|
160
|
+
return issues; // A group page — see `validateGroup`.
|
|
46
161
|
if (!page.whole || !page.sections) {
|
|
47
162
|
issues.push(`Family "${page.id}": "members" requires both "whole" and "sections".`);
|
|
48
163
|
return issues;
|
|
@@ -59,10 +174,17 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
|
|
|
59
174
|
// here, rather than left to crash the renderer or silently occupy authored order in `numberFor`.
|
|
60
175
|
// N6: a `list`-section member authored with a specimen — `FoldedRow` never renders one, so this
|
|
61
176
|
// degrades to a silently dropped specimen rather than a crash, same as the unknown-id case.
|
|
177
|
+
const externalCountById = new Map();
|
|
62
178
|
for (const section of page.sections) {
|
|
63
179
|
for (const id of section.members) {
|
|
64
180
|
const resolved = byId.get(id);
|
|
65
181
|
if (!resolved) {
|
|
182
|
+
// #63 A5: a validly-resolved external part — a group-page member, already checked by the
|
|
183
|
+
// caller against `memberLocations`/parent — is never reported as missing.
|
|
184
|
+
if (section.inWhole && externalPartIds.has(id)) {
|
|
185
|
+
externalCountById.set(id, (externalCountById.get(id) ?? 0) + 1);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
66
188
|
issues.push(`Family "${page.id}": section "${section.label}" lists "${id}", which is not a member of this page.`);
|
|
67
189
|
continue;
|
|
68
190
|
}
|
|
@@ -71,6 +193,11 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
|
|
|
71
193
|
}
|
|
72
194
|
}
|
|
73
195
|
}
|
|
196
|
+
// An external part rendered twice would duplicate its row, React key and registered node.
|
|
197
|
+
for (const [id, count] of externalCountById) {
|
|
198
|
+
if (count > 1)
|
|
199
|
+
issues.push(`Family "${page.id}": member "${id}" is in more than one section.`);
|
|
200
|
+
}
|
|
74
201
|
const sectionCountById = new Map();
|
|
75
202
|
for (const section of page.sections) {
|
|
76
203
|
for (const id of section.members)
|
|
@@ -154,6 +281,15 @@ export function validateFamily(page, allPageIds, otherMemberIds) {
|
|
|
154
281
|
continue;
|
|
155
282
|
}
|
|
156
283
|
if (!byId.has(item.member)) {
|
|
284
|
+
// #63 A5: a resolved external part (numbered by the catalog-wide pre-pass, documented on a
|
|
285
|
+
// group page) may be named in the anatomy, exactly as its `inWhole` section may list it.
|
|
286
|
+
if (externalPartIds.has(item.member)) {
|
|
287
|
+
if (seenAnatomyMembers.has(item.member)) {
|
|
288
|
+
issues.push(`Family "${page.id}": anatomy lists member "${item.member}" more than once.`);
|
|
289
|
+
}
|
|
290
|
+
seenAnatomyMembers.add(item.member);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
157
293
|
issues.push(`Family "${page.id}": anatomy item "${item.member}" is not a member of this page.`);
|
|
158
294
|
continue;
|
|
159
295
|
}
|
|
@@ -263,6 +399,183 @@ export function numberFor(page) {
|
|
|
263
399
|
}
|
|
264
400
|
return outline;
|
|
265
401
|
}
|
|
402
|
+
/** #63 A4: numbers one group member's own parts — authored (array) order among siblings, `1`,
|
|
403
|
+
* `2`, then `1.1`, … for children, exactly like `numberFor`'s own child assignment under a
|
|
404
|
+
* family's whole — scoped to `topLevelId`'s own descendants among `members` (a group page has no
|
|
405
|
+
* `sections` to scan, so "authored order" is simply the `members` array's own order). `topLevelId`
|
|
406
|
+
* itself gets no number, the same way a family's whole never does. Lifted, not reimplemented, from
|
|
407
|
+
* `numberFor`'s own `assignChildren`/cycle guard, re-anchored on one top-level member instead of
|
|
408
|
+
* one page-wide whole. */
|
|
409
|
+
export function numberForGroupMember(topLevelId, members) {
|
|
410
|
+
const outline = new Map();
|
|
411
|
+
const childrenOf = new Map();
|
|
412
|
+
for (const m of members) {
|
|
413
|
+
if (m.parent === undefined)
|
|
414
|
+
continue;
|
|
415
|
+
childrenOf.set(m.parent, [...(childrenOf.get(m.parent) ?? []), m.id]);
|
|
416
|
+
}
|
|
417
|
+
const assignChildren = (parentId, parentNumber, level, visited) => {
|
|
418
|
+
const kids = childrenOf.get(parentId) ?? [];
|
|
419
|
+
kids.forEach((childId, index) => {
|
|
420
|
+
if (visited.has(childId))
|
|
421
|
+
return;
|
|
422
|
+
visited.add(childId);
|
|
423
|
+
const number = `${parentNumber}.${index + 1}`;
|
|
424
|
+
outline.set(childId, { number, level: level + 1 });
|
|
425
|
+
assignChildren(childId, number, level + 1, visited);
|
|
426
|
+
});
|
|
427
|
+
};
|
|
428
|
+
const visited = new Set([topLevelId]);
|
|
429
|
+
const kids = childrenOf.get(topLevelId) ?? [];
|
|
430
|
+
kids.forEach((childId, index) => {
|
|
431
|
+
if (visited.has(childId))
|
|
432
|
+
return;
|
|
433
|
+
visited.add(childId);
|
|
434
|
+
const number = String(index + 1);
|
|
435
|
+
outline.set(childId, { number, level: 2 });
|
|
436
|
+
assignChildren(childId, number, 2, visited);
|
|
437
|
+
});
|
|
438
|
+
return outline;
|
|
439
|
+
}
|
|
440
|
+
/** Validates a group page (#63 A1/A3/A4/A6): no page-level `anatomy` and no `relatedPages` (a group page renders no Related list, so it is reported as unused); every `parent` resolves
|
|
441
|
+
* either to a same-page member (checked for cycles, the same guard `validateFamily` already
|
|
442
|
+
* lifts) or — A3's cross-page case — to a member known catalog-wide (`otherMemberIds`), never to
|
|
443
|
+
* neither; member and alias ids unique catalog-wide, with no privileged "whole" exemption (a group
|
|
444
|
+
* has none); and `anatomy` set only on a top-level member (one with no parent, or whose parent
|
|
445
|
+
* isn't one of this page's own members), naming only that member's own descendants on this page.
|
|
446
|
+
* Returns `[]` for a page without `members`. Mirrors `validateFamily`'s own contract and the same
|
|
447
|
+
* `allPageIds`/`otherMemberIds` meaning (every *other* page's/member's id in the catalog). */
|
|
448
|
+
export function validateGroupIssues(page, allPageIds, otherMemberIds) {
|
|
449
|
+
const { members } = page;
|
|
450
|
+
if (!members || members.length === 0)
|
|
451
|
+
return [];
|
|
452
|
+
const issues = [];
|
|
453
|
+
const shapeIssue = (message) => issues.push({ rule: 'family-invalid', message });
|
|
454
|
+
const anatomyIssue = (message) => issues.push({ rule: 'group-anatomy-member', message });
|
|
455
|
+
const byId = new Map(members.map((m) => [m.id, m]));
|
|
456
|
+
if (page.anatomy !== undefined) {
|
|
457
|
+
shapeIssue(`Group "${page.id}": page-level "anatomy" is not used on a group page — move it to the relevant top-level member instead.`);
|
|
458
|
+
}
|
|
459
|
+
if (page.relatedPages && page.relatedPages.length > 0) {
|
|
460
|
+
shapeIssue(`Group "${page.id}": "relatedPages" is not used on a group page (it renders no Related list) — name the related page in a member's "related" text or "composedOf" instead.`);
|
|
461
|
+
}
|
|
462
|
+
for (const m of members) {
|
|
463
|
+
if (m.parent === undefined)
|
|
464
|
+
continue;
|
|
465
|
+
if (!byId.has(m.parent) && !otherMemberIds.has(m.parent)) {
|
|
466
|
+
shapeIssue(`Group "${page.id}": member "${m.id}" has an unknown parent "${m.parent}".`);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
// Lifted verbatim from `validateFamily`'s own cycle guard: `byId.get(current)?.parent` already
|
|
470
|
+
// degrades to `undefined` (loop ends, no cycle) the moment `current` resolves off this page —
|
|
471
|
+
// exactly A3's cross-page top-level case, so no change is needed for that case here.
|
|
472
|
+
const seen = new Set([m.id]);
|
|
473
|
+
let current = m.parent;
|
|
474
|
+
let cycle = false;
|
|
475
|
+
while (current !== undefined) {
|
|
476
|
+
if (seen.has(current)) {
|
|
477
|
+
cycle = true;
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
seen.add(current);
|
|
481
|
+
current = byId.get(current)?.parent;
|
|
482
|
+
}
|
|
483
|
+
if (cycle)
|
|
484
|
+
shapeIssue(`Group "${page.id}": member "${m.id}" has a parent cycle.`);
|
|
485
|
+
}
|
|
486
|
+
const seenInPage = new Set();
|
|
487
|
+
const checkId = (id, isMemberOrMemberAlias = true) => {
|
|
488
|
+
// `allPageIds` excludes this page, and `resolveRoute` matches page ids before aliases — so a
|
|
489
|
+
// member (or its alias) named like its own page could never be addressed.
|
|
490
|
+
if (isMemberOrMemberAlias && id === page.id)
|
|
491
|
+
shapeIssue(`Group "${page.id}": id "${id}" collides with this group page's own id.`);
|
|
492
|
+
if (seenInPage.has(id))
|
|
493
|
+
shapeIssue(`Group "${page.id}": id "${id}" is used more than once on this page.`);
|
|
494
|
+
seenInPage.add(id);
|
|
495
|
+
if (allPageIds.has(id))
|
|
496
|
+
shapeIssue(`Group "${page.id}": id "${id}" collides with another page's id.`);
|
|
497
|
+
if (otherMemberIds.has(id))
|
|
498
|
+
shapeIssue(`Group "${page.id}": id "${id}" collides with another member or alias elsewhere in the catalog.`);
|
|
499
|
+
};
|
|
500
|
+
for (const m of members) {
|
|
501
|
+
checkId(m.id);
|
|
502
|
+
for (const alias of m.aliases ?? [])
|
|
503
|
+
checkId(alias);
|
|
504
|
+
}
|
|
505
|
+
for (const alias of page.aliases ?? [])
|
|
506
|
+
checkId(alias, false);
|
|
507
|
+
// A3/A4: a top-level member has no parent, or a parent not among this page's own members
|
|
508
|
+
// (resolved cross-page above). `anatomy` is only ever meaningful there.
|
|
509
|
+
const isTopLevel = (m) => m.parent === undefined || !byId.has(m.parent);
|
|
510
|
+
const descendantsOf = (topLevelId) => {
|
|
511
|
+
const result = new Set();
|
|
512
|
+
const stack = [topLevelId];
|
|
513
|
+
while (stack.length > 0) {
|
|
514
|
+
const current = stack.pop();
|
|
515
|
+
for (const m of members) {
|
|
516
|
+
if (m.parent === current && m.id !== topLevelId && !result.has(m.id)) {
|
|
517
|
+
result.add(m.id);
|
|
518
|
+
stack.push(m.id);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return result;
|
|
523
|
+
};
|
|
524
|
+
for (const m of members) {
|
|
525
|
+
if (!m.anatomy)
|
|
526
|
+
continue;
|
|
527
|
+
if (!isTopLevel(m)) {
|
|
528
|
+
anatomyIssue(`Group "${page.id}": member "${m.id}" has "anatomy", but anatomy is only used on a top-level member.`);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const descendants = descendantsOf(m.id);
|
|
532
|
+
const seenAnatomyMembers = new Set();
|
|
533
|
+
for (const group of m.anatomy.groups) {
|
|
534
|
+
for (const item of group.items) {
|
|
535
|
+
if (!('member' in item))
|
|
536
|
+
continue;
|
|
537
|
+
if (item.member === m.id) {
|
|
538
|
+
anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy item "${item.member}" names itself.`);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (!descendants.has(item.member)) {
|
|
542
|
+
anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy item "${item.member}" is not one of its descendants on this page.`);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (seenAnatomyMembers.has(item.member)) {
|
|
546
|
+
anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy lists member "${item.member}" more than once.`);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
seenAnatomyMembers.add(item.member);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return issues;
|
|
554
|
+
}
|
|
555
|
+
export function validateGroup(page, allPageIds, otherMemberIds) {
|
|
556
|
+
return validateGroupIssues(page, allPageIds, otherMemberIds).map((issue) => issue.message);
|
|
557
|
+
}
|
|
558
|
+
/** #63 A5/A6: resolves one family `inWhole` section id that is not one of that family's own
|
|
559
|
+
* `members` — a candidate external part — against `owners`, every catalog location that id
|
|
560
|
+
* resolves to as a real member. Exactly one owner, itself a group page, whose own `parent` equals
|
|
561
|
+
* `familyWhole`, is the only success; anything else is the validation issue A6 requires (zero or
|
|
562
|
+
* multiple owners, an owner that isn't a group page, or a parent that doesn't match). Pure and
|
|
563
|
+
* catalog-shape-agnostic — shared between `buildCatalogSections` (runtime) and `cli/doctor.ts`
|
|
564
|
+
* (static), which each build `owners` from their own page representation. */
|
|
565
|
+
export function resolveExternalPart(id, owners, familyWhole) {
|
|
566
|
+
if (owners.length === 0)
|
|
567
|
+
return { issue: `"${id}" is not a member of this page, and is not defined on any group page either.` };
|
|
568
|
+
if (owners.length > 1) {
|
|
569
|
+
return { issue: `"${id}" is defined as a member on more than one page (${owners.map((o) => o.pageId).join(', ')}).` };
|
|
570
|
+
}
|
|
571
|
+
const [owner] = owners;
|
|
572
|
+
if (!owner.isGroupPage)
|
|
573
|
+
return { issue: `"${id}" is a member of "${owner.pageId}", which is not a group page.` };
|
|
574
|
+
if (owner.parent !== familyWhole) {
|
|
575
|
+
return { issue: `"${id}"'s parent on "${owner.pageId}" ("${owner.parent ?? 'none'}") does not equal this family's whole "${familyWhole}".` };
|
|
576
|
+
}
|
|
577
|
+
return { pageId: owner.pageId };
|
|
578
|
+
}
|
|
266
579
|
function orderGroups(groupIds, groupOrder) {
|
|
267
580
|
const listed = groupOrder ?? [];
|
|
268
581
|
const labels = [...groupIds.keys()];
|
|
@@ -315,14 +628,51 @@ pages, components, groupOrder) {
|
|
|
315
628
|
// `FamilyPage`'s own `resolvesAddress`. Without resolving through the same map here, an
|
|
316
629
|
// alias-named or component-named entry would link fine going forward but derive nothing going
|
|
317
630
|
// back: a missing "Also used by" line looks identical to "nothing uses this".
|
|
631
|
+
// C1 fix: every page's own id and every member's own id is itself an exact registered address —
|
|
632
|
+
// it must win over any same-named `component` binding elsewhere, including the common case where
|
|
633
|
+
// a page's own id already equals its component name (so it never gets a chance to appear as a
|
|
634
|
+
// "claimant" in the ambiguity pass below). Registering these identities first, before any
|
|
635
|
+
// binding-name guessing runs, is what makes that guessing unable to ever shadow an exact address.
|
|
318
636
|
const canonicalMemberIdByAlias = new Map();
|
|
319
637
|
for (const page of pages) {
|
|
638
|
+
canonicalMemberIdByAlias.set(page.id, page.id);
|
|
320
639
|
for (const member of page.members ?? []) {
|
|
640
|
+
canonicalMemberIdByAlias.set(member.id, member.id);
|
|
321
641
|
for (const alias of member.aliases ?? [])
|
|
322
642
|
canonicalMemberIdByAlias.set(alias, member.id);
|
|
323
|
-
if (member.component && member.component !== member.id)
|
|
324
|
-
canonicalMemberIdByAlias.set(member.component, member.id);
|
|
325
643
|
}
|
|
644
|
+
for (const alias of page.aliases ?? [])
|
|
645
|
+
canonicalMemberIdByAlias.set(alias, page.id);
|
|
646
|
+
}
|
|
647
|
+
// #60/C1: a `composedOf` entry may instead name a component export rather than its page/member's
|
|
648
|
+
// own id or alias — an ordinary page's own `component` (when it differs from its `id`) or a family
|
|
649
|
+
// member's own `component` (when it differs from its `id`) are the same kind of claim, so both
|
|
650
|
+
// feed the one shared ambiguity pass below: a name already owned by an exact address above is
|
|
651
|
+
// never reconsidered, and a name claimed by more than one binding (ordinary-vs-ordinary,
|
|
652
|
+
// member-vs-member, or ordinary-vs-member alike) is never guessed — it is left unresolved, not
|
|
653
|
+
// silently decided by whichever binding happened to be declared first.
|
|
654
|
+
const componentBindingOwners = new Map();
|
|
655
|
+
const addComponentBindingOwner = (name, ownerId) => {
|
|
656
|
+
if (canonicalMemberIdByAlias.has(name))
|
|
657
|
+
return;
|
|
658
|
+
componentBindingOwners.set(name, [...(componentBindingOwners.get(name) ?? []), ownerId]);
|
|
659
|
+
};
|
|
660
|
+
for (const page of pages) {
|
|
661
|
+
if (page.members && page.members.length > 0) {
|
|
662
|
+
for (const member of page.members) {
|
|
663
|
+
if (member.component && member.component !== member.id)
|
|
664
|
+
addComponentBindingOwner(member.component, member.id);
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const componentName = page.component ?? page.id;
|
|
669
|
+
if (componentName !== page.id)
|
|
670
|
+
addComponentBindingOwner(componentName, page.id);
|
|
671
|
+
}
|
|
672
|
+
for (const [name, owners] of componentBindingOwners) {
|
|
673
|
+
if (owners.length !== 1)
|
|
674
|
+
continue;
|
|
675
|
+
canonicalMemberIdByAlias.set(name, owners[0]);
|
|
326
676
|
}
|
|
327
677
|
const canonicalUsedByTarget = (name) => canonicalMemberIdByAlias.get(name) ?? name;
|
|
328
678
|
// "Also used by" (derived, never authored): the inverse of every `composedOf` entry in the
|
|
@@ -353,22 +703,100 @@ pages, components, groupOrder) {
|
|
|
353
703
|
}
|
|
354
704
|
// Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
|
|
355
705
|
// front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
|
|
356
|
-
// page's ids.
|
|
706
|
+
// page's ids. #64 B5: also a tokenDefinition page's own `aliases` and each of its sections' own
|
|
707
|
+
// `aliases` (extending this same catalog-wide pool to tokenDefinition pages).
|
|
357
708
|
const allPageIds = new Set(pages.map((page) => page.id));
|
|
358
709
|
const familyIdsByPage = new Map();
|
|
359
710
|
for (const page of pages) {
|
|
360
|
-
if (!page.members)
|
|
711
|
+
if (!page.members && !page.tokenDefinition)
|
|
361
712
|
continue;
|
|
362
713
|
const ids = new Set();
|
|
363
|
-
for (const member of page.members) {
|
|
714
|
+
for (const member of page.members ?? []) {
|
|
364
715
|
ids.add(member.id);
|
|
365
716
|
for (const alias of member.aliases ?? [])
|
|
366
717
|
ids.add(alias);
|
|
367
718
|
}
|
|
368
719
|
for (const alias of page.aliases ?? [])
|
|
369
720
|
ids.add(alias);
|
|
721
|
+
for (const section of page.tokenDefinition?.sections ?? [])
|
|
722
|
+
for (const alias of section.aliases ?? [])
|
|
723
|
+
ids.add(alias);
|
|
370
724
|
familyIdsByPage.set(page.id, ids);
|
|
371
725
|
}
|
|
726
|
+
// #63 A5/A6: catalog-wide — where every member id lives, so each family's `inWhole` sections can
|
|
727
|
+
// tell an external part (a group-page member) from a genuinely unknown id. Built once, up front,
|
|
728
|
+
// the same way `familyIdsByPage` already is above.
|
|
729
|
+
const memberLocations = new Map();
|
|
730
|
+
for (const p of pages) {
|
|
731
|
+
const pHasMembers = p.members !== undefined && p.members.length > 0;
|
|
732
|
+
const pIsGroup = pHasMembers && p.whole === undefined && p.sections === undefined;
|
|
733
|
+
for (const member of p.members ?? []) {
|
|
734
|
+
memberLocations.set(member.id, [...(memberLocations.get(member.id) ?? []), { pageId: p.id, isGroupPage: pIsGroup, parent: member.parent }]);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// #63 A5: resolved once, per valid family, before the per-page pass below assembles any section
|
|
738
|
+
// — never by mutating an already-built section a second time (per the controller's own decision).
|
|
739
|
+
// `outlineByFamily` is this same pre-pass's own `numberFor` call, including a synthetic stub
|
|
740
|
+
// (`parent: page.whole`) per resolved external id so `numberFor`'s existing authored-order scan
|
|
741
|
+
// (which already walks each `inWhole` section's own `members` list) numbers it exactly like an
|
|
742
|
+
// own part, with no change to `numberFor` itself.
|
|
743
|
+
const externalIssues = [];
|
|
744
|
+
const externalPartsByFamily = new Map();
|
|
745
|
+
const partOfByMemberId = new Map();
|
|
746
|
+
const outlineByFamily = new Map();
|
|
747
|
+
for (const page of pages) {
|
|
748
|
+
const pHasMembers = page.members !== undefined && page.members.length > 0;
|
|
749
|
+
if (!pHasMembers || page.whole === undefined || page.sections === undefined)
|
|
750
|
+
continue; // not a valid family.
|
|
751
|
+
const byIdThisPage = new Set(page.members.map((m) => m.id));
|
|
752
|
+
const externalResolutions = new Map();
|
|
753
|
+
for (const section of page.sections) {
|
|
754
|
+
if (!section.inWhole)
|
|
755
|
+
continue;
|
|
756
|
+
for (const id of section.members) {
|
|
757
|
+
if (byIdThisPage.has(id) || externalResolutions.has(id))
|
|
758
|
+
continue;
|
|
759
|
+
const owners = memberLocations.get(id);
|
|
760
|
+
if (!owners)
|
|
761
|
+
continue; // genuinely unknown — `validateFamily`'s own "not a member" error covers it.
|
|
762
|
+
const resolution = resolveExternalPart(id, owners, page.whole);
|
|
763
|
+
if ('issue' in resolution)
|
|
764
|
+
externalIssues.push(`Family "${page.id}": ${resolution.issue}`);
|
|
765
|
+
else
|
|
766
|
+
externalResolutions.set(id, { pageId: resolution.pageId });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const membersForNumbering = [...page.members, ...[...externalResolutions.keys()].map((id) => ({ id, parent: page.whole }))];
|
|
770
|
+
const outline = numberFor({ ...page, members: membersForNumbering });
|
|
771
|
+
outlineByFamily.set(page.id, outline);
|
|
772
|
+
if (externalResolutions.size === 0)
|
|
773
|
+
continue;
|
|
774
|
+
const familyTitle = String(page.title ?? page.id);
|
|
775
|
+
const externalMap = new Map();
|
|
776
|
+
for (const [id, resolution] of externalResolutions) {
|
|
777
|
+
const ownerPage = pages.find((p) => p.id === resolution.pageId);
|
|
778
|
+
externalMap.set(id, {
|
|
779
|
+
pageId: resolution.pageId,
|
|
780
|
+
title: String(ownerPage?.title ?? resolution.pageId),
|
|
781
|
+
fidelity: ownerPage?.members?.find((m) => m.id === id)?.fidelity,
|
|
782
|
+
});
|
|
783
|
+
const number = outline.get(id)?.number;
|
|
784
|
+
if (number)
|
|
785
|
+
partOfByMemberId.set(id, { pageId: page.id, title: familyTitle, number });
|
|
786
|
+
}
|
|
787
|
+
externalPartsByFamily.set(page.id, externalMap);
|
|
788
|
+
}
|
|
789
|
+
if (globalThis.__DEV__) {
|
|
790
|
+
for (const issueMsg of externalIssues)
|
|
791
|
+
console.error(`[Catalog] ${issueMsg}`);
|
|
792
|
+
}
|
|
793
|
+
// #64 B4: every page whose `tokenDefinition.kind` matches another page's — one page per kind.
|
|
794
|
+
const tokenKindPages = new Map();
|
|
795
|
+
for (const page of pages) {
|
|
796
|
+
if (!page.tokenDefinition)
|
|
797
|
+
continue;
|
|
798
|
+
tokenKindPages.set(page.tokenDefinition.kind, [...(tokenKindPages.get(page.tokenDefinition.kind) ?? []), page.id]);
|
|
799
|
+
}
|
|
372
800
|
// Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
|
|
373
801
|
// whole viewer that already resolves every page's id to build sections/groups, so it costs
|
|
374
802
|
// nothing extra to also notice two pages claiming the same one (`sectionsById` in
|
|
@@ -393,9 +821,45 @@ pages, components, groupOrder) {
|
|
|
393
821
|
console.warn(`[Catalog] ${page.id}.${member.id}: ${issue}`);
|
|
394
822
|
}
|
|
395
823
|
}
|
|
824
|
+
if (page.tokenDefinition) {
|
|
825
|
+
// #64 B4: one page per kind — mirrors `cli/doctor.ts`'s `token-definition-duplicate-kind`.
|
|
826
|
+
const samekind = (tokenKindPages.get(page.tokenDefinition.kind) ?? []).filter((id) => id !== page.id);
|
|
827
|
+
if (samekind.length > 0) {
|
|
828
|
+
console.error(`[Catalog] ${page.id}: tokenDefinition kind "${page.tokenDefinition.kind}" is also used by ${samekind.join(', ')}.`);
|
|
829
|
+
}
|
|
830
|
+
// #64 B5: a tokenDefinition page's own page-level/section aliases join the same
|
|
831
|
+
// catalog-wide "one owner" pool a family's own ids/aliases already check against —
|
|
832
|
+
// mirrors `cli/doctor.ts`'s own `checkTokenDefinitionAliases` (rule id `family-invalid`).
|
|
833
|
+
const otherPageIdsForTokenDef = new Set([...allPageIds].filter((id) => id !== page.id));
|
|
834
|
+
const otherIdsForTokenDef = new Set();
|
|
835
|
+
for (const [id, ids] of familyIdsByPage)
|
|
836
|
+
if (id !== page.id)
|
|
837
|
+
for (const x of ids)
|
|
838
|
+
otherIdsForTokenDef.add(x);
|
|
839
|
+
const seenAliases = new Set();
|
|
840
|
+
const checkTokenDefAlias = (alias) => {
|
|
841
|
+
if (seenAliases.has(alias))
|
|
842
|
+
console.error(`[Catalog] Page "${page.id}": id "${alias}" is used more than once on this page.`);
|
|
843
|
+
seenAliases.add(alias);
|
|
844
|
+
if (otherPageIdsForTokenDef.has(alias))
|
|
845
|
+
console.error(`[Catalog] Page "${page.id}": id "${alias}" collides with another page's id.`);
|
|
846
|
+
if (otherIdsForTokenDef.has(alias))
|
|
847
|
+
console.error(`[Catalog] Page "${page.id}": id "${alias}" collides with another member or alias elsewhere in the catalog.`);
|
|
848
|
+
};
|
|
849
|
+
for (const alias of page.aliases ?? [])
|
|
850
|
+
checkTokenDefAlias(alias);
|
|
851
|
+
for (const section of page.tokenDefinition.sections)
|
|
852
|
+
for (const alias of section.aliases ?? [])
|
|
853
|
+
checkTokenDefAlias(alias);
|
|
854
|
+
}
|
|
396
855
|
}
|
|
397
|
-
const
|
|
398
|
-
|
|
856
|
+
const hasMembers = page.members !== undefined && page.members.length > 0;
|
|
857
|
+
// #63 A1: a page with neither `whole` nor `sections` is a group, not a family; a page with
|
|
858
|
+
// exactly one of the two stays the same family-shape error `validateFamily` already reports,
|
|
859
|
+
// via `isFamily` (unchanged from before this field existed).
|
|
860
|
+
const isGroup = hasMembers && page.whole === undefined && page.sections === undefined;
|
|
861
|
+
const isFamily = hasMembers && !isGroup;
|
|
862
|
+
const componentName = hasMembers ? undefined : (page.component ?? page.id);
|
|
399
863
|
const component = componentName ? componentByName.get(componentName) : undefined;
|
|
400
864
|
addToGroup(page.group, page.id);
|
|
401
865
|
let family;
|
|
@@ -412,7 +876,8 @@ pages, components, groupOrder) {
|
|
|
412
876
|
...member,
|
|
413
877
|
hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
|
|
414
878
|
}));
|
|
415
|
-
const
|
|
879
|
+
const externalPartIds = new Set(externalPartsByFamily.get(page.id)?.keys() ?? []);
|
|
880
|
+
const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds, externalPartIds);
|
|
416
881
|
if (globalThis.__DEV__) {
|
|
417
882
|
for (const issue of issues)
|
|
418
883
|
console.error(`[Catalog] ${issue}`);
|
|
@@ -424,34 +889,80 @@ pages, components, groupOrder) {
|
|
|
424
889
|
family = {
|
|
425
890
|
whole: page.whole,
|
|
426
891
|
sections: page.sections,
|
|
427
|
-
|
|
892
|
+
// #63 A5: the pre-pass above already ran `numberFor` with every valid external part's
|
|
893
|
+
// synthetic stub included; the "exactly one of whole/sections" invalid shape never reaches
|
|
894
|
+
// that pre-pass, so it falls back to the same plain `numberFor(page)` call this always was.
|
|
895
|
+
outline: outlineByFamily.get(page.id) ?? numberFor(page),
|
|
428
896
|
notShown: page.notShown,
|
|
429
897
|
aliases: page.aliases,
|
|
430
898
|
apiLabel: page.apiLabel,
|
|
431
899
|
anatomy: page.anatomy,
|
|
432
900
|
relatedPages: page.relatedPages,
|
|
901
|
+
externalParts: externalPartsByFamily.get(page.id),
|
|
433
902
|
members: page.members.map((member) => {
|
|
434
903
|
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
435
904
|
// Dedupe by `component`, keeping the first role: page-level and whole-member `composedOf`
|
|
436
905
|
// attribute to the same string for a family's whole (`validateFamily` forces
|
|
437
906
|
// `whole === page.id`), and `validateComposedOf` has no duplicate check of its own, so the
|
|
438
907
|
// same user declared twice would otherwise render "Also used by P and P".
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
908
|
+
const alsoUsedBy = dedupeAlsoUsedBy(usedByTarget.get(member.id) ?? [], pageMemberIds);
|
|
909
|
+
return {
|
|
910
|
+
...member,
|
|
911
|
+
props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
|
|
912
|
+
path: memberComponent?.file ?? member.source,
|
|
913
|
+
alsoUsedBy,
|
|
914
|
+
};
|
|
915
|
+
}),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
else if (isGroup) {
|
|
919
|
+
const otherPageIds = new Set([...allPageIds].filter((id) => id !== page.id));
|
|
920
|
+
const otherMemberIds = new Set();
|
|
921
|
+
for (const [id, ids] of familyIdsByPage)
|
|
922
|
+
if (id !== page.id)
|
|
923
|
+
for (const memberId of ids)
|
|
924
|
+
otherMemberIds.add(memberId);
|
|
925
|
+
const groupMembers = page.members.map((member) => ({
|
|
926
|
+
...member,
|
|
927
|
+
hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
|
|
928
|
+
}));
|
|
929
|
+
const groupIssues = validateGroup({ id: page.id, members: groupMembers, aliases: page.aliases, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
|
|
930
|
+
if (globalThis.__DEV__) {
|
|
931
|
+
for (const issue of groupIssues)
|
|
932
|
+
console.error(`[Catalog] ${issue}`);
|
|
933
|
+
}
|
|
934
|
+
// #63 A4: one top-level member is any member with no parent, or whose parent isn't one of
|
|
935
|
+
// this page's own members (A3's cross-page case) — its own parts are numbered independently
|
|
936
|
+
// of every other top-level member's (`numberForGroupMember`), then merged into one outline
|
|
937
|
+
// map the same shape a family's own `outline` already has.
|
|
938
|
+
const byIdThisPage = new Set(page.members.map((m) => m.id));
|
|
939
|
+
const groupOutline = new Map();
|
|
940
|
+
for (const m of page.members) {
|
|
941
|
+
if (m.parent !== undefined && byIdThisPage.has(m.parent))
|
|
942
|
+
continue; // not top-level.
|
|
943
|
+
for (const [id, entry] of numberForGroupMember(m.id, page.members))
|
|
944
|
+
groupOutline.set(id, entry);
|
|
945
|
+
}
|
|
946
|
+
const pageMemberIds = new Set(page.members.map((m) => m.id));
|
|
947
|
+
family = {
|
|
948
|
+
kind: 'group',
|
|
949
|
+
whole: undefined,
|
|
950
|
+
sections: [],
|
|
951
|
+
outline: groupOutline,
|
|
952
|
+
notShown: page.notShown,
|
|
953
|
+
aliases: page.aliases,
|
|
954
|
+
apiLabel: page.apiLabel,
|
|
955
|
+
relatedPages: page.relatedPages,
|
|
956
|
+
members: page.members.map((member) => {
|
|
957
|
+
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
958
|
+
const alsoUsedBy = dedupeAlsoUsedBy(usedByTarget.get(member.id) ?? [], pageMemberIds);
|
|
959
|
+
const partOf = partOfByMemberId.get(member.id);
|
|
450
960
|
return {
|
|
451
961
|
...member,
|
|
452
962
|
props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
|
|
453
963
|
path: memberComponent?.file ?? member.source,
|
|
454
964
|
alsoUsedBy,
|
|
965
|
+
...(partOf ? { partOf } : {}),
|
|
455
966
|
};
|
|
456
967
|
}),
|
|
457
968
|
};
|
|
@@ -459,21 +970,29 @@ pages, components, groupOrder) {
|
|
|
459
970
|
// Important finding 4: a standalone page (no matching component record) has no props to show
|
|
460
971
|
// and nothing to say "This component takes no props" about — hide the Props box, and fall back
|
|
461
972
|
// to the page's own file as its Source instead of an empty path.
|
|
973
|
+
// #61: resolved once so `title`/`tokenGallery`/`tokenSections` below can each fall back to it.
|
|
974
|
+
const derivedTokenDefinition = deriveTokenDefinition(page);
|
|
462
975
|
return {
|
|
463
976
|
id: page.id,
|
|
464
|
-
title: page.title,
|
|
977
|
+
title: derivedTokenDefinition?.title ?? page.title,
|
|
465
978
|
osComponent: page.osComponent,
|
|
466
979
|
description: page.description,
|
|
467
980
|
path: component?.file ?? page.source ?? page.file,
|
|
468
981
|
whenToUse: page.whenToUse,
|
|
469
982
|
props: component ? mergePropNotes(component, page.propNotes) : undefined,
|
|
470
983
|
a11y: page.a11y,
|
|
984
|
+
notes: page.notes,
|
|
471
985
|
variants: page.variants,
|
|
472
986
|
states: page.states,
|
|
473
987
|
render: page.render,
|
|
474
988
|
// `tokenSections` makes a page a token gallery by itself.
|
|
475
|
-
tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
|
|
476
|
-
tokenSections: page.tokenSections,
|
|
989
|
+
tokenGallery: derivedTokenDefinition?.tokenGallery ?? (page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined),
|
|
990
|
+
tokenSections: derivedTokenDefinition?.tokenSections ?? page.tokenSections,
|
|
991
|
+
// #61: the real entry data `SectionBlock.tsx` renders instead of the placeholder sections
|
|
992
|
+
// above, when set — see `SectionDef.tokenDefinition`'s own doc comment.
|
|
993
|
+
tokenDefinition: page.tokenDefinition,
|
|
994
|
+
// #64 B5: extends `page.aliases` to a tokenDefinition page too (see `SectionDef.tokenDefinitionAliases`).
|
|
995
|
+
tokenDefinitionAliases: page.tokenDefinition ? page.aliases : undefined,
|
|
477
996
|
tokenColumns: page.tokenColumns,
|
|
478
997
|
fullWidthLabel: page.fullWidthLabel,
|
|
479
998
|
comparison: page.comparison,
|
|
@@ -486,6 +1005,14 @@ pages, components, groupOrder) {
|
|
|
486
1005
|
internalProps: page.internalProps,
|
|
487
1006
|
intentionalStaticPreview: page.intentionalStaticPreview,
|
|
488
1007
|
family,
|
|
1008
|
+
navigation: page.navigation,
|
|
1009
|
+
// Family and group pages alike carry their own component binding per member
|
|
1010
|
+
// (`FamilyDef.members[].component` isn't a field — a member names its binding via
|
|
1011
|
+
// `CatalogMember.component` itself); only a normal page's own single binding is surfaced here.
|
|
1012
|
+
component: hasMembers ? undefined : component?.name,
|
|
1013
|
+
// #60: see `SectionDef.alsoUsedBy` (rendered by `ReferenceDetails`'s `AlsoUsedByFact`) — a family or group
|
|
1014
|
+
// page's "also used by" lives per member instead (`family.members[].alsoUsedBy`, above).
|
|
1015
|
+
alsoUsedBy: hasMembers ? undefined : dedupeAlsoUsedBy(usedByTarget.get(page.id) ?? [], new Set([page.id])),
|
|
489
1016
|
};
|
|
490
1017
|
});
|
|
491
1018
|
const undocumented = components.filter((component) => !documented.has(component.name)).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -500,7 +1027,11 @@ pages, components, groupOrder) {
|
|
|
500
1027
|
}
|
|
501
1028
|
continue;
|
|
502
1029
|
}
|
|
503
|
-
sections.push({
|
|
1030
|
+
sections.push({
|
|
1031
|
+
id, description: 'No examples documented.', path: component.file, props: mergePropNotes(component, undefined),
|
|
1032
|
+
component: component.name,
|
|
1033
|
+
alsoUsedBy: dedupeAlsoUsedBy(usedByTarget.get(id) ?? [], new Set([id])),
|
|
1034
|
+
});
|
|
504
1035
|
addToGroup('Components', id);
|
|
505
1036
|
}
|
|
506
1037
|
return { sections, groups: orderGroups(groupIds, groupOrder) };
|