@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,241 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration tests for the layout API against the real @astryxdesign/core
5
+ * registry and real template blocks. Every expansion is additionally
6
+ * checked for TSX syntactic validity with the TypeScript parser, so the
7
+ * "expansion emits compilable JSX" contract is enforced, not assumed.
8
+ */
9
+
10
+ import {describe, it, expect, beforeAll} from 'vitest';
11
+ import {mkdtempSync, writeFileSync, rmSync} from 'node:fs';
12
+ import {tmpdir} from 'node:os';
13
+ import {join} from 'node:path';
14
+ import ts from 'typescript';
15
+ import {layoutExpand, layoutCheck, layoutGrammar} from './layout.mjs';
16
+ import {buildRegistry} from '../lib/xle/registry.mjs';
17
+
18
+ // The registry imports ~140 .doc.mjs modules on first use; under full-suite
19
+ // parallel load that can exceed the default 5s test timeout. Warm it once.
20
+ beforeAll(async () => {
21
+ await buildRegistry();
22
+ }, 120_000);
23
+
24
+ const SLOW = 30_000;
25
+
26
+ /** Assert TSX parses cleanly; returns the source file for inspection. */
27
+ function expectValidTsx(code) {
28
+ const sourceFile = ts.createSourceFile('generated.tsx', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
29
+ const diagnostics = sourceFile.parseDiagnostics || [];
30
+ const messages = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, ' '));
31
+ expect(messages).toEqual([]);
32
+ return sourceFile;
33
+ }
34
+
35
+ const LOGIN_COMPACT =
36
+ 'Ctr[h="100dvh"] > C[w=400 p8] > V[g6] > ' +
37
+ '(V[g1] > Tx"Welcome back" + Tx[t=supporting]"Sign in to your account") + ' +
38
+ '(F > TI"Email"[t=email req] + TI"Password"[t=password req]) + ' +
39
+ '(H[j=between a=center] > CB"Remember me" + Lk[href="/forgot"]"Forgot password?") + ' +
40
+ 'B.primary"Sign in"';
41
+
42
+ const CHAT_OUTLINE = `
43
+ AppShell
44
+ topNav: TN
45
+ Layout > LC !scroll
46
+ ChL
47
+ composer:
48
+ ChC
49
+ ChML
50
+ ChS "Today"
51
+ repeat 2:
52
+ ChM
53
+ ChB "Reply $"
54
+ Tbar "Actions"
55
+ B "Delete" opens=#confirm
56
+
57
+ overlays:
58
+ AD#confirm "Delete item?"
59
+ `;
60
+
61
+ const DASHBOARD_COMPACT =
62
+ 'A[@topNav=TN @sideNav=SN] > L > V[g6] > ' +
63
+ '(G[c4 g4] > C{card-callout}*4) + ' +
64
+ '(C[p0] > T[striped] > (TR > THC"Name" + THC"Amount") + (TR > TC"Order $" + TC"\\$12.00")*3)';
65
+
66
+ describe('layoutExpand', () => {
67
+ it('expands the login card with typed state scaffolds', async () => {
68
+ const result = await layoutExpand(LOGIN_COMPACT);
69
+ expect(result.type).toBe('layout.expand');
70
+ const {code} = result.data;
71
+ expectValidTsx(code);
72
+
73
+ expect(code).toContain(`const [email, setEmail] = useState('');`);
74
+ expect(code).toContain(`const [rememberMe, setRememberMe] = useState(false);`);
75
+ expect(code).toContain('<XDSCenter height="100dvh">');
76
+ expect(code).toContain('variant="primary"');
77
+ // axis-neutral j/a resolved onto the HStack's real props
78
+ expect(code).toContain('hAlign="between"');
79
+ expect(code).toContain('vAlign="center"');
80
+ // payload routed to label props, not duplicated as children
81
+ expect(code).not.toMatch(/label="Forgot password\?"[\s\S]{0,80}Forgot password\?/);
82
+ }, SLOW);
83
+
84
+ it('expands the outline chat page: slots, repeat blocks, overlays, triggers', async () => {
85
+ const result = await layoutExpand(CHAT_OUTLINE);
86
+ const {code, form} = result.data;
87
+ expect(form).toBe('outline');
88
+ expectValidTsx(code);
89
+
90
+ expect(code).toContain('composer={');
91
+ // slot satisfied required prop — no double assignment, no TODO for it
92
+ expect(code.match(/composer=/g)).toHaveLength(1);
93
+ expect(code).toContain('TODO(xle): open #confirm');
94
+ expect(code).toContain('overlays — wire open state');
95
+ expect((code.match(/<XDSChatMessage\b/g) || [])).toHaveLength(2);
96
+ }, SLOW);
97
+
98
+ it('expands the dashboard: table partition, repeats with $ counter and \\$ escape', async () => {
99
+ const result = await layoutExpand(DASHBOARD_COMPACT);
100
+ const {code} = result.data;
101
+ expectValidTsx(code);
102
+
103
+ expect(code).toContain('<XDSTableHeader>');
104
+ expect(code).toContain('<XDSTableBody>');
105
+ expect(code).toContain('Order 1');
106
+ expect(code).toContain('Order 3');
107
+ expect(code).toContain('$12.00');
108
+ expect(code).not.toContain('112.00');
109
+ // {card-callout} now splices: the block is co-defined once and referenced
110
+ // four times — no TODO marker.
111
+ expect((code.match(/^function CardCallout\(\)/gm) || [])).toHaveLength(1);
112
+ expect((code.match(/<CardCallout \/>/g) || [])).toHaveLength(4);
113
+ expect(code).not.toContain("TODO(xle): content block 'CardCallout'");
114
+ }, SLOW);
115
+
116
+ it('compact and outline surfaces expand to identical TSX', async () => {
117
+ const check = await layoutCheck(LOGIN_COMPACT);
118
+ const fromCompact = await layoutExpand(LOGIN_COMPACT);
119
+ const fromOutline = await layoutExpand(check.data.outline, {form: 'outline'});
120
+ expect(fromOutline.data.code).toEqual(fromCompact.data.code);
121
+ }, SLOW);
122
+
123
+ it('expansion is deterministic', async () => {
124
+ const a = await layoutExpand(DASHBOARD_COMPACT);
125
+ const b = await layoutExpand(DASHBOARD_COMPACT);
126
+ expect(a.data.code).toEqual(b.data.code);
127
+ }, SLOW);
128
+
129
+ it('throws structured errors with suggestions on invalid expressions', async () => {
130
+ await expect(layoutExpand('A[p6] > Grdi')).rejects.toMatchObject({
131
+ code: 'ERR_LAYOUT_INVALID',
132
+ message: expect.stringMatching(/AppShell has no prop 'padding'/),
133
+ });
134
+ });
135
+
136
+ it('rejects non-PascalCase --name', async () => {
137
+ await expect(layoutExpand('V > C', {name: 'not pascal'})).rejects.toMatchObject({
138
+ code: 'ERR_INVALID_ARGUMENT',
139
+ });
140
+ });
141
+
142
+ it('surfaces parse errors with positions', async () => {
143
+ await expect(layoutExpand('V > > C')).rejects.toMatchObject({
144
+ code: 'ERR_LAYOUT_PARSE',
145
+ message: expect.stringMatching(/line 1/),
146
+ });
147
+ });
148
+ });
149
+
150
+ describe('layoutCheck', () => {
151
+ it('returns both canonical surfaces for valid input', async () => {
152
+ const result = await layoutCheck('V[g6] > C{card-callout}*2');
153
+ expect(result.data.valid).toBe(true);
154
+ expect(result.data.compact).toContain('{card-callout}');
155
+ expect(result.data.outline).toContain('C {card-callout} x2');
156
+ });
157
+
158
+ it('collects all errors instead of stopping at the first', async () => {
159
+ const result = await layoutCheck('A[p6] > V[g7] > Bd.sucess"x" + C{not-a-block}');
160
+ expect(result.data.valid).toBe(false);
161
+ expect(result.data.errors.length).toBeGreaterThanOrEqual(4);
162
+ const all = result.data.errors.map(e => e.message).join('\n');
163
+ expect(all).toMatch(/no prop 'padding'/);
164
+ expect(all).toMatch(/must be one of/);
165
+ expect(all).toMatch(/Unknown block/);
166
+ });
167
+ });
168
+
169
+ describe('template referencing', () => {
170
+ it('splices a template block: co-defined once, referenced, imports merged', async () => {
171
+ const result = await layoutExpand('S[p6] > C{card-callout}*3', {name: 'Demo'});
172
+ const {code} = result.data;
173
+ expectValidTsx(code);
174
+ // co-defined exactly once, referenced three times, no TODO
175
+ expect((code.match(/^function CardCallout\(\)/gm) || [])).toHaveLength(1);
176
+ expect((code.match(/<CardCallout \/>/g) || [])).toHaveLength(3);
177
+ expect(code).not.toContain('TODO(xle)');
178
+ // the block's own import is hoisted, and shared specifiers dedupe
179
+ expect((code.match(/from '@astryxdesign\/core\/Card'/g) || [])).toHaveLength(1);
180
+ // the block lost its export — it's a local declaration now
181
+ expect(code).not.toContain('export default function CardCallout');
182
+ expect(result.data.blocksReferenced).toEqual([{name: 'CardCallout', mode: 'splice'}]);
183
+ }, SLOW);
184
+
185
+ it('merges a stateful block useState into a single react import', async () => {
186
+ const result = await layoutExpand(
187
+ 'V > TI"Search"[t=text] + {table-column-settings-table}',
188
+ {name: 'Demo'},
189
+ );
190
+ const {code} = result.data;
191
+ expectValidTsx(code);
192
+ // page TextInput scaffolds useState; block also uses useState → one import
193
+ expect((code.match(/^import \{useState\} from 'react';$/gm) || [])).toHaveLength(1);
194
+ expect(code).toContain('function TableColumnSettingsTable()');
195
+ }, SLOW);
196
+
197
+ it('imports app-registered local components (the local-component bridge)', async () => {
198
+ // Inside the workspace so @astryxdesign/core resolves; cleaned up after.
199
+ // A package.json beside the config makes Project.load resolve it as the
200
+ // sibling-of-nearest-package.json (the standard config resolution).
201
+ const cwd = mkdtempSync(join(process.cwd(), '.xle-imp-test-'));
202
+ try {
203
+ writeFileSync(join(cwd, 'package.json'), '{"name": "xle-imp-fixture"}\n');
204
+ writeFileSync(
205
+ join(cwd, 'astryx.config.mjs'),
206
+ `export default {experimental: {xle: {components: {KpiCard: {from: '@/components/KpiCard'}, TimeRangePicker: {from: '@/components/TimeRangePicker'}}}}};\n`,
207
+ );
208
+ const result = await layoutExpand('S[p6] > (G[c4 g4] > {kpi-card}*4) + {time-range-picker}', {
209
+ name: 'Demo',
210
+ cwd,
211
+ });
212
+ const {code} = result.data;
213
+ expectValidTsx(code);
214
+ expect(code).toContain("import {KpiCard} from '@/components/KpiCard';");
215
+ expect(code).toContain("import {TimeRangePicker} from '@/components/TimeRangePicker';");
216
+ expect((code.match(/<KpiCard \/>/g) || [])).toHaveLength(4);
217
+ expect(code).toContain('<TimeRangePicker />');
218
+ expect(result.data.blocksReferenced).toContainEqual({name: 'KpiCard', mode: 'import'});
219
+ } finally {
220
+ rmSync(cwd, {recursive: true, force: true});
221
+ }
222
+ }, SLOW);
223
+
224
+ it('parses a standalone {block} in both surfaces', async () => {
225
+ const check = await layoutCheck('G[c4 g4] > {card-callout}*2');
226
+ expect(check.data.valid).toBe(true);
227
+ expect(check.data.compact).toContain('{card-callout}*2');
228
+ expect(check.data.outline).toMatch(/\{card-callout\} x2/);
229
+ });
230
+ });
231
+
232
+ describe('layoutGrammar', () => {
233
+ it('emits the cheatsheet with branch-generated aliases', async () => {
234
+ const result = await layoutGrammar();
235
+ expect(result.data.text).toContain('TWO SURFACES');
236
+ expect(result.data.aliases.V).toBe('VStack');
237
+ expect(result.data.aliases.TB).toBe('TableBody');
238
+ // every alias target must exist — the table is registry-filtered
239
+ expect(Object.values(result.data.aliases)).not.toContain(undefined);
240
+ });
241
+ });
@@ -0,0 +1,225 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration-provided template discovery (same-stem + type-driven).
5
+ *
6
+ * These tests stand up a temp consumer project with an astryx.config and an
7
+ * installed integration package that contributes templates, then exercise the
8
+ * public `template()` API to verify discovery, package/type scoping, ambiguity
9
+ * errors, and copy-to-dir naming.
10
+ */
11
+
12
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import {template} from './template.mjs';
16
+
17
+ let tmpDir;
18
+ let originalCwd;
19
+
20
+ /** Absolute path to the CLI package so integrations can import /template. */
21
+ const CLI_PKG = path.resolve(import.meta.dirname, '..', '..');
22
+
23
+ function makeConsumer() {
24
+ const dir = fs.mkdtempSync(
25
+ path.join(process.cwd(), '.astryx-template-it-'),
26
+ );
27
+ fs.writeFileSync(
28
+ path.join(dir, 'package.json'),
29
+ JSON.stringify({name: 'consumer'}),
30
+ );
31
+ fs.writeFileSync(
32
+ path.join(dir, 'astryx.config.mjs'),
33
+ `export default { integrations: ['@acme/widgets'] };\n`,
34
+ );
35
+ return dir;
36
+ }
37
+
38
+ /**
39
+ * Install an @acme/widgets integration package that declares a templates root.
40
+ * @returns the package dir.
41
+ */
42
+ function installWidgets(consumerDir) {
43
+ const pkgDir = path.join(consumerDir, 'node_modules', '@acme', 'widgets');
44
+ fs.mkdirSync(pkgDir, {recursive: true});
45
+ fs.writeFileSync(
46
+ path.join(pkgDir, 'package.json'),
47
+ JSON.stringify({name: '@acme/widgets', version: '2.0.0'}),
48
+ );
49
+ fs.writeFileSync(
50
+ path.join(pkgDir, 'astryx.integration.mjs'),
51
+ `export default { templates: './templates' };\n`,
52
+ );
53
+ fs.mkdirSync(path.join(pkgDir, 'templates'));
54
+ return pkgDir;
55
+ }
56
+
57
+ /** Write a template doc + same-stem source under the templates root. */
58
+ function writeTemplate(pkgDir, id, {kind, body, withSource = true}) {
59
+ const docPath = path.join(pkgDir, 'templates', `${id}.doc.mjs`);
60
+ fs.mkdirSync(path.dirname(docPath), {recursive: true});
61
+ const create = kind === 'page' ? 'createPageTemplate' : 'createBlockTemplate';
62
+ fs.writeFileSync(
63
+ docPath,
64
+ body ??
65
+ `import {${create}} from '${CLI_PKG}/src/template.mjs';\n` +
66
+ `export default ${create}({name: '${id} name', description: '${id} desc'});\n`,
67
+ );
68
+ if (withSource) {
69
+ fs.writeFileSync(
70
+ path.join(pkgDir, 'templates', `${id}.tsx`),
71
+ `export default function ${id.replace(/[^a-zA-Z0-9]/g, '')}() { return null; }\n`,
72
+ );
73
+ }
74
+ }
75
+
76
+ beforeEach(() => {
77
+ originalCwd = process.cwd();
78
+ tmpDir = makeConsumer();
79
+ process.chdir(tmpDir);
80
+ });
81
+
82
+ afterEach(() => {
83
+ process.chdir(originalCwd);
84
+ fs.rmSync(tmpDir, {recursive: true, force: true});
85
+ });
86
+
87
+ describe('integration template discovery', () => {
88
+ it('discovers and lists an integration template with package + type', async () => {
89
+ const pkgDir = installWidgets(tmpDir);
90
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
91
+
92
+ const result = await template(undefined, {list: true, cwd: tmpDir});
93
+ expect(result.type).toBe('template.list');
94
+ const entry = result.data.find(t => t.id === 'pricing');
95
+ expect(entry).toBeTruthy();
96
+ expect(entry.type).toBe('page');
97
+ expect(entry.package).toBe('@acme/widgets');
98
+ expect(entry.name).toBe('pricing name');
99
+ expect(entry.description).toBe('pricing desc');
100
+ });
101
+
102
+ it('lists nested-id templates (kebab path under root)', async () => {
103
+ const pkgDir = installWidgets(tmpDir);
104
+ writeTemplate(pkgDir, 'marketing/hero', {kind: 'block'});
105
+
106
+ const result = await template(undefined, {list: true, cwd: tmpDir});
107
+ const entry = result.data.find(t => t.id === 'marketing/hero');
108
+ expect(entry).toBeTruthy();
109
+ expect(entry.type).toBe('block');
110
+ expect(entry.package).toBe('@acme/widgets');
111
+ });
112
+
113
+ it('always reports core templates under @astryxdesign/core', async () => {
114
+ const result = await template(undefined, {list: true, cwd: tmpDir});
115
+ const core = result.data.filter(t => t.package === '@astryxdesign/core');
116
+ expect(core.length).toBeGreaterThan(0);
117
+ });
118
+
119
+ it('--package narrows the listing', async () => {
120
+ const pkgDir = installWidgets(tmpDir);
121
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
122
+
123
+ const result = await template(undefined, {
124
+ list: true,
125
+ package: '@acme/widgets',
126
+ cwd: tmpDir,
127
+ });
128
+ expect(result.data.length).toBe(1);
129
+ expect(result.data[0].id).toBe('pricing');
130
+ });
131
+
132
+ it('skips a template whose same-stem source is missing', async () => {
133
+ const pkgDir = installWidgets(tmpDir);
134
+ writeTemplate(pkgDir, 'orphan', {kind: 'page', withSource: false});
135
+
136
+ const result = await template(undefined, {list: true, cwd: tmpDir});
137
+ expect(result.data.find(t => t.id === 'orphan')).toBeUndefined();
138
+ });
139
+
140
+ it('skips a raw doc that is missing a type', async () => {
141
+ const pkgDir = installWidgets(tmpDir);
142
+ writeTemplate(pkgDir, 'untyped', {
143
+ kind: 'page',
144
+ body: `export default {name: 'Untyped', description: 'no type'};\n`,
145
+ });
146
+
147
+ const result = await template(undefined, {list: true, cwd: tmpDir});
148
+ expect(result.data.find(t => t.id === 'untyped')).toBeUndefined();
149
+ });
150
+
151
+ it('errors with candidates when an id is ambiguous across type/package', async () => {
152
+ const pkgDir = installWidgets(tmpDir);
153
+ // Same id "hero" as both a page and a block within the integration.
154
+ writeTemplate(pkgDir, 'hero', {kind: 'page'});
155
+ // Add a sibling block doc with the same stem in a different file is not
156
+ // possible (same file). Instead install a second package with a "hero".
157
+ const pkg2 = path.join(tmpDir, 'node_modules', '@acme', 'extra');
158
+ fs.mkdirSync(path.join(pkg2, 'templates'), {recursive: true});
159
+ fs.writeFileSync(
160
+ path.join(pkg2, 'package.json'),
161
+ JSON.stringify({name: '@acme/extra', version: '1.0.0'}),
162
+ );
163
+ fs.writeFileSync(
164
+ path.join(pkg2, 'astryx.integration.mjs'),
165
+ `export default { templates: './templates' };\n`,
166
+ );
167
+ fs.writeFileSync(
168
+ path.join(pkg2, 'templates', 'hero.doc.mjs'),
169
+ `import {createBlockTemplate} from '${CLI_PKG}/src/template.mjs';\n` +
170
+ `export default createBlockTemplate({name: 'Hero block', description: 'b'});\n`,
171
+ );
172
+ fs.writeFileSync(
173
+ path.join(pkg2, 'templates', 'hero.tsx'),
174
+ `export default function Hero() { return null; }\n`,
175
+ );
176
+ fs.writeFileSync(
177
+ path.join(tmpDir, 'astryx.config.mjs'),
178
+ `export default { integrations: ['@acme/widgets', '@acme/extra'] };\n`,
179
+ );
180
+
181
+ await expect(
182
+ template('hero', {targetPath: './out', cwd: tmpDir}),
183
+ ).rejects.toMatchObject({code: 'ERR_AMBIGUOUS_TEMPLATE'});
184
+ });
185
+
186
+ it('--type and --package narrow an ambiguous id to a single match', async () => {
187
+ const pkgDir = installWidgets(tmpDir);
188
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
189
+
190
+ const result = await template('pricing', {
191
+ type: 'page',
192
+ package: '@acme/widgets',
193
+ show: true,
194
+ cwd: tmpDir,
195
+ });
196
+ expect(result.type).toBe('template.show');
197
+ expect(result.data.type).toBe('page');
198
+ });
199
+
200
+ it('copies a page template into a directory as page.tsx', async () => {
201
+ const pkgDir = installWidgets(tmpDir);
202
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
203
+
204
+ const result = await template('pricing', {
205
+ targetPath: './dest',
206
+ cwd: tmpDir,
207
+ });
208
+ expect(result.type).toBe('template.copy');
209
+ expect(result.data.fileName).toBe('page.tsx');
210
+ expect(fs.existsSync(path.join(tmpDir, 'dest', 'page.tsx'))).toBe(true);
211
+ });
212
+
213
+ it('copies a block template into a directory as <id-basename>.tsx', async () => {
214
+ const pkgDir = installWidgets(tmpDir);
215
+ writeTemplate(pkgDir, 'marketing/hero', {kind: 'block'});
216
+
217
+ const result = await template('marketing/hero', {
218
+ targetPath: './dest',
219
+ cwd: tmpDir,
220
+ });
221
+ expect(result.type).toBe('template.copy');
222
+ expect(result.data.fileName).toBe('hero.tsx');
223
+ expect(fs.existsSync(path.join(tmpDir, 'dest', 'hero.tsx'))).toBe(true);
224
+ });
225
+ });