@krapwoo/ds-viewer 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -1
- package/dist/cli/doctor.js +182 -3
- package/dist/cli/render.js +93 -0
- package/dist/cli/staticPage.js +129 -0
- package/dist/native/catalog/catalogNavigation.js +62 -3
- package/dist/native/catalog/comparison.js +14 -3
- package/dist/native/catalog/pageApi.js +408 -0
- package/dist/native/catalog/tokens.js +7 -0
- package/native/catalog/CatalogShell.tsx +206 -25
- package/native/catalog/CatalogSidebar.tsx +57 -3
- package/native/catalog/FamilyPage.tsx +1188 -0
- package/native/catalog/SectionBlock.tsx +57 -13
- package/native/catalog/catalogNavigation.ts +89 -3
- package/native/catalog/comparison.ts +17 -6
- package/native/catalog/familyModel.ts +192 -0
- package/native/catalog/index.ts +5 -2
- package/native/catalog/overlayViewport.ts +30 -10
- package/native/catalog/pageApi.ts +357 -15
- package/native/catalog/tokens.ts +7 -0
- package/native/catalog/types.ts +106 -0
- package/package.json +1 -1
|
@@ -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),
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
export function defineCatalogPage(page) {
|
|
2
|
+
return { ...page, __dsViewerPage: true };
|
|
3
|
+
}
|
|
4
|
+
function mergePropNotes(component, propNotes) {
|
|
5
|
+
const own = component.props.map((prop) => ({
|
|
6
|
+
name: prop.name,
|
|
7
|
+
type: prop.type,
|
|
8
|
+
required: prop.required,
|
|
9
|
+
default: prop.default,
|
|
10
|
+
desc: propNotes?.[prop.name] ? `${prop.desc} ${propNotes[prop.name]}`.trim() : prop.desc,
|
|
11
|
+
}));
|
|
12
|
+
// Design §3: props inherited from node_modules (e.g. `extends TextInputProps`) are summarised as
|
|
13
|
+
// one row per source instead of being listed individually.
|
|
14
|
+
const inherited = component.inheritedFrom.map((source) => ({
|
|
15
|
+
name: `${source} props`,
|
|
16
|
+
type: 'inherited',
|
|
17
|
+
required: true,
|
|
18
|
+
desc: `Plus all ${source} props, not listed individually.`,
|
|
19
|
+
}));
|
|
20
|
+
return [...own, ...inherited];
|
|
21
|
+
}
|
|
22
|
+
/** Human-readable problems with a page's (or, V2d, a family member's own) `composedOf`, in
|
|
23
|
+
* declaration order: one line per entry whose `component` isn't in `knownNames` (every real
|
|
24
|
+
* component name, documented or not, plus every page id and family member id — the full catalog
|
|
25
|
+
* inventory `buildCatalogSections` already has on hand). Empty when every name resolves,
|
|
26
|
+
* including when `composedOf` itself is omitted. Takes only the `component` field (never the
|
|
27
|
+
* narrower `ComposedOfEntry[]` type) so `cli/doctor.ts`'s own statically-read `{ component:
|
|
28
|
+
* string }[]` shape — which never carries `role`/`relationship` — satisfies it too. */
|
|
29
|
+
export function validateComposedOf(composedOf, knownNames) {
|
|
30
|
+
if (!composedOf)
|
|
31
|
+
return [];
|
|
32
|
+
return composedOf
|
|
33
|
+
.filter((entry) => !knownNames.has(entry.component))
|
|
34
|
+
.map((entry) => `composedOf references unknown component "${entry.component}".`);
|
|
35
|
+
}
|
|
36
|
+
/** Validates a family page (spec §5.1): exactly one whole belonging to no section; every other
|
|
37
|
+
* member in exactly one section; every parent resolving within the page with no cycles; member
|
|
38
|
+
* and alias ids unique catalog-wide and never colliding with a page id, except the whole (whose
|
|
39
|
+
* 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
|
+
export function validateFamily(page, allPageIds, otherMemberIds) {
|
|
42
|
+
const { members } = page;
|
|
43
|
+
if (!members || members.length === 0)
|
|
44
|
+
return [];
|
|
45
|
+
const issues = [];
|
|
46
|
+
if (!page.whole || !page.sections) {
|
|
47
|
+
issues.push(`Family "${page.id}": "members" requires both "whole" and "sections".`);
|
|
48
|
+
return issues;
|
|
49
|
+
}
|
|
50
|
+
const whole = page.whole;
|
|
51
|
+
const byId = new Map(members.map((m) => [m.id, m]));
|
|
52
|
+
if (!byId.has(whole)) {
|
|
53
|
+
issues.push(`Family "${page.id}": whole "${whole}" is not one of its members.`);
|
|
54
|
+
}
|
|
55
|
+
if (whole !== page.id) {
|
|
56
|
+
issues.push(`Family "${page.id}": whole "${whole}" must have the same id as the page.`);
|
|
57
|
+
}
|
|
58
|
+
// V2c item 2: a section listing an id that is not one of the page's own members — reported
|
|
59
|
+
// here, rather than left to crash the renderer or silently occupy authored order in `numberFor`.
|
|
60
|
+
// N6: a `list`-section member authored with a specimen — `FoldedRow` never renders one, so this
|
|
61
|
+
// degrades to a silently dropped specimen rather than a crash, same as the unknown-id case.
|
|
62
|
+
for (const section of page.sections) {
|
|
63
|
+
for (const id of section.members) {
|
|
64
|
+
const resolved = byId.get(id);
|
|
65
|
+
if (!resolved) {
|
|
66
|
+
issues.push(`Family "${page.id}": section "${section.label}" lists "${id}", which is not a member of this page.`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (section.kind === 'list' && resolved.hasSpecimens) {
|
|
70
|
+
issues.push(`Family "${page.id}": "${id}" has specimens, but list sections show members as folded rows without specimens.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const sectionCountById = new Map();
|
|
75
|
+
for (const section of page.sections) {
|
|
76
|
+
for (const id of section.members)
|
|
77
|
+
sectionCountById.set(id, (sectionCountById.get(id) ?? 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
if (sectionCountById.has(whole)) {
|
|
80
|
+
issues.push(`Family "${page.id}": the whole "${whole}" must not appear in any section.`);
|
|
81
|
+
}
|
|
82
|
+
for (const m of members) {
|
|
83
|
+
if (m.id === whole)
|
|
84
|
+
continue;
|
|
85
|
+
const count = sectionCountById.get(m.id) ?? 0;
|
|
86
|
+
if (count === 0)
|
|
87
|
+
issues.push(`Family "${page.id}": member "${m.id}" is not in any section.`);
|
|
88
|
+
else if (count > 1)
|
|
89
|
+
issues.push(`Family "${page.id}": member "${m.id}" is in more than one section.`);
|
|
90
|
+
}
|
|
91
|
+
for (const m of members) {
|
|
92
|
+
if (m.parent === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
if (!byId.has(m.parent)) {
|
|
95
|
+
issues.push(`Family "${page.id}": member "${m.id}" has an unknown parent "${m.parent}".`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const seen = new Set([m.id]);
|
|
99
|
+
let current = m.parent;
|
|
100
|
+
let cycle = false;
|
|
101
|
+
while (current !== undefined) {
|
|
102
|
+
if (seen.has(current)) {
|
|
103
|
+
cycle = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
seen.add(current);
|
|
107
|
+
current = byId.get(current)?.parent;
|
|
108
|
+
}
|
|
109
|
+
if (cycle)
|
|
110
|
+
issues.push(`Family "${page.id}": member "${m.id}" has a parent cycle.`);
|
|
111
|
+
}
|
|
112
|
+
const seenInPage = new Set();
|
|
113
|
+
const checkId = (id, isWhole) => {
|
|
114
|
+
if (seenInPage.has(id))
|
|
115
|
+
issues.push(`Family "${page.id}": id "${id}" is used more than once on this page.`);
|
|
116
|
+
seenInPage.add(id);
|
|
117
|
+
if (isWhole)
|
|
118
|
+
return;
|
|
119
|
+
if (allPageIds.has(id))
|
|
120
|
+
issues.push(`Family "${page.id}": id "${id}" collides with another page's id.`);
|
|
121
|
+
if (otherMemberIds.has(id))
|
|
122
|
+
issues.push(`Family "${page.id}": id "${id}" collides with another member or alias elsewhere in the catalog.`);
|
|
123
|
+
};
|
|
124
|
+
for (const m of members) {
|
|
125
|
+
checkId(m.id, m.id === whole);
|
|
126
|
+
for (const alias of m.aliases ?? [])
|
|
127
|
+
checkId(alias, false);
|
|
128
|
+
}
|
|
129
|
+
for (const alias of page.aliases ?? [])
|
|
130
|
+
checkId(alias, false);
|
|
131
|
+
// Slice D1 item 3: an anatomy `member` item must name a real, non-whole member of this page, and
|
|
132
|
+
// each member may be pinned to the picture's legend at most once.
|
|
133
|
+
// D2 item A2: a member item whose member has no number — outside every `inWhole` section, or
|
|
134
|
+
// under a parent that isn't itself numbered — must also be reported; left unchecked, it renders
|
|
135
|
+
// a blank 34px pin slot in the legend and nothing at all from `AnatomyPin` in the picture.
|
|
136
|
+
if (page.anatomy) {
|
|
137
|
+
const numbered = numberFor(page);
|
|
138
|
+
const seenAnatomyMembers = new Set();
|
|
139
|
+
for (const group of page.anatomy.groups) {
|
|
140
|
+
for (const item of group.items) {
|
|
141
|
+
if (!('member' in item))
|
|
142
|
+
continue;
|
|
143
|
+
if (item.member === whole) {
|
|
144
|
+
issues.push(`Family "${page.id}": anatomy item "${item.member}" names the whole.`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!byId.has(item.member)) {
|
|
148
|
+
issues.push(`Family "${page.id}": anatomy item "${item.member}" is not a member of this page.`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (seenAnatomyMembers.has(item.member)) {
|
|
152
|
+
issues.push(`Family "${page.id}": anatomy lists member "${item.member}" more than once.`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
seenAnatomyMembers.add(item.member);
|
|
156
|
+
if (!numbered.has(item.member)) {
|
|
157
|
+
issues.push(`Family "${page.id}": anatomy item "${item.member}" has no number.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return issues;
|
|
163
|
+
}
|
|
164
|
+
/** Slice D1 item 2: anatomy-first numbering — the only numbering rule for every family page (not
|
|
165
|
+
* only one with `anatomy` set; it superseded the earlier whole-and-letters rule outright). The
|
|
166
|
+
* whole gets no number. A **top-level part** is any member of an `inWhole` section whose `parent`
|
|
167
|
+
* is the whole, or who has no `parent` at all (an in-whole root with no Swift parent edge is still
|
|
168
|
+
* top-level) — numbered `1`, `2`, `3`… in authored order (the order a member first appears
|
|
169
|
+
* scanning `inWhole` sections, in section order). A child takes its parent's number plus `.1`,
|
|
170
|
+
* `.2`, … in authored order. Members outside any `inWhole` section (Related) get no number at
|
|
171
|
+
* all. Returns an empty map for a page without members. */
|
|
172
|
+
export function numberFor(page) {
|
|
173
|
+
const outline = new Map();
|
|
174
|
+
const { whole, sections, members } = page;
|
|
175
|
+
if (!whole || !sections || !members)
|
|
176
|
+
return outline;
|
|
177
|
+
const memberIds = new Set(members.map((m) => m.id));
|
|
178
|
+
// Only `inWhole` sections contribute to numbering — a Related section's members are never
|
|
179
|
+
// top-level parts, and never occupy authored order (so they can't be skipped-over roots either).
|
|
180
|
+
const authoredOrder = [];
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
for (const section of sections) {
|
|
183
|
+
if (!section.inWhole)
|
|
184
|
+
continue;
|
|
185
|
+
for (const id of section.members) {
|
|
186
|
+
if (!seen.has(id) && memberIds.has(id)) {
|
|
187
|
+
seen.add(id);
|
|
188
|
+
authoredOrder.push(id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const parentOf = new Map(members.map((m) => [m.id, m.parent]));
|
|
193
|
+
const childrenOf = new Map();
|
|
194
|
+
for (const id of authoredOrder) {
|
|
195
|
+
const parent = parentOf.get(id);
|
|
196
|
+
if (parent === undefined)
|
|
197
|
+
continue;
|
|
198
|
+
childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), id]);
|
|
199
|
+
}
|
|
200
|
+
// V2c item 14's cycle guard: bad data already reported by `validateFamily` must degrade to a
|
|
201
|
+
// partial outline, never an infinite recursion.
|
|
202
|
+
const assignChildren = (parentId, parentNumber, level, visited) => {
|
|
203
|
+
const kids = childrenOf.get(parentId) ?? [];
|
|
204
|
+
kids.forEach((childId, index) => {
|
|
205
|
+
if (visited.has(childId))
|
|
206
|
+
return;
|
|
207
|
+
visited.add(childId);
|
|
208
|
+
const number = `${parentNumber}.${index + 1}`;
|
|
209
|
+
outline.set(childId, { number, level: level + 1 });
|
|
210
|
+
assignChildren(childId, number, level + 1, visited);
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
const visited = new Set([whole]);
|
|
214
|
+
const topLevel = authoredOrder.filter((id) => {
|
|
215
|
+
const parent = parentOf.get(id);
|
|
216
|
+
return parent === undefined || parent === whole;
|
|
217
|
+
});
|
|
218
|
+
let next = 0;
|
|
219
|
+
for (const id of topLevel) {
|
|
220
|
+
if (visited.has(id))
|
|
221
|
+
continue;
|
|
222
|
+
visited.add(id);
|
|
223
|
+
next += 1;
|
|
224
|
+
const number = String(next);
|
|
225
|
+
outline.set(id, { number, level: 2 });
|
|
226
|
+
assignChildren(id, number, 2, visited);
|
|
227
|
+
}
|
|
228
|
+
return outline;
|
|
229
|
+
}
|
|
230
|
+
function orderGroups(groupIds, groupOrder) {
|
|
231
|
+
const listed = groupOrder ?? [];
|
|
232
|
+
const labels = [...groupIds.keys()];
|
|
233
|
+
const ordered = [...listed.filter((label) => groupIds.has(label)), ...labels.filter((label) => !listed.includes(label)).sort()];
|
|
234
|
+
return ordered.map((label) => ({ label, ids: groupIds.get(label) ?? [] }));
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Builds the viewer's `sections`/`groups` from authored pages plus generated component data — the
|
|
238
|
+
* logic the generated entry file (Task 12) runs on every bundle. A documented component's page
|
|
239
|
+
* supplies everything page-authored; its props always come from `components` (never authored by
|
|
240
|
+
* hand). A component with no page still appears, with "No examples documented." (design §2).
|
|
241
|
+
*/
|
|
242
|
+
export function buildCatalogSections(
|
|
243
|
+
// The generated page index (Task 9) always fills in `id` (`id ?? component ?? file stem`) and
|
|
244
|
+
// `file` (the page's own project-relative path), so pages arriving here have both even though
|
|
245
|
+
// authors may omit them.
|
|
246
|
+
pages, components, groupOrder) {
|
|
247
|
+
const componentByName = new Map(components.map((c) => [c.name, c]));
|
|
248
|
+
// A page with `members` documents every member's own `component` instead of `page.component ??
|
|
249
|
+
// page.id` — that fallback never applies to a family page (spec §5.1).
|
|
250
|
+
const documented = new Set();
|
|
251
|
+
for (const page of pages) {
|
|
252
|
+
if (page.members && page.members.length > 0) {
|
|
253
|
+
for (const member of page.members)
|
|
254
|
+
if (member.component)
|
|
255
|
+
documented.add(member.component);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
documented.add(page.component ?? page.id);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const groupIds = new Map();
|
|
262
|
+
const addToGroup = (group, id) => groupIds.set(group, [...(groupIds.get(group) ?? []), id]);
|
|
263
|
+
// Every name a page's (or, V2d, a family member's own) `composedOf` could legitimately point
|
|
264
|
+
// at: every real component (documented or not — an undocumented one is still a real part of the
|
|
265
|
+
// design system), every page id (token pages, standalone recipes, …), every family member id
|
|
266
|
+
// (a member's composedOf may point at a sibling member, or a member of another family), and —
|
|
267
|
+
// N8 — every member alias and page-level alias: `FamilyPage`'s own `resolvesAddress` already
|
|
268
|
+
// treats these as working links (`aliasTargets`), so this warning must accept the same names.
|
|
269
|
+
const knownComposedOfNames = new Set([
|
|
270
|
+
...componentByName.keys(),
|
|
271
|
+
...pages.map((page) => page.id),
|
|
272
|
+
...pages.flatMap((page) => (page.members ?? []).map((member) => member.id)),
|
|
273
|
+
...pages.flatMap((page) => (page.members ?? []).flatMap((member) => member.aliases ?? [])),
|
|
274
|
+
...pages.flatMap((page) => page.aliases ?? []),
|
|
275
|
+
]);
|
|
276
|
+
// Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
|
|
277
|
+
// front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
|
|
278
|
+
// page's ids.
|
|
279
|
+
const allPageIds = new Set(pages.map((page) => page.id));
|
|
280
|
+
const familyIdsByPage = new Map();
|
|
281
|
+
for (const page of pages) {
|
|
282
|
+
if (!page.members)
|
|
283
|
+
continue;
|
|
284
|
+
const ids = new Set();
|
|
285
|
+
for (const member of page.members) {
|
|
286
|
+
ids.add(member.id);
|
|
287
|
+
for (const alias of member.aliases ?? [])
|
|
288
|
+
ids.add(alias);
|
|
289
|
+
}
|
|
290
|
+
for (const alias of page.aliases ?? [])
|
|
291
|
+
ids.add(alias);
|
|
292
|
+
familyIdsByPage.set(page.id, ids);
|
|
293
|
+
}
|
|
294
|
+
// Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
|
|
295
|
+
// whole viewer that already resolves every page's id to build sections/groups, so it costs
|
|
296
|
+
// nothing extra to also notice two pages claiming the same one (`sectionsById` in
|
|
297
|
+
// `CatalogShell.tsx` would otherwise just let the last one silently win).
|
|
298
|
+
const seenIds = new Set();
|
|
299
|
+
const sections = pages.map((page) => {
|
|
300
|
+
// N2: `seenIds` must be populated (and consulted, below in the undocumented-component loop)
|
|
301
|
+
// regardless of `__DEV__` — only the `console.warn` calls are development-only diagnostics;
|
|
302
|
+
// a production bundle (Metro's `__DEV__ = false`) still needs the stub-skip to work.
|
|
303
|
+
if (seenIds.has(page.id)) {
|
|
304
|
+
if (globalThis.__DEV__) {
|
|
305
|
+
console.warn(`[Catalog] Page id "${page.id}" is used by more than one page; only the last one will show. Give one of them its own "id".`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
seenIds.add(page.id);
|
|
309
|
+
if (globalThis.__DEV__) {
|
|
310
|
+
for (const issue of validateComposedOf(page.composedOf, knownComposedOfNames))
|
|
311
|
+
console.warn(`[Catalog] ${page.id}: ${issue}`);
|
|
312
|
+
// V2d item 4: the same validation, per family member's own `composedOf`.
|
|
313
|
+
for (const member of page.members ?? []) {
|
|
314
|
+
for (const issue of validateComposedOf(member.composedOf, knownComposedOfNames)) {
|
|
315
|
+
console.warn(`[Catalog] ${page.id}.${member.id}: ${issue}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const isFamily = page.members !== undefined && page.members.length > 0;
|
|
320
|
+
const componentName = isFamily ? undefined : (page.component ?? page.id);
|
|
321
|
+
const component = componentName ? componentByName.get(componentName) : undefined;
|
|
322
|
+
addToGroup(page.group, page.id);
|
|
323
|
+
let family;
|
|
324
|
+
if (isFamily) {
|
|
325
|
+
const otherPageIds = new Set([...allPageIds].filter((id) => id !== page.id));
|
|
326
|
+
const otherMemberIds = new Set();
|
|
327
|
+
for (const [id, ids] of familyIdsByPage)
|
|
328
|
+
if (id !== page.id)
|
|
329
|
+
for (const memberId of ids)
|
|
330
|
+
otherMemberIds.add(memberId);
|
|
331
|
+
// `hasSpecimens` is computed here, as doctor does, so the list-section rule also fires in
|
|
332
|
+
// the development console, not only in `ds-viewer doctor`.
|
|
333
|
+
const members = page.members.map((member) => ({
|
|
334
|
+
...member,
|
|
335
|
+
hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
|
|
336
|
+
}));
|
|
337
|
+
const issues = validateFamily({ ...page, members, anatomy: page.anatomy }, otherPageIds, otherMemberIds);
|
|
338
|
+
if (globalThis.__DEV__) {
|
|
339
|
+
for (const issue of issues)
|
|
340
|
+
console.error(`[Catalog] ${issue}`);
|
|
341
|
+
}
|
|
342
|
+
family = {
|
|
343
|
+
whole: page.whole,
|
|
344
|
+
sections: page.sections,
|
|
345
|
+
outline: numberFor(page),
|
|
346
|
+
notShown: page.notShown,
|
|
347
|
+
aliases: page.aliases,
|
|
348
|
+
apiLabel: page.apiLabel,
|
|
349
|
+
anatomy: page.anatomy,
|
|
350
|
+
members: page.members.map((member) => {
|
|
351
|
+
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
352
|
+
return {
|
|
353
|
+
...member,
|
|
354
|
+
props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
|
|
355
|
+
path: memberComponent?.file ?? member.source,
|
|
356
|
+
};
|
|
357
|
+
}),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// Important finding 4: a standalone page (no matching component record) has no props to show
|
|
361
|
+
// and nothing to say "This component takes no props" about — hide the Props box, and fall back
|
|
362
|
+
// to the page's own file as its Source instead of an empty path.
|
|
363
|
+
return {
|
|
364
|
+
id: page.id,
|
|
365
|
+
title: page.title,
|
|
366
|
+
osComponent: page.osComponent,
|
|
367
|
+
description: page.description,
|
|
368
|
+
path: component?.file ?? page.source ?? page.file,
|
|
369
|
+
whenToUse: page.whenToUse,
|
|
370
|
+
props: component ? mergePropNotes(component, page.propNotes) : undefined,
|
|
371
|
+
a11y: page.a11y,
|
|
372
|
+
variants: page.variants,
|
|
373
|
+
states: page.states,
|
|
374
|
+
render: page.render,
|
|
375
|
+
// `tokenSections` makes a page a token gallery by itself.
|
|
376
|
+
tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
|
|
377
|
+
tokenSections: page.tokenSections,
|
|
378
|
+
tokenColumns: page.tokenColumns,
|
|
379
|
+
fullWidthLabel: page.fullWidthLabel,
|
|
380
|
+
comparison: page.comparison,
|
|
381
|
+
specimenSize: page.specimenSize,
|
|
382
|
+
specimenSurface: page.specimenSurface,
|
|
383
|
+
previewWidths: page.previewWidths,
|
|
384
|
+
previewLayout: page.previewLayout,
|
|
385
|
+
hide: component ? page.hide : { ...page.hide, props: true },
|
|
386
|
+
composedOf: page.composedOf,
|
|
387
|
+
internalProps: page.internalProps,
|
|
388
|
+
intentionalStaticPreview: page.intentionalStaticPreview,
|
|
389
|
+
family,
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
const undocumented = components.filter((component) => !documented.has(component.name)).sort((a, b) => a.name.localeCompare(b.name));
|
|
393
|
+
for (const component of undocumented) {
|
|
394
|
+
const id = component.name;
|
|
395
|
+
// V2c item 7: a component with no page of its own, whose name happens to collide with an
|
|
396
|
+
// authored page's own id (most likely a family page, which never resolves `documented` via the
|
|
397
|
+
// `page.component ?? page.id` fallback) — its stub must never win that id in `sections`.
|
|
398
|
+
if (seenIds.has(id)) {
|
|
399
|
+
if (globalThis.__DEV__) {
|
|
400
|
+
console.warn(`[Catalog] Component "${id}" has no catalog page of its own, and "${id}" is already used by another page's id; skipping its stub.`);
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
sections.push({ id, description: 'No examples documented.', path: component.file, props: mergePropNotes(component, undefined) });
|
|
405
|
+
addToGroup('Components', id);
|
|
406
|
+
}
|
|
407
|
+
return { sections, groups: orderGroups(groupIds, groupOrder) };
|
|
408
|
+
}
|
|
@@ -172,6 +172,13 @@ export const CATALOG_COLOR = {
|
|
|
172
172
|
accentSubtle: '#e9efff',
|
|
173
173
|
/** 3px keyboard focus ring on every catalog control. */
|
|
174
174
|
focusRing: '#c9d7ff',
|
|
175
|
+
/** Family-page Part tree connector line (Slice D1/D2) — the approved reference's own line color
|
|
176
|
+
* has no match among the other tokens above. */
|
|
177
|
+
treeLine: '#a9bdf0',
|
|
178
|
+
/** Family-page API code block: a dark surface and its text, the one place the catalog shows a
|
|
179
|
+
* member's raw API signature. Dark on purpose so it reads as code, not as catalog chrome. */
|
|
180
|
+
codeSurface: '#1d1d1f',
|
|
181
|
+
codeOnSurface: '#e8e8e8',
|
|
175
182
|
/** @deprecated kept for the public API; re-points at `CATALOG_FONT.code` so there is one source
|
|
176
183
|
* of truth. New code should reach for `CATALOG_FONT.code` (or a `CATALOG_TEXT` `code*` role)
|
|
177
184
|
* instead. */
|