@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
package/dist/cli/doctor.js
CHANGED
|
@@ -7,7 +7,8 @@ import { importedNames, readStaticPages } from "./staticPage.js";
|
|
|
7
7
|
import { resolveGlob } from "./glob.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
9
|
import { gridColumnLimit } from "../native/catalog/comparison.js";
|
|
10
|
-
import { validateComposedOf, validateFamily } from "../native/catalog/pageApi.js";
|
|
10
|
+
import { resolveExternalPart, validateComposedOf, validateFamily, validateGroupIssues, } from "../native/catalog/pageApi.js";
|
|
11
|
+
import { canonicalTokenTitle, findDuplicateIds, isTokenKind } from "../native/catalog/tokenDefinition.js";
|
|
11
12
|
function issue(fields) {
|
|
12
13
|
return fields;
|
|
13
14
|
}
|
|
@@ -156,24 +157,22 @@ function checkFamilyMembers(pages, components) {
|
|
|
156
157
|
}
|
|
157
158
|
return issues;
|
|
158
159
|
}
|
|
159
|
-
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// V2c item 6: `members.length > 0`, the same predicate the runtime uses — an empty array is not
|
|
167
|
-
// a family page.
|
|
168
|
-
const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
|
|
169
|
-
const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
|
|
170
|
-
const familyIdsByPageId = new Map();
|
|
160
|
+
/** #64 B5: every id/alias one page itself registers — a family's member ids/aliases plus its own
|
|
161
|
+
* page-level `aliases` (the existing universe `checkFamilyValidity` already built inline), now
|
|
162
|
+
* also including a tokenDefinition page's own `aliases` and each of its sections' own `aliases`
|
|
163
|
+
* (extending routing/uniqueness to tokenDefinition pages — #64 B5's own requirement). Keyed by
|
|
164
|
+
* each page's own resolved id; a page contributing nothing is simply absent, same as before. */
|
|
165
|
+
function idsRegisteredByPage(pages) {
|
|
166
|
+
const byId = new Map();
|
|
171
167
|
// I-B (independent review, confirmation pass): built from every page with members, not only
|
|
172
168
|
// checkable ones — the same precedent `checkMemberComposedOf` above already follows for
|
|
173
169
|
// `knownNames` (no `checkable` filter). A non-checkable family page still contributes a working
|
|
174
170
|
// link at runtime (`pageApi.ts`'s `otherMemberIds` has no `checkable` concept either), so
|
|
175
171
|
// excluding it here turned a valid `relatedPages` entry into a false CI-failing error.
|
|
176
|
-
for (const page of pages
|
|
172
|
+
for (const page of pages) {
|
|
173
|
+
const hasMembers = Boolean(page.members && page.members.length > 0);
|
|
174
|
+
if (!hasMembers && !page.tokenDefinition)
|
|
175
|
+
continue;
|
|
177
176
|
const id = resolvePageId(page, page.file);
|
|
178
177
|
const ids = new Set();
|
|
179
178
|
for (const member of page.members ?? []) {
|
|
@@ -183,9 +182,87 @@ function checkFamilyValidity(pages) {
|
|
|
183
182
|
}
|
|
184
183
|
for (const alias of page.aliases ?? [])
|
|
185
184
|
ids.add(alias);
|
|
186
|
-
|
|
185
|
+
for (const section of page.tokenDefinition?.sections ?? [])
|
|
186
|
+
for (const alias of section.aliases ?? [])
|
|
187
|
+
ids.add(alias);
|
|
188
|
+
byId.set(id, ids);
|
|
189
|
+
}
|
|
190
|
+
return byId;
|
|
191
|
+
}
|
|
192
|
+
/** Review F9: a page authored with neither `whole` nor `sections` — by presence (`hasWhole` /
|
|
193
|
+
* `hasSections`), never by value, so a family whose `whole`/`sections` isn't a literal (and is
|
|
194
|
+
* therefore left unset, non-checkable) is never mistaken for a group page. A hand-built
|
|
195
|
+
* `StaticPage` without the flags falls back to whether the value is set. */
|
|
196
|
+
function isGroupShape(p) {
|
|
197
|
+
return !(p.hasWhole ?? p.whole !== undefined) && !(p.hasSections ?? p.sections !== undefined);
|
|
198
|
+
}
|
|
199
|
+
/** #63 A5/A6: the static-side equivalent of `buildCatalogSections`'s own external-part pre-pass —
|
|
200
|
+
* built from `StaticPage[]` instead of the runtime catalog, but the same `resolveExternalPart`
|
|
201
|
+
* rule (imported, never reimplemented). Returns, per family page id, the external part ids it
|
|
202
|
+
* resolved (fed into `validateFamily`'s own `externalPartIds` exemption below) and every
|
|
203
|
+
* resolution problem as its own `family-external-part` finding. */
|
|
204
|
+
function collectExternalParts(pages) {
|
|
205
|
+
const memberLocations = new Map();
|
|
206
|
+
for (const p of pages) {
|
|
207
|
+
const pHasMembers = Boolean(p.members && p.members.length > 0);
|
|
208
|
+
const pIsGroup = pHasMembers && isGroupShape(p);
|
|
209
|
+
for (const member of p.members ?? []) {
|
|
210
|
+
memberLocations.set(member.id, [...(memberLocations.get(member.id) ?? []), { pageId: resolvePageId(p, p.file), isGroupPage: pIsGroup, parent: member.parent }]);
|
|
211
|
+
}
|
|
187
212
|
}
|
|
213
|
+
const externalPartIdsByPage = new Map();
|
|
188
214
|
const issues = [];
|
|
215
|
+
for (const page of pages) {
|
|
216
|
+
const hasMembers = Boolean(page.members && page.members.length > 0);
|
|
217
|
+
if (!hasMembers || page.whole === undefined || page.sections === undefined || !page.checkable)
|
|
218
|
+
continue; // not a valid family.
|
|
219
|
+
const id = resolvePageId(page, page.file);
|
|
220
|
+
const byIdThisPage = new Set(page.members.map((m) => m.id));
|
|
221
|
+
const external = new Set();
|
|
222
|
+
for (const section of page.sections) {
|
|
223
|
+
if (!section.inWhole)
|
|
224
|
+
continue;
|
|
225
|
+
for (const memberId of section.members) {
|
|
226
|
+
if (byIdThisPage.has(memberId) || external.has(memberId))
|
|
227
|
+
continue;
|
|
228
|
+
const owners = memberLocations.get(memberId);
|
|
229
|
+
if (!owners)
|
|
230
|
+
continue; // genuinely unknown — `family-invalid`'s own "not a member" already covers it.
|
|
231
|
+
const resolution = resolveExternalPart(memberId, owners, page.whole);
|
|
232
|
+
if ('issue' in resolution) {
|
|
233
|
+
issues.push(issue({
|
|
234
|
+
id: 'family-external-part', severity: 'error', ...pageRef(page),
|
|
235
|
+
message: `Family "${id}": ${resolution.issue}`,
|
|
236
|
+
fix: 'Make the id resolve to exactly one group page\'s member, with that member\'s own "parent" equal to this family\'s "whole".',
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
external.add(memberId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (external.size > 0)
|
|
245
|
+
externalPartIdsByPage.set(id, external);
|
|
246
|
+
}
|
|
247
|
+
return { externalPartIdsByPage, issues };
|
|
248
|
+
}
|
|
249
|
+
/** Plan V3's `family-invalid`: reuses `native/catalog/pageApi.ts`'s own `validateFamily` —
|
|
250
|
+
* the exact same rule `pageApi.ts`'s `buildCatalogSections` runs at runtime — against every
|
|
251
|
+
* checkable family page, with the same catalog-wide "every other page/member id" contract that
|
|
252
|
+
* function already takes. N3: `StaticPage.sections` already carries each section's real
|
|
253
|
+
* `label`/`kind`, so `validateFamily`'s messages name the real section (never an empty label),
|
|
254
|
+
* and its N6 list-section rule can tell a `list` section from a `blocks` one. #63 A5: a page with
|
|
255
|
+
* neither `whole` nor `sections` (a group) is still included in `familyPages` below, but
|
|
256
|
+
* `validateFamily` is now a no-op for it (see its own doc comment) — `checkGroupValidity` is its
|
|
257
|
+
* real validator. */
|
|
258
|
+
function checkFamilyValidity(pages) {
|
|
259
|
+
// V2c item 6: `members.length > 0`, the same predicate the runtime uses — an empty array is not
|
|
260
|
+
// a family page.
|
|
261
|
+
const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
|
|
262
|
+
const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
|
|
263
|
+
const familyIdsByPageId = idsRegisteredByPage(pages);
|
|
264
|
+
const { externalPartIdsByPage, issues: externalIssues } = collectExternalParts(pages);
|
|
265
|
+
const issues = [...externalIssues];
|
|
189
266
|
for (const page of familyPages) {
|
|
190
267
|
const id = resolvePageId(page, page.file);
|
|
191
268
|
const otherPageIds = new Set([...allPageIds].filter((pageId) => pageId !== id));
|
|
@@ -211,12 +288,56 @@ function checkFamilyValidity(pages) {
|
|
|
211
288
|
// not only ever silently warned in a browser console (`npm run check:catalog`).
|
|
212
289
|
relatedPages: page.relatedPages,
|
|
213
290
|
};
|
|
214
|
-
|
|
291
|
+
const externalPartIds = externalPartIdsByPage.get(id) ?? new Set();
|
|
292
|
+
for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds, externalPartIds)) {
|
|
215
293
|
issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message, fix: 'Fix the family\'s members/whole/sections to satisfy the rule above.' }));
|
|
216
294
|
}
|
|
217
295
|
}
|
|
218
296
|
return issues;
|
|
219
297
|
}
|
|
298
|
+
/** #63 A1/A3/A4/A6: the static-side equivalent of `buildCatalogSections`'s own `validateGroup`
|
|
299
|
+
* call, against every checkable group page (`members` with no `whole`/`sections`). Reuses the
|
|
300
|
+
* `family-invalid` rule id for every shape error (same class the family-shaped version already
|
|
301
|
+
* reports under), and the new `group-anatomy-member` rule id only for an embedded anatomy
|
|
302
|
+
* problem (A4) — per the controller's own decision. */
|
|
303
|
+
function checkGroupValidity(pages) {
|
|
304
|
+
const groupPages = pages.filter((p) => p.members && p.members.length > 0 && isGroupShape(p) && p.checkable);
|
|
305
|
+
const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
|
|
306
|
+
const familyIdsByPageId = idsRegisteredByPage(pages);
|
|
307
|
+
const issues = [];
|
|
308
|
+
for (const page of groupPages) {
|
|
309
|
+
const id = resolvePageId(page, page.file);
|
|
310
|
+
const otherPageIds = new Set([...allPageIds].filter((pageId) => pageId !== id));
|
|
311
|
+
const otherMemberIds = new Set();
|
|
312
|
+
for (const [pageId, ids] of familyIdsByPageId)
|
|
313
|
+
if (pageId !== id)
|
|
314
|
+
for (const memberId of ids)
|
|
315
|
+
otherMemberIds.add(memberId);
|
|
316
|
+
const pageLike = {
|
|
317
|
+
id,
|
|
318
|
+
members: (page.members ?? []).map((m) => ({
|
|
319
|
+
id: m.id,
|
|
320
|
+
parent: m.parent,
|
|
321
|
+
aliases: m.aliases,
|
|
322
|
+
anatomy: m.anatomy ? { groups: [{ label: '', items: m.anatomy.memberIds.map((memberId) => ({ member: memberId, description: '' })) }] } : undefined,
|
|
323
|
+
})),
|
|
324
|
+
aliases: page.aliases,
|
|
325
|
+
anatomy: page.hasAnatomy ? {} : undefined,
|
|
326
|
+
relatedPages: page.relatedPages,
|
|
327
|
+
};
|
|
328
|
+
for (const { rule, message } of validateGroupIssues(pageLike, otherPageIds, otherMemberIds)) {
|
|
329
|
+
const isAnatomyRule = rule === 'group-anatomy-member';
|
|
330
|
+
issues.push(issue({
|
|
331
|
+
id: rule,
|
|
332
|
+
severity: 'error', ...pageRef(page), message,
|
|
333
|
+
fix: isAnatomyRule
|
|
334
|
+
? 'Name only this member\'s own descendants on this page, and set "anatomy" only on a top-level member.'
|
|
335
|
+
: 'Fix the group\'s members/aliases/anatomy to satisfy the rule above.',
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return issues;
|
|
340
|
+
}
|
|
220
341
|
/** Deprecation (`relatedPages`): a family page still using one or more non-`inWhole` sections to
|
|
221
342
|
* document a merely-related component inline, instead of giving it its own page and naming it in
|
|
222
343
|
* the page's `relatedPages` list. One finding per page (never per section), naming every member
|
|
@@ -409,6 +530,146 @@ function checkMissingComposition(pages, components) {
|
|
|
409
530
|
}
|
|
410
531
|
return issues;
|
|
411
532
|
}
|
|
533
|
+
/** #66: a page is a family or group (it has `members`) — presence, so a page whose `members` could
|
|
534
|
+
* not be read is still never treated as a plain page. */
|
|
535
|
+
function hasMembers(page) {
|
|
536
|
+
return page.hasMembers === true || Boolean(page.members && page.members.length > 0);
|
|
537
|
+
}
|
|
538
|
+
/** #66: a token page — a `tokenDefinition`, a token gallery, or token sections, by presence. */
|
|
539
|
+
function isTokenPage(page) {
|
|
540
|
+
return page.hasTokenDefinition === true || page.tokenDefinition !== undefined || page.tokenGallery === true
|
|
541
|
+
|| page.hasExplicitTokenGallery === true || page.hasExplicitTokenSections === true || Boolean(page.tokenSectionTitles?.length);
|
|
542
|
+
}
|
|
543
|
+
/** #66: a non-blank `standalone` reason opts a page out of `part-own-page` and `nothing-to-show`. */
|
|
544
|
+
function hasStandaloneReason(page) {
|
|
545
|
+
return page.standalone !== undefined && page.standalone.trim() !== '';
|
|
546
|
+
}
|
|
547
|
+
/** #66: the pages the two structure rules consider — readable, plain (no members, not a token page),
|
|
548
|
+
* browse-listed, and without a recorded `standalone` reason. */
|
|
549
|
+
function isStructureCandidate(page) {
|
|
550
|
+
return page.checkable && !hasMembers(page) && !isTokenPage(page) && page.navigation !== 'reference' && !hasStandaloneReason(page);
|
|
551
|
+
}
|
|
552
|
+
/** #66: the default `patternGroups` — the starter kit's own group for example screens. */
|
|
553
|
+
export const DEFAULT_PATTERN_GROUPS = ['Patterns'];
|
|
554
|
+
/** #66: whether a page sits in a pattern group (case-insensitive). A pattern page uses components; it
|
|
555
|
+
* is never the whole they are parts of. */
|
|
556
|
+
function isPatternPage(page, patternGroups) {
|
|
557
|
+
const group = page.group?.trim().toLowerCase();
|
|
558
|
+
return group !== undefined && patternGroups.some((name) => name.trim().toLowerCase() === group);
|
|
559
|
+
}
|
|
560
|
+
/** #66 `standalone-reason-empty`: a `standalone` that is blank or whitespace records no reason, so it
|
|
561
|
+
* is not an opt-out. */
|
|
562
|
+
function checkStandaloneReason(pages) {
|
|
563
|
+
return pages
|
|
564
|
+
.filter((page) => page.standalone !== undefined && page.standalone.trim() === '')
|
|
565
|
+
.map((page) => issue({
|
|
566
|
+
id: 'standalone-reason-empty', severity: 'warning', ...pageRef(page),
|
|
567
|
+
message: '`standalone` is empty, so it does not opt this page out of the structure checks.',
|
|
568
|
+
fix: "Write the reason, e.g. standalone: 'Not a part of GitFileCard; it draws its own counts.', or remove the field.",
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
571
|
+
/** #66: every page that uses one of `names`, excluding `self`, deduplicated by host page (a page
|
|
572
|
+
* counts once however many of its entries name the component). A built-in entry anywhere on the
|
|
573
|
+
* host wins over a non-built-in one. */
|
|
574
|
+
function usesOf(pages, self, names) {
|
|
575
|
+
const byHost = new Map();
|
|
576
|
+
for (const host of pages) {
|
|
577
|
+
if (host === self)
|
|
578
|
+
continue;
|
|
579
|
+
const entries = [...(host.composedOf ?? []), ...(host.members ?? []).flatMap((member) => member.composedOf ?? [])];
|
|
580
|
+
for (const entry of entries) {
|
|
581
|
+
if (!names.has(entry.component))
|
|
582
|
+
continue;
|
|
583
|
+
const known = byHost.get(host);
|
|
584
|
+
if (!known || (known.relationship !== 'built-in' && entry.relationship === 'built-in'))
|
|
585
|
+
byHost.set(host, { host, relationship: entry.relationship });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return [...byHost.values()];
|
|
589
|
+
}
|
|
590
|
+
/** #66: the names a page answers to — its id and its component. */
|
|
591
|
+
function pageNames(page) {
|
|
592
|
+
return new Set([resolvePageId(page, page.file), ...(page.component ? [page.component] : [])]);
|
|
593
|
+
}
|
|
594
|
+
/** #66: whether some other page has composition this reader couldn't read — a page-level or
|
|
595
|
+
* member-level `composedOf`, or a `members` list that wasn't read at all — so "only" and "nothing
|
|
596
|
+
* uses it" can't be claimed outright. */
|
|
597
|
+
function hasUnreadableComposition(pages, self) {
|
|
598
|
+
return pages.some((other) => other !== self && ((other.hasComposedOf === true && other.composedOf === undefined)
|
|
599
|
+
|| (other.hasMembers === true && other.members === undefined)
|
|
600
|
+
|| Boolean(other.members?.some((member) => member.hasComposedOf === true && member.composedOf === undefined))));
|
|
601
|
+
}
|
|
602
|
+
const pageList = (pages) => [...new Set(pages.map((p) => `\`${resolvePageId(p, p.file)}\``))].join(', ');
|
|
603
|
+
/** #66 `part-own-page`: a candidate page whose component is declared `{ relationship: 'built-in' }`
|
|
604
|
+
* (at page or member level) by exactly one other non-pattern page, which doesn't already list it
|
|
605
|
+
* as a member (by member id, component or alias). Two or more users can't give it one owner, so it
|
|
606
|
+
* is exempt; `slot`/`related`/missing/non-literal relationships never count. Reports; never infers
|
|
607
|
+
* an owner or a relationship. */
|
|
608
|
+
function checkPartOwnPage(pages, patternGroups) {
|
|
609
|
+
const issues = [];
|
|
610
|
+
for (const page of pages) {
|
|
611
|
+
if (!isStructureCandidate(page))
|
|
612
|
+
continue;
|
|
613
|
+
const names = pageNames(page);
|
|
614
|
+
const owners = usesOf(pages, page, names)
|
|
615
|
+
.filter((use) => use.relationship === 'built-in' && !isPatternPage(use.host, patternGroups))
|
|
616
|
+
.map((use) => use.host);
|
|
617
|
+
if (owners.length !== 1)
|
|
618
|
+
continue;
|
|
619
|
+
const owner = owners[0];
|
|
620
|
+
if (owner.members?.some((member) => names.has(member.id) || (member.component !== undefined && names.has(member.component))
|
|
621
|
+
|| member.aliases?.some((alias) => names.has(alias))))
|
|
622
|
+
continue;
|
|
623
|
+
const ownerId = resolvePageId(owner, owner.file);
|
|
624
|
+
const id = resolvePageId(page, page.file);
|
|
625
|
+
const caveat = hasUnreadableComposition(pages, page) ? ' (Some pages\' `composedOf` could not be read, so another user may exist.)' : '';
|
|
626
|
+
issues.push(issue({
|
|
627
|
+
id: 'part-own-page', severity: 'warning', ...pageRef(page),
|
|
628
|
+
message: `\`${id}\` is a built-in part of \`${ownerId}\` only, but has its own page.${caveat}`,
|
|
629
|
+
fix: `List it as a part in ${ownerId}'s family (\`members\`/\`sections\`), or set \`standalone: '<reason>'\` if it is not really part of ${ownerId}.`,
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
return issues;
|
|
633
|
+
}
|
|
634
|
+
/** #66 `nothing-to-show`: a candidate page with no variants, states, comparison or `render`. A page
|
|
635
|
+
* this reader could not fully read is never a candidate (`checkable`), so "nothing" is only claimed
|
|
636
|
+
* about a page that was read in full. `previewWidths` alone is not content: it only sizes `render`. */
|
|
637
|
+
function checkNothingToShow(pages, patternGroups) {
|
|
638
|
+
const issues = [];
|
|
639
|
+
for (const page of pages) {
|
|
640
|
+
if (!isStructureCandidate(page))
|
|
641
|
+
continue;
|
|
642
|
+
const hasSpecimens = Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true;
|
|
643
|
+
if (hasSpecimens)
|
|
644
|
+
continue;
|
|
645
|
+
const id = resolvePageId(page, page.file);
|
|
646
|
+
const hosts = usesOf(pages, page, pageNames(page)).map((use) => use.host);
|
|
647
|
+
const users = hosts.filter((host) => !isPatternPage(host, patternGroups));
|
|
648
|
+
const patternUsers = hosts.filter((host) => isPatternPage(host, patternGroups));
|
|
649
|
+
const unreadable = hasUnreadableComposition(pages, page);
|
|
650
|
+
const base = { id: 'nothing-to-show', severity: 'warning', ...pageRef(page) };
|
|
651
|
+
const otherFix = "Add a specimen, set `navigation: 'reference'`, or `standalone: '<reason>'`.";
|
|
652
|
+
if (users.length > 0) {
|
|
653
|
+
const alsoPatterns = patternUsers.length > 0 ? ` Pattern pages also use it (${pageList(patternUsers)}).` : '';
|
|
654
|
+
issues.push(issue({ ...base,
|
|
655
|
+
message: `\`${id}\` shows no specimens. It is used by ${pageList(users)}.${alsoPatterns}`,
|
|
656
|
+
fix: "List it on that page as a documentation-only part, or set `navigation: 'reference'` to keep it reachable through Source reference search without a browse entry, or `standalone: '<reason>'`.",
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
659
|
+
else if (patternUsers.length > 0) {
|
|
660
|
+
issues.push(issue({ ...base, message: `\`${id}\` shows no specimens. Only pattern pages use it (${pageList(patternUsers)}).`, fix: otherFix }));
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
issues.push(issue({ ...base,
|
|
664
|
+
message: unreadable
|
|
665
|
+
? `\`${id}\` shows no specimens, and no page whose \`composedOf\` doctor could read uses it.`
|
|
666
|
+
: `\`${id}\` shows no specimens and no other page uses it.`,
|
|
667
|
+
fix: otherFix,
|
|
668
|
+
}));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return issues;
|
|
672
|
+
}
|
|
412
673
|
/** The viewer exports that frame an example as a phone (or another bounded document), so an
|
|
413
674
|
* overlay that portals to the document body opens inside it instead of over the catalog. */
|
|
414
675
|
const DEVICE_FRAME_EXPORTS = ['PhoneFrame', 'OverlayDemo', 'PhoneScreen', 'BoundedOverlayViewport'];
|
|
@@ -719,8 +980,165 @@ function checkComponentPages(pages, components) {
|
|
|
719
980
|
}
|
|
720
981
|
return issues;
|
|
721
982
|
}
|
|
983
|
+
/** #61: errors for an opted-in `tokenDefinition` that is provably malformed, conflicts with an
|
|
984
|
+
* explicit legacy `render`/`tokenGallery`/`tokenSections`/`members` recipe on the same page, has a
|
|
985
|
+
* duplicate section id or a duplicate entry id within one section, or has an explicit page `title`
|
|
986
|
+
* that conflicts with its kind's canonical title (design R4's own severity list). A `tokenDefinition`
|
|
987
|
+
* that isn't itself statically readable (non-literal `kind`/`sections`) already made the whole page
|
|
988
|
+
* `checkable: false` in `cli/staticPage.ts` — `checkNotCheckable` reports that case as the established
|
|
989
|
+
* `page-not-checkable` warning; this rule only ever runs against a definition that WAS readable. */
|
|
990
|
+
/** Opted-in token pages whose sections take their entries from runtime imports — the intended
|
|
991
|
+
* authoring path, since values come from the app's own token modules. Doctor never executes them,
|
|
992
|
+
* so those sections are listed as not statically validated. Informational only, like the
|
|
993
|
+
* "unbound examples" count: it never becomes an issue, never changes the exit code, and is not
|
|
994
|
+
* promoted by `doctor.strict`. `undefined` when there are none, so legacy output is unchanged. */
|
|
995
|
+
function runtimeOnlyTokenSections(pages) {
|
|
996
|
+
// #64 B1: a section counts as runtime-only when its own entries are opaque, OR its own `groups`
|
|
997
|
+
// couldn't be read, OR any one group's own entries are opaque — groups are read the same
|
|
998
|
+
// lenient way as a section's own entries (`cli/tokenDefinition.ts`'s own `readStaticTokenDefinition`).
|
|
999
|
+
const isOpaque = (section) => section.entriesOpaque || Boolean(section.groupsOpaque) || (section.groups ?? []).some((g) => g.entriesOpaque);
|
|
1000
|
+
const found = pages.flatMap((page) => {
|
|
1001
|
+
const sections = page.tokenDefinition?.sections.filter(isOpaque).map((section) => section.id) ?? [];
|
|
1002
|
+
return sections.length > 0 ? [{ page: resolvePageId(page, page.file), sections }] : [];
|
|
1003
|
+
});
|
|
1004
|
+
return found.length > 0 ? found : undefined;
|
|
1005
|
+
}
|
|
1006
|
+
function checkTokenDefinition(pages) {
|
|
1007
|
+
const issues = [];
|
|
1008
|
+
for (const page of pages) {
|
|
1009
|
+
if (!page.hasTokenDefinition || !page.tokenDefinition)
|
|
1010
|
+
continue;
|
|
1011
|
+
const ref = pageRef(page);
|
|
1012
|
+
const def = page.tokenDefinition;
|
|
1013
|
+
if (!isTokenKind(def.kind)) {
|
|
1014
|
+
issues.push(issue({
|
|
1015
|
+
id: 'token-definition-invalid-kind', severity: 'error', ...ref,
|
|
1016
|
+
message: `tokenDefinition has an unknown kind "${def.kind}".`,
|
|
1017
|
+
fix: 'Use one of: colors, typography, spacing, radius, dimensions, shadows, opacity, motion.',
|
|
1018
|
+
}));
|
|
1019
|
+
continue; // The canonical-title check below needs a valid kind to compare against.
|
|
1020
|
+
}
|
|
1021
|
+
if (page.hasRender) {
|
|
1022
|
+
issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "render".', fix: 'Remove one of them.' }));
|
|
1023
|
+
}
|
|
1024
|
+
if (page.hasExplicitTokenGallery) {
|
|
1025
|
+
issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "tokenGallery".', fix: 'Remove one of them.' }));
|
|
1026
|
+
}
|
|
1027
|
+
if (page.hasExplicitTokenSections) {
|
|
1028
|
+
issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "tokenSections".', fix: 'Remove one of them.' }));
|
|
1029
|
+
}
|
|
1030
|
+
if (page.hasMembers) {
|
|
1031
|
+
issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "members" (a family page).', fix: 'Remove one of them.' }));
|
|
1032
|
+
}
|
|
1033
|
+
if (page.title !== undefined && page.title !== canonicalTokenTitle(def.kind)) {
|
|
1034
|
+
issues.push(issue({
|
|
1035
|
+
id: 'token-definition-title-conflict', severity: 'error', ...ref,
|
|
1036
|
+
message: `tokenDefinition: title "${page.title}" conflicts with kind "${def.kind}" (expected "${canonicalTokenTitle(def.kind)}").`,
|
|
1037
|
+
fix: 'Omit "title" to use the kind\'s canonical title automatically, or change it to match.',
|
|
1038
|
+
}));
|
|
1039
|
+
}
|
|
1040
|
+
for (const id of findDuplicateIds(def.sections.map((s) => s.id))) {
|
|
1041
|
+
issues.push(issue({ id: 'token-definition-duplicate-id', severity: 'error', ...ref, message: `tokenDefinition has more than one section with id "${id}".`, fix: 'Give each section its own id.' }));
|
|
1042
|
+
}
|
|
1043
|
+
for (const section of def.sections) {
|
|
1044
|
+
for (const id of findDuplicateIds(section.entryIds)) {
|
|
1045
|
+
issues.push(issue({
|
|
1046
|
+
id: 'token-definition-duplicate-id', severity: 'error', ...ref,
|
|
1047
|
+
message: `tokenDefinition section "${section.id}" has more than one entry with id "${id}".`,
|
|
1048
|
+
fix: 'Give each entry within this section its own id (the same id may still be reused in a different section).',
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
1051
|
+
// #64 B1: a section's own groups — group ids unique within the section, entry ids unique
|
|
1052
|
+
// within their own group (the same scoping `native/catalog/tokenDefinition.ts`'s own
|
|
1053
|
+
// `validateTokenDefinition` enforces at runtime).
|
|
1054
|
+
for (const id of findDuplicateIds((section.groups ?? []).map((g) => g.id))) {
|
|
1055
|
+
issues.push(issue({
|
|
1056
|
+
id: 'token-definition-duplicate-id', severity: 'error', ...ref,
|
|
1057
|
+
message: `tokenDefinition section "${section.id}" has more than one group with id "${id}".`,
|
|
1058
|
+
fix: 'Give each group within this section its own id.',
|
|
1059
|
+
}));
|
|
1060
|
+
}
|
|
1061
|
+
for (const group of section.groups ?? []) {
|
|
1062
|
+
for (const id of findDuplicateIds(group.entryIds)) {
|
|
1063
|
+
issues.push(issue({
|
|
1064
|
+
id: 'token-definition-duplicate-id', severity: 'error', ...ref,
|
|
1065
|
+
message: `tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`,
|
|
1066
|
+
fix: 'Give each entry within this group its own id (the same id may still be reused in a different group or in the section\'s own entries).',
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return issues;
|
|
1073
|
+
}
|
|
1074
|
+
/** #64 B4: a literal `tokenDefinition.kind` shared by two or more pages — one issue per page in
|
|
1075
|
+
* the group (every one of them is reported, each naming the other page(s)). A page with no
|
|
1076
|
+
* `tokenDefinition` at all is never involved. */
|
|
1077
|
+
function checkTokenDefinitionDuplicateKind(pages) {
|
|
1078
|
+
const byKind = new Map();
|
|
1079
|
+
for (const page of pages) {
|
|
1080
|
+
if (!page.tokenDefinition)
|
|
1081
|
+
continue;
|
|
1082
|
+
const kind = page.tokenDefinition.kind;
|
|
1083
|
+
byKind.set(kind, [...(byKind.get(kind) ?? []), page]);
|
|
1084
|
+
}
|
|
1085
|
+
const issues = [];
|
|
1086
|
+
for (const [kind, group] of byKind) {
|
|
1087
|
+
if (group.length < 2)
|
|
1088
|
+
continue;
|
|
1089
|
+
for (const page of group) {
|
|
1090
|
+
const others = group.filter((p) => p !== page).map((p) => resolvePageId(p, p.file)).join(', ');
|
|
1091
|
+
issues.push(issue({
|
|
1092
|
+
id: 'token-definition-duplicate-kind', severity: 'error', ...pageRef(page),
|
|
1093
|
+
message: `tokenDefinition kind "${kind}" is also used by ${others}.`,
|
|
1094
|
+
fix: 'Give one of these pages its own kind, or merge them into one page.',
|
|
1095
|
+
}));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return issues;
|
|
1099
|
+
}
|
|
1100
|
+
/** #64 B5: a tokenDefinition page's own page-level `aliases`, and each of its sections' own
|
|
1101
|
+
* `aliases`, join the exact same catalog-wide "one owner" pool `family-invalid` already checks
|
|
1102
|
+
* family ids/aliases against (`idsRegisteredByPage`) — reusing that rule id rather than adding a
|
|
1103
|
+
* second alias-collision rule. */
|
|
1104
|
+
function checkTokenDefinitionAliases(pages) {
|
|
1105
|
+
const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
|
|
1106
|
+
const idsByPage = idsRegisteredByPage(pages);
|
|
1107
|
+
const issues = [];
|
|
1108
|
+
for (const page of pages) {
|
|
1109
|
+
if (!page.tokenDefinition)
|
|
1110
|
+
continue;
|
|
1111
|
+
const id = resolvePageId(page, page.file);
|
|
1112
|
+
const otherPageIds = new Set([...allPageIds].filter((pid) => pid !== id));
|
|
1113
|
+
const otherIds = new Set();
|
|
1114
|
+
for (const [pid, ids] of idsByPage)
|
|
1115
|
+
if (pid !== id)
|
|
1116
|
+
for (const x of ids)
|
|
1117
|
+
otherIds.add(x);
|
|
1118
|
+
const seen = new Set();
|
|
1119
|
+
const checkAlias = (alias) => {
|
|
1120
|
+
if (seen.has(alias)) {
|
|
1121
|
+
issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message: `Page "${id}": id "${alias}" is used more than once on this page.`, fix: 'Give each alias its own name.' }));
|
|
1122
|
+
}
|
|
1123
|
+
seen.add(alias);
|
|
1124
|
+
if (otherPageIds.has(alias)) {
|
|
1125
|
+
issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message: `Page "${id}": id "${alias}" collides with another page's id.`, fix: 'Choose a different alias.' }));
|
|
1126
|
+
}
|
|
1127
|
+
if (otherIds.has(alias)) {
|
|
1128
|
+
issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message: `Page "${id}": id "${alias}" collides with another member or alias elsewhere in the catalog.`, fix: 'Choose a different alias.' }));
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
for (const alias of page.aliases ?? [])
|
|
1132
|
+
checkAlias(alias);
|
|
1133
|
+
for (const section of page.tokenDefinition.sections)
|
|
1134
|
+
for (const alias of section.aliases ?? [])
|
|
1135
|
+
checkAlias(alias);
|
|
1136
|
+
}
|
|
1137
|
+
return issues;
|
|
1138
|
+
}
|
|
722
1139
|
/** Every page-scoped issue, from every rule above. */
|
|
723
|
-
export function collectIssues(pages, components) {
|
|
1140
|
+
export function collectIssues(pages, components, options = {}) {
|
|
1141
|
+
const patternGroups = options.patternGroups ?? DEFAULT_PATTERN_GROUPS;
|
|
724
1142
|
return [
|
|
725
1143
|
...checkParseErrors(pages),
|
|
726
1144
|
...checkNotCheckable(pages),
|
|
@@ -732,12 +1150,19 @@ export function collectIssues(pages, components) {
|
|
|
732
1150
|
...checkDegenerateGridAxis(pages),
|
|
733
1151
|
...checkComponentPages(pages, components),
|
|
734
1152
|
...checkMissingComposition(pages, components),
|
|
1153
|
+
...checkStandaloneReason(pages),
|
|
1154
|
+
...checkPartOwnPage(pages, patternGroups),
|
|
1155
|
+
...checkNothingToShow(pages, patternGroups),
|
|
735
1156
|
...checkMissingWorkingPreview(pages, components),
|
|
736
1157
|
...checkPreviewOnlyPages(pages),
|
|
737
1158
|
...checkFamilyMembers(pages, components),
|
|
738
1159
|
...checkFamilyValidity(pages),
|
|
1160
|
+
...checkGroupValidity(pages),
|
|
739
1161
|
...checkMemberComposedOf(pages, components),
|
|
740
1162
|
...checkDeprecatedRelatedSections(pages),
|
|
1163
|
+
...checkTokenDefinition(pages),
|
|
1164
|
+
...checkTokenDefinitionDuplicateKind(pages),
|
|
1165
|
+
...checkTokenDefinitionAliases(pages),
|
|
741
1166
|
];
|
|
742
1167
|
}
|
|
743
1168
|
/** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
|
|
@@ -874,7 +1299,7 @@ export function runDoctor(config) {
|
|
|
874
1299
|
? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
|
|
875
1300
|
: undefined;
|
|
876
1301
|
let issues = [
|
|
877
|
-
...collectIssues(pages, components),
|
|
1302
|
+
...collectIssues(pages, components, { patternGroups: config.patternGroups }),
|
|
878
1303
|
...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
|
|
879
1304
|
...checkOverlaysFramed(pages, components, config.projectRoot),
|
|
880
1305
|
...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
|
|
@@ -893,6 +1318,9 @@ export function runDoctor(config) {
|
|
|
893
1318
|
withExamples: countWithExamples(pages, components),
|
|
894
1319
|
unboundExamples: countUnboundExamples(pages),
|
|
895
1320
|
};
|
|
1321
|
+
const runtimeOnly = runtimeOnlyTokenSections(pages);
|
|
1322
|
+
if (runtimeOnly)
|
|
1323
|
+
summary.runtimeOnlyTokenSections = runtimeOnly;
|
|
896
1324
|
const pageFileById = new Map(pages.map((p) => [resolvePageId(p, p.file), path.relative(config.projectRoot, p.file)]));
|
|
897
1325
|
return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0, pageFiles: pageFileById };
|
|
898
1326
|
}
|
|
@@ -930,7 +1358,12 @@ export function formatHuman(result) {
|
|
|
930
1358
|
renderIssue(issue);
|
|
931
1359
|
lines.push('');
|
|
932
1360
|
}
|
|
933
|
-
const { errors, warnings, components, withExamples, unboundExamples } = result.summary;
|
|
1361
|
+
const { errors, warnings, components, withExamples, unboundExamples, runtimeOnlyTokenSections: runtimeOnly } = result.summary;
|
|
1362
|
+
if (runtimeOnly && runtimeOnly.length > 0) {
|
|
1363
|
+
const count = runtimeOnly.reduce((sum, entry) => sum + entry.sections.length, 0);
|
|
1364
|
+
const where = runtimeOnly.map((entry) => `${entry.page} (${entry.sections.join(', ')})`).join('; ');
|
|
1365
|
+
lines.push(`${count} token section${count === 1 ? '' : 's'} with runtime-only entries (not statically validated): ${where}.`, '');
|
|
1366
|
+
}
|
|
934
1367
|
lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} — ` +
|
|
935
1368
|
`${components} components (${withExamples} with examples), ${unboundExamples} unbound examples.`);
|
|
936
1369
|
return lines.join('\n');
|
package/dist/cli/explain.js
CHANGED
|
@@ -5,6 +5,7 @@ import { resolveGlob } from "./glob.js";
|
|
|
5
5
|
import { axisItems, blockMinWidth, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
|
|
6
6
|
import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
|
|
7
7
|
import { sectionColumns } from "../native/catalog/tokenLayout.js";
|
|
8
|
+
import { numberForGroupMember } from "../native/catalog/pageApi.js";
|
|
8
9
|
function placeholderItem(item) {
|
|
9
10
|
return {
|
|
10
11
|
key: item.key, name: item.name, node: null, group: item.group,
|
|
@@ -152,6 +153,20 @@ export function explainPage(config, pageId, options = {}) {
|
|
|
152
153
|
// to never drift from what the viewer actually renders.
|
|
153
154
|
if (!page.checkable)
|
|
154
155
|
return { pageId, notCheckable: true, blocks: [] };
|
|
156
|
+
// #63 A10: a group page (`members` with no `whole`/`sections`) — its real content lives entirely
|
|
157
|
+
// on its members, which `toSectionDef`/`presentationBlocks` below never read, so it gets its own
|
|
158
|
+
// outline instead of an (otherwise empty) block list.
|
|
159
|
+
if (page.members !== undefined && page.members.length > 0 && page.whole === undefined && page.sections === undefined) {
|
|
160
|
+
const members = page.members;
|
|
161
|
+
const byId = new Set(members.map((m) => m.id));
|
|
162
|
+
const topLevelMembers = members.filter((m) => m.parent === undefined || !byId.has(m.parent));
|
|
163
|
+
const topLevel = topLevelMembers.map((m) => ({
|
|
164
|
+
id: m.id,
|
|
165
|
+
qualifier: m.component ? m.component : 'documentation-only',
|
|
166
|
+
parts: [...numberForGroupMember(m.id, members).entries()].map(([id, entry]) => ({ number: entry.number, id })),
|
|
167
|
+
}));
|
|
168
|
+
return { pageId, blocks: [], group: { topLevel } };
|
|
169
|
+
}
|
|
155
170
|
const def = toSectionDef(page);
|
|
156
171
|
const blocks = presentationBlocks(def, { defaultPreviewWidths: [402] });
|
|
157
172
|
// Two blocks placed side by side get half the row each, which changes how many columns fit.
|
|
@@ -194,6 +209,17 @@ export function formatExplain(result) {
|
|
|
194
209
|
if (result.notCheckable) {
|
|
195
210
|
return `${result.pageId}\n\nPage not statically checkable — some of its layout data is not a literal doctor can read.`;
|
|
196
211
|
}
|
|
212
|
+
// #63 A10: a group page prints its own outline — title, each top-level member with its
|
|
213
|
+
// qualifier, nested parts with numbers — instead of the block-based report below.
|
|
214
|
+
if (result.group) {
|
|
215
|
+
const lines = [result.pageId, ''];
|
|
216
|
+
for (const top of result.group.topLevel) {
|
|
217
|
+
lines.push(`${top.id} (${top.qualifier})`);
|
|
218
|
+
for (const part of top.parts)
|
|
219
|
+
lines.push(` ${part.number}. ${part.id}`);
|
|
220
|
+
}
|
|
221
|
+
return lines.join('\n').replace(/\n+$/, '');
|
|
222
|
+
}
|
|
197
223
|
const lines = [result.pageId, ''];
|
|
198
224
|
for (const block of result.blocks) {
|
|
199
225
|
lines.push(block.title);
|