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

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.
@@ -115,19 +115,36 @@ export function discoverExternalComponentsGrouped(docsDir: string): Record<strin
115
115
  * @returns {string | null}
116
116
  */
117
117
  export function findExternalComponentDoc(docsDir: string, name: string): string | null;
118
+ /** @param {string} docPath */
119
+ export function docStem(docPath: string): string;
120
+ /**
121
+ * Visit every component doc under an integration's components root, hidden
122
+ * ones included, in discovery order, with its stem.
123
+ * @param {string | undefined} componentsDir
124
+ * @param {(docPath: string, stem: string) => void} visit
125
+ */
126
+ export function forEachIntegrationDoc(componentsDir: string | undefined, visit: (docPath: string, stem: string) => void): void;
118
127
  /**
119
128
  * Discover ownership records for the components contributed by a single loaded
120
129
  * integration. Scans the integration's resolved `components` dir for same-stem
121
- * doc files and records each with owner package + issuesUrl + sourcePath.
130
+ * doc files and records each with owner package + issuesUrl + sourcePath. A doc
131
+ * that names a differently named export of its file is recorded under that
132
+ * export unless the name is taken: every doc's file name (a hidden doc's too),
133
+ * a Core name a doc in the package replaces, and a name an earlier doc records.
122
134
  *
123
135
  * @param {{name: string, components?: string, issuesUrl?: string}} integration
124
136
  * a single entry from `loadedIntegrations` (lib/integrations.mjs)
137
+ * @param {{documentedNames?: boolean}} [options] `documentedNames: false`
138
+ * records each doc under its file name alone, without reading the names
139
+ * docs document: for a caller that needs only what each doc says
125
140
  * @returns {Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>}
126
141
  */
