@astryxdesign/cli 0.1.5 → 0.1.6-canary.02f2602
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/docs/integration-authoring.md +105 -0
- package/docs/internationalization.doc.mjs +243 -0
- package/package.json +13 -9
- package/src/api/integration-block-exports.test.mjs +240 -0
- package/src/api/template-suffix.test.mjs +246 -0
- package/src/api/template.mjs +104 -28
- package/src/api/validate-integration.mjs +0 -8
- package/src/config.mjs +5 -14
- package/src/doc.mjs +27 -0
- package/src/doc.test.mjs +383 -0
- package/src/integration.mjs +4 -15
- package/src/lib/component-discovery.importpath.test.mjs +59 -0
- package/src/lib/component-discovery.mjs +15 -5
- package/src/lib/component-format.mjs +45 -13
- package/src/lib/component-format.test.mjs +95 -1
- package/src/lib/component-loader.mjs +104 -2
- package/src/lib/componentDocOverlay.test.mjs +111 -0
- package/src/lib/config-schema.mjs +0 -30
- package/src/lib/hook-format.mjs +8 -3
- package/src/lib/xle/registry.mjs +0 -5
- package/src/schemas/doc-schema.mjs +226 -0
- package/src/schemas/template-schema.mjs +47 -0
- package/src/template.mjs +9 -67
- package/src/types/config.d.ts +11 -66
- package/src/types/doc.d.ts +23 -0
- package/src/types/integration.d.ts +7 -18
- package/src/types/template-api.d.ts +14 -50
- package/templates/blocks/components/Avatar/AvatarGroup.tsx +5 -7
- package/templates/blocks/components/Avatar/AvatarShowcase.tsx +4 -6
- package/templates/blocks/components/Avatar/AvatarUserCard.tsx +3 -5
- package/templates/blocks/components/Avatar/AvatarWithImage.tsx +8 -6
- package/templates/blocks/components/Avatar/AvatarWithStatus.tsx +3 -5
- package/templates/blocks/components/ChatComposerInput/ChatComposerInputControlledInput.tsx +1 -1
- package/templates/blocks/components/ChatComposerInput/ChatComposerInputDisabled.tsx +1 -1
- package/templates/blocks/components/ChatComposerInput/ChatComposerInputMentionTrigger.tsx +1 -1
- package/templates/blocks/components/ChatComposerInput/ChatComposerInputMultipleTriggers.tsx +1 -1
- package/templates/blocks/components/ChatComposerInput/ChatComposerInputShowcase.tsx +1 -1
- package/templates/blocks/components/ChatComposerInput/ChatComposerInputSlashCommands.tsx +1 -1
- package/templates/blocks/components/Table/TableGroupedRowsTable.doc.mjs +14 -0
- package/templates/blocks/components/Table/TableGroupedRowsTable.tsx +66 -0
- package/templates/blocks/components/Table/TableRowIndexTable.doc.mjs +14 -0
- package/templates/blocks/components/Table/TableRowIndexTable.tsx +47 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenLiveRegion.doc.mjs +14 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenLiveRegion.tsx +41 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenShowcase.doc.mjs +13 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenShowcase.tsx +78 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenStructuralHeading.doc.mjs +14 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenStructuralHeading.tsx +38 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenSupplementaryContext.doc.mjs +14 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenSupplementaryContext.tsx +47 -0
- package/templates/pages/theme-showcase/page.tsx +7 -7
- package/templates/themes/neutral/neutralTheme.ts +63 -32
package/src/doc.test.mjs
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
createComponentDoc,
|
|
8
|
+
createFunctionDoc,
|
|
9
|
+
createDoc,
|
|
10
|
+
ComponentDocSchema,
|
|
11
|
+
ComponentDocKindSchema,
|
|
12
|
+
FunctionDocKindSchema,
|
|
13
|
+
GenericDocKindSchema,
|
|
14
|
+
} from './doc.mjs';
|
|
15
|
+
import {loadComponentDoc} from './lib/component-loader.mjs';
|
|
16
|
+
|
|
17
|
+
// The factories are stamp-only (like createConfig / createBlockTemplate): they
|
|
18
|
+
// inject a `type` discriminant and are otherwise identity, performing NO
|
|
19
|
+
// runtime validation. Validation happens at the LOAD boundary
|
|
20
|
+
// (loadComponentDoc runs the loaded value through ComponentDocSchema), so the
|
|
21
|
+
// rejection cases assert the schema rejects rather than the factory throwing.
|
|
22
|
+
|
|
23
|
+
const goodComponent = {
|
|
24
|
+
name: 'Widget',
|
|
25
|
+
displayName: 'Widget',
|
|
26
|
+
description: 'A small widget.',
|
|
27
|
+
props: [
|
|
28
|
+
{name: 'label', type: 'string', description: 'Visible label.', required: true},
|
|
29
|
+
{name: 'size', type: "'sm' | 'md'", description: 'Control size.', default: "'md'"},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const goodFunction = {
|
|
34
|
+
name: 'useThing',
|
|
35
|
+
displayName: 'useThing',
|
|
36
|
+
description: 'A thing hook.',
|
|
37
|
+
params: [{name: 'input', type: 'string', description: 'The input.', required: true}],
|
|
38
|
+
returns: [{name: 'value', type: 'string', description: 'The result.'}],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const goodGeneric = {
|
|
42
|
+
name: 'Theming',
|
|
43
|
+
displayName: 'Theming',
|
|
44
|
+
description: 'How theming works.',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('doc factories (stamp-only)', () => {
|
|
48
|
+
it('createComponentDoc stamps type: component and is otherwise identity', () => {
|
|
49
|
+
const doc = createComponentDoc(goodComponent);
|
|
50
|
+
expect(doc.type).toBe('component');
|
|
51
|
+
const {type, ...rest} = doc;
|
|
52
|
+
expect(rest).toEqual(goodComponent);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('createFunctionDoc stamps type: function and is otherwise identity', () => {
|
|
56
|
+
const doc = createFunctionDoc(goodFunction);
|
|
57
|
+
expect(doc.type).toBe('function');
|
|
58
|
+
const {type, ...rest} = doc;
|
|
59
|
+
expect(rest).toEqual(goodFunction);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('createDoc stamps type: generic and is otherwise identity', () => {
|
|
63
|
+
const doc = createDoc(goodGeneric);
|
|
64
|
+
expect(doc.type).toBe('generic');
|
|
65
|
+
const {type, ...rest} = doc;
|
|
66
|
+
expect(rest).toEqual(goodGeneric);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does NOT validate — stamps invalid shapes unchanged', () => {
|
|
70
|
+
const bogus = {group: 'Buttons'}; // no name
|
|
71
|
+
expect(createComponentDoc(bogus)).toEqual({...bogus, type: 'component'});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('per-kind schemas (new stamped format)', () => {
|
|
76
|
+
it('ComponentDocKindSchema accepts a valid component doc', () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
ComponentDocKindSchema.parse(createComponentDoc(goodComponent)),
|
|
79
|
+
).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('ComponentDocKindSchema rejects a missing name with a readable message', () => {
|
|
83
|
+
expect(() =>
|
|
84
|
+
ComponentDocKindSchema.parse({type: 'component', name: '', props: []}),
|
|
85
|
+
).toThrow(/name is required/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('ComponentDocKindSchema rejects a prop missing its type with a readable message', () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
ComponentDocKindSchema.parse({
|
|
91
|
+
type: 'component',
|
|
92
|
+
name: 'Widget',
|
|
93
|
+
props: [{name: 'label', description: 'no type'}],
|
|
94
|
+
}),
|
|
95
|
+
).toThrow(/type/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('ComponentDocKindSchema surfaces the custom message for an empty prop type', () => {
|
|
99
|
+
expect(() =>
|
|
100
|
+
ComponentDocKindSchema.parse({
|
|
101
|
+
type: 'component',
|
|
102
|
+
name: 'Widget',
|
|
103
|
+
props: [{name: 'label', type: '', description: 'empty type'}],
|
|
104
|
+
}),
|
|
105
|
+
).toThrow(/prop type is required/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('FunctionDocKindSchema accepts a valid function doc', () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
FunctionDocKindSchema.parse(createFunctionDoc(goodFunction)),
|
|
111
|
+
).not.toThrow();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('FunctionDocKindSchema rejects a function doc missing returns', () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
FunctionDocKindSchema.parse({
|
|
117
|
+
type: 'function',
|
|
118
|
+
name: 'useThing',
|
|
119
|
+
params: [],
|
|
120
|
+
}),
|
|
121
|
+
).toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('GenericDocKindSchema accepts a valid generic doc', () => {
|
|
125
|
+
expect(() =>
|
|
126
|
+
GenericDocKindSchema.parse(createDoc(goodGeneric)),
|
|
127
|
+
).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('keeps nested rich blobs loose (usage/theming/playground passthrough)', () => {
|
|
131
|
+
const doc = createComponentDoc({
|
|
132
|
+
...goodComponent,
|
|
133
|
+
usage: {description: 'Use it.', anatomy: [{name: 'root'}]},
|
|
134
|
+
theming: {targets: [{className: 'astryx-widget'}]},
|
|
135
|
+
playground: {defaults: {label: 'Hi'}},
|
|
136
|
+
examples: [{title: 'Basic', code: '<Widget />'}],
|
|
137
|
+
});
|
|
138
|
+
expect(() => ComponentDocKindSchema.parse(doc)).not.toThrow();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('accepts parent + relatedDocs on the shared base', () => {
|
|
142
|
+
const doc = createComponentDoc({
|
|
143
|
+
...goodComponent,
|
|
144
|
+
parent: 'WidgetGroup',
|
|
145
|
+
relatedDocs: ['Gauge', 'useThing'],
|
|
146
|
+
group: 'Widgets',
|
|
147
|
+
});
|
|
148
|
+
const parsed = ComponentDocKindSchema.parse(doc);
|
|
149
|
+
expect(parsed.parent).toBe('WidgetGroup');
|
|
150
|
+
expect(parsed.relatedDocs).toEqual(['Gauge', 'useThing']);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('ComponentDocSchema (load-boundary, both formats)', () => {
|
|
155
|
+
it('accepts a stamped component doc via the per-kind schema', () => {
|
|
156
|
+
expect(() =>
|
|
157
|
+
ComponentDocSchema.parse(createComponentDoc(goodComponent)),
|
|
158
|
+
).not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('accepts a stamped function doc', () => {
|
|
162
|
+
expect(() =>
|
|
163
|
+
ComponentDocSchema.parse(createFunctionDoc(goodFunction)),
|
|
164
|
+
).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('accepts a stamped generic doc', () => {
|
|
168
|
+
expect(() =>
|
|
169
|
+
ComponentDocSchema.parse(createDoc(goodGeneric)),
|
|
170
|
+
).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('accepts the OLD loose single-component shape (no type)', () => {
|
|
174
|
+
expect(() => ComponentDocSchema.parse(goodComponent)).not.toThrow();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('accepts the OLD loose multi-component shape (components[])', () => {
|
|
178
|
+
const multi = {
|
|
179
|
+
name: 'Table',
|
|
180
|
+
displayName: 'Table',
|
|
181
|
+
components: [{name: 'TableRow', displayName: 'Table Row', description: 'A row.'}],
|
|
182
|
+
};
|
|
183
|
+
expect(() => ComponentDocSchema.parse(multi)).not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('accepts the OLD loose sub-component shape (subComponentOf)', () => {
|
|
187
|
+
const sub = {
|
|
188
|
+
name: 'GaugeItem',
|
|
189
|
+
subComponentOf: 'Gauge',
|
|
190
|
+
displayName: 'Gauge Item',
|
|
191
|
+
description: 'A gauge item.',
|
|
192
|
+
props: [{name: 'item', type: 'Item', description: 'The item.'}],
|
|
193
|
+
};
|
|
194
|
+
expect(() => ComponentDocSchema.parse(sub)).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('accepts the OLD loose standalone-hook shape (params + returns, no type)', () => {
|
|
198
|
+
const hook = {
|
|
199
|
+
name: 'useThing',
|
|
200
|
+
displayName: 'useThing',
|
|
201
|
+
params: [{name: 'q', type: 'string', description: 'query'}],
|
|
202
|
+
returns: [{name: 'value', type: 'boolean', description: 'match'}],
|
|
203
|
+
};
|
|
204
|
+
expect(() => ComponentDocSchema.parse(hook)).not.toThrow();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('accepts BOTH parent and legacy subComponentOf', () => {
|
|
208
|
+
const withParent = {name: 'A', parent: 'B', props: []};
|
|
209
|
+
const withSubComponentOf = {
|
|
210
|
+
name: 'A',
|
|
211
|
+
subComponentOf: 'B',
|
|
212
|
+
description: 'sub',
|
|
213
|
+
props: [],
|
|
214
|
+
};
|
|
215
|
+
expect(() => ComponentDocSchema.parse(withParent)).not.toThrow();
|
|
216
|
+
expect(() => ComponentDocSchema.parse(withSubComponentOf)).not.toThrow();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('accepts BOTH relatedDocs and legacy relatedComponents/relatedHooks', () => {
|
|
220
|
+
const legacy = {
|
|
221
|
+
name: 'useThing',
|
|
222
|
+
displayName: 'useThing',
|
|
223
|
+
params: [],
|
|
224
|
+
returns: [],
|
|
225
|
+
relatedComponents: ['Gauge'],
|
|
226
|
+
relatedHooks: ['useOther'],
|
|
227
|
+
};
|
|
228
|
+
const modern = {...goodComponent, relatedDocs: ['Gauge', 'useThing']};
|
|
229
|
+
expect(() => ComponentDocSchema.parse(legacy)).not.toThrow();
|
|
230
|
+
expect(() =>
|
|
231
|
+
ComponentDocSchema.parse(createComponentDoc(modern)),
|
|
232
|
+
).not.toThrow();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('passes through loose extras (usage, playground, theming, importPath, showcase)', () => {
|
|
236
|
+
const loose = {
|
|
237
|
+
...goodComponent,
|
|
238
|
+
usage: {description: 'Use it wisely.'},
|
|
239
|
+
playground: {defaults: {label: 'Hi'}},
|
|
240
|
+
theming: {targets: [{className: 'astryx-widget'}]},
|
|
241
|
+
keywords: ['widget', 'thing'],
|
|
242
|
+
category: 'Content',
|
|
243
|
+
isHiddenFromOverview: true,
|
|
244
|
+
importPath: '@astryxdesign/core/Widget',
|
|
245
|
+
showcase: 'WidgetHero',
|
|
246
|
+
};
|
|
247
|
+
const parsed = ComponentDocSchema.parse(loose);
|
|
248
|
+
expect(parsed.importPath).toBe('@astryxdesign/core/Widget');
|
|
249
|
+
expect(parsed.showcase).toBe('WidgetHero');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('rejects a doc with no name', () => {
|
|
253
|
+
expect(() => ComponentDocSchema.parse({props: []})).toThrow();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('rejects an empty name with a readable message', () => {
|
|
257
|
+
expect(() => ComponentDocSchema.parse({name: '', props: []})).toThrow(
|
|
258
|
+
/name is required/,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('loadComponentDoc (load boundary)', () => {
|
|
264
|
+
let tmpDir;
|
|
265
|
+
|
|
266
|
+
beforeEach(() => {
|
|
267
|
+
tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-doc-test-'));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const docModule = () => JSON.stringify(path.resolve(import.meta.dirname, 'doc.mjs'));
|
|
275
|
+
|
|
276
|
+
it('loads a .doc.ts fixture via jiti (createComponentDoc default export)', async () => {
|
|
277
|
+
const file = path.join(tmpDir, 'Widget.doc.ts');
|
|
278
|
+
fs.writeFileSync(
|
|
279
|
+
file,
|
|
280
|
+
[
|
|
281
|
+
`import {createComponentDoc} from ${docModule()};`,
|
|
282
|
+
'export default createComponentDoc({',
|
|
283
|
+
" name: 'Widget',",
|
|
284
|
+
" displayName: 'Widget',",
|
|
285
|
+
" description: 'A small widget.',",
|
|
286
|
+
' props: [',
|
|
287
|
+
" {name: 'label', type: 'string', description: 'Visible label.', required: true},",
|
|
288
|
+
' ],',
|
|
289
|
+
'});',
|
|
290
|
+
].join('\n'),
|
|
291
|
+
);
|
|
292
|
+
const docs = await loadComponentDoc(file);
|
|
293
|
+
expect(docs.type).toBe('component');
|
|
294
|
+
expect(docs.name).toBe('Widget');
|
|
295
|
+
expect(docs.props).toHaveLength(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('loads a .doc.ts fixture via jiti (createFunctionDoc default export)', async () => {
|
|
299
|
+
const file = path.join(tmpDir, 'useThing.doc.ts');
|
|
300
|
+
fs.writeFileSync(
|
|
301
|
+
file,
|
|
302
|
+
[
|
|
303
|
+
`import {createFunctionDoc} from ${docModule()};`,
|
|
304
|
+
'export default createFunctionDoc({',
|
|
305
|
+
" name: 'useThing',",
|
|
306
|
+
" displayName: 'useThing',",
|
|
307
|
+
" params: [{name: 'input', type: 'string', description: 'The input.'}],",
|
|
308
|
+
" returns: [{name: 'value', type: 'string', description: 'The result.'}],",
|
|
309
|
+
'});',
|
|
310
|
+
].join('\n'),
|
|
311
|
+
);
|
|
312
|
+
const docs = await loadComponentDoc(file);
|
|
313
|
+
expect(docs.type).toBe('function');
|
|
314
|
+
expect(docs.params).toHaveLength(1);
|
|
315
|
+
expect(docs.returns).toHaveLength(1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('loads a .doc.ts fixture via jiti (createDoc generic default export)', async () => {
|
|
319
|
+
const file = path.join(tmpDir, 'Theming.doc.ts');
|
|
320
|
+
fs.writeFileSync(
|
|
321
|
+
file,
|
|
322
|
+
[
|
|
323
|
+
`import {createDoc} from ${docModule()};`,
|
|
324
|
+
'export default createDoc({',
|
|
325
|
+
" name: 'Theming',",
|
|
326
|
+
" displayName: 'Theming',",
|
|
327
|
+
" description: 'How theming works.',",
|
|
328
|
+
'});',
|
|
329
|
+
].join('\n'),
|
|
330
|
+
);
|
|
331
|
+
const docs = await loadComponentDoc(file);
|
|
332
|
+
expect(docs.type).toBe('generic');
|
|
333
|
+
expect(docs.name).toBe('Theming');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('REGRESSION: loads the OLD loose `export const docs = {}` format', async () => {
|
|
337
|
+
const file = path.join(tmpDir, 'Legacy.doc.mjs');
|
|
338
|
+
fs.writeFileSync(
|
|
339
|
+
file,
|
|
340
|
+
[
|
|
341
|
+
'export const docs = {',
|
|
342
|
+
" name: 'Legacy',",
|
|
343
|
+
" displayName: 'Legacy',",
|
|
344
|
+
" description: 'A loose named-export doc.',",
|
|
345
|
+
" relatedComponents: ['Gauge'],",
|
|
346
|
+
" relatedHooks: ['useThing'],",
|
|
347
|
+
" importPath: '@astryxdesign/core/Legacy',",
|
|
348
|
+
" props: [{name: 'value', type: 'string', description: 'A value.'}],",
|
|
349
|
+
'};',
|
|
350
|
+
].join('\n'),
|
|
351
|
+
);
|
|
352
|
+
const docs = await loadComponentDoc(file);
|
|
353
|
+
expect(docs.name).toBe('Legacy');
|
|
354
|
+
expect(docs.props[0].name).toBe('value');
|
|
355
|
+
expect(docs.relatedComponents).toEqual(['Gauge']);
|
|
356
|
+
expect(docs.importPath).toBe('@astryxdesign/core/Legacy');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('REGRESSION: loads the OLD loose standalone-hook `docs` format', async () => {
|
|
360
|
+
const file = path.join(tmpDir, 'useLegacy.doc.mjs');
|
|
361
|
+
fs.writeFileSync(
|
|
362
|
+
file,
|
|
363
|
+
[
|
|
364
|
+
'export const docs = {',
|
|
365
|
+
" name: 'useLegacy',",
|
|
366
|
+
" displayName: 'useLegacy',",
|
|
367
|
+
" params: [{name: 'q', type: 'string', description: 'query'}],",
|
|
368
|
+
" returns: [{name: 'value', type: 'boolean', description: 'match'}],",
|
|
369
|
+
" relatedHooks: ['useThing'],",
|
|
370
|
+
'};',
|
|
371
|
+
].join('\n'),
|
|
372
|
+
);
|
|
373
|
+
const docs = await loadComponentDoc(file);
|
|
374
|
+
expect(docs.name).toBe('useLegacy');
|
|
375
|
+
expect(docs.returns[0].name).toBe('value');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('throws a readable error for an invalid doc', async () => {
|
|
379
|
+
const file = path.join(tmpDir, 'Bad.doc.mjs');
|
|
380
|
+
fs.writeFileSync(file, 'export default {group: "Buttons"};\n');
|
|
381
|
+
await expect(loadComponentDoc(file)).rejects.toThrow(/is invalid|name/);
|
|
382
|
+
});
|
|
383
|
+
});
|
package/src/integration.mjs
CHANGED
|
@@ -1,19 +1,8 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* argument unchanged. Its value is the exported TypeScript surface from
|
|
8
|
-
* `@astryxdesign/cli/integration`, so manifests get editor/type feedback
|
|
9
|
-
* without coupling to CLI internals. Validation is NOT performed here — it
|
|
10
|
-
* happens at the load boundary (see `loadModuleWithSchema` +
|
|
11
|
-
* `AstryxIntegrationSchema`).
|
|
12
|
-
*
|
|
13
|
-
* @template {import('./types/integration').AstryxIntegration} T
|
|
14
|
-
* @param {T} integration
|
|
15
|
-
* @returns {T}
|
|
4
|
+
* Re-export of the integration-authoring helper, which now lives in
|
|
5
|
+
* `@astryxdesign/core/authoring`. Kept here so existing
|
|
6
|
+
* `@astryxdesign/cli/integration` imports continue to work unchanged.
|
|
16
7
|
*/
|
|
17
|
-
export
|
|
18
|
-
return integration;
|
|
19
|
-
}
|
|
8
|
+
export {createIntegration} from '@astryxdesign/core/authoring';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Verifies that `.ts`-authored sources (hooks and other functions) are
|
|
5
|
+
* found by `findComponentSource`, so `resolveImportPath` derives a
|
|
6
|
+
* tree-shakeable subpath instead of falling back to the bare package root.
|
|
7
|
+
*
|
|
8
|
+
* Runs against the real packages/core source, not mocks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {describe, it, expect} from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
findComponentSource,
|
|
14
|
+
resolveImportPath,
|
|
15
|
+
} from './component-discovery.mjs';
|
|
16
|
+
import {findCoreDir} from '../utils/paths.mjs';
|
|
17
|
+
|
|
18
|
+
describe('findComponentSource resolves .ts-authored sources', () => {
|
|
19
|
+
const coreDir = findCoreDir();
|
|
20
|
+
|
|
21
|
+
it('finds a hook authored as a .ts file (not just .tsx)', () => {
|
|
22
|
+
const src = findComponentSource(coreDir, 'useMediaQuery');
|
|
23
|
+
expect(src).toBeTruthy();
|
|
24
|
+
expect(src.endsWith('useMediaQuery.ts')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('still finds a component authored as .tsx', () => {
|
|
28
|
+
const src = findComponentSource(coreDir, 'Button');
|
|
29
|
+
expect(src).toBeTruthy();
|
|
30
|
+
expect(src.endsWith('.tsx')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('resolveImportPath reproduces authored hook importPaths', () => {
|
|
35
|
+
const coreDir = findCoreDir();
|
|
36
|
+
|
|
37
|
+
// Representative sample of core hooks whose sources are `.ts` files. Before
|
|
38
|
+
// the `.ts` fix these fell back to bare `@astryxdesign/core`; the derived
|
|
39
|
+
// subpath must now match the value each hook's doc currently authors.
|
|
40
|
+
const cases = [
|
|
41
|
+
['useMediaQuery', '@astryxdesign/core/hooks'],
|
|
42
|
+
['useResizable', '@astryxdesign/core/Resizable'],
|
|
43
|
+
['useTheme', '@astryxdesign/core/theme'],
|
|
44
|
+
['useFocusTrap', '@astryxdesign/core/hooks'],
|
|
45
|
+
['useOverflow', '@astryxdesign/core/hooks'],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const [name, expected] of cases) {
|
|
49
|
+
it(`${name} derives ${expected}`, () => {
|
|
50
|
+
expect(resolveImportPath(coreDir, name)).toBe(expected);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('never falls back to the bare package root for a .ts hook', () => {
|
|
55
|
+
for (const [name] of cases) {
|
|
56
|
+
expect(resolveImportPath(coreDir, name)).not.toBe('@astryxdesign/core');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -273,14 +273,24 @@ export function findComponentReadme(coreDir, name) {
|
|
|
273
273
|
* For "Button" finds src/Button/XDSButton.tsx
|
|
274
274
|
* For "Layout" finds src/Layout/XDSLayout/XDSLayout.tsx
|
|
275
275
|
* For "Card" finds src/Layout/Container/XDSCard.tsx (deep search fallback)
|
|
276
|
+
*
|
|
277
|
+
* Hooks and other functions are authored as `.ts` (e.g. `useMediaQuery.ts`,
|
|
278
|
+
* `useResizable.ts`), so `.ts` candidates are searched alongside `.tsx`. Without
|
|
279
|
+
* this, deriving the import path for a `.ts`-authored function falls back to the
|
|
280
|
+
* bare `@astryxdesign/core` root instead of its tree-shakeable subpath.
|
|
276
281
|
*/
|
|
277
282
|
export function findComponentSource(coreDir, name) {
|
|
278
283
|
const srcDir = path.join(coreDir, 'src');
|
|
279
|
-
// Try the prefixed
|
|
280
|
-
// on-disk convention, then the bare
|
|
281
|
-
// migration (P4) renames to.
|
|
282
|
-
//
|
|
283
|
-
const candidateFiles = [
|
|
284
|
+
// Try the prefixed forms (`XDSButton.tsx`) first since that is the current
|
|
285
|
+
// on-disk convention, then the bare forms (`Button.tsx`) that the Astryx-prefix
|
|
286
|
+
// migration (P4) renames to. `.tsx` before `.ts` within each so a component's
|
|
287
|
+
// `.tsx` wins over a same-named `.ts` helper; `.ts` covers hooks/functions.
|
|
288
|
+
const candidateFiles = [
|
|
289
|
+
`XDS${name}.tsx`,
|
|
290
|
+
`${name}.tsx`,
|
|
291
|
+
`XDS${name}.ts`,
|
|
292
|
+
`${name}.ts`,
|
|
293
|
+
];
|
|
284
294
|
|
|
285
295
|
function searchDir(dirPath) {
|
|
286
296
|
if (!fs.existsSync(dirPath)) return null;
|
|
@@ -34,15 +34,30 @@ function getTargetDataAttributes(target) {
|
|
|
34
34
|
];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Escape a value for a markdown table cell.
|
|
39
|
+
*
|
|
40
|
+
* A prop type is a union, and a union is spelled with the same `|` that GFM
|
|
41
|
+
* uses to separate cells — backticks do not protect it. A row with more cells
|
|
42
|
+
* than the header has columns gets the excess *discarded*, so an unescaped
|
|
43
|
+
* `gap: 0 | 0.5 | ...` silently eats its own Default and Description columns.
|
|
44
|
+
* <!-- SYNC: packages/core/src/Markdown/parser.ts (splits on unescaped pipes) -->
|
|
45
|
+
*/
|
|
46
|
+
export function mdCell(value) {
|
|
47
|
+
return String(value ?? '').replace(/\|/g, '\\|');
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
function formatPropsTable(props) {
|
|
38
51
|
if (!props || props.length === 0) return '';
|
|
39
52
|
const lines = [];
|
|
40
53
|
lines.push('| Prop | Type | Default | Description |');
|
|
41
54
|
lines.push('|------|------|---------|-------------|');
|
|
42
55
|
for (const p of props) {
|
|
43
|
-
const def = p.default ? `\`${p.default}\`` : '—';
|
|
56
|
+
const def = p.default ? `\`${mdCell(p.default)}\`` : '—';
|
|
44
57
|
const req = p.required ? ' **(required)**' : '';
|
|
45
|
-
lines.push(
|
|
58
|
+
lines.push(
|
|
59
|
+
`| \`${mdCell(p.name)}\` | \`${mdCell(p.type)}\` | ${def} | ${mdCell(p.description)}${req} |`,
|
|
60
|
+
);
|
|
46
61
|
}
|
|
47
62
|
return lines.join('\n');
|
|
48
63
|
}
|
|
@@ -177,7 +192,7 @@ export function formatFull(docs, options = {}) {
|
|
|
177
192
|
sections.push('|---------|----------|-------------|');
|
|
178
193
|
for (const el of docs.usage.anatomy) {
|
|
179
194
|
const req = el.required ? 'Yes' : 'No';
|
|
180
|
-
sections.push(`| ${el.name} | ${req} | ${el.description} |`);
|
|
195
|
+
sections.push(`| ${mdCell(el.name)} | ${req} | ${mdCell(el.description)} |`);
|
|
181
196
|
}
|
|
182
197
|
sections.push('');
|
|
183
198
|
}
|
|
@@ -282,7 +297,9 @@ export function formatFull(docs, options = {}) {
|
|
|
282
297
|
varLines.push('| CSS Variable | Default | Description |');
|
|
283
298
|
varLines.push('|-------------|---------|-------------|');
|
|
284
299
|
for (const v of publicVars) {
|
|
285
|
-
varLines.push(
|
|
300
|
+
varLines.push(
|
|
301
|
+
`| \`${mdCell(v.name)}\` | \`${mdCell(v.default)}\` | ${mdCell(v.description)} |`,
|
|
302
|
+
);
|
|
286
303
|
}
|
|
287
304
|
sections.push(varLines.join('\n') + '\n');
|
|
288
305
|
}
|
|
@@ -378,8 +395,8 @@ export function formatCompact(docs, componentName, importHint) {
|
|
|
378
395
|
propLines.push('| CSS Property | Sets |');
|
|
379
396
|
propLines.push('|-------------|------|');
|
|
380
397
|
for (const d of docs.theming.derived) {
|
|
381
|
-
const target = d.expand === 'container' ? 'container layout tokens' : (d.vars || []).map(v => `\`${v}\``).join(', ');
|
|
382
|
-
propLines.push(`| \`${d.property}\` | ${target} |`);
|
|
398
|
+
const target = d.expand === 'container' ? 'container layout tokens' : (d.vars || []).map(v => `\`${mdCell(v)}\``).join(', ');
|
|
399
|
+
propLines.push(`| \`${mdCell(d.property)}\` | ${target} |`);
|
|
383
400
|
}
|
|
384
401
|
sections.push(propLines.join('\n') + '\n');
|
|
385
402
|
}
|
|
@@ -395,6 +412,19 @@ export function formatCompact(docs, componentName, importHint) {
|
|
|
395
412
|
*
|
|
396
413
|
* For multi-component docs, extracts the entry matching componentName.
|
|
397
414
|
*/
|
|
415
|
+
/**
|
|
416
|
+
* Longest union `--detail brief` will spell out inside the one-line signature.
|
|
417
|
+
*
|
|
418
|
+
* The signature is a glance, not a reference: a six-member enum like
|
|
419
|
+
* `start|center|end|between|around|evenly` reads at a glance, an eleven-member
|
|
420
|
+
* spacing scale does not — and Stack carries four of them (gap, padding,
|
|
421
|
+
* paddingInline, paddingBlock), so it printed the same scale four times. Longer
|
|
422
|
+
* unions fall through to the terse prop list, exactly as they did when the type
|
|
423
|
+
* was still the bare name `SpacingStep`. The full values are always one
|
|
424
|
+
* `astryx component <Name>` away.
|
|
425
|
+
*/
|
|
426
|
+
const SIGNATURE_UNION_MAX_MEMBERS = 8;
|
|
427
|
+
|
|
398
428
|
export function formatBrief(docs, componentName, importHint, options = {}) {
|
|
399
429
|
const displayName = componentName.startsWith('XDS')
|
|
400
430
|
? componentName.slice(3)
|
|
@@ -421,13 +451,15 @@ export function formatBrief(docs, componentName, importHint, options = {}) {
|
|
|
421
451
|
const otherProps = [];
|
|
422
452
|
|
|
423
453
|
for (const prop of props) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
454
|
+
const values =
|
|
455
|
+
prop.type.includes('|') && !prop.type.includes('ReactNode')
|
|
456
|
+
? prop.type
|
|
457
|
+
.replace(/['"]/g, '')
|
|
458
|
+
.split('|')
|
|
459
|
+
.map(v => v.trim())
|
|
460
|
+
: null;
|
|
461
|
+
if (values && values.length <= SIGNATURE_UNION_MAX_MEMBERS) {
|
|
462
|
+
signatureProps.push(`${prop.name}: ${values.join('|')}`);
|
|
431
463
|
} else if (prop.required) {
|
|
432
464
|
otherProps.unshift(`${prop.name}: ${prop.type.split('|')[0].trim()}`);
|
|
433
465
|
} else {
|