@krapwoo/ds-viewer 0.14.2 → 0.15.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 +224 -2
- package/dist/cli/doctor.js +306 -17
- package/dist/cli/explain.js +26 -0
- package/dist/cli/staticPage.js +90 -6
- 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 +595 -23
- package/native/catalog/tokenDefinition.ts +430 -0
- package/native/catalog/types.ts +77 -5
- package/package.json +1 -1
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
|
|
@@ -719,6 +840,162 @@ function checkComponentPages(pages, components) {
|
|
|
719
840
|
}
|
|
720
841
|
return issues;
|
|
721
842
|
}
|
|
843
|
+
/** #61: errors for an opted-in `tokenDefinition` that is provably malformed, conflicts with an
|
|
844
|
+
* explicit legacy `render`/`tokenGallery`/`tokenSections`/`members` recipe on the same page, has a
|
|
845
|
+
* duplicate section id or a duplicate entry id within one section, or has an explicit page `title`
|
|
846
|
+
* that conflicts with its kind's canonical title (design R4's own severity list). A `tokenDefinition`
|
|
847
|
+
* that isn't itself statically readable (non-literal `kind`/`sections`) already made the whole page
|
|
848
|
+
* `checkable: false` in `cli/staticPage.ts` — `checkNotCheckable` reports that case as the established
|
|
849
|
+
* `page-not-checkable` warning; this rule only ever runs against a definition that WAS readable. */
|
|
850
|
+
/** Opted-in token pages whose sections take their entries from runtime imports — the intended
|
|
851
|
+
* authoring path, since values come from the app's own token modules. Doctor never executes them,
|
|
852
|
+
* so those sections are listed as not statically validated. Informational only, like the
|
|
853
|
+
* "unbound examples" count: it never becomes an issue, never changes the exit code, and is not
|
|
854
|
+
* promoted by `doctor.strict`. `undefined` when there are none, so legacy output is unchanged. */
|
|
855
|
+
function runtimeOnlyTokenSections(pages) {
|
|
856
|
+
// #64 B1: a section counts as runtime-only when its own entries are opaque, OR its own `groups`
|
|
857
|
+
// couldn't be read, OR any one group's own entries are opaque — groups are read the same
|
|
858
|
+
// lenient way as a section's own entries (`cli/tokenDefinition.ts`'s own `readStaticTokenDefinition`).
|
|
859
|
+
const isOpaque = (section) => section.entriesOpaque || Boolean(section.groupsOpaque) || (section.groups ?? []).some((g) => g.entriesOpaque);
|
|
860
|
+
const found = pages.flatMap((page) => {
|
|
861
|
+
const sections = page.tokenDefinition?.sections.filter(isOpaque).map((section) => section.id) ?? [];
|
|
862
|
+
return sections.length > 0 ? [{ page: resolvePageId(page, page.file), sections }] : [];
|
|
863
|
+
});
|
|
864
|
+
return found.length > 0 ? found : undefined;
|
|
865
|
+
}
|
|
866
|
+
function checkTokenDefinition(pages) {
|
|
867
|
+
const issues = [];
|
|
868
|
+
for (const page of pages) {
|
|
869
|
+
if (!page.hasTokenDefinition || !page.tokenDefinition)
|
|
870
|
+
continue;
|
|
871
|
+
const ref = pageRef(page);
|
|
872
|
+
const def = page.tokenDefinition;
|
|
873
|
+
if (!isTokenKind(def.kind)) {
|
|
874
|
+
issues.push(issue({
|
|
875
|
+
id: 'token-definition-invalid-kind', severity: 'error', ...ref,
|
|
876
|
+
message: `tokenDefinition has an unknown kind "${def.kind}".`,
|
|
877
|
+
fix: 'Use one of: colors, typography, spacing, radius, dimensions, shadows, opacity, motion.',
|
|
878
|
+
}));
|
|
879
|
+
continue; // The canonical-title check below needs a valid kind to compare against.
|
|
880
|
+
}
|
|
881
|
+
if (page.hasRender) {
|
|
882
|
+
issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "render".', fix: 'Remove one of them.' }));
|
|
883
|
+
}
|
|
884
|
+
if (page.hasExplicitTokenGallery) {
|
|
885
|
+
issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "tokenGallery".', fix: 'Remove one of them.' }));
|
|
886
|
+
}
|
|
887
|
+
if (page.hasExplicitTokenSections) {
|
|
888
|
+
issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "tokenSections".', fix: 'Remove one of them.' }));
|
|
889
|
+
}
|
|
890
|
+
if (page.hasMembers) {
|
|
891
|
+
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.' }));
|
|
892
|
+
}
|
|
893
|
+
if (page.title !== undefined && page.title !== canonicalTokenTitle(def.kind)) {
|
|
894
|
+
issues.push(issue({
|
|
895
|
+
id: 'token-definition-title-conflict', severity: 'error', ...ref,
|
|
896
|
+
message: `tokenDefinition: title "${page.title}" conflicts with kind "${def.kind}" (expected "${canonicalTokenTitle(def.kind)}").`,
|
|
897
|
+
fix: 'Omit "title" to use the kind\'s canonical title automatically, or change it to match.',
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
900
|
+
for (const id of findDuplicateIds(def.sections.map((s) => s.id))) {
|
|
901
|
+
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.' }));
|
|
902
|
+
}
|
|
903
|
+
for (const section of def.sections) {
|
|
904
|
+
for (const id of findDuplicateIds(section.entryIds)) {
|
|
905
|
+
issues.push(issue({
|
|
906
|
+
id: 'token-definition-duplicate-id', severity: 'error', ...ref,
|
|
907
|
+
message: `tokenDefinition section "${section.id}" has more than one entry with id "${id}".`,
|
|
908
|
+
fix: 'Give each entry within this section its own id (the same id may still be reused in a different section).',
|
|
909
|
+
}));
|
|
910
|
+
}
|
|
911
|
+
// #64 B1: a section's own groups — group ids unique within the section, entry ids unique
|
|
912
|
+
// within their own group (the same scoping `native/catalog/tokenDefinition.ts`'s own
|
|
913
|
+
// `validateTokenDefinition` enforces at runtime).
|
|
914
|
+
for (const id of findDuplicateIds((section.groups ?? []).map((g) => g.id))) {
|
|
915
|
+
issues.push(issue({
|
|
916
|
+
id: 'token-definition-duplicate-id', severity: 'error', ...ref,
|
|
917
|
+
message: `tokenDefinition section "${section.id}" has more than one group with id "${id}".`,
|
|
918
|
+
fix: 'Give each group within this section its own id.',
|
|
919
|
+
}));
|
|
920
|
+
}
|
|
921
|
+
for (const group of section.groups ?? []) {
|
|
922
|
+
for (const id of findDuplicateIds(group.entryIds)) {
|
|
923
|
+
issues.push(issue({
|
|
924
|
+
id: 'token-definition-duplicate-id', severity: 'error', ...ref,
|
|
925
|
+
message: `tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`,
|
|
926
|
+
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).',
|
|
927
|
+
}));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return issues;
|
|
933
|
+
}
|
|
934
|
+
/** #64 B4: a literal `tokenDefinition.kind` shared by two or more pages — one issue per page in
|
|
935
|
+
* the group (every one of them is reported, each naming the other page(s)). A page with no
|
|
936
|
+
* `tokenDefinition` at all is never involved. */
|
|
937
|
+
function checkTokenDefinitionDuplicateKind(pages) {
|
|
938
|
+
const byKind = new Map();
|
|
939
|
+
for (const page of pages) {
|
|
940
|
+
if (!page.tokenDefinition)
|
|
941
|
+
continue;
|
|
942
|
+
const kind = page.tokenDefinition.kind;
|
|
943
|
+
byKind.set(kind, [...(byKind.get(kind) ?? []), page]);
|
|
944
|
+
}
|
|
945
|
+
const issues = [];
|
|
946
|
+
for (const [kind, group] of byKind) {
|
|
947
|
+
if (group.length < 2)
|
|
948
|
+
continue;
|
|
949
|
+
for (const page of group) {
|
|
950
|
+
const others = group.filter((p) => p !== page).map((p) => resolvePageId(p, p.file)).join(', ');
|
|
951
|
+
issues.push(issue({
|
|
952
|
+
id: 'token-definition-duplicate-kind', severity: 'error', ...pageRef(page),
|
|
953
|
+
message: `tokenDefinition kind "${kind}" is also used by ${others}.`,
|
|
954
|
+
fix: 'Give one of these pages its own kind, or merge them into one page.',
|
|
955
|
+
}));
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return issues;
|
|
959
|
+
}
|
|
960
|
+
/** #64 B5: a tokenDefinition page's own page-level `aliases`, and each of its sections' own
|
|
961
|
+
* `aliases`, join the exact same catalog-wide "one owner" pool `family-invalid` already checks
|
|
962
|
+
* family ids/aliases against (`idsRegisteredByPage`) — reusing that rule id rather than adding a
|
|
963
|
+
* second alias-collision rule. */
|
|
964
|
+
function checkTokenDefinitionAliases(pages) {
|
|
965
|
+
const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
|
|
966
|
+
const idsByPage = idsRegisteredByPage(pages);
|
|
967
|
+
const issues = [];
|
|
968
|
+
for (const page of pages) {
|
|
969
|
+
if (!page.tokenDefinition)
|
|
970
|
+
continue;
|
|
971
|
+
const id = resolvePageId(page, page.file);
|
|
972
|
+
const otherPageIds = new Set([...allPageIds].filter((pid) => pid !== id));
|
|
973
|
+
const otherIds = new Set();
|
|
974
|
+
for (const [pid, ids] of idsByPage)
|
|
975
|
+
if (pid !== id)
|
|
976
|
+
for (const x of ids)
|
|
977
|
+
otherIds.add(x);
|
|
978
|
+
const seen = new Set();
|
|
979
|
+
const checkAlias = (alias) => {
|
|
980
|
+
if (seen.has(alias)) {
|
|
981
|
+
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.' }));
|
|
982
|
+
}
|
|
983
|
+
seen.add(alias);
|
|
984
|
+
if (otherPageIds.has(alias)) {
|
|
985
|
+
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.' }));
|
|
986
|
+
}
|
|
987
|
+
if (otherIds.has(alias)) {
|
|
988
|
+
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.' }));
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
for (const alias of page.aliases ?? [])
|
|
992
|
+
checkAlias(alias);
|
|
993
|
+
for (const section of page.tokenDefinition.sections)
|
|
994
|
+
for (const alias of section.aliases ?? [])
|
|
995
|
+
checkAlias(alias);
|
|
996
|
+
}
|
|
997
|
+
return issues;
|
|
998
|
+
}
|
|
722
999
|
/** Every page-scoped issue, from every rule above. */
|
|
723
1000
|
export function collectIssues(pages, components) {
|
|
724
1001
|
return [
|
|
@@ -736,8 +1013,12 @@ export function collectIssues(pages, components) {
|
|
|
736
1013
|
...checkPreviewOnlyPages(pages),
|
|
737
1014
|
...checkFamilyMembers(pages, components),
|
|
738
1015
|
...checkFamilyValidity(pages),
|
|
1016
|
+
...checkGroupValidity(pages),
|
|
739
1017
|
...checkMemberComposedOf(pages, components),
|
|
740
1018
|
...checkDeprecatedRelatedSections(pages),
|
|
1019
|
+
...checkTokenDefinition(pages),
|
|
1020
|
+
...checkTokenDefinitionDuplicateKind(pages),
|
|
1021
|
+
...checkTokenDefinitionAliases(pages),
|
|
741
1022
|
];
|
|
742
1023
|
}
|
|
743
1024
|
/** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
|
|
@@ -893,6 +1174,9 @@ export function runDoctor(config) {
|
|
|
893
1174
|
withExamples: countWithExamples(pages, components),
|
|
894
1175
|
unboundExamples: countUnboundExamples(pages),
|
|
895
1176
|
};
|
|
1177
|
+
const runtimeOnly = runtimeOnlyTokenSections(pages);
|
|
1178
|
+
if (runtimeOnly)
|
|
1179
|
+
summary.runtimeOnlyTokenSections = runtimeOnly;
|
|
896
1180
|
const pageFileById = new Map(pages.map((p) => [resolvePageId(p, p.file), path.relative(config.projectRoot, p.file)]));
|
|
897
1181
|
return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0, pageFiles: pageFileById };
|
|
898
1182
|
}
|
|
@@ -930,7 +1214,12 @@ export function formatHuman(result) {
|
|
|
930
1214
|
renderIssue(issue);
|
|
931
1215
|
lines.push('');
|
|
932
1216
|
}
|
|
933
|
-
const { errors, warnings, components, withExamples, unboundExamples } = result.summary;
|
|
1217
|
+
const { errors, warnings, components, withExamples, unboundExamples, runtimeOnlyTokenSections: runtimeOnly } = result.summary;
|
|
1218
|
+
if (runtimeOnly && runtimeOnly.length > 0) {
|
|
1219
|
+
const count = runtimeOnly.reduce((sum, entry) => sum + entry.sections.length, 0);
|
|
1220
|
+
const where = runtimeOnly.map((entry) => `${entry.page} (${entry.sections.join(', ')})`).join('; ');
|
|
1221
|
+
lines.push(`${count} token section${count === 1 ? '' : 's'} with runtime-only entries (not statically validated): ${where}.`, '');
|
|
1222
|
+
}
|
|
934
1223
|
lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} — ` +
|
|
935
1224
|
`${components} components (${withExamples} with examples), ${unboundExamples} unbound examples.`);
|
|
936
1225
|
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);
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { readStaticTokenDefinition } from "./tokenDefinition.js";
|
|
3
4
|
const TRANSPILE_OPTIONS = {
|
|
4
5
|
compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
5
6
|
reportDiagnostics: true,
|
|
@@ -58,21 +59,26 @@ function readStaticPage(file) {
|
|
|
58
59
|
return { file, checkable: false, viewerImports };
|
|
59
60
|
const page = { file, checkable: true, viewerImports };
|
|
60
61
|
const idProp = findProp(pageObject, 'id');
|
|
62
|
+
const titleProp = findProp(pageObject, 'title');
|
|
61
63
|
const componentProp = findProp(pageObject, 'component');
|
|
62
64
|
const groupProp = findProp(pageObject, 'group');
|
|
63
65
|
const specimenSizeProp = findProp(pageObject, 'specimenSize');
|
|
64
66
|
const tokenGalleryProp = findProp(pageObject, 'tokenGallery');
|
|
65
67
|
if (idProp)
|
|
66
68
|
page.id = stringLiteral(resolve(idProp.initializer, consts, new Set()));
|
|
69
|
+
if (titleProp)
|
|
70
|
+
page.title = stringLiteral(resolve(titleProp.initializer, consts, new Set()));
|
|
67
71
|
if (componentProp)
|
|
68
72
|
page.component = stringLiteral(resolve(componentProp.initializer, consts, new Set()));
|
|
69
73
|
if (groupProp)
|
|
70
74
|
page.group = stringLiteral(resolve(groupProp.initializer, consts, new Set()));
|
|
71
75
|
if (specimenSizeProp)
|
|
72
76
|
page.specimenSize = stringLiteral(resolve(specimenSizeProp.initializer, consts, new Set()));
|
|
77
|
+
page.hasExplicitTokenGallery = tokenGalleryProp !== undefined;
|
|
73
78
|
if (tokenGalleryProp)
|
|
74
79
|
page.tokenGallery = resolve(tokenGalleryProp.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword;
|
|
75
80
|
const tokenSectionsProp = findProp(pageObject, 'tokenSections');
|
|
81
|
+
page.hasExplicitTokenSections = tokenSectionsProp !== undefined;
|
|
76
82
|
if (tokenSectionsProp) {
|
|
77
83
|
const list = resolve(tokenSectionsProp.initializer, consts, new Set());
|
|
78
84
|
const items = ts.isArrayLiteralExpression(list) ? list.elements.map((element) => resolve(element, consts, new Set())) : [list];
|
|
@@ -201,6 +207,7 @@ function readStaticPage(file) {
|
|
|
201
207
|
}
|
|
202
208
|
}
|
|
203
209
|
const membersProp = findProp(pageObject, 'members');
|
|
210
|
+
page.hasMembers = membersProp !== undefined;
|
|
204
211
|
if (membersProp) {
|
|
205
212
|
const members = readFamilyMembers(membersProp.initializer, consts, gridNames, sourceFile);
|
|
206
213
|
if (!members)
|
|
@@ -209,6 +216,7 @@ function readStaticPage(file) {
|
|
|
209
216
|
page.members = members;
|
|
210
217
|
}
|
|
211
218
|
const wholeProp = findProp(pageObject, 'whole');
|
|
219
|
+
page.hasWhole = wholeProp !== undefined;
|
|
212
220
|
if (wholeProp) {
|
|
213
221
|
const whole = stringLiteral(resolve(wholeProp.initializer, consts, new Set()));
|
|
214
222
|
if (whole === undefined)
|
|
@@ -217,6 +225,7 @@ function readStaticPage(file) {
|
|
|
217
225
|
page.whole = whole;
|
|
218
226
|
}
|
|
219
227
|
const sectionsProp = findProp(pageObject, 'sections');
|
|
228
|
+
page.hasSections = sectionsProp !== undefined;
|
|
220
229
|
if (sectionsProp) {
|
|
221
230
|
const sectionsRead = readSections(sectionsProp.initializer, consts);
|
|
222
231
|
if (!sectionsRead)
|
|
@@ -235,11 +244,43 @@ function readStaticPage(file) {
|
|
|
235
244
|
const relatedPagesProp = findProp(pageObject, 'relatedPages');
|
|
236
245
|
if (relatedPagesProp)
|
|
237
246
|
page.relatedPages = readRelatedPages(relatedPagesProp.initializer, consts);
|
|
247
|
+
page.hasAnatomy = findProp(pageObject, 'anatomy') !== undefined;
|
|
248
|
+
const navigationProp = findProp(pageObject, 'navigation');
|
|
249
|
+
if (navigationProp) {
|
|
250
|
+
const resolved = resolve(navigationProp.initializer, consts, new Set());
|
|
251
|
+
if (ts.isStringLiteral(resolved) && (resolved.text === 'browse' || resolved.text === 'reference')) {
|
|
252
|
+
page.navigation = resolved.text;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
page.checkable = false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const tokenDefinitionProp = findProp(pageObject, 'tokenDefinition');
|
|
259
|
+
page.hasTokenDefinition = tokenDefinitionProp !== undefined;
|
|
260
|
+
if (tokenDefinitionProp) {
|
|
261
|
+
const tokenDefinition = readStaticTokenDefinition(tokenDefinitionProp.initializer, consts);
|
|
262
|
+
if (!tokenDefinition)
|
|
263
|
+
page.checkable = false;
|
|
264
|
+
else {
|
|
265
|
+
page.tokenDefinition = tokenDefinition;
|
|
266
|
+
// #61 geometry parity (design R4): the same titles/wide flags `buildCatalogSections` derives
|
|
267
|
+
// at runtime from `tokenDefinition.sections` — only when no separate `tokenSections` already
|
|
268
|
+
// set them (an explicit conflict is reported on its own terms by `cli/doctor.ts`; whichever
|
|
269
|
+
// geometry already won here doesn't change that).
|
|
270
|
+
if (page.tokenSectionTitles === undefined) {
|
|
271
|
+
page.tokenSectionTitles = tokenDefinition.sections.map((section) => section.title);
|
|
272
|
+
page.tokenSectionWide = tokenDefinition.sections.map((section) => section.wide ?? false);
|
|
273
|
+
page.tokenGallery = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
238
277
|
return page;
|
|
239
278
|
}
|
|
240
279
|
/** A literal array of string literals (or same-file `const` references to one) — undefined for
|
|
241
|
-
* anything else, including an array containing even one non-literal element.
|
|
242
|
-
|
|
280
|
+
* anything else, including an array containing even one non-literal element. Exported for
|
|
281
|
+
* `cli/tokenDefinition.ts`'s own static read of a tokenDefinition section's `aliases` (#64 B5),
|
|
282
|
+
* the same leniency every other string-literal-array field here already gets. */
|
|
283
|
+
export function readStringLiteralArray(node, consts) {
|
|
243
284
|
const resolved = resolve(node, consts, new Set());
|
|
244
285
|
if (!ts.isArrayLiteralExpression(resolved))
|
|
245
286
|
return undefined;
|
|
@@ -295,6 +336,8 @@ function readFamilyMembers(node, consts, gridNames, sourceFile) {
|
|
|
295
336
|
if (!comparison)
|
|
296
337
|
return undefined;
|
|
297
338
|
}
|
|
339
|
+
const anatomyProp = findProp(resolvedElement, 'anatomy');
|
|
340
|
+
const memberIds = anatomyProp ? readAnatomyMemberIds(anatomyProp.initializer, consts) : undefined;
|
|
298
341
|
members.push({
|
|
299
342
|
id,
|
|
300
343
|
component,
|
|
@@ -304,6 +347,7 @@ function readFamilyMembers(node, consts, gridNames, sourceFile) {
|
|
|
304
347
|
hasWhenToUse: findProp(resolvedElement, 'whenToUse') !== undefined,
|
|
305
348
|
hasA11y: findProp(resolvedElement, 'a11y') !== undefined,
|
|
306
349
|
hasExamples: Boolean(variants.items?.length) || Boolean(states.items?.length) || Boolean(comparison),
|
|
350
|
+
anatomy: memberIds ? { memberIds } : undefined,
|
|
307
351
|
});
|
|
308
352
|
}
|
|
309
353
|
return members;
|
|
@@ -360,6 +404,46 @@ function readComposedOf(node, consts) {
|
|
|
360
404
|
}
|
|
361
405
|
return entries;
|
|
362
406
|
}
|
|
407
|
+
/** #63 A4/A10: `CatalogMember.anatomy`/`CatalogPageInput.anatomy`'s own `groups[].items[]`, read
|
|
408
|
+
* down to just the literal `member` ids a `{ member: string }` item names — an `{ other: string }`
|
|
409
|
+
* item is skipped (it never names a descendant), and `description`/`picture` are never read (design
|
|
410
|
+
* parity with `readComposedOf`/`readRelatedPages`: `doctor`'s own rule never needs them). Undefined
|
|
411
|
+
* — never a partial read — for anything that isn't itself a literal `{ groups: [...] }` object with
|
|
412
|
+
* a literal `groups` array of literal `{ items: [...] }` objects whose own `items` array is itself
|
|
413
|
+
* literal; an `other`/`description`-shaped item still reads fine even though its own `member` (if
|
|
414
|
+
* any) is simply absent from the result. */
|
|
415
|
+
function readAnatomyMemberIds(node, consts) {
|
|
416
|
+
const resolved = resolve(node, consts, new Set());
|
|
417
|
+
if (!ts.isObjectLiteralExpression(resolved))
|
|
418
|
+
return undefined;
|
|
419
|
+
const groupsProp = findProp(resolved, 'groups');
|
|
420
|
+
if (!groupsProp)
|
|
421
|
+
return undefined;
|
|
422
|
+
const groupsResolved = resolve(groupsProp.initializer, consts, new Set());
|
|
423
|
+
if (!ts.isArrayLiteralExpression(groupsResolved))
|
|
424
|
+
return undefined;
|
|
425
|
+
const memberIds = [];
|
|
426
|
+
for (const groupElement of groupsResolved.elements) {
|
|
427
|
+
const group = resolve(groupElement, consts, new Set());
|
|
428
|
+
if (!ts.isObjectLiteralExpression(group))
|
|
429
|
+
return undefined;
|
|
430
|
+
const itemsProp = findProp(group, 'items');
|
|
431
|
+
if (!itemsProp)
|
|
432
|
+
return undefined;
|
|
433
|
+
const itemsResolved = resolve(itemsProp.initializer, consts, new Set());
|
|
434
|
+
if (!ts.isArrayLiteralExpression(itemsResolved))
|
|
435
|
+
return undefined;
|
|
436
|
+
for (const itemElement of itemsResolved.elements) {
|
|
437
|
+
const item = resolve(itemElement, consts, new Set());
|
|
438
|
+
if (!ts.isObjectLiteralExpression(item))
|
|
439
|
+
return undefined;
|
|
440
|
+
const member = propertyStringLiteral(item, 'member', consts);
|
|
441
|
+
if (member !== undefined)
|
|
442
|
+
memberIds.push(member);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return memberIds;
|
|
446
|
+
}
|
|
363
447
|
/** A literal array of `RelatedPage`-shaped object literals, read down to just `page` (design
|
|
364
448
|
* parity with `readComposedOf`: `note` is authored prose `doctor` never needs). Undefined — not
|
|
365
449
|
* an empty array — for anything that isn't itself a literal array, or whose element isn't an
|
|
@@ -433,7 +517,7 @@ function topLevelConsts(sourceFile) {
|
|
|
433
517
|
* reference to its own initializer (same pattern as `cli/tokens.ts`'s `readExpression`) — returns
|
|
434
518
|
* the resolved expression node itself, not a value, so callers can pattern-match on its kind
|
|
435
519
|
* (array/object literal, call expression, …). `seen` guards a reference cycle. */
|
|
436
|
-
function resolve(node, consts, seen) {
|
|
520
|
+
export function resolve(node, consts, seen) {
|
|
437
521
|
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
438
522
|
return resolve(node.expression, consts, seen);
|
|
439
523
|
}
|
|
@@ -444,7 +528,7 @@ function resolve(node, consts, seen) {
|
|
|
444
528
|
}
|
|
445
529
|
return node;
|
|
446
530
|
}
|
|
447
|
-
function stringLiteral(node) {
|
|
531
|
+
export function stringLiteral(node) {
|
|
448
532
|
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined;
|
|
449
533
|
}
|
|
450
534
|
/** `true`/`false` only from a known `true`/`false` literal; any other expression (a variable, a
|
|
@@ -464,11 +548,11 @@ function literalBooleanOrUnknown(node, consts) {
|
|
|
464
548
|
function propertyName(name) {
|
|
465
549
|
return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText();
|
|
466
550
|
}
|
|
467
|
-
function findProp(obj, name) {
|
|
551
|
+
export function findProp(obj, name) {
|
|
468
552
|
const prop = obj.properties.find((p) => ts.isPropertyAssignment(p) && propertyName(p.name) === name);
|
|
469
553
|
return prop;
|
|
470
554
|
}
|
|
471
|
-
function propertyStringLiteral(obj, name, consts) {
|
|
555
|
+
export function propertyStringLiteral(obj, name, consts) {
|
|
472
556
|
const prop = findProp(obj, name);
|
|
473
557
|
return prop ? stringLiteral(resolve(prop.initializer, consts, new Set())) : undefined;
|
|
474
558
|
}
|