@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,5 +1,6 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
2
|
import type { AlsoUsedByEntry, CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, RelatedPage, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
|
|
3
|
+
import type { TokenDefinition, TokenKind } from './tokenDefinition.ts';
|
|
3
4
|
|
|
4
5
|
/** `SectionDef` without the derived fields (`id`/`path`/`props` come from detection and
|
|
5
6
|
* generated props), plus the three fields design §1 "Page shape" adds. */
|
|
@@ -20,6 +21,12 @@ export interface CatalogPageInput<TId extends string = string> {
|
|
|
20
21
|
description: string;
|
|
21
22
|
whenToUse?: string;
|
|
22
23
|
a11y?: string;
|
|
24
|
+
/** Page-level notes, shown as a "Notes" entry in the page's Quick reference (the page-level
|
|
25
|
+
* counterpart of a family member's `notes`). */
|
|
26
|
+
notes?: string[];
|
|
27
|
+
/** Why this page stands on its own. Opts it out of `doctor`'s `part-own-page` and
|
|
28
|
+
* `nothing-to-show` checks and records the decision in source; the viewer does not render it. */
|
|
29
|
+
standalone?: string;
|
|
23
30
|
variants?: VariantSlot;
|
|
24
31
|
states?: VariantSlot;
|
|
25
32
|
render?: () => React.ReactNode;
|
|
@@ -27,6 +34,16 @@ export interface CatalogPageInput<TId extends string = string> {
|
|
|
27
34
|
tokenSections?: TokenSection[];
|
|
28
35
|
tokenColumns?: 1 | 2 | 3;
|
|
29
36
|
fullWidthLabel?: string;
|
|
37
|
+
/** #61: an opt-in structured token definition — real entries (name/value/provenance/source) for
|
|
38
|
+
* one of the eight approved kinds, rendered through the existing token-gallery grammar
|
|
39
|
+
* (`StructuredTokenPage.tsx`, via `SectionBlock.tsx`'s own `tokenSections` block case) instead
|
|
40
|
+
* of a hand-built `render()`/`tokenSections`. Omitting it leaves every existing
|
|
41
|
+
* `tokenGallery`/`tokenSections`/`render` page unchanged. Setting it alongside an explicit
|
|
42
|
+
* `render`/`tokenGallery`/`tokenSections`/`members`, or an explicit `title` that doesn't match
|
|
43
|
+
* the kind's canonical title, is an actionable conflict (`buildCatalogSections` logs it in dev;
|
|
44
|
+
* `cli/doctor.ts` fails CI on it) rather than silent precedence — see
|
|
45
|
+
* `validateTokenDefinitionUsage`. See README's "Token pages" for the authoring shape. */
|
|
46
|
+
tokenDefinition?: TokenDefinition;
|
|
30
47
|
comparison?: ComparisonDef;
|
|
31
48
|
specimenSize?: SpecimenSize;
|
|
32
49
|
specimenSurface?: SpecimenSurfaceKind;
|
|
@@ -82,6 +99,13 @@ export interface CatalogPageInput<TId extends string = string> {
|
|
|
82
99
|
* issues, and an entry that still fails to resolve at runtime renders its name as plain text
|
|
83
100
|
* instead of a dead link. Only takes effect on a page with `members`. */
|
|
84
101
|
relatedPages?: RelatedPage[];
|
|
102
|
+
/** #60 controller decision: opts this page into the "reference" browse layer instead of the
|
|
103
|
+
* default `'browse'` (omitting the field is identical to `'browse'` — no legacy page changes
|
|
104
|
+
* behavior). A reference page keeps its full address, aliases, and (on a family page) every
|
|
105
|
+
* member address — only the browse-facing sidebar/pager/hash-fallback sequence drops it; see
|
|
106
|
+
* `SectionDef.navigation`. A family page's members inherit this same layer; there is no
|
|
107
|
+
* separate per-member opt-in. */
|
|
108
|
+
navigation?: 'browse' | 'reference';
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
export interface CatalogPage<TId extends string = string> extends CatalogPageInput<TId> {
|
|
@@ -112,6 +136,21 @@ export interface GeneratedComponent {
|
|
|
112
136
|
inheritedFrom: string[];
|
|
113
137
|
}
|
|
114
138
|
|
|
139
|
+
/** Shared by a family member's own "also used by" (legacy) and, #60, an ordinary page's own —
|
|
140
|
+
* dedupes by `component`, keeping the first role, and drops any user in `excludeIds` (a family
|
|
141
|
+
* member excludes its own family's ids; an ordinary page excludes only itself). */
|
|
142
|
+
function dedupeAlsoUsedBy(
|
|
143
|
+
entries: ReadonlyArray<{ component: string; role: string }>,
|
|
144
|
+
excludeIds: ReadonlySet<string>,
|
|
145
|
+
): AlsoUsedByEntry[] {
|
|
146
|
+
const deduped = new Map<string, { component: string; role: string }>();
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (excludeIds.has(entry.component)) continue;
|
|
149
|
+
if (!deduped.has(entry.component)) deduped.set(entry.component, entry);
|
|
150
|
+
}
|
|
151
|
+
return [...deduped.values()].map((entry) => ({ component: entry.component, role: entry.role || undefined }));
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
function mergePropNotes(component: GeneratedComponent, propNotes: Record<string, string> | undefined): PropDef[] {
|
|
116
155
|
const own = component.props.map((prop) => ({
|
|
117
156
|
name: prop.name,
|
|
@@ -131,6 +170,99 @@ function mergePropNotes(component: GeneratedComponent, propNotes: Record<string,
|
|
|
131
170
|
return [...own, ...inherited];
|
|
132
171
|
}
|
|
133
172
|
|
|
173
|
+
// #61: the same canonical-title map `tokenDefinition.ts`'s own `canonicalTokenTitle` holds —
|
|
174
|
+
// duplicated here, deliberately, rather than value-imported: `pageApi.ts` must stay importable
|
|
175
|
+
// from a plain `node --experimental-strip-types` test (`pageApi.test.ts`) with no JSX/bundler
|
|
176
|
+
// loader, which needs a real resolvable `./tokenDefinition.ts` specifier, while the SAME file is
|
|
177
|
+
// also reachable from `kit-host`'s/`viewer-host`'s own "how the app actually resolves this"
|
|
178
|
+
// `moduleResolution: "bundler"` type-check (`npm run check:types`), which rejects a `.ts`-extension
|
|
179
|
+
// *value* import outright (`allowImportingTsExtensions` is off there, and that tsconfig is read-
|
|
180
|
+
// only). A `import type` (erased entirely, so its extension is never resolved at either runtime)
|
|
181
|
+
// stays fine either way — only the three lines of logic below that would otherwise need a real
|
|
182
|
+
// value import are kept local instead. `tokenDefinition.ts`'s own exported `canonicalTokenTitle`/
|
|
183
|
+
// `validateTokenDefinition`/`validateTokenDefinitionUsage` remain the canonical versions for every
|
|
184
|
+
// other consumer (`StructuredTokenPage.tsx`, `cli/doctor.ts`, the public `index.ts` barrel) — only
|
|
185
|
+
// `pageApi.ts` keeps this one local copy, and only for this one reason.
|
|
186
|
+
const TOKEN_KIND_TITLES: Record<TokenKind, string> = {
|
|
187
|
+
colors: 'Colors', typography: 'Typography', spacing: 'Spacing', radius: 'Radius',
|
|
188
|
+
dimensions: 'Dimensions', shadows: 'Shadows', opacity: 'Opacity', motion: 'Motion',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** #61: resolves `page.tokenDefinition` into the page's real `title`/`tokenGallery`/`tokenSections`
|
|
192
|
+
* — the only three fields `comparison.ts`'s own `presentationBlocks` (not writable here) reads to
|
|
193
|
+
* place a token-gallery block, so an opted-in page needs zero changes anywhere in that layout
|
|
194
|
+
* pipeline. Each derived `TokenSection`'s own `render` is a trivial `() => null` placeholder, never
|
|
195
|
+
* actually called: `SectionBlock.tsx`'s own `tokenSections` block case renders this page's real
|
|
196
|
+
* `tokenDefinition` entries instead whenever it's set (`StructuredTokenPage.tsx`) — keeping every
|
|
197
|
+
* JSX-producing closure at that existing rendering boundary, never inside this plain-TS module.
|
|
198
|
+
* Conflicts (duplicate section/entry ids, a legacy `render`/`tokenGallery`/`tokenSections`/
|
|
199
|
+
* `members` set alongside it, or an explicit title that disagrees with the kind) are logged to the
|
|
200
|
+
* dev console only — the same "soft" runtime posture `validateComposedOf`/`validateFamily` already
|
|
201
|
+
* use; `cli/doctor.ts`'s own static read of the equivalent rules (`tokenDefinition.ts`'s exported
|
|
202
|
+
* `validateTokenDefinition`/`validateTokenDefinitionUsage`) is what actually fails CI on them.
|
|
203
|
+
* Undefined when the page didn't opt in — every other field keeps the author's own values
|
|
204
|
+
* untouched. */
|
|
205
|
+
function deriveTokenDefinition(page: {
|
|
206
|
+
tokenDefinition?: TokenDefinition;
|
|
207
|
+
title?: string;
|
|
208
|
+
render?: () => React.ReactNode;
|
|
209
|
+
tokenGallery?: boolean;
|
|
210
|
+
tokenSections?: TokenSection[];
|
|
211
|
+
members?: unknown[];
|
|
212
|
+
id: string;
|
|
213
|
+
}): { title: string; tokenGallery: true; tokenSections: TokenSection[] } | undefined {
|
|
214
|
+
const def = page.tokenDefinition;
|
|
215
|
+
if (!def) return undefined;
|
|
216
|
+
if ((globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
217
|
+
const issues: string[] = [];
|
|
218
|
+
if (page.render !== undefined) issues.push('tokenDefinition conflicts with this page\'s own "render" — remove one of them.');
|
|
219
|
+
if (page.tokenGallery === true) issues.push('tokenDefinition conflicts with this page\'s own "tokenGallery" — remove one of them.');
|
|
220
|
+
if (page.tokenSections !== undefined && page.tokenSections.length > 0) issues.push('tokenDefinition conflicts with this page\'s own "tokenSections" — remove one of them.');
|
|
221
|
+
if (page.members !== undefined && page.members.length > 0) issues.push('tokenDefinition conflicts with this page\'s own "members" (a family page) — remove one of them.');
|
|
222
|
+
const canonicalTitle = TOKEN_KIND_TITLES[def.kind];
|
|
223
|
+
if (page.title !== undefined && page.title !== canonicalTitle) {
|
|
224
|
+
issues.push(`tokenDefinition: title "${page.title}" conflicts with kind "${def.kind}" (expected "${canonicalTitle}"). Omit "title" to use it automatically.`);
|
|
225
|
+
}
|
|
226
|
+
const duplicateIds = (ids: readonly string[]): string[] => {
|
|
227
|
+
const seen = new Set<string>();
|
|
228
|
+
const duplicates = new Set<string>();
|
|
229
|
+
for (const id of ids) {
|
|
230
|
+
if (seen.has(id)) duplicates.add(id);
|
|
231
|
+
seen.add(id);
|
|
232
|
+
}
|
|
233
|
+
return [...duplicates];
|
|
234
|
+
};
|
|
235
|
+
for (const id of duplicateIds(def.sections.map((s) => s.id))) issues.push(`tokenDefinition has more than one section with id "${id}".`);
|
|
236
|
+
for (const section of def.sections) {
|
|
237
|
+
for (const id of duplicateIds(section.entries.map((e) => e.id))) {
|
|
238
|
+
issues.push(`tokenDefinition section "${section.id}" has more than one entry with id "${id}".`);
|
|
239
|
+
}
|
|
240
|
+
for (const entry of section.entries) {
|
|
241
|
+
if (entry.kind !== def.kind) issues.push(`tokenDefinition section "${section.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
|
|
242
|
+
}
|
|
243
|
+
// #64 B1: groups inside a category — the same duplicate-id/kind-mismatch checks, scoped to
|
|
244
|
+
// each group's own entries, mirroring `tokenDefinition.ts`'s own `validateTokenDefinition`.
|
|
245
|
+
for (const id of duplicateIds((section.groups ?? []).map((g) => g.id))) {
|
|
246
|
+
issues.push(`tokenDefinition section "${section.id}" has more than one group with id "${id}".`);
|
|
247
|
+
}
|
|
248
|
+
for (const group of section.groups ?? []) {
|
|
249
|
+
for (const id of duplicateIds(group.entries.map((e) => e.id))) {
|
|
250
|
+
issues.push(`tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`);
|
|
251
|
+
}
|
|
252
|
+
for (const entry of group.entries) {
|
|
253
|
+
if (entry.kind !== def.kind) issues.push(`tokenDefinition section "${section.id}" group "${group.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const issueMsg of issues) console.error(`[Catalog] ${page.id}: ${issueMsg}`);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
title: page.title ?? TOKEN_KIND_TITLES[def.kind],
|
|
261
|
+
tokenGallery: true,
|
|
262
|
+
tokenSections: def.sections.map((section) => ({ id: section.id, title: section.title, desc: section.desc, wide: section.wide, render: () => null })),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
134
266
|
/** Human-readable problems with a page's (or, V2d, a family member's own) `composedOf`, in
|
|
135
267
|
* declaration order: one line per entry whose `component` isn't in `knownNames` (every real
|
|
136
268
|
* component name, documented or not, plus every page id and family member id — the full catalog
|
|
@@ -178,12 +310,25 @@ export interface FamilyAnatomyLike {
|
|
|
178
310
|
* member in exactly one section; every parent resolving within the page with no cycles; member
|
|
179
311
|
* and alias ids unique catalog-wide and never colliding with a page id, except the whole (whose
|
|
180
312
|
* id equals its own page's id). Returns `[]` for a page without `members`. `allPageIds` and
|
|
181
|
-
* `otherMemberIds` are the ids used by every *other* page/member in the catalog.
|
|
182
|
-
|
|
313
|
+
* `otherMemberIds` are the ids used by every *other* page/member in the catalog. #63 A1: a page
|
|
314
|
+
* with neither `whole` nor `sections` is a *group* page, not a family — validated instead by
|
|
315
|
+
* `validateGroup`; this function is a no-op for it (its own "both present" behavior and messages
|
|
316
|
+
* are otherwise unchanged). A page with exactly one of the two stays a family-shape error, as
|
|
317
|
+
* before. #63 A5: `externalPartIds` names every id this family's own `inWhole` sections list that
|
|
318
|
+
* was resolved (catalog-wide, by the caller) to a real, validly-parented group-page member —
|
|
319
|
+
* these are exempted from the "not a member of this page" check below; omit it (the default, `[]`)
|
|
320
|
+
* to keep every existing call site's behavior byte-for-byte unchanged. */
|
|
321
|
+
export function validateFamily(
|
|
322
|
+
page: FamilyPageLike,
|
|
323
|
+
allPageIds: ReadonlySet<string>,
|
|
324
|
+
otherMemberIds: ReadonlySet<string>,
|
|
325
|
+
externalPartIds: ReadonlySet<string> = new Set(),
|
|
326
|
+
): string[] {
|
|
183
327
|
const { members } = page;
|
|
184
328
|
if (!members || members.length === 0) return [];
|
|
185
329
|
const issues: string[] = [];
|
|
186
330
|
|
|
331
|
+
if (!page.whole && !page.sections) return issues; // A group page — see `validateGroup`.
|
|
187
332
|
if (!page.whole || !page.sections) {
|
|
188
333
|
issues.push(`Family "${page.id}": "members" requires both "whole" and "sections".`);
|
|
189
334
|
return issues;
|
|
@@ -202,10 +347,17 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
|
|
|
202
347
|
// here, rather than left to crash the renderer or silently occupy authored order in `numberFor`.
|
|
203
348
|
// N6: a `list`-section member authored with a specimen — `FoldedRow` never renders one, so this
|
|
204
349
|
// degrades to a silently dropped specimen rather than a crash, same as the unknown-id case.
|
|
350
|
+
const externalCountById = new Map<string, number>();
|
|
205
351
|
for (const section of page.sections) {
|
|
206
352
|
for (const id of section.members) {
|
|
207
353
|
const resolved = byId.get(id);
|
|
208
354
|
if (!resolved) {
|
|
355
|
+
// #63 A5: a validly-resolved external part — a group-page member, already checked by the
|
|
356
|
+
// caller against `memberLocations`/parent — is never reported as missing.
|
|
357
|
+
if (section.inWhole && externalPartIds.has(id)) {
|
|
358
|
+
externalCountById.set(id, (externalCountById.get(id) ?? 0) + 1);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
209
361
|
issues.push(`Family "${page.id}": section "${section.label}" lists "${id}", which is not a member of this page.`);
|
|
210
362
|
continue;
|
|
211
363
|
}
|
|
@@ -215,6 +367,11 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
|
|
|
215
367
|
}
|
|
216
368
|
}
|
|
217
369
|
|
|
370
|
+
// An external part rendered twice would duplicate its row, React key and registered node.
|
|
371
|
+
for (const [id, count] of externalCountById) {
|
|
372
|
+
if (count > 1) issues.push(`Family "${page.id}": member "${id}" is in more than one section.`);
|
|
373
|
+
}
|
|
374
|
+
|
|
218
375
|
const sectionCountById = new Map<string, number>();
|
|
219
376
|
for (const section of page.sections) {
|
|
220
377
|
for (const id of section.members) sectionCountById.set(id, (sectionCountById.get(id) ?? 0) + 1);
|
|
@@ -286,6 +443,15 @@ export function validateFamily(page: FamilyPageLike, allPageIds: ReadonlySet<str
|
|
|
286
443
|
continue;
|
|
287
444
|
}
|
|
288
445
|
if (!byId.has(item.member)) {
|
|
446
|
+
// #63 A5: a resolved external part (numbered by the catalog-wide pre-pass, documented on a
|
|
447
|
+
// group page) may be named in the anatomy, exactly as its `inWhole` section may list it.
|
|
448
|
+
if (externalPartIds.has(item.member)) {
|
|
449
|
+
if (seenAnatomyMembers.has(item.member)) {
|
|
450
|
+
issues.push(`Family "${page.id}": anatomy lists member "${item.member}" more than once.`);
|
|
451
|
+
}
|
|
452
|
+
seenAnatomyMembers.add(item.member);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
289
455
|
issues.push(`Family "${page.id}": anatomy item "${item.member}" is not a member of this page.`);
|
|
290
456
|
continue;
|
|
291
457
|
}
|
|
@@ -399,6 +565,224 @@ export function numberFor(page: FamilyOutlineInput): Map<string, { number: strin
|
|
|
399
565
|
return outline;
|
|
400
566
|
}
|
|
401
567
|
|
|
568
|
+
/** #63 A4: numbers one group member's own parts — authored (array) order among siblings, `1`,
|
|
569
|
+
* `2`, then `1.1`, … for children, exactly like `numberFor`'s own child assignment under a
|
|
570
|
+
* family's whole — scoped to `topLevelId`'s own descendants among `members` (a group page has no
|
|
571
|
+
* `sections` to scan, so "authored order" is simply the `members` array's own order). `topLevelId`
|
|
572
|
+
* itself gets no number, the same way a family's whole never does. Lifted, not reimplemented, from
|
|
573
|
+
* `numberFor`'s own `assignChildren`/cycle guard, re-anchored on one top-level member instead of
|
|
574
|
+
* one page-wide whole. */
|
|
575
|
+
export function numberForGroupMember(
|
|
576
|
+
topLevelId: string,
|
|
577
|
+
members: ReadonlyArray<Pick<CatalogMember, 'id' | 'parent'>>,
|
|
578
|
+
): Map<string, { number: string; level: number }> {
|
|
579
|
+
const outline = new Map<string, { number: string; level: number }>();
|
|
580
|
+
const childrenOf = new Map<string, string[]>();
|
|
581
|
+
for (const m of members) {
|
|
582
|
+
if (m.parent === undefined) continue;
|
|
583
|
+
childrenOf.set(m.parent, [...(childrenOf.get(m.parent) ?? []), m.id]);
|
|
584
|
+
}
|
|
585
|
+
const assignChildren = (parentId: string, parentNumber: string, level: number, visited: Set<string>) => {
|
|
586
|
+
const kids = childrenOf.get(parentId) ?? [];
|
|
587
|
+
kids.forEach((childId, index) => {
|
|
588
|
+
if (visited.has(childId)) return;
|
|
589
|
+
visited.add(childId);
|
|
590
|
+
const number = `${parentNumber}.${index + 1}`;
|
|
591
|
+
outline.set(childId, { number, level: level + 1 });
|
|
592
|
+
assignChildren(childId, number, level + 1, visited);
|
|
593
|
+
});
|
|
594
|
+
};
|
|
595
|
+
const visited = new Set<string>([topLevelId]);
|
|
596
|
+
const kids = childrenOf.get(topLevelId) ?? [];
|
|
597
|
+
kids.forEach((childId, index) => {
|
|
598
|
+
if (visited.has(childId)) return;
|
|
599
|
+
visited.add(childId);
|
|
600
|
+
const number = String(index + 1);
|
|
601
|
+
outline.set(childId, { number, level: 2 });
|
|
602
|
+
assignChildren(childId, number, 2, visited);
|
|
603
|
+
});
|
|
604
|
+
return outline;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** #63 A3/A6: the minimal shape `validateGroup` needs from a group page's own `members`. */
|
|
608
|
+
export interface GroupMemberLike {
|
|
609
|
+
id: string;
|
|
610
|
+
parent?: string;
|
|
611
|
+
aliases?: string[];
|
|
612
|
+
/** #63 A4: only meaningful on a top-level member — see `CatalogMember.anatomy`. */
|
|
613
|
+
anatomy?: FamilyAnatomyLike;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface GroupPageLike {
|
|
617
|
+
id: string;
|
|
618
|
+
members?: GroupMemberLike[];
|
|
619
|
+
aliases?: string[];
|
|
620
|
+
/** #63 A1: forbidden on a group page — only ever checked for presence here, never for shape
|
|
621
|
+
* (an author who sets a family-shaped `anatomy` object here still gets this one message, not a
|
|
622
|
+
* second, confusing shape error). */
|
|
623
|
+
anatomy?: unknown;
|
|
624
|
+
/** Review F8: a group page has no "Related" layer, so a non-empty list here would silently never
|
|
625
|
+
* render — reported as unused instead. Presence only, never shape. */
|
|
626
|
+
relatedPages?: readonly unknown[];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** One group-page problem with the doctor rule it belongs to, decided where the issue is raised
|
|
630
|
+
* (never by matching its message text): `group-anatomy-member` for an embedded `anatomy` problem
|
|
631
|
+
* (A4), `family-invalid` for every other shape error. */
|
|
632
|
+
export interface GroupIssue {
|
|
633
|
+
rule: 'family-invalid' | 'group-anatomy-member';
|
|
634
|
+
message: string;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** 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
|
|
638
|
+
* either to a same-page member (checked for cycles, the same guard `validateFamily` already
|
|
639
|
+
* lifts) or — A3's cross-page case — to a member known catalog-wide (`otherMemberIds`), never to
|
|
640
|
+
* neither; member and alias ids unique catalog-wide, with no privileged "whole" exemption (a group
|
|
641
|
+
* has none); and `anatomy` set only on a top-level member (one with no parent, or whose parent
|
|
642
|
+
* isn't one of this page's own members), naming only that member's own descendants on this page.
|
|
643
|
+
* Returns `[]` for a page without `members`. Mirrors `validateFamily`'s own contract and the same
|
|
644
|
+
* `allPageIds`/`otherMemberIds` meaning (every *other* page's/member's id in the catalog). */
|
|
645
|
+
export function validateGroupIssues(page: GroupPageLike, allPageIds: ReadonlySet<string>, otherMemberIds: ReadonlySet<string>): GroupIssue[] {
|
|
646
|
+
const { members } = page;
|
|
647
|
+
if (!members || members.length === 0) return [];
|
|
648
|
+
const issues: GroupIssue[] = [];
|
|
649
|
+
const shapeIssue = (message: string) => issues.push({ rule: 'family-invalid', message });
|
|
650
|
+
const anatomyIssue = (message: string) => issues.push({ rule: 'group-anatomy-member', message });
|
|
651
|
+
const byId = new Map(members.map((m) => [m.id, m]));
|
|
652
|
+
|
|
653
|
+
if (page.anatomy !== undefined) {
|
|
654
|
+
shapeIssue(`Group "${page.id}": page-level "anatomy" is not used on a group page — move it to the relevant top-level member instead.`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (page.relatedPages && page.relatedPages.length > 0) {
|
|
658
|
+
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.`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
for (const m of members) {
|
|
662
|
+
if (m.parent === undefined) continue;
|
|
663
|
+
if (!byId.has(m.parent) && !otherMemberIds.has(m.parent)) {
|
|
664
|
+
shapeIssue(`Group "${page.id}": member "${m.id}" has an unknown parent "${m.parent}".`);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
// Lifted verbatim from `validateFamily`'s own cycle guard: `byId.get(current)?.parent` already
|
|
668
|
+
// degrades to `undefined` (loop ends, no cycle) the moment `current` resolves off this page —
|
|
669
|
+
// exactly A3's cross-page top-level case, so no change is needed for that case here.
|
|
670
|
+
const seen = new Set([m.id]);
|
|
671
|
+
let current: string | undefined = m.parent;
|
|
672
|
+
let cycle = false;
|
|
673
|
+
while (current !== undefined) {
|
|
674
|
+
if (seen.has(current)) {
|
|
675
|
+
cycle = true;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
seen.add(current);
|
|
679
|
+
current = byId.get(current)?.parent;
|
|
680
|
+
}
|
|
681
|
+
if (cycle) shapeIssue(`Group "${page.id}": member "${m.id}" has a parent cycle.`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const seenInPage = new Set<string>();
|
|
685
|
+
const checkId = (id: string, isMemberOrMemberAlias = true) => {
|
|
686
|
+
// `allPageIds` excludes this page, and `resolveRoute` matches page ids before aliases — so a
|
|
687
|
+
// member (or its alias) named like its own page could never be addressed.
|
|
688
|
+
if (isMemberOrMemberAlias && id === page.id) shapeIssue(`Group "${page.id}": id "${id}" collides with this group page's own id.`);
|
|
689
|
+
if (seenInPage.has(id)) shapeIssue(`Group "${page.id}": id "${id}" is used more than once on this page.`);
|
|
690
|
+
seenInPage.add(id);
|
|
691
|
+
if (allPageIds.has(id)) shapeIssue(`Group "${page.id}": id "${id}" collides with another page's id.`);
|
|
692
|
+
if (otherMemberIds.has(id)) shapeIssue(`Group "${page.id}": id "${id}" collides with another member or alias elsewhere in the catalog.`);
|
|
693
|
+
};
|
|
694
|
+
for (const m of members) {
|
|
695
|
+
checkId(m.id);
|
|
696
|
+
for (const alias of m.aliases ?? []) checkId(alias);
|
|
697
|
+
}
|
|
698
|
+
for (const alias of page.aliases ?? []) checkId(alias, false);
|
|
699
|
+
|
|
700
|
+
// A3/A4: a top-level member has no parent, or a parent not among this page's own members
|
|
701
|
+
// (resolved cross-page above). `anatomy` is only ever meaningful there.
|
|
702
|
+
const isTopLevel = (m: GroupMemberLike) => m.parent === undefined || !byId.has(m.parent);
|
|
703
|
+
const descendantsOf = (topLevelId: string): Set<string> => {
|
|
704
|
+
const result = new Set<string>();
|
|
705
|
+
const stack = [topLevelId];
|
|
706
|
+
while (stack.length > 0) {
|
|
707
|
+
const current = stack.pop()!;
|
|
708
|
+
for (const m of members) {
|
|
709
|
+
if (m.parent === current && m.id !== topLevelId && !result.has(m.id)) {
|
|
710
|
+
result.add(m.id);
|
|
711
|
+
stack.push(m.id);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return result;
|
|
716
|
+
};
|
|
717
|
+
for (const m of members) {
|
|
718
|
+
if (!m.anatomy) continue;
|
|
719
|
+
if (!isTopLevel(m)) {
|
|
720
|
+
anatomyIssue(`Group "${page.id}": member "${m.id}" has "anatomy", but anatomy is only used on a top-level member.`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const descendants = descendantsOf(m.id);
|
|
724
|
+
const seenAnatomyMembers = new Set<string>();
|
|
725
|
+
for (const group of m.anatomy.groups) {
|
|
726
|
+
for (const item of group.items) {
|
|
727
|
+
if (!('member' in item)) continue;
|
|
728
|
+
if (item.member === m.id) {
|
|
729
|
+
anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy item "${item.member}" names itself.`);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (!descendants.has(item.member)) {
|
|
733
|
+
anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy item "${item.member}" is not one of its descendants on this page.`);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (seenAnatomyMembers.has(item.member)) {
|
|
737
|
+
anatomyIssue(`Group "${page.id}": member "${m.id}": anatomy lists member "${item.member}" more than once.`);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
seenAnatomyMembers.add(item.member);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return issues;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function validateGroup(page: GroupPageLike, allPageIds: ReadonlySet<string>, otherMemberIds: ReadonlySet<string>): string[] {
|
|
749
|
+
return validateGroupIssues(page, allPageIds, otherMemberIds).map((issue) => issue.message);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** #63 A5/A6: where one catalog member id lives — every page that declares it as one of its own
|
|
753
|
+
* `members` (never an alias), whether that page is itself a group page, and that member's own
|
|
754
|
+
* `parent`. Built catalog-wide, once, by the caller (`buildCatalogSections`'s own pre-pass, or
|
|
755
|
+
* `cli/doctor.ts`'s equivalent over `StaticPage[]`) — never a second, independent registry. */
|
|
756
|
+
export interface MemberLocation {
|
|
757
|
+
pageId: string;
|
|
758
|
+
isGroupPage: boolean;
|
|
759
|
+
parent?: string;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** #63 A5/A6: resolves one family `inWhole` section id that is not one of that family's own
|
|
763
|
+
* `members` — a candidate external part — against `owners`, every catalog location that id
|
|
764
|
+
* resolves to as a real member. Exactly one owner, itself a group page, whose own `parent` equals
|
|
765
|
+
* `familyWhole`, is the only success; anything else is the validation issue A6 requires (zero or
|
|
766
|
+
* multiple owners, an owner that isn't a group page, or a parent that doesn't match). Pure and
|
|
767
|
+
* catalog-shape-agnostic — shared between `buildCatalogSections` (runtime) and `cli/doctor.ts`
|
|
768
|
+
* (static), which each build `owners` from their own page representation. */
|
|
769
|
+
export function resolveExternalPart(
|
|
770
|
+
id: string,
|
|
771
|
+
owners: ReadonlyArray<MemberLocation>,
|
|
772
|
+
familyWhole: string,
|
|
773
|
+
): { pageId: string } | { issue: string } {
|
|
774
|
+
if (owners.length === 0) return { issue: `"${id}" is not a member of this page, and is not defined on any group page either.` };
|
|
775
|
+
if (owners.length > 1) {
|
|
776
|
+
return { issue: `"${id}" is defined as a member on more than one page (${owners.map((o) => o.pageId).join(', ')}).` };
|
|
777
|
+
}
|
|
778
|
+
const [owner] = owners;
|
|
779
|
+
if (!owner.isGroupPage) return { issue: `"${id}" is a member of "${owner.pageId}", which is not a group page.` };
|
|
780
|
+
if (owner.parent !== familyWhole) {
|
|
781
|
+
return { issue: `"${id}"'s parent on "${owner.pageId}" ("${owner.parent ?? 'none'}") does not equal this family's whole "${familyWhole}".` };
|
|
782
|
+
}
|
|
783
|
+
return { pageId: owner.pageId };
|
|
784
|
+
}
|
|
785
|
+
|
|
402
786
|
function orderGroups<TId extends string>(groupIds: Map<string, TId[]>, groupOrder: string[] | undefined): NavGroup<TId>[] {
|
|
403
787
|
const listed = groupOrder ?? [];
|
|
404
788
|
const labels = [...groupIds.keys()];
|
|
@@ -453,12 +837,45 @@ export function buildCatalogSections<TId extends string>(
|
|
|
453
837
|
// `FamilyPage`'s own `resolvesAddress`. Without resolving through the same map here, an
|
|
454
838
|
// alias-named or component-named entry would link fine going forward but derive nothing going
|
|
455
839
|
// back: a missing "Also used by" line looks identical to "nothing uses this".
|
|
840
|
+
// C1 fix: every page's own id and every member's own id is itself an exact registered address —
|
|
841
|
+
// it must win over any same-named `component` binding elsewhere, including the common case where
|
|
842
|
+
// a page's own id already equals its component name (so it never gets a chance to appear as a
|
|
843
|
+
// "claimant" in the ambiguity pass below). Registering these identities first, before any
|
|
844
|
+
// binding-name guessing runs, is what makes that guessing unable to ever shadow an exact address.
|
|
456
845
|
const canonicalMemberIdByAlias = new Map<string, string>();
|
|
457
846
|
for (const page of pages) {
|
|
847
|
+
canonicalMemberIdByAlias.set(page.id, page.id);
|
|
458
848
|
for (const member of page.members ?? []) {
|
|
849
|
+
canonicalMemberIdByAlias.set(member.id, member.id);
|
|
459
850
|
for (const alias of member.aliases ?? []) canonicalMemberIdByAlias.set(alias, member.id);
|
|
460
|
-
if (member.component && member.component !== member.id) canonicalMemberIdByAlias.set(member.component, member.id);
|
|
461
851
|
}
|
|
852
|
+
for (const alias of page.aliases ?? []) canonicalMemberIdByAlias.set(alias, page.id);
|
|
853
|
+
}
|
|
854
|
+
// #60/C1: a `composedOf` entry may instead name a component export rather than its page/member's
|
|
855
|
+
// own id or alias — an ordinary page's own `component` (when it differs from its `id`) or a family
|
|
856
|
+
// member's own `component` (when it differs from its `id`) are the same kind of claim, so both
|
|
857
|
+
// feed the one shared ambiguity pass below: a name already owned by an exact address above is
|
|
858
|
+
// never reconsidered, and a name claimed by more than one binding (ordinary-vs-ordinary,
|
|
859
|
+
// member-vs-member, or ordinary-vs-member alike) is never guessed — it is left unresolved, not
|
|
860
|
+
// silently decided by whichever binding happened to be declared first.
|
|
861
|
+
const componentBindingOwners = new Map<string, string[]>();
|
|
862
|
+
const addComponentBindingOwner = (name: string, ownerId: string) => {
|
|
863
|
+
if (canonicalMemberIdByAlias.has(name)) return;
|
|
864
|
+
componentBindingOwners.set(name, [...(componentBindingOwners.get(name) ?? []), ownerId]);
|
|
865
|
+
};
|
|
866
|
+
for (const page of pages) {
|
|
867
|
+
if (page.members && page.members.length > 0) {
|
|
868
|
+
for (const member of page.members) {
|
|
869
|
+
if (member.component && member.component !== member.id) addComponentBindingOwner(member.component, member.id);
|
|
870
|
+
}
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
const componentName = page.component ?? page.id;
|
|
874
|
+
if (componentName !== page.id) addComponentBindingOwner(componentName, page.id);
|
|
875
|
+
}
|
|
876
|
+
for (const [name, owners] of componentBindingOwners) {
|
|
877
|
+
if (owners.length !== 1) continue;
|
|
878
|
+
canonicalMemberIdByAlias.set(name, owners[0]);
|
|
462
879
|
}
|
|
463
880
|
const canonicalUsedByTarget = (name: string) => canonicalMemberIdByAlias.get(name) ?? name;
|
|
464
881
|
|
|
@@ -487,20 +904,89 @@ export function buildCatalogSections<TId extends string>(
|
|
|
487
904
|
|
|
488
905
|
// Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
|
|
489
906
|
// front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
|
|
490
|
-
// page's ids.
|
|
907
|
+
// page's ids. #64 B5: also a tokenDefinition page's own `aliases` and each of its sections' own
|
|
908
|
+
// `aliases` (extending this same catalog-wide pool to tokenDefinition pages).
|
|
491
909
|
const allPageIds = new Set(pages.map((page) => page.id));
|
|
492
910
|
const familyIdsByPage = new Map<TId, Set<string>>();
|
|
493
911
|
for (const page of pages) {
|
|
494
|
-
if (!page.members) continue;
|
|
912
|
+
if (!page.members && !page.tokenDefinition) continue;
|
|
495
913
|
const ids = new Set<string>();
|
|
496
|
-
for (const member of page.members) {
|
|
914
|
+
for (const member of page.members ?? []) {
|
|
497
915
|
ids.add(member.id);
|
|
498
916
|
for (const alias of member.aliases ?? []) ids.add(alias);
|
|
499
917
|
}
|
|
500
918
|
for (const alias of page.aliases ?? []) ids.add(alias);
|
|
919
|
+
for (const section of page.tokenDefinition?.sections ?? []) for (const alias of section.aliases ?? []) ids.add(alias);
|
|
501
920
|
familyIdsByPage.set(page.id, ids);
|
|
502
921
|
}
|
|
503
922
|
|
|
923
|
+
// #63 A5/A6: catalog-wide — where every member id lives, so each family's `inWhole` sections can
|
|
924
|
+
// tell an external part (a group-page member) from a genuinely unknown id. Built once, up front,
|
|
925
|
+
// the same way `familyIdsByPage` already is above.
|
|
926
|
+
const memberLocations = new Map<string, MemberLocation[]>();
|
|
927
|
+
for (const p of pages) {
|
|
928
|
+
const pHasMembers = p.members !== undefined && p.members.length > 0;
|
|
929
|
+
const pIsGroup = pHasMembers && p.whole === undefined && p.sections === undefined;
|
|
930
|
+
for (const member of p.members ?? []) {
|
|
931
|
+
memberLocations.set(member.id, [...(memberLocations.get(member.id) ?? []), { pageId: p.id, isGroupPage: pIsGroup, parent: member.parent }]);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// #63 A5: resolved once, per valid family, before the per-page pass below assembles any section
|
|
936
|
+
// — never by mutating an already-built section a second time (per the controller's own decision).
|
|
937
|
+
// `outlineByFamily` is this same pre-pass's own `numberFor` call, including a synthetic stub
|
|
938
|
+
// (`parent: page.whole`) per resolved external id so `numberFor`'s existing authored-order scan
|
|
939
|
+
// (which already walks each `inWhole` section's own `members` list) numbers it exactly like an
|
|
940
|
+
// own part, with no change to `numberFor` itself.
|
|
941
|
+
const externalIssues: string[] = [];
|
|
942
|
+
const externalPartsByFamily = new Map<TId, Map<string, { pageId: TId; title: string; fidelity?: CatalogMember['fidelity'] }>>();
|
|
943
|
+
const partOfByMemberId = new Map<string, { pageId: TId; title: string; number: string }>();
|
|
944
|
+
const outlineByFamily = new Map<TId, Map<string, { number: string; level: number }>>();
|
|
945
|
+
for (const page of pages) {
|
|
946
|
+
const pHasMembers = page.members !== undefined && page.members.length > 0;
|
|
947
|
+
if (!pHasMembers || page.whole === undefined || page.sections === undefined) continue; // not a valid family.
|
|
948
|
+
const byIdThisPage = new Set(page.members!.map((m) => m.id));
|
|
949
|
+
const externalResolutions = new Map<string, { pageId: TId }>();
|
|
950
|
+
for (const section of page.sections) {
|
|
951
|
+
if (!section.inWhole) continue;
|
|
952
|
+
for (const id of section.members) {
|
|
953
|
+
if (byIdThisPage.has(id) || externalResolutions.has(id)) continue;
|
|
954
|
+
const owners = memberLocations.get(id);
|
|
955
|
+
if (!owners) continue; // genuinely unknown — `validateFamily`'s own "not a member" error covers it.
|
|
956
|
+
const resolution = resolveExternalPart(id, owners, page.whole);
|
|
957
|
+
if ('issue' in resolution) externalIssues.push(`Family "${page.id}": ${resolution.issue}`);
|
|
958
|
+
else externalResolutions.set(id, { pageId: resolution.pageId as TId });
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
const membersForNumbering = [...page.members!, ...[...externalResolutions.keys()].map((id) => ({ id, parent: page.whole }))];
|
|
962
|
+
const outline = numberFor({ ...page, members: membersForNumbering });
|
|
963
|
+
outlineByFamily.set(page.id, outline);
|
|
964
|
+
if (externalResolutions.size === 0) continue;
|
|
965
|
+
const familyTitle = String(page.title ?? page.id);
|
|
966
|
+
const externalMap = new Map<string, { pageId: TId; title: string; fidelity?: CatalogMember['fidelity'] }>();
|
|
967
|
+
for (const [id, resolution] of externalResolutions) {
|
|
968
|
+
const ownerPage = pages.find((p) => p.id === resolution.pageId);
|
|
969
|
+
externalMap.set(id, {
|
|
970
|
+
pageId: resolution.pageId,
|
|
971
|
+
title: String(ownerPage?.title ?? resolution.pageId),
|
|
972
|
+
fidelity: ownerPage?.members?.find((m) => m.id === id)?.fidelity,
|
|
973
|
+
});
|
|
974
|
+
const number = outline.get(id)?.number;
|
|
975
|
+
if (number) partOfByMemberId.set(id, { pageId: page.id, title: familyTitle, number });
|
|
976
|
+
}
|
|
977
|
+
externalPartsByFamily.set(page.id, externalMap);
|
|
978
|
+
}
|
|
979
|
+
if ((globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
980
|
+
for (const issueMsg of externalIssues) console.error(`[Catalog] ${issueMsg}`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// #64 B4: every page whose `tokenDefinition.kind` matches another page's — one page per kind.
|
|
984
|
+
const tokenKindPages = new Map<TokenKind, TId[]>();
|
|
985
|
+
for (const page of pages) {
|
|
986
|
+
if (!page.tokenDefinition) continue;
|
|
987
|
+
tokenKindPages.set(page.tokenDefinition.kind, [...(tokenKindPages.get(page.tokenDefinition.kind) ?? []), page.id]);
|
|
988
|
+
}
|
|
989
|
+
|
|
504
990
|
// Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
|
|
505
991
|
// whole viewer that already resolves every page's id to build sections/groups, so it costs
|
|
506
992
|
// nothing extra to also notice two pages claiming the same one (`sectionsById` in
|
|
@@ -524,9 +1010,36 @@ export function buildCatalogSections<TId extends string>(
|
|
|
524
1010
|
console.warn(`[Catalog] ${page.id}.${member.id}: ${issue}`);
|
|
525
1011
|
}
|
|
526
1012
|
}
|
|
1013
|
+
if (page.tokenDefinition) {
|
|
1014
|
+
// #64 B4: one page per kind — mirrors `cli/doctor.ts`'s `token-definition-duplicate-kind`.
|
|
1015
|
+
const samekind = (tokenKindPages.get(page.tokenDefinition.kind) ?? []).filter((id) => id !== page.id);
|
|
1016
|
+
if (samekind.length > 0) {
|
|
1017
|
+
console.error(`[Catalog] ${page.id}: tokenDefinition kind "${page.tokenDefinition.kind}" is also used by ${samekind.join(', ')}.`);
|
|
1018
|
+
}
|
|
1019
|
+
// #64 B5: a tokenDefinition page's own page-level/section aliases join the same
|
|
1020
|
+
// catalog-wide "one owner" pool a family's own ids/aliases already check against —
|
|
1021
|
+
// mirrors `cli/doctor.ts`'s own `checkTokenDefinitionAliases` (rule id `family-invalid`).
|
|
1022
|
+
const otherPageIdsForTokenDef = new Set<string>([...allPageIds].filter((id) => id !== page.id));
|
|
1023
|
+
const otherIdsForTokenDef = new Set<string>();
|
|
1024
|
+
for (const [id, ids] of familyIdsByPage) if (id !== page.id) for (const x of ids) otherIdsForTokenDef.add(x);
|
|
1025
|
+
const seenAliases = new Set<string>();
|
|
1026
|
+
const checkTokenDefAlias = (alias: string) => {
|
|
1027
|
+
if (seenAliases.has(alias)) console.error(`[Catalog] Page "${page.id}": id "${alias}" is used more than once on this page.`);
|
|
1028
|
+
seenAliases.add(alias);
|
|
1029
|
+
if (otherPageIdsForTokenDef.has(alias)) console.error(`[Catalog] Page "${page.id}": id "${alias}" collides with another page's id.`);
|
|
1030
|
+
if (otherIdsForTokenDef.has(alias)) console.error(`[Catalog] Page "${page.id}": id "${alias}" collides with another member or alias elsewhere in the catalog.`);
|
|
1031
|
+
};
|
|
1032
|
+
for (const alias of page.aliases ?? []) checkTokenDefAlias(alias);
|
|
1033
|
+
for (const section of page.tokenDefinition.sections) for (const alias of section.aliases ?? []) checkTokenDefAlias(alias);
|
|
1034
|
+
}
|
|
527
1035
|
}
|
|
528
|
-
const
|
|
529
|
-
|
|
1036
|
+
const hasMembers = page.members !== undefined && page.members.length > 0;
|
|
1037
|
+
// #63 A1: a page with neither `whole` nor `sections` is a group, not a family; a page with
|
|
1038
|
+
// exactly one of the two stays the same family-shape error `validateFamily` already reports,
|
|
1039
|
+
// via `isFamily` (unchanged from before this field existed).
|
|
1040
|
+
const isGroup = hasMembers && page.whole === undefined && page.sections === undefined;
|
|
1041
|
+
const isFamily = hasMembers && !isGroup;
|
|
1042
|
+
const componentName = hasMembers ? undefined : (page.component ?? page.id);
|
|
530
1043
|
const component = componentName ? componentByName.get(componentName) : undefined;
|
|
531
1044
|
addToGroup(page.group, page.id);
|
|
532
1045
|
|
|
@@ -541,7 +1054,8 @@ export function buildCatalogSections<TId extends string>(
|
|
|
541
1054
|
...member,
|
|
542
1055
|
hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
|
|
543
1056
|
}));
|
|
544
|
-
const
|
|
1057
|
+
const externalPartIds = new Set(externalPartsByFamily.get(page.id)?.keys() ?? []);
|
|
1058
|
+
const issues = validateFamily({ ...page, members, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds, externalPartIds);
|
|
545
1059
|
if ((globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
546
1060
|
for (const issue of issues) console.error(`[Catalog] ${issue}`);
|
|
547
1061
|
}
|
|
@@ -552,27 +1066,23 @@ export function buildCatalogSections<TId extends string>(
|
|
|
552
1066
|
family = {
|
|
553
1067
|
whole: page.whole!,
|
|
554
1068
|
sections: page.sections!,
|
|
555
|
-
|
|
1069
|
+
// #63 A5: the pre-pass above already ran `numberFor` with every valid external part's
|
|
1070
|
+
// synthetic stub included; the "exactly one of whole/sections" invalid shape never reaches
|
|
1071
|
+
// that pre-pass, so it falls back to the same plain `numberFor(page)` call this always was.
|
|
1072
|
+
outline: outlineByFamily.get(page.id) ?? numberFor(page),
|
|
556
1073
|
notShown: page.notShown,
|
|
557
1074
|
aliases: page.aliases,
|
|
558
1075
|
apiLabel: page.apiLabel,
|
|
559
1076
|
anatomy: page.anatomy,
|
|
560
1077
|
relatedPages: page.relatedPages,
|
|
1078
|
+
externalParts: externalPartsByFamily.get(page.id),
|
|
561
1079
|
members: page.members!.map((member) => {
|
|
562
1080
|
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
563
1081
|
// Dedupe by `component`, keeping the first role: page-level and whole-member `composedOf`
|
|
564
1082
|
// attribute to the same string for a family's whole (`validateFamily` forces
|
|
565
1083
|
// `whole === page.id`), and `validateComposedOf` has no duplicate check of its own, so the
|
|
566
1084
|
// same user declared twice would otherwise render "Also used by P and P".
|
|
567
|
-
const
|
|
568
|
-
for (const entry of usedByTarget.get(member.id) ?? []) {
|
|
569
|
-
if (pageMemberIds.has(entry.component)) continue;
|
|
570
|
-
if (!dedupedUsers.has(entry.component)) dedupedUsers.set(entry.component, entry);
|
|
571
|
-
}
|
|
572
|
-
const alsoUsedBy: AlsoUsedByEntry[] = [...dedupedUsers.values()].map((entry) => ({
|
|
573
|
-
component: entry.component,
|
|
574
|
-
role: entry.role || undefined,
|
|
575
|
-
}));
|
|
1085
|
+
const alsoUsedBy = dedupeAlsoUsedBy(usedByTarget.get(member.id) ?? [], pageMemberIds);
|
|
576
1086
|
return {
|
|
577
1087
|
...member,
|
|
578
1088
|
props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
|
|
@@ -581,26 +1091,79 @@ export function buildCatalogSections<TId extends string>(
|
|
|
581
1091
|
};
|
|
582
1092
|
}),
|
|
583
1093
|
};
|
|
1094
|
+
} else if (isGroup) {
|
|
1095
|
+
const otherPageIds = new Set([...allPageIds].filter((id) => id !== page.id));
|
|
1096
|
+
const otherMemberIds = new Set<string>();
|
|
1097
|
+
for (const [id, ids] of familyIdsByPage) if (id !== page.id) for (const memberId of ids) otherMemberIds.add(memberId);
|
|
1098
|
+
const groupMembers = page.members!.map((member) => ({
|
|
1099
|
+
...member,
|
|
1100
|
+
hasSpecimens: Boolean(member.variants?.items?.length || member.states?.items?.length || member.comparison),
|
|
1101
|
+
}));
|
|
1102
|
+
const groupIssues = validateGroup({ id: page.id, members: groupMembers, aliases: page.aliases, anatomy: page.anatomy, relatedPages: page.relatedPages }, otherPageIds, otherMemberIds);
|
|
1103
|
+
if ((globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
1104
|
+
for (const issue of groupIssues) console.error(`[Catalog] ${issue}`);
|
|
1105
|
+
}
|
|
1106
|
+
// #63 A4: one top-level member is any member with no parent, or whose parent isn't one of
|
|
1107
|
+
// this page's own members (A3's cross-page case) — its own parts are numbered independently
|
|
1108
|
+
// of every other top-level member's (`numberForGroupMember`), then merged into one outline
|
|
1109
|
+
// map the same shape a family's own `outline` already has.
|
|
1110
|
+
const byIdThisPage = new Set(page.members!.map((m) => m.id));
|
|
1111
|
+
const groupOutline = new Map<string, { number: string; level: number }>();
|
|
1112
|
+
for (const m of page.members!) {
|
|
1113
|
+
if (m.parent !== undefined && byIdThisPage.has(m.parent)) continue; // not top-level.
|
|
1114
|
+
for (const [id, entry] of numberForGroupMember(m.id, page.members!)) groupOutline.set(id, entry);
|
|
1115
|
+
}
|
|
1116
|
+
const pageMemberIds = new Set(page.members!.map((m) => m.id));
|
|
1117
|
+
family = {
|
|
1118
|
+
kind: 'group',
|
|
1119
|
+
whole: undefined,
|
|
1120
|
+
sections: [],
|
|
1121
|
+
outline: groupOutline,
|
|
1122
|
+
notShown: page.notShown,
|
|
1123
|
+
aliases: page.aliases,
|
|
1124
|
+
apiLabel: page.apiLabel,
|
|
1125
|
+
relatedPages: page.relatedPages,
|
|
1126
|
+
members: page.members!.map((member) => {
|
|
1127
|
+
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
1128
|
+
const alsoUsedBy = dedupeAlsoUsedBy(usedByTarget.get(member.id) ?? [], pageMemberIds);
|
|
1129
|
+
const partOf = partOfByMemberId.get(member.id);
|
|
1130
|
+
return {
|
|
1131
|
+
...member,
|
|
1132
|
+
props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
|
|
1133
|
+
path: memberComponent?.file ?? member.source,
|
|
1134
|
+
alsoUsedBy,
|
|
1135
|
+
...(partOf ? { partOf } : {}),
|
|
1136
|
+
};
|
|
1137
|
+
}),
|
|
1138
|
+
};
|
|
584
1139
|
}
|
|
585
1140
|
|
|
586
1141
|
// Important finding 4: a standalone page (no matching component record) has no props to show
|
|
587
1142
|
// and nothing to say "This component takes no props" about — hide the Props box, and fall back
|
|
588
1143
|
// to the page's own file as its Source instead of an empty path.
|
|
1144
|
+
// #61: resolved once so `title`/`tokenGallery`/`tokenSections` below can each fall back to it.
|
|
1145
|
+
const derivedTokenDefinition = deriveTokenDefinition(page);
|
|
589
1146
|
return {
|
|
590
1147
|
id: page.id,
|
|
591
|
-
title: page.title,
|
|
1148
|
+
title: derivedTokenDefinition?.title ?? page.title,
|
|
592
1149
|
osComponent: page.osComponent,
|
|
593
1150
|
description: page.description,
|
|
594
1151
|
path: component?.file ?? page.source ?? page.file,
|
|
595
1152
|
whenToUse: page.whenToUse,
|
|
596
1153
|
props: component ? mergePropNotes(component, page.propNotes) : undefined,
|
|
597
1154
|
a11y: page.a11y,
|
|
1155
|
+
notes: page.notes,
|
|
598
1156
|
variants: page.variants,
|
|
599
1157
|
states: page.states,
|
|
600
1158
|
render: page.render,
|
|
601
1159
|
// `tokenSections` makes a page a token gallery by itself.
|
|
602
|
-
tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
|
|
603
|
-
tokenSections: page.tokenSections,
|
|
1160
|
+
tokenGallery: derivedTokenDefinition?.tokenGallery ?? (page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined),
|
|
1161
|
+
tokenSections: derivedTokenDefinition?.tokenSections ?? page.tokenSections,
|
|
1162
|
+
// #61: the real entry data `SectionBlock.tsx` renders instead of the placeholder sections
|
|
1163
|
+
// above, when set — see `SectionDef.tokenDefinition`'s own doc comment.
|
|
1164
|
+
tokenDefinition: page.tokenDefinition,
|
|
1165
|
+
// #64 B5: extends `page.aliases` to a tokenDefinition page too (see `SectionDef.tokenDefinitionAliases`).
|
|
1166
|
+
tokenDefinitionAliases: page.tokenDefinition ? page.aliases : undefined,
|
|
604
1167
|
tokenColumns: page.tokenColumns,
|
|
605
1168
|
fullWidthLabel: page.fullWidthLabel,
|
|
606
1169
|
comparison: page.comparison,
|
|
@@ -613,6 +1176,14 @@ export function buildCatalogSections<TId extends string>(
|
|
|
613
1176
|
internalProps: page.internalProps,
|
|
614
1177
|
intentionalStaticPreview: page.intentionalStaticPreview,
|
|
615
1178
|
family,
|
|
1179
|
+
navigation: page.navigation,
|
|
1180
|
+
// Family and group pages alike carry their own component binding per member
|
|
1181
|
+
// (`FamilyDef.members[].component` isn't a field — a member names its binding via
|
|
1182
|
+
// `CatalogMember.component` itself); only a normal page's own single binding is surfaced here.
|
|
1183
|
+
component: hasMembers ? undefined : component?.name,
|
|
1184
|
+
// #60: see `SectionDef.alsoUsedBy` (rendered by `ReferenceDetails`'s `AlsoUsedByFact`) — a family or group
|
|
1185
|
+
// page's "also used by" lives per member instead (`family.members[].alsoUsedBy`, above).
|
|
1186
|
+
alsoUsedBy: hasMembers ? undefined : dedupeAlsoUsedBy(usedByTarget.get(page.id) ?? [], new Set([page.id])),
|
|
616
1187
|
};
|
|
617
1188
|
});
|
|
618
1189
|
|
|
@@ -628,7 +1199,11 @@ export function buildCatalogSections<TId extends string>(
|
|
|
628
1199
|
}
|
|
629
1200
|
continue;
|
|
630
1201
|
}
|
|
631
|
-
sections.push({
|
|
1202
|
+
sections.push({
|
|
1203
|
+
id, description: 'No examples documented.', path: component.file, props: mergePropNotes(component, undefined),
|
|
1204
|
+
component: component.name,
|
|
1205
|
+
alsoUsedBy: dedupeAlsoUsedBy(usedByTarget.get(id) ?? [], new Set([id])),
|
|
1206
|
+
});
|
|
632
1207
|
addToGroup('Components', id);
|
|
633
1208
|
}
|
|
634
1209
|
|