127
142
  export function discoverIntegrationComponents(integration: {
128
143
  name: string;
129
144
  components?: string;
130
145
  issuesUrl?: string;
146
+ }, { documentedNames }?: {
147
+ documentedNames?: boolean;
131
148
  }): Array<{
132
149
  name: string;
133
150
  package: string;
@@ -141,9 +158,12 @@ export function discoverIntegrationComponents(integration: {
141
158
  * Invalid metadata or a missing same-stem source removes only that component;
142
159
  * callers can report the returned errors while retaining valid siblings. A
143
160
  * component whose doc sets `replaces` carries the value as written; the
144
- * component replacement resolver decides what it means.
161
+ * component replacement resolver decides what it means. A doc that fails to
162
+ * load is reported under its file name: what it says cannot be trusted.
145
163
  *
146
164
  * @param {{name: string, components?: string, issuesUrl?: string}} integration
165
+ * @param {{documentedNames?: boolean}} [options] as for
166
+ * {@link discoverIntegrationComponents}
147
167
  * @returns {Promise<{
148
168
  * components: Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>,
149
169
  * discovered: Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>,
@@ -154,6 +174,8 @@ export function discoverValidIntegrationComponents(integration: {
154
174
  name: string;
155
175
  components?: string;
156
176
  issuesUrl?: string;
177
+ }, options?: {
178
+ documentedNames?: boolean;
157
179
  }): Promise<{
158
180
  components: Array<{
159
181
  name: string;
@@ -177,10 +199,43 @@ export function discoverValidIntegrationComponents(integration: {
177
199
  message: string;
178
200
  }>;
179
201
  }>;
202
+ /**
203
+ * How an owner holds a component name, which the owner rule
204
+ * ({@link unscopedComponentOwners}) reads: `core` when Core documents it;
205
+ * `file` when an integration doc named after its file is recorded under it;
206
+ * `doc` when only a doc records it: an export name, or the file name of a doc
207
+ * recorded under another export.
208
+ * @typedef {'core' | 'file' | 'doc'} NameHold
209
+ */
210
+ /**
211
+ * How a discovered integration record holds its name.
212
+ * @param {{name: string, docPath: string|null}} record
213
+ * @returns {'file' | 'doc'}
214
+ */
215
+ export function integrationRecordHold(record: {
216
+ name: string;
217
+ docPath: string | null;
218
+ }): "file" | "doc";
219
+ /**
220
+ * The integration component a name finds, as discovery records it, and its
221
+ * hold: the same-stem doc first, then a doc that records the name.
222
+ *
223
+ * @param {{components?: string}} integration
224
+ * @param {string} name
225
+ * @returns {{docPath: string, sourcePath: string|null, hold: 'file' | 'doc'} | null}
226
+ */
227
+ export function findIntegrationComponent(integration: {
228
+ components?: string;
229
+ }, name: string): {
230
+ docPath: string;
231
+ sourcePath: string | null;
232
+ hold: "file" | "doc";
233
+ } | null;
180
234
  /**
181
235
  * Find an integration component's doc file by name within a loaded
182
236
  * integration's resolved `components` dir. Honors the same-stem convention
183
- * (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js`.
237
+ * (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js`, and finds a
238
+ * doc that records the component under a differently named export of its file.
184
239
  *
185
240
  * @param {{components?: string}} integration
186
241
  * @param {string} name bare component name (no `XDS`/`Astryx` prefix)
@@ -190,9 +245,9 @@ export function findIntegrationComponentDoc(integration: {
190
245
  components?: string;
191
246
  }, name: string): string | null;
192
247
  /**
193
- * Find an integration component's swizzleable source file (`{Name}.tsx`) by
194
- * name within a loaded integration's resolved `components` dir. Returns null
195
- * when the integration ships docs without source.
248
+ * Find an integration component's swizzleable source file by name within a
249
+ * loaded integration's resolved `components` dir: the same-stem `{Name}.tsx`
250
+ * of its doc. Returns null when the integration ships docs without source.
196
251
  *
197
252
  * @param {{components?: string}} integration
198
253
  * @param {string} name bare component name
@@ -201,6 +256,20 @@ export function findIntegrationComponentDoc(integration: {
201
256
  export function findIntegrationComponentSource(integration: {
202
257
  components?: string;
203
258
  }, name: string): string | null;
259
+ /**
260
+ * The owner rule: an owner that holds a name only because a doc records it
261
+ * (`hold: 'doc'`) yields to Core and to every file-named owner; `--package`
262
+ * still reaches it. Surfaces resolve a replacement first, then this rule, then
263
+ * which docs load.
264
+ *
265
+ * @template {{hold?: string}} T
266
+ * @param {T[]} owners each with its {@link NameHold}; an owner without one
267
+ * counts as Core's or file-named
268
+ * @returns {T[]}
269
+ */
270
+ export function unscopedComponentOwners<T extends {
271
+ hold?: string;
272
+ }>(owners: T[]): T[];
204
273
  /**
205
274
  * Build a flat list of ownership records for ALL discoverable components —
206
275
  * core (built-in) plus every loaded integration. This is the authoritative
@@ -229,3 +298,11 @@ export function discoverOwnedComponents(coreDir: string, loadedIntegrations?: Ar
229
298
  }>;
230
299
  /** The owner package name for built-in (core) components. */
231
300
  export const CORE_PACKAGE: import("../../authoring/index.js").ProviderId;
301
+ /**
302
+ * How an owner holds a component name, which the owner rule
303
+ * ({@link unscopedComponentOwners}) reads: `core` when Core documents it;
304
+ * `file` when an integration doc named after its file is recorded under it;
305
+ * `doc` when only a doc records it: an export name, or the file name of a doc
306
+ * recorded under another export.
307
+ */
308
+ export type NameHold = "core" | "file" | "doc";
@@ -0,0 +1,293 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration components are found by the names their docs document;
5
+ * every doc that works today reads exactly as before.
6
+ */
7
+
8
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
9
+ import * as fs from 'node:fs';
10
+ import * as os from 'node:os';
11
+ import * as path from 'node:path';
12
+ import {
13
+ discoverIntegrationComponents,
14
+ discoverValidIntegrationComponents,
15
+ findIntegrationComponent,
16
+ findIntegrationComponentDoc,
17
+ findIntegrationComponentSource,
18
+ unscopedComponentOwners,
19
+ } from './component-discovery.mjs';
20
+
21
+ /** @type {string} the integration's components root */
22
+ let root;
23
+
24
+ beforeEach(() => {
25
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-export-names-'));
26
+ });
27
+
28
+ afterEach(() => {
29
+ fs.rmSync(root, {recursive: true, force: true});
30
+ });
31
+
32
+ /**
33
+ * Write a component doc, and its same-stem source when given.
34
+ * @param {string} file path under the root, without extension
35
+ * @param {{name: string, source?: string, form?: 'default' | 'docs', suffix?: string, extra?: string}} options
36
+ * `name` is written as a JavaScript expression
37
+ */
38
+ function writeComponent(
39
+ file,
40
+ {name, source, form = 'default', suffix = '.doc.mjs', extra = ''},
41
+ ) {
42
+ const target = path.join(root, file);
43
+ fs.mkdirSync(path.dirname(target), {recursive: true});
44
+ const doc = `{\n type: 'component',\n name: ${name},\n usage: {description: 'A component.'},\n props: [],${extra}\n}`;
45
+ fs.writeFileSync(
46
+ `${target}${suffix}`,
47
+ form === 'default'
48
+ ? `export default ${doc};\n`
49
+ : `export const docs = ${doc};\n`,
50
+ );
51
+ if (source != null) fs.writeFileSync(`${target}.tsx`, source);
52
+ }
53
+
54
+ const integration = () => ({name: '@acme/widgets', components: root});
55
+
56
+ /** @returns {string[]} */
57
+ const names = () =>
58
+ discoverIntegrationComponents(integration())
59
+ .map(record => record.name)
60
+ .sort();
61
+
62
+ describe('integration components whose doc names another export of its file', () => {
63
+ it('records a doc beside a kebab-case file under the export its doc names', async () => {
64
+ writeComponent('artifact-viewer', {
65
+ name: "'AcmeArtifactViewer'",
66
+ source: 'export function AcmeArtifactViewer() { return null; }\n',
67
+ });
68
+
69
+ const [record] = discoverIntegrationComponents(integration());
70
+ expect(record).toMatchObject({
71
+ name: 'AcmeArtifactViewer',
72
+ package: '@acme/widgets',
73
+ docPath: path.join(root, 'artifact-viewer.doc.mjs'),
74
+ sourcePath: path.join(root, 'artifact-viewer.tsx'),
75
+ });
76
+ expect(
77
+ (await discoverValidIntegrationComponents(integration())).components,
78
+ ).toEqual([expect.objectContaining({name: 'AcmeArtifactViewer'})]);
79
+ expect(
80
+ findIntegrationComponentDoc(integration(), 'AcmeArtifactViewer'),
81
+ ).toBe(record.docPath);
82
+ expect(findIntegrationComponentDoc(integration(), 'artifact-viewer')).toBe(
83
+ record.docPath,
84
+ );
85
+ expect(
86
+ findIntegrationComponentSource(integration(), 'AcmeArtifactViewer'),
87
+ ).toBe(record.sourcePath);
88
+ });
89
+
90
+ it('leaves a name to Core or a file-named component over one a doc records', () => {
91
+ const core = {package: '@astryxdesign/core', hold: 'core'};
92
+ const fileNamed = {package: '@beta/kit', hold: 'file'};
93
+ const documented = {package: '@acme/widgets', hold: 'doc'};
94
+ expect(unscopedComponentOwners([documented, fileNamed])).toEqual([
95
+ fileNamed,
96
+ ]);
97
+ expect(unscopedComponentOwners([core, documented])).toEqual([core]);
98
+ expect(unscopedComponentOwners([documented])).toEqual([documented]);
99
+
100
+ // Badge.doc.mjs records AcmeBadge, so the doc holds both names only
101
+ // because it documents them; Panel.doc.mjs names an export another file
102
+ // has, so it keeps its file name.
103
+ writeComponent('Badge', {
104
+ name: "'AcmeBadge'",
105
+ source: 'export function AcmeBadge() { return null; }\n',
106
+ });
107
+ writeComponent('Panel', {
108
+ name: "'AcmeChip'",
109
+ source: 'export function AcmeChip() { return null; }\n',
110
+ });
111
+ writeComponent('AcmeChip', {
112
+ name: "'AcmeChip'",
113
+ source: 'export function AcmeChip() { return null; }\n',
114
+ });
115
+ const hold = (/** @type {string} */ name) =>
116
+ findIntegrationComponent(integration(), name)?.hold;
117
+ expect(hold('AcmeBadge')).toBe('doc');
118
+ expect(hold('Badge')).toBe('doc');
119
+ expect(hold('Panel')).toBe('file');
120
+ expect(hold('AcmeChip')).toBe('file');
121
+ });
122
+
123
+ it('takes no name a hidden doc or a replacement in the package claims', () => {
124
+ // A hidden doc is still the file named AcmeCarousel: its name stays its own.
125
+ writeComponent('AcmeCarousel', {
126
+ name: "'AcmeCarousel'",
127
+ extra: '\n hidden: true,',
128
+ source: 'export function AcmeCarousel() { return null; }\n',
129
+ });
130
+ writeComponent('gallery/Slides', {
131
+ name: "'AcmeCarousel'",
132
+ source: 'export function AcmeCarousel() { return null; }\n',
133
+ });
134
+ // A doc in the package replaces Carousel, so that name answers with it.
135
+ writeComponent('Pager', {
136
+ name: "'AcmePager'",
137
+ extra: "\n replaces: 'Carousel',",
138
+ source: 'export function AcmePager() { return null; }\n',
139
+ });
140
+ writeComponent('Kit', {
141
+ name: "'Carousel'",
142
+ source: 'export function Carousel() { return null; }\n',
143
+ });
144
+
145
+ expect(names()).toEqual(['AcmePager', 'Kit', 'Slides']);
146
+ expect(findIntegrationComponentDoc(integration(), 'AcmeCarousel')).toBe(
147
+ path.join(root, 'AcmeCarousel.doc.mjs'),
148
+ );
149
+ expect(findIntegrationComponentDoc(integration(), 'Carousel')).toBeNull();
150
+ });
151
+
152
+ it('settles a name two docs document by path, the same in every locale', () => {
153
+ // By UTF-16 code unit `B/` sorts before `a/`; a locale's collation puts
154
+ // `a/` first.
155
+ for (const [file, stem] of [
156
+ ['a/Alpha', 'Alpha'],
157
+ ['B/Beta', 'Beta'],
158
+ ]) {
159
+ writeComponent(file, {
160
+ name: "'Twin'",
161
+ source: `export function Twin() { return null; }\n// ${stem}\n`,
162
+ });
163
+ }
164
+
165
+ expect(names()).toEqual(['Alpha', 'Twin']);
166
+ expect(findIntegrationComponentDoc(integration(), 'Twin')).toBe(
167
+ path.join(root, 'B', 'Beta.doc.mjs'),
168
+ );
169
+ });
170
+
171
+ it('reads a name a spread can override, and reports an unreadable doc under its file name', async () => {
172
+ fs.writeFileSync(
173
+ path.join(root, 'Based.doc.mjs'),
174
+ "const base = {\n name: 'Based',\n type: 'component',\n usage: {description: 'x'},\n props: [],\n};\nexport default {...base, name: 'AcmeBased'};\n",
175
+ );
176
+ fs.writeFileSync(
177
+ path.join(root, 'Based.tsx'),
178
+ 'export function AcmeBased() { return null; }\n',
179
+ );
180
+ writeComponent('Throws', {
181
+ name: "'AcmeThrows'",
182
+ source: 'export function AcmeThrows() { return null; }\n',
183
+ });
184
+ const throws = path.join(root, 'Throws.doc.mjs');
185
+ fs.writeFileSync(
186
+ throws,
187
+ `throw new Error('boom');\n${fs.readFileSync(throws, 'utf-8')}`,
188
+ );
189
+
190
+ expect(names()).toEqual(['AcmeBased', 'AcmeThrows']);
191
+ const {errors} = await discoverValidIntegrationComponents(integration());
192
+ expect(errors).toEqual([
193
+ {
194
+ name: 'Throws',
195
+ message: 'Component "Throws" has invalid metadata: boom',
196
+ },
197
+ ]);
198
+ });
199
+
200
+ it('reads a legacy export, a .doc.ts doc and a re-exported component the same way', () => {
201
+ writeComponent('Slides', {
202
+ name: "'AcmeCarousel'",
203
+ form: 'docs',
204
+ source: "export {AcmeCarousel} from './carousel/impl';\n",
205
+ });
206
+ fs.mkdirSync(path.join(root, 'carousel'));
207
+ fs.writeFileSync(
208
+ path.join(root, 'carousel', 'impl.tsx'),
209
+ 'export function AcmeCarousel() { return null; }\n',
210
+ );
211
+ writeComponent('Pager', {
212
+ name: "'AcmePager'",
213
+ suffix: '.doc.ts',
214
+ source: 'export const AcmePager = () => null;\n',
215
+ });
216
+
217
+ expect(names()).toEqual(['AcmeCarousel', 'AcmePager']);
218
+ });
219
+
220
+ it('keeps the file name for every doc that reads as before', async () => {
221
+ // Named after its file.
222
+ writeComponent('AcmeBadge', {
223
+ name: "'AcmeBadge'",
224
+ source: 'export function AcmeBadge() { return null; }\n',
225
+ });
226
+ // The file exports its own name, so that name stays.
227
+ writeComponent('Chip', {
228
+ name: "'AcmeChip'",
229
+ source:
230
+ 'export function Chip() { return null; }\nexport function AcmeChip() { return null; }\n',
231
+ });
232
+ // The doc names an export the file does not have.
233
+ writeComponent('Menu', {
234
+ name: "'AcmeMenu'",
235
+ source: 'export function Dropdown() { return null; }\n',
236
+ });
237
+ // A type is not a runtime export.
238
+ writeComponent('Shape', {
239
+ name: "'AcmeShape'",
240
+ source: 'export type AcmeShape = {id: string};\n',
241
+ });
242
+ // A name the doc computes is not read.
243
+ writeComponent('Tile', {
244
+ name: "`${'Acme'}Tile`",
245
+ source: 'export function AcmeTile() { return null; }\n',
246
+ });
247
+ // Another file already has the name.
248
+ writeComponent('Panel', {
249
+ name: "'AcmeBadge'",
250
+ source: 'export function AcmeBadge() { return null; }\n',
251
+ });
252
+ // No source beside it.
253
+ writeComponent('Orphan', {name: "'AcmeOrphan'"});
254
+
255
+ expect(names()).toEqual([
256
+ 'AcmeBadge',
257
+ 'Chip',
258
+ 'Menu',
259
+ 'Orphan',
260
+ 'Panel',
261
+ 'Shape',
262
+ 'Tile',
263
+ ]);
264
+ for (const name of [
265
+ 'AcmeChip',
266
+ 'AcmeMenu',
267
+ 'AcmeShape',
268
+ 'AcmeTile',
269
+ 'AcmeOrphan',
270
+ ]) {
271
+ expect(findIntegrationComponentDoc(integration(), name)).toBeNull();
272
+ }
273
+ expect(
274
+ (await discoverValidIntegrationComponents(integration())).errors,
275
+ ).toEqual([
276
+ {
277
+ name: 'Orphan',
278
+ message:
279
+ 'Component "Orphan" is missing its same-stem source file Orphan.tsx.',
280
+ },
281
+ ]);
282
+ });
283
+
284
+ it('still leaves a hidden doc out', () => {
285
+ writeComponent('Slides', {
286
+ name: "'AcmeCarousel'",
287
+ extra: '\n hidden: true,',
288
+ source: 'export function AcmeCarousel() { return null; }\n',
289
+ });
290
+
291
+ expect(names()).toEqual([]);
292
+ });
293
+ });