@astryxdesign/cli 0.1.1 → 0.1.2-canary.043f3fd

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 (185) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +31 -8
  5. package/src/api/component.mjs +253 -12
  6. package/src/api/discover.mjs +87 -28
  7. package/src/api/doctor.mjs +7 -22
  8. package/src/api/index.mjs +1 -0
  9. package/src/api/layout.mjs +302 -0
  10. package/src/api/layout.test.mjs +241 -0
  11. package/src/api/template-integration.test.mjs +225 -0
  12. package/src/api/template.mjs +421 -122
  13. package/src/api/template.test.mjs +2 -0
  14. package/src/api/theme-add.mjs +182 -0
  15. package/src/api/validate-integration.mjs +370 -0
  16. package/src/api/validate-integration.test.mjs +222 -0
  17. package/src/codemod.mjs +93 -0
  18. package/src/codemod.test.mjs +134 -0
  19. package/src/codemods/__tests__/registry.test.mjs +2 -0
  20. package/src/codemods/__tests__/runner.test.mjs +103 -0
  21. package/src/codemods/integration-discovery.mjs +168 -0
  22. package/src/codemods/integration-discovery.test.mjs +234 -0
  23. package/src/codemods/integration-runner.mjs +109 -0
  24. package/src/codemods/registry.mjs +2 -0
  25. package/src/codemods/run-codemod.mjs +207 -0
  26. package/src/codemods/runner.mjs +73 -96
  27. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  28. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +81 -0
  29. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  30. package/src/codemods/transforms/v0.1.0/index.mjs +15 -6
  31. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  32. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  33. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  34. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  35. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  36. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  37. package/src/commands/build-theme.import-path.test.mjs +22 -2
  38. package/src/commands/build-theme.mjs +185 -16
  39. package/src/commands/component/index.mjs +44 -10
  40. package/src/commands/component-ownership.test.mjs +227 -0
  41. package/src/commands/discover.mjs +5 -16
  42. package/src/commands/doctor.test.mjs +3 -3
  43. package/src/commands/init.mjs +34 -8
  44. package/src/commands/init.next-steps.test.mjs +46 -0
  45. package/src/commands/json-contract.test.mjs +0 -32
  46. package/src/commands/layout.mjs +139 -0
  47. package/src/commands/swizzle.mjs +241 -224
  48. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  49. package/src/commands/swizzle.routing.test.mjs +279 -0
  50. package/src/commands/template.mjs +33 -37
  51. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  52. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  53. package/src/commands/upgrade.mjs +349 -239
  54. package/src/commands/validate-integration.mjs +110 -0
  55. package/src/commands/validate-integration.test.mjs +124 -0
  56. package/src/config.mjs +18 -0
  57. package/src/config.test.mjs +100 -0
  58. package/src/index.mjs +13 -3
  59. package/src/integration.mjs +19 -0
  60. package/src/lib/component-discovery.mjs +177 -0
  61. package/src/lib/config-cache.mjs +91 -0
  62. package/src/lib/config-cache.test.mjs +83 -0
  63. package/src/lib/config-schema.mjs +105 -0
  64. package/src/lib/error-codes.mjs +18 -12
  65. package/src/lib/integration-warnings.mjs +62 -0
  66. package/src/lib/integration-warnings.test.mjs +102 -0
  67. package/src/lib/integrations.mjs +132 -0
  68. package/src/lib/integrations.test.mjs +135 -0
  69. package/src/lib/levenshtein.mjs +29 -0
  70. package/src/lib/manifest.mjs +15 -2
  71. package/src/lib/module-loader.mjs +80 -0
  72. package/src/lib/module-loader.test.mjs +106 -0
  73. package/src/lib/package-scanner.mjs +31 -7
  74. package/src/lib/project.mjs +502 -0
  75. package/src/lib/project.test.mjs +308 -0
  76. package/src/lib/string-utils.mjs +5 -14
  77. package/src/lib/xle/browser.d.ts +100 -0
  78. package/src/lib/xle/browser.mjs +120 -0
  79. package/src/lib/xle/expand.mjs +622 -0
  80. package/src/lib/xle/parse.mjs +581 -0
  81. package/src/lib/xle/print.mjs +174 -0
  82. package/src/lib/xle/registry-core.mjs +170 -0
  83. package/src/lib/xle/registry.mjs +237 -0
  84. package/src/lib/xle/splice.mjs +137 -0
  85. package/src/lib/xle/validate.mjs +356 -0
  86. package/src/lib/xle/xle.test.mjs +333 -0
  87. package/src/template.mjs +73 -0
  88. package/src/template.test.mjs +127 -0
  89. package/src/types/api.d.ts +2 -6
  90. package/src/types/base.d.ts +3 -9
  91. package/src/types/codemod.d.ts +81 -0
  92. package/src/types/component.d.ts +27 -2
  93. package/src/types/config.d.ts +70 -0
  94. package/src/types/error-codes.d.ts +5 -3
  95. package/src/types/index.d.ts +0 -1
  96. package/src/types/integration.d.ts +29 -0
  97. package/src/types/swizzle.d.ts +9 -2
  98. package/src/types/template-api.d.ts +54 -0
  99. package/src/types/template.d.ts +10 -7
  100. package/src/types/upgrade.d.ts +29 -0
  101. package/src/types/validate-integration.d.ts +24 -0
  102. package/src/utils/github.mjs +0 -252
  103. package/src/utils/interactive.mjs +2 -2
  104. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  105. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  106. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  107. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  108. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  109. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  110. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  111. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  112. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  113. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  114. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  115. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  116. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  117. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  118. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  119. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  120. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  121. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  122. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  123. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  124. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  125. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  126. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  127. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  128. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  129. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  130. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  131. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  132. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  133. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  134. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  135. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  136. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  137. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  138. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  139. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  140. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  141. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  142. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  143. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  144. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  145. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  146. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  147. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  148. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  149. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  150. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  151. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  152. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  153. package/templates/pages/shell-nav/page.tsx +321 -0
  154. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  155. package/templates/pages/shell-side-nav/page.tsx +242 -0
  156. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  157. package/templates/pages/shell-top-nav/page.tsx +224 -0
  158. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  159. package/templates/pages/theme-showcase/page.tsx +8 -19
  160. package/templates/themes/butter/butterTheme.ts +916 -0
  161. package/templates/themes/butter/icons.tsx +77 -0
  162. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  163. package/templates/themes/chocolate/icons.tsx +77 -0
  164. package/templates/themes/gothic/gothicTheme.ts +657 -0
  165. package/templates/themes/gothic/icons.tsx +77 -0
  166. package/templates/themes/manifest.json +90 -0
  167. package/templates/themes/matcha/icons.tsx +67 -0
  168. package/templates/themes/matcha/matchaTheme.ts +247 -0
  169. package/templates/themes/neutral/icons.tsx +77 -0
  170. package/templates/themes/neutral/neutralTheme.ts +603 -0
  171. package/templates/themes/stone/icons.tsx +77 -0
  172. package/templates/themes/stone/stoneTheme.ts +652 -0
  173. package/templates/themes/y2k/icons.tsx +67 -0
  174. package/templates/themes/y2k/y2kTheme.ts +617 -0
  175. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  176. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  177. package/src/commands/gap-report.mjs +0 -456
  178. package/src/commands/gap-report.test.mjs +0 -163
  179. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  180. package/src/lib/config.mjs +0 -86
  181. package/src/lib/config.test.mjs +0 -42
  182. package/src/types/gap-report.d.ts +0 -29
  183. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  184. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
  185. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -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,73 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Static template authoring API.
