@astryxdesign/cli 0.7.2-canary.ebcc404 → 0.7.2-canary.f8f7cf1

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.
Files changed (134) hide show
  1. package/README.md +9 -8
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +14 -0
  4. package/api/component/_adapter.mjs +48 -0
  5. package/api/component/component.mjs +10 -2
  6. package/api/component/integration-export-name.test.mjs +233 -8
  7. package/api/docs/_adapter.d.mts +11 -0
  8. package/api/docs/_adapter.mjs +244 -1
  9. package/api/docs/integrationDocs.test.mjs +124 -0
  10. package/api/doctor/theme-checks.mjs +1 -1
  11. package/api/gap-report/gap-report.mjs +2 -1
  12. package/api/index.d.mts +2 -0
  13. package/api/index.mjs +2 -0
  14. package/api/init/run/run.mjs +2 -2
  15. package/api/integration/_adapter.d.mts +12 -0
  16. package/api/integration/_adapter.mjs +54 -0
  17. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  18. package/api/integration/add-contribution.mjs +8 -4
  19. package/api/integration/add-contribution.test.mjs +4 -2
  20. package/api/integration/authoring-checks.mjs +11 -2
  21. package/api/integration/authoring-checks.test.mjs +39 -0
  22. package/api/integration/authoring-checks.type.d.mts +9 -0
  23. package/api/integration/authoring-checks.type.mjs +14 -1
  24. package/api/integration/check/check.d.mts +96 -0
  25. package/api/integration/check/check.mjs +357 -0
  26. package/api/integration/check/check.test.mjs +489 -0
  27. package/api/integration/integration-check.type.d.mts +118 -0
  28. package/api/integration/integration-check.type.mjs +84 -0
  29. package/api/integration/integrationCheck.doc.d.mts +10 -0
  30. package/api/integration/integrationCheck.doc.mjs +73 -0
  31. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  32. package/api/integration/pack-check.mjs +48 -3
  33. package/api/integration/pack-check.test.mjs +247 -0
  34. package/api/integration/validate-integration.mjs +8 -0
  35. package/api/integration/validate-integration.test.mjs +11 -0
  36. package/api/integration/validate-integration.type.d.mts +8 -0
  37. package/api/integration/validate-integration.type.mjs +12 -1
  38. package/api/integration/validateIntegration.doc.mjs +17 -3
  39. package/api/search/search.integration-check.test.mjs +24 -0
  40. package/api/search/search.mjs +20 -8
  41. package/api/theme/build/build.family.test.mjs +54 -6
  42. package/api/theme/build/build.mjs +114 -17
  43. package/api/theme/themeBuild.doc.mjs +4 -4
  44. package/assets/docs/README.md +10 -0
  45. package/assets/docs/author-a-theme.doc.mjs +1 -1
  46. package/assets/docs/getting-started.doc.mjs +3 -3
  47. package/assets/docs/theme.doc.dense.mjs +1 -1
  48. package/assets/docs/theme.doc.mjs +1 -1
  49. package/assets/docs/theme.doc.zh.mjs +1 -1
  50. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  51. package/assets/docs/tree/block-template.doc.mjs +2 -2
  52. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  53. package/assets/docs/tree/check-your-docs.doc.mjs +44 -3
  54. package/assets/docs/tree/checks.doc.mjs +21 -13
  55. package/assets/docs/tree/component-family.doc.mjs +5 -1
  56. package/assets/docs/tree/component-imports.doc.mjs +1 -1
  57. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  58. package/assets/docs/tree/links.doc.mjs +2 -2
  59. package/assets/docs/tree/page-template.doc.mjs +1 -1
  60. package/assets/docs/tree/quick-start.doc.mjs +1 -1
  61. package/assets/docs/tree/replace-a-core-template.doc.mjs +1 -1
  62. package/assets/docs/tree/single-component.doc.mjs +3 -2
  63. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  64. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  65. package/assets/docs/tree/versioning.doc.mjs +1 -0
  66. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  67. package/assets/docs/use-a-theme.doc.mjs +2 -2
  68. package/assets/theme.template.ts +1 -1
  69. package/authoring/doctypes/component/component.doc.mjs +1 -1
  70. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  71. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  72. package/authoring/doctypes/template/parse.d.mts +6 -2
  73. package/authoring/doctypes/template/parse.mjs +3 -1
  74. package/authoring/doctypes/template/template.doc.mjs +1 -1
  75. package/authoring/doctypes/template/type.ts +22 -0
  76. package/authoring/index.d.ts +1 -0
  77. package/authoring/integration/integration.doc.mjs +8 -1
  78. package/authoring/integration/parse.test.mjs +10 -0
  79. package/authoring/integration/schema.d.mts +2 -0
  80. package/authoring/integration/schema.mjs +1 -0
  81. package/authoring/integration/type.ts +4 -0
  82. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  83. package/clients/cli/commands/build-theme.mjs +1 -1
  84. package/clients/cli/commands/build-theme.path-safety.test.mjs +155 -21
  85. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  86. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  87. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  88. package/clients/cli/commands/doctor.mjs +190 -0
  89. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  90. package/clients/cli/commands/integration-check.test.mjs +267 -0
  91. package/clients/cli/commands/integration.doc.mjs +6 -2
  92. package/clients/cli/commands/integration.mjs +107 -1
  93. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  94. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  95. package/clients/cli/index.mjs +1 -0
  96. package/foundation/config/project.mjs +82 -0
  97. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  98. package/foundation/discovery/component-discovery.d.mts +108 -17
  99. package/foundation/discovery/component-discovery.export-names.test.mjs +469 -2
  100. package/foundation/discovery/component-discovery.mjs +380 -47
  101. package/foundation/discovery/component-replacement.test.mjs +20 -0
  102. package/foundation/discovery/doc-types.d.mts +157 -0
  103. package/foundation/discovery/doc-types.mjs +1224 -0
  104. package/foundation/discovery/doc-types.test.mjs +652 -0
  105. package/foundation/discovery/docs-discovery.d.mts +81 -0
  106. package/foundation/discovery/docs-discovery.mjs +57 -0
  107. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  108. package/foundation/discovery/integration-doc-files.mjs +231 -0
  109. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  110. package/foundation/discovery/module-exports.d.mts +6 -0
  111. package/foundation/discovery/module-exports.mjs +15 -2
  112. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  113. package/foundation/discovery/package-tsconfig.mjs +228 -0
  114. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  115. package/foundation/discovery/project-typescript.d.mts +23 -0
  116. package/foundation/discovery/project-typescript.mjs +61 -0
  117. package/foundation/discovery/prop-drift.d.mts +278 -0
  118. package/foundation/discovery/prop-drift.mjs +2263 -0
  119. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  120. package/foundation/discovery/type-match.d.mts +53 -0
  121. package/foundation/discovery/type-match.mjs +660 -0
  122. package/foundation/discovery/type-match.test.mjs +169 -0
  123. package/foundation/discovery/type-text.d.mts +68 -0
  124. package/foundation/discovery/type-text.mjs +658 -0
  125. package/foundation/discovery/type-text.test.mjs +196 -0
  126. package/foundation/discovery/unresolved-references.d.mts +62 -0
  127. package/foundation/discovery/unresolved-references.mjs +790 -0
  128. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  129. package/foundation/doc-compiler/doc-loads.test.mjs +6 -0
  130. package/foundation/doc-compiler/links.d.mts +9 -5
  131. package/foundation/doc-compiler/links.mjs +4 -2
  132. package/foundation/integrations/contribution-inventory.mjs +6 -1
  133. package/foundation/response/response-types.doc.mjs +7 -2
  134. package/package.json +9 -9
