@astryxdesign/cli 0.1.0 → 0.1.1-canary.080d887

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 (176) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +117 -75
  3. package/bin/astryx.mjs +22 -7
  4. package/docs/getting-started.doc.mjs +11 -11
  5. package/docs/icons.doc.mjs +1 -1
  6. package/docs/migration.doc.mjs +2 -2
  7. package/docs/shape.doc.mjs +1 -1
  8. package/docs/styling.doc.mjs +3 -4
  9. package/docs/theme.doc.dense.mjs +2 -2
  10. package/docs/theme.doc.mjs +14 -0
  11. package/docs/theme.doc.zh.mjs +2 -2
  12. package/docs/working-with-ai.doc.mjs +4 -4
  13. package/package.json +20 -9
  14. package/src/api/discover.mjs +78 -26
  15. package/src/api/doctor.mjs +3 -3
  16. package/src/api/layout.mjs +301 -0
  17. package/src/api/layout.test.mjs +238 -0
  18. package/src/api/search.mjs +207 -13
  19. package/src/api/template.mjs +193 -51
  20. package/src/api/template.test.mjs +2 -0
  21. package/src/codemods/__tests__/registry.test.mjs +1 -0
  22. package/src/codemods/registry.mjs +1 -0
  23. package/src/codemods/runner.mjs +105 -51
  24. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +116 -0
  25. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +51 -0
  26. package/src/codemods/transforms/v0.1.0/index.mjs +28 -0
  27. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +230 -0
  28. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +84 -0
  29. package/src/commands/agent-docs.mjs +119 -66
  30. package/src/commands/agent-docs.path-safety.test.mjs +1 -1
  31. package/src/commands/agent-docs.test.mjs +87 -31
  32. package/src/commands/build-theme.import-path.test.mjs +1 -1
  33. package/src/commands/build-theme.path-safety.test.mjs +1 -1
  34. package/src/commands/build-theme.prose.test.mjs +1 -1
  35. package/src/commands/build.mjs +196 -0
  36. package/src/commands/component-package.test.mjs +1 -1
  37. package/src/commands/component.test.mjs +1 -1
  38. package/src/commands/docs.test.mjs +1 -1
  39. package/src/commands/doctor.test.mjs +1 -1
  40. package/src/commands/external-showcase.test.mjs +1 -1
  41. package/src/commands/gap-report.mjs +17 -9
  42. package/src/commands/gap-report.test.mjs +21 -16
  43. package/src/commands/init.mjs +43 -9
  44. package/src/commands/init.next-steps.test.mjs +46 -0
  45. package/src/commands/interactive-guard.test.mjs +1 -1
  46. package/src/commands/json-contract.test.mjs +10 -3
  47. package/src/commands/layout.mjs +139 -0
  48. package/src/commands/swizzle-gap-safety.test.mjs +1 -1
  49. package/src/commands/swizzle.mjs +51 -23
  50. package/src/commands/swizzle.path-safety.test.mjs +1 -1
  51. package/src/commands/template.path-safety.test.mjs +1 -1
  52. package/src/commands/template.test.mjs +1 -1
  53. package/src/commands/upgrade.mjs +292 -177
  54. package/src/commands/upgrade.test.mjs +41 -27
  55. package/src/config.mjs +31 -0
  56. package/src/config.test.mjs +24 -0
  57. package/src/index.mjs +5 -0
  58. package/src/lib/config-schema.mjs +119 -0
  59. package/src/lib/config.mjs +45 -6
  60. package/src/lib/config.test.mjs +91 -0
  61. package/src/lib/error-codes.mjs +11 -0
  62. package/src/lib/integrations.mjs +155 -0
  63. package/src/lib/integrations.test.mjs +154 -0
  64. package/src/lib/levenshtein.mjs +29 -0
  65. package/src/lib/manifest.mjs +6 -0
  66. package/src/lib/package-scanner.mjs +31 -7
  67. package/src/lib/string-utils.mjs +5 -14
  68. package/src/lib/xle/browser.d.ts +91 -0
  69. package/src/lib/xle/browser.mjs +120 -0
  70. package/src/lib/xle/expand.mjs +622 -0
  71. package/src/lib/xle/parse.mjs +581 -0
  72. package/src/lib/xle/print.mjs +174 -0
  73. package/src/lib/xle/registry-core.mjs +170 -0
  74. package/src/lib/xle/registry.mjs +237 -0
  75. package/src/lib/xle/splice.mjs +137 -0
  76. package/src/lib/xle/validate.mjs +356 -0
  77. package/src/lib/xle/xle.test.mjs +333 -0
  78. package/src/types/config.d.ts +99 -0
  79. package/src/types/error-codes.d.ts +1 -0
  80. package/src/utils/github.mjs +12 -27
  81. package/src/utils/interactive.mjs +1 -1
  82. package/src/utils/interactive.test.mjs +2 -0
  83. package/src/utils/package-manager.mjs +1 -1
  84. package/src/utils/package-manager.test.mjs +1 -1
  85. package/src/utils/path-safety.test.mjs +1 -1
  86. package/src/utils/paths.test.mjs +8 -8
  87. package/src/utils/update-check.mjs +4 -26
  88. package/src/utils/update-check.test.mjs +2 -64
  89. package/templates/blocks/components/AppShell/AppShellContentOnly.tsx +1 -9
  90. package/templates/blocks/components/AppShell/AppShellShowcase.tsx +1 -10
  91. package/templates/blocks/components/AppShell/AppShellSideNavOnly.tsx +1 -9
  92. package/templates/blocks/components/AppShell/AppShellTopNavOnly.tsx +1 -9
  93. package/templates/blocks/components/AppShell/AppShellTopNavWithSideNav.tsx +1 -9
  94. package/templates/blocks/components/AppShell/AppShellWithBanner.tsx +1 -9
  95. package/templates/blocks/components/AspectRatio/AspectRatioShowcase.tsx +12 -19
  96. package/templates/blocks/components/Banner/BannerShowcase.tsx +1 -8
  97. package/templates/blocks/components/Blockquote/BlockquoteShowcase.tsx +1 -8
  98. package/templates/blocks/components/Carousel/CarouselShowcase.tsx +2 -12
  99. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerShowcase.tsx +6 -9
  100. package/templates/blocks/components/ChatLayout/ChatLayoutPanelChat.tsx +10 -12
  101. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +1 -9
  102. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -9
  103. package/templates/blocks/components/ChatMessageList/ChatMessageListShowcase.tsx +1 -9
  104. package/templates/blocks/components/ChatMessageMetadata/ChatMessageMetadataShowcase.tsx +1 -8
  105. package/templates/blocks/components/ChatSendButton/ChatSendButtonInComposer.tsx +1 -8
  106. package/templates/blocks/components/Citation/CitationInlineText.tsx +4 -4
  107. package/templates/blocks/components/Code/CodeInlineInParagraph.tsx +1 -8
  108. package/templates/blocks/components/CodeBlock/CodeBlockBashCommand.tsx +1 -1
  109. package/templates/blocks/components/CodeBlock/CodeBlockJSONConfig.tsx +1 -1
  110. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  111. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  112. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemShowcase.tsx +9 -12
  113. package/templates/blocks/components/ContextMenu/ContextMenuShowcase.tsx +13 -15
  114. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  115. package/templates/blocks/components/Divider/DividerShowcase.tsx +1 -8
  116. package/templates/blocks/components/Divider/DividerVertical.tsx +7 -9
  117. package/templates/blocks/components/Field/FieldShowcase.tsx +1 -8
  118. package/templates/blocks/components/FormLayout/FormLayoutHorizontal.tsx +1 -6
  119. package/templates/blocks/components/Grid/GridResponsiveAutoFit.tsx +1 -9
  120. package/templates/blocks/components/HoverCard/HoverCardInlineTextHoverCard.tsx +4 -6
  121. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +1 -6
  122. package/templates/blocks/components/HoverCard/HoverCardProfileHoverCard.tsx +2 -8
  123. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -8
  124. package/templates/blocks/components/OverflowList/OverflowListOverflowBadges.tsx +8 -11
  125. package/templates/blocks/components/OverflowList/OverflowListOverflowDropdownActions.tsx +9 -12
  126. package/templates/blocks/components/Overlay/OverlayBottomStrip.tsx +4 -17
  127. package/templates/blocks/components/Overlay/OverlayHoverReveal.tsx +15 -16
  128. package/templates/blocks/components/Overlay/OverlayShowcase.tsx +5 -21
  129. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -14
  130. package/templates/blocks/components/Pagination/PaginationPageSize.tsx +12 -14
  131. package/templates/blocks/components/Pagination/PaginationVariants.tsx +1 -8
  132. package/templates/blocks/components/Pagination/PaginationWithTable.tsx +2 -14
  133. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  134. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  135. package/templates/blocks/components/Tokenizer/TokenizerClear.tsx +1 -6
  136. package/templates/blocks/components/Tokenizer/TokenizerCreatable.tsx +2 -7
  137. package/templates/blocks/components/Tokenizer/TokenizerEndContent.tsx +1 -6
  138. package/templates/blocks/components/Tokenizer/TokenizerIcon.tsx +1 -6
  139. package/templates/blocks/components/Tokenizer/TokenizerMaxEntries.tsx +1 -6
  140. package/templates/blocks/components/Tokenizer/TokenizerOverflow.tsx +2 -7
  141. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +1 -6
  142. package/templates/blocks/components/Tokenizer/TokenizerStates.tsx +4 -9
  143. package/templates/blocks/components/Toolbar/ToolbarCardHeader.tsx +1 -10
  144. package/templates/blocks/components/Toolbar/ToolbarSizes.tsx +1 -8
  145. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +1 -8
  146. package/templates/blocks/components/Toolbar/ToolbarThreeSlot.tsx +1 -10
  147. package/templates/blocks/components/Toolbar/ToolbarWithTabs.tsx +8 -11
  148. package/templates/pages/ai-chat/page.tsx +71 -64
  149. package/templates/pages/ai-chat-landing/page.tsx +8 -12
  150. package/templates/pages/centered-hero/page.tsx +13 -15
  151. package/templates/pages/classic-gallery/page.tsx +27 -34
  152. package/templates/pages/detail-page/page.tsx +18 -18
  153. package/templates/pages/documentation/page.tsx +42 -58
  154. package/templates/pages/documentation-design/page.tsx +82 -60
  155. package/templates/pages/documentation-technical/page.tsx +101 -60
  156. package/templates/pages/editor/page.tsx +42 -54
  157. package/templates/pages/file-explorer/page.tsx +13 -16
  158. package/templates/pages/form-two-column/page.tsx +13 -17
  159. package/templates/pages/gallery-hero/page.tsx +13 -15
  160. package/templates/pages/ide/page.tsx +188 -264
  161. package/templates/pages/library/page.tsx +16 -23
  162. package/templates/pages/login/page.tsx +14 -18
  163. package/templates/pages/login-card/page.tsx +14 -18
  164. package/templates/pages/login-split/page.tsx +50 -48
  165. package/templates/pages/login-sso/page.tsx +9 -13
  166. package/templates/pages/mixed-gallery/page.tsx +51 -45
  167. package/templates/pages/payment-form/page.tsx +56 -70
  168. package/templates/pages/product-detail/page.tsx +27 -33
  169. package/templates/pages/product-gallery/page.tsx +7 -13
  170. package/templates/pages/settings-dialog/page.tsx +35 -43
  171. package/templates/pages/settings-sidebar/page.tsx +39 -47
  172. package/templates/pages/side-gallery/page.tsx +6 -9
  173. package/templates/pages/table-grouped/page.tsx +11 -15
  174. package/templates/pages/theme-showcase/page.tsx +33 -37
  175. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  176. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -67