5
+ *
6
+ * Helpers for authoring Astryx page/block template docs. Like
7
+ * `createConfig`/`createIntegration`, these are tiny runtime identity helpers
8
+ * whose real value is the exported TypeScript surface from
9
+ * `@astryxdesign/cli/template`. They inject the discriminant `type` so a
10
+ * discovered doc always knows whether it is a page or a block. They do NOT
11
+ * validate — validation happens at the load boundary, where integration
12
+ * template discovery runs the module's default export through
13
+ * {@link TemplateEnvelopeSchema} (see `loadModuleWithSchema`).
14
+ */
15
+
16
+ import {z} from 'zod';
17
+
18
+ const PreviewSchema = z
19
+ .object({
20
+ image: z.string().optional(),
21
+ aspectRatio: z.string().optional(),
22
+ })
23
+ .strict();
24
+
25
+ /**
26
+ * Shared authored-template shape. `type` is injected by the create* helpers,
27
+ * so authors never write it. Inline source/sourceFile are intentionally NOT
28
+ * part of v1 — a template's source is the required same-stem sibling file.
29
+ * Exported so integration template discovery can validate the stamped result.
30
+ */
31
+ export const BaseTemplateSchema = z
32
+ .object({
33
+ name: z.string().min(1, 'name is required'),
34
+ description: z.string().min(1, 'description is required'),
35
+ category: z.string().optional(),
36
+ componentsUsed: z.array(z.string()).optional(),
37
+ preview: PreviewSchema.optional(),
38
+ })
39
+ .strict();
40
+
41
+ /**
42
+ * The metadata envelope integration template discovery validates: a stamped
43
+ * template doc. This is the LOAD-boundary contract — a hand-written plain
44
+ * object that matches this shape is accepted (discovery does not check "was it
45
+ * made by the factory", only the shape).
46
+ */
47
+ export const TemplateEnvelopeSchema = BaseTemplateSchema.extend({
48
+ type: z.enum(['page', 'block']),
49
+ });
50
+
51
+ /**
52
+ * Author an Astryx page template doc. Stamp-only: returns the def with
53
+ * `type: 'page'` injected. Validation happens at the load boundary.
54
+ *
55
+ * @template {import('./types/template-api').AstryxPageTemplateInput} T
56
+ * @param {T} def
57
+ * @returns {T & {type: 'page'}}
58
+ */
59
+ export function createPageTemplate(def) {
60
+ return /** @type {T & {type: 'page'}} */ ({...def, type: 'page'});
61
+ }
62
+
63
+ /**
64
+ * Author an Astryx block template doc. Stamp-only: returns the def with
65
+ * `type: 'block'` injected. Validation happens at the load boundary.
66
+ *
67
+ * @template {import('./types/template-api').AstryxBlockTemplateInput} T
68
+ * @param {T} def
69
+ * @returns {T & {type: 'block'}}
70
+ */
71
+ export function createBlockTemplate(def) {
72
+ return /** @type {T & {type: 'block'}} */ ({...def, type: 'block'});
73
+ }
@@ -0,0 +1,127 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, it, expect} from 'vitest';
4
+ import {
5
+ createPageTemplate,
6
+ createBlockTemplate,
7
+ TemplateEnvelopeSchema,
8
+ } from './template.mjs';
9
+
10
+ // createPageTemplate/createBlockTemplate are now stamp-only: they inject the
11
+ // `type` discriminant and return the def otherwise unchanged, with NO runtime
12
+ // validation. Validation happens at the LOAD boundary (integration template
13
+ // discovery runs the default export through TemplateEnvelopeSchema), so the
14
+ // rejection cases that used to assert factory throws now assert the envelope
15
+ // schema rejects the same shapes.
16
+
17
+ describe('createPageTemplate (stamp-only)', () => {
18
+ it('returns the def with type "page" injected', () => {
19
+ const t = createPageTemplate({
20
+ name: 'Landing',
21
+ description: 'A landing page.',
22
+ });
23
+ expect(t).toEqual({
24
+ name: 'Landing',
25
+ description: 'A landing page.',
26
+ type: 'page',
27
+ });
28
+ });
29
+
30
+ it('preserves optional fields', () => {
31
+ const t = createPageTemplate({
32
+ name: 'Landing',
33
+ description: 'A landing page.',
34
+ category: 'Marketing',
35
+ componentsUsed: ['Button', 'Card'],
36
+ preview: {image: './preview.png', aspectRatio: '16 / 9'},
37
+ });
38
+ expect(t.category).toBe('Marketing');
39
+ expect(t.componentsUsed).toEqual(['Button', 'Card']);
40
+ expect(t.preview).toEqual({image: './preview.png', aspectRatio: '16 / 9'});
41
+ expect(t.type).toBe('page');
42
+ });
43
+
44
+ it('does NOT validate — returns an invalid def stamped, unchanged', () => {
45
+ const t = createPageTemplate({description: 'x'});
46
+ expect(t.type).toBe('page');
47
+ expect(t.name).toBeUndefined();
48
+ });
49
+ });
50
+
51
+ describe('createBlockTemplate (stamp-only)', () => {
52
+ it('returns the def with type "block" injected', () => {
53
+ const t = createBlockTemplate({
54
+ name: 'Hero',
55
+ description: 'A hero block.',
56
+ });
57
+ expect(t.type).toBe('block');
58
+ expect(t.name).toBe('Hero');
59
+ });
60
+ });
61
+
62
+ describe('TemplateEnvelopeSchema (load-boundary validation)', () => {
63
+ it('accepts a stamped page template', () => {
64
+ const parsed = TemplateEnvelopeSchema.parse(
65
+ createPageTemplate({name: 'Landing', description: 'A landing page.'}),
66
+ );
67
+ expect(parsed.type).toBe('page');
68
+ });
69
+
70
+ it('accepts a PLAIN OBJECT envelope (no factory required)', () => {
71
+ const parsed = TemplateEnvelopeSchema.parse({
72
+ type: 'block',
73
+ name: 'Hero',
74
+ description: 'A hero block.',
75
+ });
76
+ expect(parsed.name).toBe('Hero');
77
+ });
78
+
79
+ it('rejects a missing name', () => {
80
+ expect(() =>
81
+ TemplateEnvelopeSchema.parse({type: 'page', description: 'x'}),
82
+ ).toThrow(/name/);
83
+ });
84
+
85
+ it('rejects a missing description', () => {
86
+ expect(() =>
87
+ TemplateEnvelopeSchema.parse({type: 'page', name: 'x'}),
88
+ ).toThrow(/description/);
89
+ });
90
+
91
+ it('rejects an empty-string name', () => {
92
+ expect(() =>
93
+ TemplateEnvelopeSchema.parse({type: 'page', name: '', description: 'x'}),
94
+ ).toThrow(/name/);
95
+ });
96
+
97
+ it('rejects a missing/invalid type', () => {
98
+ expect(() =>
99
+ TemplateEnvelopeSchema.parse({name: 'x', description: 'y'}),
100
+ ).toThrow();
101
+ expect(() =>
102
+ TemplateEnvelopeSchema.parse({type: 'bogus', name: 'x', description: 'y'}),
103
+ ).toThrow();
104
+ });
105
+
106
+ it('rejects unknown keys (strict)', () => {
107
+ expect(() =>
108
+ TemplateEnvelopeSchema.parse({
109
+ type: 'page',
110
+ name: 'x',
111
+ description: 'y',
112
+ source: './x.tsx',
113
+ }),
114
+ ).toThrow();
115
+ });
116
+
117
+ it('rejects inline sourceFile (not supported in v1)', () => {
118
+ expect(() =>
119
+ TemplateEnvelopeSchema.parse({
120
+ type: 'page',
121
+ name: 'x',
122
+ description: 'y',
123
+ sourceFile: './x.tsx',
124
+ }),
125
+ ).toThrow();
126
+ });
127
+ });
@@ -33,7 +33,6 @@ import type {
33
33
  TemplateShowResponse,
34
34
  TemplateSkeletonResponse,
35
35
  TemplateCopyResponse,
36
- TemplateGetResponse,
37
36
  } from './template';