@@ -12,7 +12,13 @@ import {listDir, listDirEntries, pathExists} from '../fs/listing-cache.mjs';
12
12
  import {existsCaseExact} from '../fs/paths.mjs';
13
13
  import {CORE_PROVIDER_ID} from '../identity/providers.mjs';
14
14
  import {loadComponentDoc} from './component-loader.mjs';
15
- import {moduleExportsName, staticExportObject} from './module-exports.mjs';
15
+ import {
16
+ HAS_COMPUTED,
17
+ moduleExportSource,
18
+ moduleExportsName,
19
+ staticExportObject,
20
+ } from './module-exports.mjs';
21
+ import {inspectPackageExport} from './package-exports.mjs';
16
22
 
17
23
  const require = createRequire(import.meta.url);
18
24
 
@@ -87,22 +93,27 @@ const HIDDEN_RE = /(?:^|\n) {0,2}hidden:\s*true/;
87
93
  // Every top-level `name: '...'` (or `"name": "..."`) in the file: a quick check
88
94
  // that a doc is named after its file before anything parses it.
89
95
  const NAME_FIELD_RE = /(?:^|\n) {0,2}['"]?name['"]?\s*:\s*['"]([^'"\n]+)['"]/g;
96
+ // A `components` field anywhere in the file, written out or as a shorthand: the
97
+ // quick check for a family doc, before anything parses it. A false match only
98
+ // costs the parse.
99
+ const MEMBERS_FIELD_RE = /\bcomponents\b/;
90
100
  // A top-level `replaces: '...'`: the Core name a doc claims for its package.
91
101
  const REPLACES_FIELD_RE =
92
102
  /(?:^|\n) {0,2}['"]?replaces['"]?\s*:\s*['"]([^'"\n]+)['"]/;
93
103
 
94
104
  /**
95
105
  * What a quick scan of a doc's text finds before anything parses it: the
96
- * top-level `name` values it writes (none when a spread could set the name)
97
- * and the Core name it replaces.
106
+ * top-level `name` values it writes (none when a spread could set the name),
107
+ * whether it may list family members, and the Core name it replaces.
98
108
  * @param {string} text
99
- * @returns {{names: string[], replaces: string | null}}
109
+ * @returns {{names: string[], hasMembers: boolean, replaces: string | null}}
100
110
  */
101
111
  function scanDocText(text) {
102
112
  return {
103
113
  names: text.includes('...')
104
114
  ? []
105
115
  : [...text.matchAll(NAME_FIELD_RE)].map(match => match[1]),
116
+ hasMembers: MEMBERS_FIELD_RE.test(text),
106
117
  replaces: REPLACES_FIELD_RE.exec(text)?.[1] ?? null,
107
118
  };
108
119
  }
@@ -112,7 +123,7 @@ function scanDocText(text) {
112
123
  * component's .doc.mjs file (synchronous), with what {@link scanDocText}
113
124
  * finds in it.
114
125
  * @param {string} docPath
115
- * @returns {{group: string | null, hiddenComponents: Set<string>, hidden: boolean, names: string[], replaces: string | null}}
126
+ * @returns {{group: string | null, hiddenComponents: Set<string>, hidden: boolean, names: string[], hasMembers: boolean, replaces: string | null}}
116
127
  */
117
128
  function readDocMeta(docPath) {
118
129
  try {
@@ -139,6 +150,7 @@ function readDocMeta(docPath) {
139
150
  hiddenComponents: new Set(),
140
151
  hidden: false,
141
152
  names: [],
153
+ hasMembers: false,
142
154
  replaces: null,
143
155
  };
144
156
  }
@@ -180,9 +192,12 @@ function docMemberNames(source) {
180
192
  let ast;
181
193
  try {
182
194
  babelParser ??= require('@babel/parser');
183
- ast = /** @type {typeof import('@babel/parser')} */ (babelParser).parse(source, {
184
- sourceType: 'module',
185
- });
195
+ ast = /** @type {typeof import('@babel/parser')} */ (babelParser).parse(
196
+ source,
197
+ {
198
+ sourceType: 'module',
199
+ },
200
+ );
186
201
  } catch {
187
202
  return [];
188
203
  }
@@ -197,12 +212,16 @@ function docMemberNames(source) {
197
212
  for (const declarator of declaration.declarations) {
198
213
  if (declarator.id.type !== 'Identifier' || !declarator.init) continue;
199
214
  constants.set(declarator.id.name, declarator.init);
200
- if (node.type === 'ExportNamedDeclaration' && declarator.id.name === 'docs') {
215
+ if (
216
+ node.type === 'ExportNamedDeclaration' &&
217
+ declarator.id.name === 'docs'
218
+ ) {
201
219
  docsExport = declarator.init;
202
220
  }
203
221
  }
204
222
  }
205
- if (node.type === 'ExportDefaultDeclaration') defaultExport = node.declaration;
223
+ if (node.type === 'ExportDefaultDeclaration')
224
+ defaultExport = node.declaration;
206
225
  }
207
226
  let doc = defaultExport ?? docsExport;
208
227
  if (doc?.type === 'Identifier') doc = constants.get(doc.name);
@@ -420,7 +439,9 @@ export function discoverComponents(coreDir) {
420
439
  if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
421
440
  const name = entry.name;
422
441
  if (componentGroups.has(name) || name.endsWith('Context')) continue;
423
- if (listDir(path.join(srcDir, name)).some(file => file.endsWith('.doc.mjs'))) {
442
+ if (
443
+ listDir(path.join(srcDir, name)).some(file => file.endsWith('.doc.mjs'))
444
+ ) {
424
445
  continue;
425
446
  }
426
447
  if (!hasOwnFolder(srcDir, name)) continue;
@@ -875,8 +896,8 @@ export function findExternalComponentDoc(docsDir, name) {
875
896
  // commands — and the future integration-component swizzle — can disambiguate
876
897
  // by package and route source/issues correctly.
877
898
 
878
- // A doc's `name` can name another export of its file; every other doc reads as
879
- // before.
899
+ // A doc's `name` can name another export of its file, and a family doc's full
900
+ // entries are components under their own names; every other doc reads as before.
880
901
  const COMPONENT_NAME_RE = /^[A-Z][\w$]*$/;
881
902
 
882
903
  /** @type {Map<string, {stamp: string, value: Record<string, any> | null}>} doc reads, reused while the doc file is unchanged */
@@ -940,6 +961,256 @@ function otherExportName({docPath, sourcePath, stem, names}) {
940
961
  : null;
941
962
  }
942
963
 
964
+ /**
965
+ * The file Node loads for a package without `exports`: its `main`, else its
966
+ * `index` file.
967
+ * @param {string} packageDir
968
+ * @returns {string | null}
969
+ */
970
+ function packageMain(packageDir) {
971
+ let main;
972
+ try {
973
+ main = JSON.parse(
974
+ fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'),
975
+ ).main;
976
+ } catch {
977
+ return null;
978
+ }
979
+ const entry =
980
+ typeof main === 'string' && main !== ''
981
+ ? `./${path.posix.normalize(main).replace(/^\.\//, '')}`
982
+ : './index';
983
+ return inspectPackageExport({}, packageDir, entry).target;
984
+ }
985
+
986
+ /**
987
+ * The local module a doc's import names: the doc's own `import`, else the
988
+ * subpath its directory publishes, read through the package's `exports` (its
989
+ * `main` when it has none). Undefined when the package directory is unknown;
990
+ * null when the import names another package or resolves to no file.
991
+ *
992
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
993
+ * @param {string} docPath
994
+ * @param {Record<string, any> | null} doc
995
+ * @returns {string | null | undefined}
996
+ */
997
+ function importedModule(integration, docPath, doc) {
998
+ const packageName = integration?.name;
999
+ const packageDir = integration?.__packageDir;
1000
+ if (!packageName || !packageDir) return undefined;
1001
+ const exportsMap =
1002
+ integration.__packageExports === undefined
1003
+ ? readPackageExports(packageDir)
1004
+ : integration.__packageExports;
1005
+ const specifier = resolveIntegrationImportPath(
1006
+ {exportsMap, packageDir, docPath, packageName},
1007
+ docStem(docPath),
1008
+ typeof doc?.import === 'string' ? doc.import : null,
1009
+ );
1010
+ if (specifier !== packageName && !specifier.startsWith(`${packageName}/`)) {
1011
+ return null;
1012
+ }
1013
+ if (specifier === packageName && exportsMap == null) {
1014
+ return packageMain(packageDir);
1015
+ }
1016
+ const subpath =
1017
+ specifier === packageName
1018
+ ? '.'
1019
+ : `./${specifier.slice(packageName.length + 1)}`;
1020
+ return inspectPackageExport(
1021
+ {name: packageName, exports: exportsMap ?? undefined},
1022
+ packageDir,
1023
+ subpath,
1024
+ ).target;
1025
+ }
1026
+
1027
+ /**
1028
+ * Whether `file` sits in `folder` or below it.
1029
+ * @param {string} folder
1030
+ * @param {string} file
1031
+ */
1032
+ function isWithin(folder, file) {
1033
+ const relative = path.relative(folder, file);
1034
+ return (
1035
+ relative !== '' &&
1036
+ !path.isAbsolute(relative) &&
1037
+ relative.split(path.sep)[0] !== '..'
1038
+ );
1039
+ }
1040
+
1041
+ /**
1042
+ * The `.tsx` file in `folder`, or below it, that declares `name`, taking files
1043
+ * in path order: the source of a member whose import never reaches a `.tsx`
1044
+ * file, as when the import names built output.
1045
+ *
1046
+ * @param {string} folder
1047
+ * @param {string} name
1048
+ * @returns {string | null}
1049
+ */
1050
+ export function folderSourceFor(folder, name) {
1051
+ /** @type {string[]} */
1052
+ const files = [];
1053
+ /** @param {string} dirPath */
1054
+ const scan = dirPath => {
1055
+ for (const entry of listDirEntries(dirPath)) {
1056
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
1057
+ if (entry.name.startsWith('.')) continue;
1058
+ const fullPath = path.join(dirPath, entry.name);
1059
+ if (entry.isDirectory()) scan(fullPath);
1060
+ else if (
1061
+ entry.name.endsWith('.tsx') &&
1062
+ !/\.(?:test|spec|stories)\.tsx$/.test(entry.name)
1063
+ ) {
1064
+ files.push(fullPath);
1065
+ }
1066
+ }
1067
+ };
1068
+ scan(folder);
1069
+ for (const file of files.sort(byCodeUnit)) {
1070
+ const declared = moduleExportSource(file, name);
1071
+ if (
1072
+ declared != null &&
1073
+ declared.endsWith('.tsx') &&
1074
+ isWithin(folder, declared)
1075
+ ) {
1076
+ return declared;
1077
+ }
1078
+ }
1079
+ return null;
1080
+ }
1081
+
1082
+ /**
1083
+ * The full entries a family doc lists in `components` that the module its
1084
+ * import names exports, each with its source. An entry is full when it says
1085
+ * more than its `name`; the name must be static PascalCase, not hidden, and
1086
+ * neither the doc's stem nor the export the doc is recorded under. A member's
1087
+ * source is the `.tsx` file in the family doc's folder, or below it, that
1088
+ * declares it: the one the family's own source or the imported module reaches,
1089
+ * else the one in that folder that declares the name, as when the import names
1090
+ * built output. A member that only a bundle or a file outside the folder
1091
+ * declares has no source.
1092
+ *
1093
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1094
+ * @param {{docPath: string, sourcePath: string|null}} record the doc's own
1095
+ * @param {string | null | undefined} ownName the export the doc is recorded
1096
+ * under, when it names one
1097
+ * @param {string} [only] look for this member alone
1098
+ * @returns {Array<{name: string, sourcePath: string | null}>}
1099
+ */
1100
+ function familyMembers(integration, record, ownName, only) {
1101
+ const familySource = record.sourcePath;
1102
+ const doc = docObject(record.docPath);
1103
+ const entries = Array.isArray(doc?.components) ? doc.components : [];
1104
+ const hidden = new Set(
1105
+ Array.isArray(doc?.hiddenComponents) ? doc.hiddenComponents : [],
1106
+ );
1107
+ const stem = docStem(record.docPath);
1108
+ /** @type {string[]} */
1109
+ const names = [];
1110
+ for (const entry of entries) {
1111
+ if (entry == null || typeof entry !== 'object') continue;
1112
+ const name = entry.name;
1113
+ const full = Object.keys(entry).length > 1 || entry[HAS_COMPUTED] === true;
1114
+ if (typeof name !== 'string' || !full) continue;
1115
+ if (!COMPONENT_NAME_RE.test(name) || hidden.has(name)) continue;
1116
+ if (name === stem || name === ownName) continue;
1117
+ if (only != null && name !== only) continue;
1118
+ names.push(name);
1119
+ }
1120
+ if (names.length === 0) return [];
1121
+ const imported = importedModule(integration, record.docPath, doc);
1122
+ const module = imported === undefined ? familySource : imported;
1123
+ if (module == null) return [];
1124
+ const folder = path.dirname(record.docPath);
1125
+ /** @param {string | null} file */
1126
+ const inFolder = file =>
1127
+ file != null && file.endsWith('.tsx') && isWithin(folder, file);
1128
+ return names.flatMap(name => {
1129
+ const exported = moduleExportSource(module, name);
1130
+ if (exported == null) return [];
1131
+ // A member's source is a `.tsx` file in the family's folder, as a doc's own
1132
+ // is: never a bundle, even one under a family at the package root.
1133
+ const reached = [
1134
+ familySource == null ? null : moduleExportSource(familySource, name),
1135
+ exported,
1136
+ ].find(inFolder);
1137
+ return [{name, sourcePath: reached ?? folderSourceFor(folder, name)}];
1138
+ });
1139
+ }
1140
+
1141
+ /**
1142
+ * Each family doc under an integration's components root that names an
1143
+ * import, with the file the import resolves to: null when it resolves to none,
1144
+ * such as build output that does not exist yet.
1145
+ *
1146
+ * @param {{name: string, components?: string, issuesUrl?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1147
+ * @returns {Array<{docPath: string, module: string | null}>}
1148
+ */
1149
+ export function familyImports(integration) {
1150
+ return discoverIntegrationComponents(integration).flatMap(record => {
1151
+ if (record.docPath == null || !isOwnRecord(record)) return [];
1152
+ const doc = docObject(record.docPath);
1153
+ if (!Array.isArray(doc?.components) || doc.components.length === 0) {
1154
+ return [];
1155
+ }
1156
+ const module = importedModule(integration, record.docPath, doc);
1157
+ return module === undefined ? [] : [{docPath: record.docPath, module}];
1158
+ });
1159
+ }
1160
+
1161
+ /**
1162
+ * Whether `record` is its doc's own component (under the doc's stem or the
1163
+ * export its `name` documents) rather than a member the doc lists.
1164
+ *
1165
+ * @param {{name: string, docPath: string|null}} record
1166
+ * @returns {boolean}
1167
+ */
1168
+ function isOwnRecord(record) {
1169
+ if (record.docPath == null) return true;
1170
+ const stem = docStem(record.docPath);
1171
+ if (stem === record.name) return true;
1172
+ return (
1173
+ otherExportName({
1174
+ docPath: record.docPath,
1175
+ sourcePath: integrationSourceForDoc(record.docPath),
1176
+ stem,
1177
+ names: [],
1178
+ }) === record.name
1179
+ );
1180
+ }
1181
+
1182
+ /**
1183
+ * Whether a discovered integration record is a member its family doc lists,
1184
+ * decided from the files, not from whether the doc loads.
1185
+ *
1186
+ * @param {{name: string, docPath: string|null}} record
1187
+ * @returns {boolean}
1188
+ */
1189
+ export function isIntegrationMember(record) {
1190
+ return !isOwnRecord(record);
1191
+ }
1192
+
1193
+ /**
1194
+ * The entry a family doc gives a member that discovery lists under its own
1195
+ * name, or null when the record is the doc's own component or the loaded doc
1196
+ * has no entry for it.
1197
+ *
1198
+ * @param {any} doc the doc loaded from `record.docPath`
1199
+ * @param {{name: string, docPath: string|null}} record
1200
+ * @returns {any}
1201
+ */
1202
+ export function integrationMemberEntry(doc, record) {
1203
+ if (doc == null || typeof doc !== 'object' || isOwnRecord(record)) {
1204
+ return null;
1205
+ }
1206
+ if (!Array.isArray(doc.components)) return null;
1207
+ return (
1208
+ doc.components.find(
1209
+ (/** @type {any} */ entry) => entry?.name === record.name,
1210
+ ) ?? null
1211
+ );
1212
+ }
1213
+
943
1214
  /**
944
1215
  * Visit every component doc under an integration's components root, hidden
945
1216
  * ones included, in discovery order, with its stem.
@@ -971,7 +1242,7 @@ export function forEachIntegrationDoc(componentsDir, visit) {
971
1242
  * @param {string} docPath absolute path to a `{Name}.doc.{ts,mjs,js}` file
972
1243
  * @returns {string|null}
973
1244
  */
974
- function integrationSourceForDoc(docPath) {
1245
+ export function integrationSourceForDoc(docPath) {
975
1246
  const dir = path.dirname(docPath);
976
1247
  const base = path.basename(docPath).replace(/\.doc\.(ts|mjs|js)$/, '');
977
1248
  const candidate = path.join(dir, `${base}.tsx`);
@@ -983,10 +1254,11 @@ function integrationSourceForDoc(docPath) {
983
1254
  * integration. Scans the integration's resolved `components` dir for same-stem
984
1255
  * doc files and records each with owner package + issuesUrl + sourcePath. A doc
985
1256
  * that names a differently named export of its file is recorded under that
986
- * export unless the name is taken: every doc's file name (a hidden doc's too),
987
- * a Core name a doc in the package replaces, and a name an earlier doc records.
1257
+ * export, and each family member it lists, under the member's name, unless the
1258
+ * name is taken: every doc's file name (a hidden doc's too), a Core name a doc
1259
+ * in the package replaces, and a name an earlier doc records.
988
1260
  *
989
- * @param {{name: string, components?: string, issuesUrl?: string}} integration
1261
+ * @param {{name: string, components?: string, issuesUrl?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
990
1262
  * a single entry from `loadedIntegrations` (lib/integrations.mjs)
991
1263
  * @param {{documentedNames?: boolean}} [options] `documentedNames: false`
992
1264
  * records each doc under its file name alone, without reading the names
@@ -1002,14 +1274,14 @@ export function discoverIntegrationComponents(
1002
1274
 
1003
1275
  /** @type {Map<string, {name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>} */
1004
1276
  const byName = new Map();
1005
- /** @type {Map<string, {names: string[], replaces: string | null}>} stem → quick scan */
1277
+ /** @type {Map<string, {names: string[], hasMembers: boolean, replaces: string | null}>} stem → quick scan */
1006
1278
  const scans = new Map();
1007
1279
  /** @type {Set<string>} names no doc can record under another name */
1008
1280
  const taken = new Set();
1009
1281
 
1010
1282
  forEachIntegrationDoc(componentsDir, (fullPath, name) => {
1011
1283
  taken.add(name);
1012
- const {group, hidden, names, replaces} = readDocMeta(fullPath);
1284
+ const {group, hidden, names, hasMembers, replaces} = readDocMeta(fullPath);
1013
1285
  if (hidden) return;
1014
1286
  // First doc wins per name (precedence matches INTEGRATION_DOC_SUFFIXES).
1015
1287
  if (byName.has(name)) return;
@@ -1021,12 +1293,13 @@ export function discoverIntegrationComponents(
1021
1293
  issuesUrl: integration.issuesUrl,
1022
1294
  group: group ?? null,
1023
1295
  });
1024
- scans.set(name, {names, replaces});
1296
+ scans.set(name, {names, hasMembers, replaces});
1025
1297
  });
1026
1298
  if (!documentedNames) return [...byName.values()];
1027
1299
 
1028
1300
  // A doc that names a differently named export of its file is listed under
1029
- // that export, unless the name is taken.
1301
+ // that export, and each member a family doc lists under the member's name,
1302
+ // unless the name is taken.
1030
1303
  for (const {replaces} of scans.values()) if (replaces) taken.add(replaces);
1031
1304
  const records = [...byName.values()];
1032
1305
  const byPath = [...records].sort((a, b) => byCodeUnit(a.docPath, b.docPath));
@@ -1042,10 +1315,22 @@ export function discoverIntegrationComponents(
1042
1315
  taken.add(exportName);
1043
1316
  exportNames.set(record.name, exportName);
1044
1317
  }
1045
- return records.map(record => {
1318
+ /** @type {typeof records} */
1319
+ const members = [];
1320
+ for (const record of byPath) {
1321
+ if (!scans.get(record.name)?.hasMembers) continue;
1322
+ const ownName = exportNames.get(record.name);
1323
+ for (const member of familyMembers(integration, record, ownName)) {
1324
+ if (taken.has(member.name)) continue;
1325
+ taken.add(member.name);
1326
+ members.push({...record, ...member});
1327
+ }
1328
+ }
1329
+ const named = records.map(record => {
1046
1330
  const name = exportNames.get(record.name);
1047
1331
  return name ? {...record, name} : record;
1048
1332
  });
1333
+ return [...named, ...members];
1049
1334
  }
1050
1335
 
1051
1336
  /**
@@ -1053,27 +1338,33 @@ export function discoverIntegrationComponents(
1053
1338
  * Invalid metadata or a missing same-stem source removes only that component;
1054
1339
  * callers can report the returned errors while retaining valid siblings. A
1055
1340
  * component whose doc sets `replaces` carries the value as written; the
1056
- * component replacement resolver decides what it means. A doc that fails to
1057
- * load is reported under its file name: what it says cannot be trusted.
1341
+ * component replacement resolver decides what it means. A family member is
1342
+ * read through its family's doc: it never carries the family's `replaces`, and
1343
+ * a family doc that fails to load is reported once. A doc that fails to load
1344
+ * is reported under its file name: what it says cannot be trusted.
1058
1345
  *
1059
1346
  * @param {{name: string, components?: string, issuesUrl?: string}} integration
1060
1347
  * @param {{documentedNames?: boolean}} [options] as for
1061
1348
  * {@link discoverIntegrationComponents}
1062
1349
  * @returns {Promise<{
1063
- * components: Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>,
1350
+ * components: Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>,
1064
1351
  * discovered: Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>,
1065
1352
  * errors: Array<{name: string, message: string}>,
1066
1353
  * }>}
1067
1354
  */
1068
1355
  export async function discoverValidIntegrationComponents(integration, options) {
1069
1356
  const discovered = discoverIntegrationComponents(integration, options);
1070
- /** @type {Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>} */
1357
+ /** @type {Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>} */
1071
1358
  const components = [];
1072
1359
  /** @type {Array<{name: string, message: string}>} */
1073
1360
  const errors = [];
1361
+ /** @type {Set<string>} docs that failed to load */
1362
+ const failedDocs = new Set();
1074
1363
 
1075
1364
  for (const record of discovered) {
1076
- if (record.sourcePath == null) {
1365
+ // A doc's own component needs its same-stem source. A member has none when
1366
+ // only a bundle or a file outside its family's folder declares it.
1367
+ if (record.sourcePath == null && isOwnRecord(record)) {
1077
1368
  errors.push({
1078
1369
  name: record.name,
1079
1370
  message: `Component "${record.name}" is missing its same-stem source file ${record.name}.tsx.`,
@@ -1083,15 +1374,20 @@ export async function discoverValidIntegrationComponents(integration, options) {
1083
1374
  try {
1084
1375
  const doc = await loadComponentDoc(record.docPath);
1085
1376
  const valid =
1086
- /** @type {{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null}} */ (
1377
+ /** @type {{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}} */ (
1087
1378
  record
1088
1379
  );
1089
1380
  components.push(
1090
- doc != null && typeof doc === 'object' && doc.replaces !== undefined
1381
+ isOwnRecord(record) &&
1382
+ doc != null &&
1383
+ typeof doc === 'object' &&
1384
+ doc.replaces !== undefined
1091
1385
  ? {...valid, replaces: doc.replaces}
1092
1386
  : valid,
1093
1387
  );
1094
1388
  } catch (err) {
1389
+ if (failedDocs.has(record.docPath)) continue;
1390
+ failedDocs.add(record.docPath);
1095
1391
  const name = docStem(record.docPath);
1096
1392
  errors.push({
1097
1393
  name,
@@ -1109,8 +1405,8 @@ export async function discoverValidIntegrationComponents(integration, options) {
1109
1405
  * How an owner holds a component name, which the owner rule
1110
1406
  * ({@link unscopedComponentOwners}) reads: `core` when Core documents it;
1111
1407
  * `file` when an integration doc named after its file is recorded under it;
1112
- * `doc` when only a doc records it: an export name, or the file name of a doc
1113
- * recorded under another export.
1408
+ * `doc` when only a doc records it: an export name, a member, or the file name
1409
+ * of a doc recorded under another export.
1114
1410
  * @typedef {'core' | 'file' | 'doc'} NameHold
1115
1411
  */
1116
1412
 
@@ -1126,19 +1422,20 @@ export function integrationRecordHold(record) {
1126
1422
  }
1127
1423
 
1128
1424
  /**
1129
- * The doc that records `name` though its file is named otherwise, with its
1130
- * source, reading only docs that write the name as a string.
1425
+ * The doc that records `name` as its export or a member though its file is
1426
+ * named otherwise, with the source, reading only docs that write the name.
1131
1427
  *
1428
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1132
1429
  * @param {string} componentsDir
1133
1430
  * @param {string} name
1134
1431
  * @returns {{docPath: string, sourcePath: string|null} | null}
1135
1432
  */
1136
- function findDocumentedComponent(componentsDir, name) {
1433
+ function findDocumentedComponent(integration, componentsDir, name) {
1137
1434
  if (!COMPONENT_NAME_RE.test(name)) return null;
1138
1435
  const literals = [`'${name}'`, `"${name}"`, `\`${name}\``];
1139
1436
  /** @type {Set<string>} */
1140
1437
  const stems = new Set();
1141
- /** @type {Array<{docPath: string, stem: string, names: string[], replaces: string | null, sourcePath: string|null}>} */
1438
+ /** @type {Array<{docPath: string, stem: string, names: string[], hasMembers: boolean, replaces: string | null, sourcePath: string|null}>} */
1142
1439
  const candidates = [];
1143
1440
  forEachIntegrationDoc(componentsDir, (docPath, stem) => {
1144
1441
  let text;
@@ -1161,17 +1458,28 @@ function findDocumentedComponent(componentsDir, name) {
1161
1458
  // A Core name a doc in the package replaces is the replacement's to answer.
1162
1459
  if (candidates.some(candidate => candidate.replaces === name)) return null;
1163
1460
  candidates.sort((a, b) => byCodeUnit(a.docPath, b.docPath));
1164
- const found = candidates.find(
1165
- candidate => otherExportName(candidate) === name,
1166
- );
1167
- return found ? {docPath: found.docPath, sourcePath: found.sourcePath} : null;
1461
+ for (const candidate of candidates) {
1462
+ if (otherExportName(candidate) === name) {
1463
+ return {docPath: candidate.docPath, sourcePath: candidate.sourcePath};
1464
+ }
1465
+ }
1466
+ for (const candidate of candidates) {
1467
+ if (!candidate.hasMembers) continue;
1468
+ const ownName = otherExportName(candidate);
1469
+ const [member] = familyMembers(integration, candidate, ownName, name);
1470
+ if (member) {
1471
+ return {docPath: candidate.docPath, sourcePath: member.sourcePath};
1472
+ }
1473
+ }
1474
+ return null;
1168
1475
  }
1169
1476
 
1170
1477
  /**
1171
1478
  * The integration component a name finds, as discovery records it, and its
1172
- * hold: the same-stem doc first, then a doc that records the name.
1479
+ * hold: the same-stem doc first, then a doc that records the name as an export
1480
+ * or a member.
1173
1481
  *
1174
- * @param {{components?: string}} integration
1482
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1175
1483
  * @param {string} name
1176
1484
  * @returns {{docPath: string, sourcePath: string|null, hold: 'file' | 'doc'} | null}
1177
1485
  */
@@ -1189,24 +1497,48 @@ export function findIntegrationComponent(integration, name) {
1189
1497
  const renamed =
1190
1498
  exportName != null &&
1191
1499
  findSameStemDoc(componentsDir, exportName) == null &&
1192
- findDocumentedComponent(componentsDir, exportName)?.docPath === sameStem;
1500
+ findDocumentedComponent(integration, componentsDir, exportName)
1501
+ ?.docPath === sameStem;
1193
1502
  return {
1194
1503
  docPath: sameStem,
1195
1504
  sourcePath: integrationSourceForDoc(sameStem),
1196
1505
  hold: renamed ? 'doc' : 'file',
1197
1506
  };
1198
1507
  }
1199
- const found = findDocumentedComponent(componentsDir, name);
1508
+ const found = findDocumentedComponent(integration, componentsDir, name);
1200
1509
  return found && {...found, hold: 'doc'};
1201
1510
  }
1202
1511
 
1512
+ /**
1513
+ * The name discovery records a component doc under: the export its `name`
1514
+ * documents when the doc is recorded under it, else the doc's file name.
1515
+ *
1516
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1517
+ * @param {string} docPath
1518
+ * @returns {string}
1519
+ */
1520
+ export function recordedDocName(integration, docPath) {
1521
+ const stem = docStem(docPath);
1522
+ const found = findIntegrationComponent(integration, stem);
1523
+ if (found?.docPath !== docPath || found.hold !== 'doc') return stem;
1524
+ return (
1525
+ otherExportName({
1526
+ docPath,
1527
+ sourcePath: integrationSourceForDoc(docPath),
1528
+ stem,
1529
+ names: readDocMeta(docPath).names,
1530
+ }) ?? stem
1531
+ );
1532
+ }
1533
+
1203
1534
  /**
1204
1535
  * Find an integration component's doc file by name within a loaded
1205
1536
  * integration's resolved `components` dir. Honors the same-stem convention
1206
1537
  * (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js`, and finds a
1207
- * doc that records the component under a differently named export of its file.
1538
+ * doc that records the component under another name: a differently named
1539
+ * export of its file, or a member its family doc lists.
1208
1540
  *
1209
- * @param {{components?: string}} integration
1541
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1210
1542
  * @param {string} name bare component name (no `XDS`/`Astryx` prefix)
1211
1543
  * @returns {string|null}
1212
1544
  */
@@ -1248,9 +1580,10 @@ function findSameStemDoc(componentsDir, name) {
1248
1580
  /**
1249
1581
  * Find an integration component's swizzleable source file by name within a
1250
1582
  * loaded integration's resolved `components` dir: the same-stem `{Name}.tsx`
1251
- * of its doc. Returns null when the integration ships docs without source.
1583
+ * of its doc, or, for a family member, the file that declares it. Returns null
1584
+ * when the integration ships docs without source.
1252
1585
  *
1253
- * @param {{components?: string}} integration
1586
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1254
1587
  * @param {string} name bare component name
1255
1588
  * @returns {string|null}
1256
1589
  */
@@ -279,6 +279,26 @@ describe('resolveComponentReplacements', () => {
279
279
  });
280
280
  });
281
281
 
282
+ it("counts a doc's replaces once when the doc lists its own name as a member", async () => {
283
+ // Chip.doc.mjs documents AcmeChip beside a Chip.tsx that exports both, and
284
+ // lists itself first, as the component-family guide asks.
285
+ const acme = integrationWithDocSource(
286
+ '@acme/chips',
287
+ 'Chip',
288
+ `export default {type: 'component', name: 'AcmeChip', replaces: 'Badge', usage: {description: 'Acme chip.'}, components: [{name: 'AcmeChip', description: 'A chip.', props: []}]};\n`,
289
+ );
290
+ fs.writeFileSync(
291
+ path.join(acme.components, 'Chip.tsx'),
292
+ 'export function Chip() { return null; }\nexport function AcmeChip() { return null; }\n',
293
+ );
294
+ const result = await resolveComponentReplacements(CORE_DIR, [acme]);
295
+ expect(result.findings).toEqual([]);
296
+ expect(result.forTarget('Badge')).toMatchObject({
297
+ name: 'Chip',
298
+ package: '@acme/chips',
299
+ });
300
+ });
301
+
282
302
  it('lets a replacement keep the Core name it replaces', async () => {
283
303
  const acme = integration(
284
304
  '@acme/nav',