@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.
@@ -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
- /** Plan V3's `family-invalid`: reuses `native/catalog/pageApi.ts`'s own `validateFamily` —
160
- * the exact same rule `pageApi.ts`'s `buildCatalogSections` runs at runtime — against every
161
- * checkable family page, with the same catalog-wide "every other page/member id" contract that
162
- * function already takes. N3: `StaticPage.sections` already carries each section's real
163
- * `label`/`kind`, so `validateFamily`'s messages name the real section (never an empty label),
164
- * and its N6 list-section rule can tell a `list` section from a `blocks` one. */
165
- function checkFamilyValidity(pages) {
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.filter((p) => p.members && p.members.length > 0)) {
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
- familyIdsByPageId.set(id, ids);
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
- for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds)) {
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');
@@ -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);
@@ -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
- function readStringLiteralArray(node, consts) {
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
  }