38
37
  import type {
39
38
  HookListResponse,
@@ -138,6 +137,8 @@ export interface TemplateOptions {
138
137
  show?: boolean;
139
138
  /** Filter templates by kind: 'page' or 'block'. Only applies to list views. */
140
139
  type?: 'page' | 'block';
140
+ /** Narrow to templates from a specific package (id-only lookups across packages are ambiguous). */
141
+ package?: string;
141
142
  targetPath?: string;
142
143
  cwd?: string;
143
144
  }
@@ -153,11 +154,6 @@ export declare function template(
153
154
  options?: TemplateOptions,
154
155
  ): Promise<TemplateResult>;
155
156
 
156
- export declare function getTemplateById(
157
- id: string,
158
- options?: {cwd?: string},
159
- ): Promise<TemplateGetResponse>;
160
-
161
157
  // ── Hook ─────────────────────────────────────────────────────────────
162
158
 
163
159
  export interface HookOptions {
@@ -32,7 +32,6 @@ import type {
32
32
  TemplateShowResponse,
33
33
  TemplateSkeletonResponse,
34
34
  TemplateCopyResponse,
35
- TemplateGetResponse,
36
35
  } from './template';
37
36
  import type {
38
37
  HookListResponse,
@@ -44,14 +43,11 @@ import type {
44
43
  import type {SwizzleListResponse, SwizzleCopyResponse} from './swizzle';
45
44
  import type {ThemeBuildResponse} from './theme';
46
45
  import type {UpgradeListResponse, UpgradeRunResponse} from './upgrade';
47
- import type {
48
- GapReportCategoriesResponse,
49
- GapReportFileResponse,
50
- } from './gap-report';
51
46
  import type {SearchResponse} from './search';
52
47
  import type {ErrorCode} from './error-codes';
53
48
  import type {ManifestResponse} from './manifest';
54
49
  import type {DoctorResponse} from './doctor';
50
+ import type {ValidateIntegrationResponse} from './validate-integration';
55
51
 
56
52
  /**
57
53
  * Structured error. Check `'error' in result` to discriminate.
@@ -93,7 +89,6 @@ export type CLIAnyResponse =
93
89
  | TemplateShowResponse
94
90
  | TemplateSkeletonResponse
95
91
  | TemplateCopyResponse
96
- | TemplateGetResponse
97
92
  | HookListResponse
98
93
  | HookBriefResponse
99
94
  | HookFullResponse
@@ -104,11 +99,10 @@ export type CLIAnyResponse =
104
99
  | ThemeBuildResponse
105
100
  | UpgradeListResponse
106
101
  | UpgradeRunResponse
107
- | GapReportCategoriesResponse
108
- | GapReportFileResponse
109
102
  | SearchResponse
110
103
  | ManifestResponse
111
- | DoctorResponse;
104
+ | DoctorResponse
105
+ | ValidateIntegrationResponse;
112
106
 
113
107
  /** Union of all type discriminator string literals. */
114
108
  export type CLIResponseType = CLIAnyResponse['type'];
@@ -0,0 +1,81 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * Public type surface for the file-based codemod-authoring API exported from
5
+ * `@astryxdesign/cli/codemod`.
6
+ */
7
+
8
+ /** A single source file presented to a codemod's transform. */
9
+ export interface AstryxCodemodFile {
10
+ /** Absolute path to the file being transformed. */
11
+ path: string;
12
+ /** The current source contents of the file. */
13
+ source: string;
14
+ }
15
+
16
+ /** Helpers and context passed to a codemod's transform as the second argument. */
17
+ export interface AstryxCodemodApi {
18
+ /** A jscodeshift instance configured with a parser for the file. */
19
+ jscodeshift: unknown;
20
+ /** Report a statistic (no-op-friendly; provided for jscodeshift parity). */
21
+ stats: (...args: unknown[]) => void;
22
+ /** Report progress (no-op-friendly; provided for jscodeshift parity). */
23
+ report: (...args: unknown[]) => void;
24
+ }
25
+
26
+ /**
27
+ * A codemod's transform. Return the new source to rewrite the file, or
28
+ * `null`/`undefined` to leave the file unchanged.
29
+ */
30
+ export type AstryxCodemodTransform = (
31
+ file: AstryxCodemodFile,
32
+ api: AstryxCodemodApi,
33
+ ) => string | null | undefined;
34
+
35
+ /** Definition accepted by {@link createCodemod}. */
36
+ export interface AstryxCodemodDef {
37
+ /** Short, human-readable title shown in upgrade output. */
38
+ title: string;
39
+ /** Optional longer description. */
40
+ description?: string;
41
+ /** When true, the codemod runs only when explicitly requested. */
42
+ isOptional?: boolean;
43
+ /** File extensions this codemod applies to (e.g. ['.tsx', '.ts']). */
44
+ fileExtensions?: string[];
45
+ /** The transform function. */
46
+ transform: AstryxCodemodTransform;
47
+ }
48
+
49
+ /** Result of {@link createCodemod}. */
50
+ export interface AstryxCodemod extends AstryxCodemodDef {
51
+ isOptional: boolean;
52
+ type: 'code';
53
+ }
54
+
55
+ /** Definition accepted by {@link createConfigCodemod}. */
56
+ export interface AstryxConfigCodemodDef {
57
+ /** Short, human-readable title shown in upgrade output. */
58
+ title: string;
59
+ /** Optional longer description. */
60
+ description?: string;
61
+ /** When true, the codemod runs only when explicitly requested. */
62
+ isOptional?: boolean;
63
+ /** The transform function applied to the astryx.config.* file. */
64
+ transform: AstryxCodemodTransform;
65
+ }
66
+
67
+ /** Result of {@link createConfigCodemod}. */
68
+ export interface AstryxConfigCodemod extends AstryxConfigCodemodDef {
69
+ isOptional: boolean;
70
+ type: 'config';
71
+ }
72
+
73
+ /** Define a file-transforming codemod. */
74
+ export declare function createCodemod<T extends AstryxCodemodDef>(
75
+ def: T,
76
+ ): AstryxCodemod;
77
+
78
+ /** Define a codemod that targets the consumer's astryx.config.* file. */
79
+ export declare function createConfigCodemod<T extends AstryxConfigCodemodDef>(
80
+ def: T,
81
+ ): AstryxConfigCodemod;