@@ -0,0 +1,333 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Unit tests for the XLE/XLO parser, printers, and validator.
5
+ * Parser/printer tests are registry-free; validator tests use a small
6
+ * synthetic registry so they don't depend on @xds/core docs.
7
+ */
8
+
9
+ import {describe, it, expect} from 'vitest';
10
+ import {parse, parseCompact, parseOutline, detectForm, XLEParseError} from './parse.mjs';
11
+ import {toCompact, toOutline} from './print.mjs';
12
+ import {validate} from './validate.mjs';
13
+ import {parseEnumValues, SPACING_STEPS} from './registry.mjs';
14
+
15
+ // ─── parser: compact ───────────────────────────────────────────────────────
16
+
17
+ describe('parseCompact', () => {
18
+ it('parses child and sibling operators', () => {
19
+ const doc = parseCompact('A > B + C');
20
+ expect(doc.roots).toHaveLength(1);
21
+ expect(doc.roots[0].name).toBe('A');
22
+ expect(doc.roots[0].children.map(c => c.name)).toEqual(['B', 'C']);
23
+ });
24
+
25
+ it('parses groups with repeat', () => {
26
+ const doc = parseCompact('V > (C + D)*3');
27
+ const group = doc.roots[0].children[0];
28
+ expect(group.kind).toBe('group');
29
+ expect(group.repeat).toBe(3);
30
+ expect(group.children.map(c => c.name)).toEqual(['C', 'D']);
31
+ });
32
+
33
+ it('parses node anatomy: id, enum mods, payload, attrs, hint, repeat, selected', () => {
34
+ const doc = parseCompact('Tab#main.lg"Overview"[divider sz=md]{card-callout}!*2');
35
+ const node = doc.roots[0];
36
+ expect(node.id).toBe('main');
37
+ expect(node.enumMods).toEqual(['lg']);
38
+ expect(node.payload).toBe('Overview');
39
+ expect(node.attrs).toEqual([
40
+ expect.objectContaining({kind: 'flag', key: 'divider'}),
41
+ expect.objectContaining({kind: 'kv', key: 'sz', value: 'md'}),
42
+ ]);
43
+ expect(node.hint.name).toBe('card-callout');
44
+ expect(node.selected).toBe(true);
45
+ expect(node.repeat).toBe(2);
46
+ });
47
+
48
+ it('parses fused shorthands, objects, lists, and negation', () => {
49
+ const doc = parseCompact('G[c{min:340,fit} g4 dv=[top,bottom] !scroll]');
50
+ const attrs = doc.roots[0].attrs;
51
+ expect(attrs[0]).toEqual(expect.objectContaining({key: 'c', value: {min: 340, fit: true}}));
52
+ expect(attrs[1]).toEqual(expect.objectContaining({key: 'g', value: 4}));
53
+ expect(attrs[2]).toEqual(expect.objectContaining({key: 'dv', value: ['top', 'bottom']}));
54
+ expect(attrs[3]).toEqual(expect.objectContaining({kind: 'neg', key: 'scroll'}));
55
+ });
56
+
57
+ it('parses slot attrs with nested sub-expressions', () => {
58
+ const doc = parseCompact('A[@topNav=(TN > TNI"Home" + TNI"Docs") @banner=\'hello\']');
59
+ const [topNav, banner] = doc.roots[0].slots;
60
+ expect(topNav.key).toBe('topNav');
61
+ expect(topNav.value.subexpr[0].name).toBe('TN');
62
+ expect(topNav.value.subexpr[0].children).toHaveLength(2);
63
+ expect(banner.value).toBe('hello');
64
+ });
65
+
66
+ it('parses detached overlays after ;;', () => {
67
+ const doc = parseCompact('V > B"Delete"[opens=#confirm] ;; AD#confirm"Sure?"');
68
+ expect(doc.roots).toHaveLength(1);
69
+ expect(doc.overlays).toHaveLength(1);
70
+ expect(doc.overlays[0].id).toBe('confirm');
71
+ });
72
+
73
+ it('rejects ^ with a correction toward groups', () => {
74
+ expect(() => parseCompact('A > B ^ C')).toThrow(/group siblings with \(\.\.\.\)/);
75
+ });
76
+
77
+ it('rejects brace content that is not a block name, teaching the payload form', () => {
78
+ try {
79
+ parseCompact('C{Total Revenue}');
80
+ expect.unreachable();
81
+ } catch (e) {
82
+ expect(e).toBeInstanceOf(XLEParseError);
83
+ expect(e.message).toMatch(/not a block reference/);
84
+ expect(e.message).toMatch(/quoted payload/);
85
+ }
86
+ });
87
+
88
+ it('reports unterminated constructs with positions', () => {
89
+ expect(() => parseCompact('V[g6 > C')).toThrow(XLEParseError);
90
+ expect(() => parseCompact('Tx"unclosed')).toThrow(/Unterminated/);
91
+ });
92
+ });
93
+
94
+ // ─── parser: outline ───────────────────────────────────────────────────────
95
+
96
+ const OUTLINE = `
97
+ AppShell
98
+ topNav: TN "Acme"
99
+ Layout > LC !scroll
100
+ V g=6
101
+ Tx.lg "Hello"
102
+ repeat 3:
103
+ C {card-callout}
104
+ Tbar "Actions"
105
+ B "Delete" opens=#confirm
106
+
107
+ overlays:
108
+ AD#confirm "Sure?"
109
+ `;
110
+
111
+ describe('parseOutline', () => {
112
+ it('parses indentation, slot lines, inline chains, repeats, overlays', () => {
113
+ const doc = parseOutline(OUTLINE);
114
+ const shell = doc.roots[0];
115
+ expect(shell.name).toBe('AppShell');
116
+ expect(shell.slots[0].key).toBe('topNav');
117
+ expect(shell.slots[0].value.subexpr[0].payload).toBe('Acme');
118
+
119
+ const layout = shell.children[0];
120
+ expect(layout.name).toBe('Layout');
121
+ const lc = layout.children[0];
122
+ expect(lc.name).toBe('LC');
123
+ expect(lc.attrs[0]).toEqual(expect.objectContaining({kind: 'neg', key: 'scroll'}));
124
+
125
+ const v = lc.children[0];
126
+ expect(v.children[0].enumMods).toEqual(['lg']);
127
+ const repeat = v.children[1];
128
+ expect(repeat.kind).toBe('group');
129
+ expect(repeat.repeat).toBe(3);
130
+ expect(repeat.children[0].hint.name).toBe('card-callout');
131
+
132
+ // Tbar is a sibling of V (child of LC), not swallowed by the repeat block
133
+ expect(lc.children.map(c => c.name ?? 'group')).toEqual(['V', 'Tbar']);
134
+ expect(doc.overlays).toHaveLength(1);
135
+ expect(doc.overlays[0].id).toBe('confirm');
136
+ });
137
+
138
+ it('parses block-form slots', () => {
139
+ const doc = parseOutline('ChL\n composer:\n ChC\n ChML');
140
+ const chl = doc.roots[0];
141
+ expect(chl.slots[0].key).toBe('composer');
142
+ expect(chl.slots[0].value.subexpr[0].name).toBe('ChC');
143
+ expect(chl.children.map(c => c.name)).toEqual(['ChML']);
144
+ });
145
+
146
+ it('errors on a slot line with no parent component', () => {
147
+ expect(() => parseOutline('topNav: TN')).toThrow(/no parent component/);
148
+ });
149
+ });
150
+
151
+ // ─── form detection ────────────────────────────────────────────────────────
152
+
153
+ describe('detectForm', () => {
154
+ it('single line → compact', () => {
155
+ expect(detectForm('A > B + C')).toBe('compact');
156
+ });
157
+ it('multi-line ending in operators → compact', () => {
158
+ expect(detectForm('A > B +\n C > D')).toBe('compact');
159
+ });
160
+ it('indented multi-line → outline', () => {
161
+ expect(detectForm('A\n B\n C')).toBe('outline');
162
+ });
163
+ it('repeat/overlays markers → outline', () => {
164
+ expect(detectForm('V\nrepeat 2:\n C')).toBe('outline');
165
+ });
166
+ });
167
+
168
+ // ─── printers: round-trips ─────────────────────────────────────────────────
169
+
170
+ /** Strip positions for structural comparison. */
171
+ function shape(doc) {
172
+ return JSON.parse(JSON.stringify(doc, (key, value) =>
173
+ key === 'line' || key === 'col' || key === 'form' || key === 'raw' ? undefined : value));
174
+ }
175
+
176
+ const ROUND_TRIP_CASES = [
177
+ 'A > B + C',
178
+ 'V[g6] > (C{card-callout}*4) + T[striped]',
179
+ 'Ctr[h=100dvh] > C[w=400 p8] > B.primary"Sign in"',
180
+ 'A[@topNav=(TN > TNI"Home")] > L > LC[!scroll] > Tx"hi"',
181
+ 'V > B"Del"[opens=#confirm] ;; AD#confirm"Sure?"',
182
+ 'TL > Tab"Overview"! + Tab"Activity"',
183
+ ];
184
+
185
+ describe('print round-trips', () => {
186
+ for (const source of ROUND_TRIP_CASES) {
187
+ it(`compact → compact: ${source}`, () => {
188
+ const doc = parse(source, {form: 'compact'});
189
+ const reparsed = parse(toCompact(doc), {form: 'compact'});
190
+ expect(shape(reparsed)).toEqual(shape(doc));
191
+ });
192
+
193
+ it(`compact → outline → parse: ${source}`, () => {
194
+ const doc = parse(source, {form: 'compact'});
195
+ const outline = toOutline(doc);
196
+ const reparsed = parse(outline, {form: 'outline'});
197
+ // Outline flattens repeat-less disambiguation groups — compare the
198
+ // outline printed from both, which is canonical.
199
+ expect(toOutline(reparsed)).toEqual(outline);
200
+ });
201
+ }
202
+
203
+ it('outline → compact → parse → outline is stable', () => {
204
+ const doc = parseOutline(OUTLINE);
205
+ const compact = toCompact(doc);
206
+ const reparsed = parseCompact(compact);
207
+ expect(toOutline(reparsed)).toEqual(toOutline(doc));
208
+ });
209
+ });
210
+
211
+ // ─── validator (synthetic registry) ────────────────────────────────────────
212
+
213
+ function prop(name, type, required = false) {
214
+ return {
215
+ name, type, required,
216
+ enumValues: parseEnumValues(type),
217
+ isBoolean: type === 'boolean',
218
+ isFunction: /^\(/.test(type),
219
+ isNode: /ReactNode/.test(type),
220
+ };
221
+ }
222
+
223
+ function makeTestRegistry() {
224
+ const make = (name, props) => ({
225
+ name, exportName: `XDS${name}`, dirName: name, importPath: `@xds/core/${name}`,
226
+ props: new Map(props.map(p => [p.name, p])),
227
+ });
228
+ const components = new Map([
229
+ ['AppShell', make('AppShell', [prop('contentPadding', 'SpacingStep'), prop('children', 'ReactNode'), prop('topNav', 'ReactNode')])],
230
+ ['Stack', make('Stack', [prop('gap', 'SpacingStep'), prop('hAlign', "'start' | 'center' | 'end' | 'between'"), prop('vAlign', "'start' | 'center' | 'end'")])],
231
+ ['Chip', make('Chip', [prop('variant', "'neutral' | 'success' | 'error'"), prop('label', 'string', true)])],
232
+ ['Box', make('Box', [prop('isScrollable', 'boolean'), prop('children', 'ReactNode')])],
233
+ ]);
234
+ const aliases = new Map([['Sh', 'AppShell'], ['St', 'Stack'], ['Ch', 'Chip'], ['Bx', 'Box']]);
235
+ return {components, aliases, componentNames: [...components.keys()]};
236
+ }
237
+
238
+ const BLOCKS = [{dirName: 'CardCallout', description: '', category: 'components/Card'}];
239
+
240
+ describe('validate', () => {
241
+ const registry = makeTestRegistry();
242
+
243
+ it('binds aliases, full names, and XDS-prefixed names identically', () => {
244
+ for (const name of ['Sh', 'AppShell', 'XDSAppShell']) {
245
+ const doc = parse(`${name}[cp6]`);
246
+ const {errors} = validate(doc, registry, BLOCKS);
247
+ expect(errors).toEqual([]);
248
+ expect(doc.roots[0].bound.props.get('contentPadding')).toBe(6);
249
+ }
250
+ });
251
+
252
+ it('rejects unknown components with ranked suggestions', () => {
253
+ const doc = parse('AppShel');
254
+ const {errors} = validate(doc, registry, BLOCKS);
255
+ expect(errors[0].message).toMatch(/Unknown component/);
256
+ expect(errors[0].suggestions).toContain('AppShell');
257
+ });
258
+
259
+ it('rejects per-component shorthand misuse and suggests the right prop', () => {
260
+ const doc = parse('Sh[p6]');
261
+ const {errors} = validate(doc, registry, BLOCKS);
262
+ expect(errors[0].message).toMatch(/AppShell has no prop 'padding'/);
263
+ expect(errors[0].suggestions).toContain('contentPadding');
264
+ });
265
+
266
+ it('rejects out-of-enum values, listing the legal set', () => {
267
+ const doc = parse('St[g7]');
268
+ const {errors} = validate(doc, registry, BLOCKS);
269
+ expect(errors[0].message).toMatch(/must be one of 0 \| 0\.5/);
270
+ });
271
+
272
+ it('resolves axis-neutral j= per component axis', () => {
273
+ const doc = parse('St[j=between]');
274
+ const {errors} = validate(doc, registry, BLOCKS);
275
+ expect(errors).toEqual([]);
276
+ // synthetic Stack has no `justify`, so j= falls back to hAlign
277
+ expect(doc.roots[0].bound.props.get('hAlign')).toBe('between');
278
+ });
279
+
280
+ it('resolves dot-modifiers against unique enum values, with typo help', () => {
281
+ const ok = parse('Ch.success"Active"');
282
+ expect(validate(ok, registry, BLOCKS).errors).toEqual([]);
283
+ expect(ok.roots[0].bound.props.get('variant')).toBe('success');
284
+
285
+ const bad = parse('Ch.sucess"Active"');
286
+ const {errors} = validate(bad, registry, BLOCKS);
287
+ expect(errors[0].suggestions).toContain('success');
288
+ });
289
+
290
+ it('resolves negation flags to boolean props', () => {
291
+ const doc = parse('Bx[!scroll]');
292
+ expect(validate(doc, registry, BLOCKS).errors).toEqual([]);
293
+ expect(doc.roots[0].bound.props.get('isScrollable')).toBe(false);
294
+ });
295
+
296
+ it('rejects unknown blocks unless loose', () => {
297
+ const doc = parse('Bx{nope-not-real}');
298
+ expect(validate(doc, registry, BLOCKS).errors[0].message).toMatch(/Unknown block/);
299
+
300
+ const loose = parse('Bx{nope-not-real}');
301
+ const result = validate(loose, registry, BLOCKS, {loose: true});
302
+ expect(result.errors).toEqual([]);
303
+ expect(result.warnings[0].message).toMatch(/TODO placeholder/);
304
+ });
305
+
306
+ it('resolves block hints case/kebab-insensitively', () => {
307
+ const doc = parse('Bx{card-callout}');
308
+ expect(validate(doc, registry, BLOCKS).errors).toEqual([]);
309
+ expect(doc.roots[0].hint.block.name).toBe('CardCallout');
310
+ });
311
+
312
+ it('enforces structural pairings', () => {
313
+ const doc = parse('Bx > Sh');
314
+ const {errors} = validate(doc, registry, BLOCKS);
315
+ expect(errors[0].message).toMatch(/outermost/);
316
+ });
317
+ });
318
+
319
+ // ─── registry helpers ──────────────────────────────────────────────────────
320
+
321
+ describe('parseEnumValues', () => {
322
+ it('parses quoted string unions', () => {
323
+ expect(parseEnumValues("'a' | 'b' | 'c'")).toEqual(['a', 'b', 'c']);
324
+ });
325
+ it('parses numeric unions and SpacingStep', () => {
326
+ expect(parseEnumValues('1|2|3')).toEqual([1, 2, 3]);
327
+ expect(parseEnumValues('SpacingStep')).toEqual(SPACING_STEPS);
328
+ });
329
+ it('returns null for non-enums', () => {
330
+ expect(parseEnumValues('number | string')).toBeNull();
331
+ expect(parseEnumValues('boolean')).toBeNull();
332
+ });
333
+ });
@@ -0,0 +1,99 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** User config exported from astryx.config.mjs. */
4
+ export interface AstryxConfig {
5
+ /** External package directories to scan for package.json astryx.docs metadata. */
6
+ packages?: string | string[];
7
+ /** Integration package names or manifest paths. */
8
+ integrations?: string | string[];
9
+ /** Gap report delivery override. Omit for default GitHub issue filing. */
10
+ gapReport?: false | {command: string};
11
+ /** Template hooks. */
12
+ template?: {
13
+ get?: (id: string) => string | Promise<string>;
14
+ };
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ export interface AstryxIntegrationCodemod {
19
+ name: string;
20
+ from?: string;
21
+ to?: string;
22
+ title?: string;
23
+ description?: string;
24
+ pr?: string;
25
+ optional?: boolean;
26
+ fileExtensions?: string[];
27
+ transform:
28
+ | string
29
+ | ((file: unknown, api: unknown) => string | undefined | null);
30
+ }
31
+
32
+ export interface AstryxPostCodemodContext {
33
+ packageDir: string;
34
+ codemodDir: string;
35
+ changedFiles: string[];
36
+ absoluteChangedFiles: string[];
37
+ packageChangedFiles: string[];
38
+ apply: boolean;
39
+ run: (
40
+ command: string,
41
+ args?: string[],
42
+ options?: {
43
+ cwd?: string;
44
+ timeoutMs?: number;
45
+ env?: Record<string, string>;
46
+ },
47
+ ) => Promise<void>;
48
+ }
49
+
50
+ export interface AstryxPostCodemodHook {
51
+ name?: string;
52
+ run?: (context: AstryxPostCodemodContext) => void | Promise<void>;
53
+ command?: (context: AstryxPostCodemodContext) =>
54
+ | {
55
+ command: string;
56
+ args?: string[];
57
+ cwd?: string;
58
+ timeoutMs?: number;
59
+ env?: Record<string, string>;
60
+ }
61
+ | null
62
+ | undefined
63
+ | Promise<
64
+ | {
65
+ command: string;
66
+ args?: string[];
67
+ cwd?: string;
68
+ timeoutMs?: number;
69
+ env?: Record<string, string>;
70
+ }
71
+ | null
72
+ | undefined
73
+ >;
74
+ }
75
+
76
+ /** Integration manifest exported from an astryx.integration.mjs file. */
77
+ export interface AstryxIntegration {
78
+ name: string;
79
+ version?: string;
80
+ displayName?: string;
81
+ description?: string;
82
+ /** Relative docs root containing *.doc.mjs files. */
83
+ docs?: string;
84
+ category?: string;
85
+ /** Relative block-template root. */
86
+ blocks?: string;
87
+ gapReport?: false | {command: string};
88
+ template?: {
89
+ get?: string | ((id: string) => string | Promise<string>);
90
+ };
91
+ codemods?: AstryxIntegrationCodemod[];
92
+ postCodemod?: AstryxPostCodemodHook[];
93
+ [key: string]: unknown;
94
+ }
95
+
96
+ export declare function createConfig<T extends AstryxConfig>(config: T): T;
97
+ export declare function createIntegration<T extends AstryxIntegration>(
98
+ integration: T,
99
+ ): T;
@@ -29,6 +29,7 @@ export type ErrorCode =
29
29
  | 'ERR_UNKNOWN_AGENT'
30
30
  | 'ERR_UNKNOWN_FEATURE'
31
31
  | 'ERR_UNKNOWN_CODEMOD'
32
+ | 'ERR_CODEMOD_FAILED'
32
33
  | 'ERR_NOT_FOUND'
33
34
  | 'ERR_NO_DOC'
34
35
  | 'ERR_NO_SHOWCASE'
@@ -16,8 +16,8 @@
16
16
  */
17
17
 
18
18
  import {execFileSync} from 'node:child_process';
19
- import * as fs from 'node:fs';
20
19
  import * as path from 'node:path';
20
+ import {loadConfig} from '../lib/config.mjs';
21
21
 
22
22
  /** Default target repository for gap report issues. */
23
23
  const DEFAULT_REPO = 'facebookexperimental/xds';
@@ -44,7 +44,7 @@ export const GAP_CATEGORIES = [
44
44
  * - command: string → run this script instead of creating a GitHub issue
45
45
  * - neither → default behavior (GitHub issue to facebookexperimental/xds)
46
46
  */
47
- export function loadGapReportConfig() {
47
+ export async function loadGapReportConfig() {
48
48
  // 1. Check environment variable
49
49
  const envVar = process.env.ASTRYX_GAP_REPORT;
50
50
  if (envVar) {
@@ -55,27 +55,12 @@ export function loadGapReportConfig() {
55
55
  return {enabled: true, command: envVar};
56
56
  }
57
57
 
58
- // 2. Check astryx.config.mjs
59
- const configPath = path.join(process.cwd(), 'astryx.config.mjs');
60
- if (fs.existsSync(configPath)) {
61
- try {
62
- const content = fs.readFileSync(configPath, 'utf-8');
63
- const match = content.match(/gapReport\s*:\s*(.+?)[\s,}]/s);
64
- if (match) {
65
- const value = match[1].trim();
66
- if (value === 'false') {
67
- return {enabled: false};
68
- }
69
- // Look for command property in object literal
70
- const cmdMatch = content.match(
71
- /gapReport\s*:\s*\{[^}]*command\s*:\s*['"](.+?)['"]/s,
72
- );
73
- if (cmdMatch) {
74
- return {enabled: true, command: cmdMatch[1]};
75
- }
76
- }
77
- } catch {
78
- // Config parse error — fall through to defaults
58
+ // 2. Check astryx.config.mjs + integration manifests
59
+ const config = await loadConfig(process.cwd());
60
+ if (config.gapReport !== undefined) {
61
+ if (config.gapReport === false) return {enabled: false};
62
+ if (config.gapReport?.command) {
63
+ return {enabled: true, command: config.gapReport.command};
79
64
  }
80
65
  }
81
66
 
@@ -172,14 +157,14 @@ function runCustomCommand(command, report) {
172
157
  * repo: string,
173
158
  * }}
174
159
  */
175
- export function buildGapReportPreview({
160
+ export async function buildGapReportPreview({
176
161
  component,
177
162
  category,
178
163
  intention,
179
164
  detail,
180
165
  source = 'cli',
181
166
  }) {
182
- const config = loadGapReportConfig();
167
+ const config = await loadGapReportConfig();
183
168
  const categoryLabel =
184
169
  GAP_CATEGORIES.find(c => c.value === category)?.label ?? category;
185
170
 
@@ -238,8 +223,8 @@ export function buildGapReportPreview({
238
223
  * @param {string} [opts.source] Who filed it: "interactive", "llm-auto", "cli"
239
224
  * @returns {string|null} URL of the created issue (or custom command output), null if disabled
240
225
  */
241
- export function createGapReport(opts) {
242
- const preview = buildGapReportPreview(opts);
226
+ export async function createGapReport(opts) {
227
+ const preview = await buildGapReportPreview(opts);
243
228
 
244
229
  if (!preview.enabled) {
245
230
  return null;
@@ -60,7 +60,7 @@ export function isInteractive({
60
60
  */
61
61
  export function requireInteractive({command, hint, json = false} = {}, env) {
62
62
  if (isInteractive(env)) return;
63
- const name = command ? `xds ${command}` : 'this command';
63
+ const name = command ? `astryx ${command}` : 'this command';
64
64
  console.error(
65
65
  `Error: \`${name}\` with no flags is interactive and requires a TTY.`,
66
66
  );
@@ -64,5 +64,7 @@ describe('requireInteractive', () => {
64
64
  const output = err.mock.calls.map(c => c.join(' ')).join('\n');
65
65
  expect(output).toMatch(/requires a TTY/i);
66
66
  expect(output).toMatch(/astryx theme <preset>/);
67
+ expect(output).toMatch(/`astryx theme`/);
68
+ expect(output).not.toMatch(/\bxds\b/);
67
69
  });
68
70
  });
@@ -4,7 +4,7 @@
4
4
  * @file Detect the project's package manager from lockfiles.
5
5
  *
6
6
  * Returns the correct command prefix for running package binaries
7
- * (e.g. 'npx xds', 'yarn xds', 'pnpm exec xds').
7
+ * (e.g. 'npx astryx', 'yarn astryx', 'pnpm exec astryx').
8
8
  */
9
9
 
10
10
  import * as fs from 'node:fs';
@@ -18,7 +18,7 @@ afterEach(() => {
18
18
  });
19
19
 
20
20
  function makeTmpDir() {
21
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xds-pm-test-'));
21
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-pm-test-'));
22
22
  return tmpDir;
23
23
  }
24
24
 
@@ -13,7 +13,7 @@ import {
13
13
 
14
14
  let tmpDir;
15
15
  beforeEach(() => {
16
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xds-path-safety-'));
16
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-path-safety-'));
17
17
  });
18
18
  afterEach(() => {
19
19
  fs.rmSync(tmpDir, {recursive: true, force: true});
@@ -9,7 +9,7 @@ import {findCoreDir, findProjectRoot, listComponents, discoverExternalPackages}
9
9
  let tmpDir;
10
10
 
11
11
  function makeTmpDir() {
12
- return fs.mkdtempSync(path.join(os.tmpdir(), 'xds-paths-test-'));
12
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-paths-test-'));
13
13
  }
14
14
 
15
15
  beforeEach(() => {
@@ -69,12 +69,12 @@ describe('findProjectRoot', () => {
69
69
  describe('discoverExternalPackages', () => {
70
70
  it('finds packages with an "astryx" field in package.json', () => {
71
71
  const nm = path.join(tmpDir, 'node_modules');
72
- const extDir = path.join(nm, 'xds-charts');
72
+ const extDir = path.join(nm, 'astryx-charts');
73
73
  fs.mkdirSync(extDir, {recursive: true});
74
74
  fs.writeFileSync(
75
75
  path.join(extDir, 'package.json'),
76
76
  JSON.stringify({
77
- name: 'xds-charts',
77
+ name: 'astryx-charts',
78
78
  astryx: {docs: './src', category: 'Data Viz'},
79
79
  }),
80
80
  );
@@ -82,7 +82,7 @@ describe('discoverExternalPackages', () => {
82
82
  const result = discoverExternalPackages(tmpDir);
83
83
  expect(result).toEqual([
84
84
  {
85
- name: 'xds-charts',
85
+ name: 'astryx-charts',
86
86
  category: 'Data Viz',
87
87
  docsDir: path.join(extDir, 'src'),
88
88
  blocksDir: null,
@@ -92,7 +92,7 @@ describe('discoverExternalPackages', () => {
92
92
 
93
93
  it('handles scoped packages (@org/pkg)', () => {
94
94
  const nm = path.join(tmpDir, 'node_modules');
95
- const scopedDir = path.join(nm, '@acme', 'xds-widgets');
95
+ const scopedDir = path.join(nm, '@acme', 'astryx-widgets');
96
96
  fs.mkdirSync(scopedDir, {recursive: true});
97
97
  fs.writeFileSync(
98
98
  path.join(scopedDir, 'package.json'),
@@ -165,12 +165,12 @@ describe('discoverExternalPackages', () => {
165
165
 
166
166
  it('walks up directories to find node_modules', () => {
167
167
  const nm = path.join(tmpDir, 'node_modules');
168
- const extDir = path.join(nm, 'xds-ext');
168
+ const extDir = path.join(nm, 'astryx-ext');
169
169
  fs.mkdirSync(extDir, {recursive: true});
170
170
  fs.writeFileSync(
171
171
  path.join(extDir, 'package.json'),
172
172
  JSON.stringify({
173
- name: 'xds-ext',
173
+ name: 'astryx-ext',
174
174
  astryx: {docs: './src'},
175
175
  }),
176
176
  );
@@ -180,7 +180,7 @@ describe('discoverExternalPackages', () => {
180
180
 
181
181
  const result = discoverExternalPackages(nested);
182
182
  expect(result).toHaveLength(1);
183
- expect(result[0].name).toBe('xds-ext');
183
+ expect(result[0].name).toBe('astryx-ext');
184
184
  });
185
185
 
186
186
  it('handles invalid JSON in package.json gracefully', () => {