@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,308 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import {pathToFileURL} from 'node:url';
7
+ import {Project, DEFAULT_ISSUES_URL, findConfigPath} from './project.mjs';
8
+ import {InMemoryConfigCache} from './config-cache.mjs';
9
+ import * as componentDiscovery from './component-discovery.mjs';
10
+
11
+ let tmpDir;
12
+ let originalCwd;
13
+
14
+ const codemodModulePath = path.resolve(
15
+ process.cwd(),
16
+ 'packages/cli/src/codemod.mjs',
17
+ );
18
+ const codemodModuleUrl = pathToFileURL(codemodModulePath).href;
19
+
20
+ /**
21
+ * Scaffold a consumer project (package.json + astryx.config.mjs) under a
22
+ * repo-local temp dir, plus an installed integration package under
23
+ * node_modules. The repo-local location is required so Vite permits dynamic
24
+ * import of the config/integration modules (it blocks /tmp).
25
+ */
26
+ function scaffold({
27
+ integrations = ['@acme/widgets'],
28
+ issuesUrl,
29
+ withComponents = true,
30
+ withTemplates = true,
31
+ withCodemods = true,
32
+ brokenComponent = false,
33
+ brokenCodemod = false,
34
+ integrationIssuesUrl = 'https://example.com/widgets/issues',
35
+ } = {}) {
36
+ fs.writeFileSync(
37
+ path.join(tmpDir, 'package.json'),
38
+ JSON.stringify({name: 'consumer'}),
39
+ );
40
+ const config = {integrations};
41
+ if (issuesUrl) config.issuesUrl = issuesUrl;
42
+ fs.writeFileSync(
43
+ path.join(tmpDir, 'astryx.config.mjs'),
44
+ `export default ${JSON.stringify(config)};\n`,
45
+ );
46
+
47
+ const pkgDir = path.join(tmpDir, 'node_modules', '@acme', 'widgets');
48
+ fs.mkdirSync(pkgDir, {recursive: true});
49
+ fs.writeFileSync(
50
+ path.join(pkgDir, 'package.json'),
51
+ JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
52
+ );
53
+
54
+ const manifest = {};
55
+ if (withComponents) manifest.components = './components';
56
+ if (withTemplates) manifest.templates = './templates';
57
+ if (withCodemods) manifest.codemods = './codemods';
58
+ if (integrationIssuesUrl) manifest.issuesUrl = integrationIssuesUrl;
59
+ fs.writeFileSync(
60
+ path.join(pkgDir, 'astryx.integration.mjs'),
61
+ `export default ${JSON.stringify(manifest)};\n`,
62
+ );
63
+
64
+ if (withComponents) {
65
+ const compDir = path.join(pkgDir, 'components');
66
+ fs.mkdirSync(compDir, {recursive: true});
67
+ fs.writeFileSync(
68
+ path.join(compDir, 'Widget.doc.mjs'),
69
+ `export const docs = { name: 'Widget', usage: {description: 'A widget'} };\n`,
70
+ );
71
+ if (!brokenComponent) {
72
+ fs.writeFileSync(
73
+ path.join(compDir, 'Widget.tsx'),
74
+ `export function Widget() { return null; }\n`,
75
+ );
76
+ }
77
+ // brokenComponent => doc with no sibling .tsx (invalid_component issue).
78
+ }
79
+
80
+ if (withTemplates) {
81
+ const tDir = path.join(pkgDir, 'templates');
82
+ fs.mkdirSync(tDir, {recursive: true});
83
+ fs.writeFileSync(
84
+ path.join(tDir, 'hero.doc.mjs'),
85
+ `export default { type: 'block', name: 'Hero', description: 'A hero' };\n`,
86
+ );
87
+ fs.writeFileSync(
88
+ path.join(tDir, 'hero.tsx'),
89
+ `export default function Hero() { return null; }\n`,
90
+ );
91
+ }
92
+
93
+ if (withCodemods) {
94
+ const cmDir = path.join(pkgDir, 'codemods', '0.2.0');
95
+ fs.mkdirSync(cmDir, {recursive: true});
96
+ if (brokenCodemod) {
97
+ fs.writeFileSync(
98
+ path.join(cmDir, 'bad.mjs'),
99
+ `export default { not: 'a codemod' };\n`,
100
+ );
101
+ } else {
102
+ fs.writeFileSync(
103
+ path.join(cmDir, 'drop-foo.mjs'),
104
+ `import {createCodemod} from ${JSON.stringify(codemodModuleUrl)};\n` +
105
+ `export default createCodemod({ title: 'Drop foo', transform: (file) => file.source });\n`,
106
+ );
107
+ }
108
+ }
109
+
110
+ return pkgDir;
111
+ }
112
+
113
+ beforeEach(() => {
114
+ originalCwd = process.cwd();
115
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-project-test-'));
116
+ process.chdir(tmpDir);
117
+ });
118
+
119
+ afterEach(() => {
120
+ process.chdir(originalCwd);
121
+ fs.rmSync(tmpDir, {recursive: true, force: true});
122
+ vi.restoreAllMocks();
123
+ });
124
+
125
+ describe('Project.load', () => {
126
+ it('returns an empty config + no integrations when no config file exists', async () => {
127
+ fs.writeFileSync(
128
+ path.join(tmpDir, 'package.json'),
129
+ JSON.stringify({name: 'consumer'}),
130
+ );
131
+ const project = await Project.load(tmpDir);
132
+ expect(project.config).toEqual({integrations: []});
133
+ expect(project.integrations).toEqual([]);
134
+ expect(project.loadedIntegrations).toEqual([]);
135
+ expect(project.cwd).toBe(tmpDir);
136
+ });
137
+
138
+ it('exposes the validated config surface and loaded integrations', async () => {
139
+ scaffold({issuesUrl: 'https://example.com/issues'});
140
+ const project = await Project.load(tmpDir);
141
+ expect(project.config.issuesUrl).toBe('https://example.com/issues');
142
+ expect(project.integrations).toEqual(['@acme/widgets']);
143
+ expect(project.loadedIntegrations).toHaveLength(1);
144
+ expect(project.loadedIntegrations[0].name).toBe('@acme/widgets');
145
+ });
146
+
147
+ it('rejects an invalid config shape (strict validation)', async () => {
148
+ fs.writeFileSync(
149
+ path.join(tmpDir, 'package.json'),
150
+ JSON.stringify({name: 'consumer'}),
151
+ );
152
+ fs.writeFileSync(
153
+ path.join(tmpDir, 'astryx.config.mjs'),
154
+ `export default { integrations: [42] };\n`,
155
+ );
156
+ await expect(Project.load(tmpDir)).rejects.toThrow(/integrations/);
157
+ });
158
+ });
159
+
160
+ describe('findConfigPath', () => {
161
+ it('rejects multiple config files at the same root', () => {
162
+ fs.writeFileSync(
163
+ path.join(tmpDir, 'package.json'),
164
+ JSON.stringify({name: 'consumer'}),
165
+ );
166
+ fs.writeFileSync(
167
+ path.join(tmpDir, 'astryx.config.mjs'),
168
+ `export default {};\n`,
169
+ );
170
+ fs.writeFileSync(
171
+ path.join(tmpDir, 'astryx.config.js'),
172
+ `module.exports = {};\n`,
173
+ );
174
+ expect(() => findConfigPath(tmpDir)).toThrow(/Multiple Astryx config files/);
175
+ });
176
+ });
177
+
178
+ describe('Project discovery', () => {
179
+ it('components() returns integration ownership records', async () => {
180
+ scaffold();
181
+ const project = await Project.load(tmpDir);
182
+ const comps = await project.components();
183
+ const widget = comps.find(
184
+ c => c.name === 'Widget' && c.package === '@acme/widgets',
185
+ );
186
+ expect(widget).toBeTruthy();
187
+ expect(widget.sourcePath).toMatch(/Widget\.tsx$/);
188
+ });
189
+
190
+ it('templates() returns integration templates type-tagged', async () => {
191
+ scaffold();
192
+ const project = await Project.load(tmpDir);
193
+ const templates = await project.templates();
194
+ const hero = templates.find(t => t.package === '@acme/widgets');
195
+ expect(hero).toBeTruthy();
196
+ expect(hero.type).toBe('block');
197
+ });
198
+
199
+ it('codemods() returns core registry groups + integration groups', async () => {
200
+ scaffold();
201
+ const project = await Project.load(tmpDir);
202
+ const {core, integration} = await project.codemods('0.1.0', '0.2.0');
203
+ expect(Array.isArray(core)).toBe(true);
204
+ expect(integration).toHaveLength(1);
205
+ expect(integration[0].version).toBe('0.2.0');
206
+ expect(integration[0].codemods[0].id).toBe('drop-foo');
207
+ });
208
+
209
+ it('memoizes components() — the second call does not re-walk', async () => {
210
+ scaffold();
211
+ const project = await Project.load(tmpDir);
212
+ const spy = vi.spyOn(componentDiscovery, 'discoverIntegrationComponents');
213
+ const first = await project.components();
214
+ const callsAfterFirst = spy.mock.calls.length;
215
+ const second = await project.components();
216
+ // Same identity (cached value) and no additional discovery walk.
217
+ expect(second).toBe(first);
218
+ expect(spy.mock.calls.length).toBe(callsAfterFirst);
219
+ });
220
+ });
221
+
222
+ describe('Project issues (skip + warn)', () => {
223
+ it('collects issues for a broken integration and skips its contributions', async () => {
224
+ scaffold({brokenComponent: true});
225
+ const project = await Project.load(tmpDir);
226
+ const comps = await project.components();
227
+ // Widget is skipped because its source is missing.
228
+ expect(comps.find(c => c.package === '@acme/widgets')).toBeUndefined();
229
+ const issues = await project.issues();
230
+ expect(issues.length).toBeGreaterThan(0);
231
+ expect(issues.some(i => i.package === '@acme/widgets')).toBe(true);
232
+ });
233
+
234
+ it('does not throw when an integration codemod is broken', async () => {
235
+ scaffold({brokenCodemod: true});
236
+ const project = await Project.load(tmpDir);
237
+ const {integration} = await project.codemods('0.1.0', '0.2.0');
238
+ // Broken integration is skipped, not thrown.
239
+ expect(integration).toHaveLength(0);
240
+ const issues = await project.issues();
241
+ expect(issues.some(i => i.code === 'invalid_codemod')).toBe(true);
242
+ });
243
+
244
+ it('issues() dedupes and is complete on demand', async () => {
245
+ scaffold({brokenComponent: true});
246
+ const project = await Project.load(tmpDir);
247
+ // Call a discovery method first (collects some), then issues().
248
+ await project.components();
249
+ const a = await project.issues();
250
+ const b = await project.issues();
251
+ expect(a).toEqual(b);
252
+ // No duplicate (package, code, message) tuples.
253
+ const seen = new Set(a.map(i => `${i.package}\u0000${i.code}\u0000${i.message}`));
254
+ expect(seen.size).toBe(a.length);
255
+ });
256
+
257
+ it('issues() validates integrations not yet visited by a discovery call', async () => {
258
+ scaffold({brokenComponent: true});
259
+ const project = await Project.load(tmpDir);
260
+ // No discovery call at all — issues() must still surface the broken one.
261
+ const issues = await project.issues();
262
+ expect(issues.some(i => i.package === '@acme/widgets')).toBe(true);
263
+ });
264
+ });
265
+
266
+ describe('Project.issuesUrl routing', () => {
267
+ it('routes core to config.issuesUrl, falling back to the default', async () => {
268
+ scaffold({issuesUrl: 'https://example.com/core'});
269
+ const project = await Project.load(tmpDir);
270
+ expect(project.issuesUrl()).toBe('https://example.com/core');
271
+ expect(project.issuesUrl({package: '@astryxdesign/core'})).toBe(
272
+ 'https://example.com/core',
273
+ );
274
+ });
275
+
276
+ it('falls back to the default core URL when config has none', async () => {
277
+ scaffold();
278
+ const project = await Project.load(tmpDir);
279
+ expect(project.issuesUrl()).toBe(DEFAULT_ISSUES_URL);
280
+ });
281
+
282
+ it('routes an integration package to its manifest issuesUrl', async () => {
283
+ scaffold({integrationIssuesUrl: 'https://example.com/widgets/issues'});
284
+ const project = await Project.load(tmpDir);
285
+ expect(project.issuesUrl({package: '@acme/widgets'})).toBe(
286
+ 'https://example.com/widgets/issues',
287
+ );
288
+ });
289
+
290
+ it('returns undefined for an integration that ships no issuesUrl', async () => {
291
+ scaffold({integrationIssuesUrl: null});
292
+ const project = await Project.load(tmpDir);
293
+ expect(project.issuesUrl({package: '@acme/widgets'})).toBeUndefined();
294
+ });
295
+ });
296
+
297
+ describe('Project caching', () => {
298
+ it('reuses a shared cache across instances for the same config bytes', async () => {
299
+ scaffold();
300
+ const cache = new InMemoryConfigCache();
301
+ const p1 = await Project.load(tmpDir, {cache});
302
+ const comps1 = await p1.components();
303
+ const p2 = await Project.load(tmpDir, {cache});
304
+ const comps2 = await p2.components();
305
+ // Same cached value across Project instances (same key path).
306
+ expect(comps2).toBe(comps1);
307
+ });
308
+ });
@@ -4,20 +4,11 @@
4
4
  * @file String utilities — fuzzy matching and semantic search for component names
5
5
  */
6
6
 
7
- export function levenshteinDistance(a, b) {
8
- const m = a.length, n = b.length;
9
- const dp = Array.from({length: m + 1}, () => Array(n + 1).fill(0));
10
- for (let i = 0; i <= m; i++) dp[i][0] = i;
11
- for (let j = 0; j <= n; j++) dp[0][j] = j;
12
- for (let i = 1; i <= m; i++) {
13
- for (let j = 1; j <= n; j++) {
14
- dp[i][j] = a[i-1] === b[j-1]
15
- ? dp[i-1][j-1]
16
- : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
17
- }
18
- }
19
- return dp[m][n];
20
- }
7
+ // levenshteinDistance lives in its own pure module so browser-bundled code
8
+ // (the XLE/XLO language) can use it without this file's dynamic node:fs/path
9
+ // imports landing in the webpack graph. Re-exported for existing consumers.
10
+ import {levenshteinDistance} from './levenshtein.mjs';
11
+ export {levenshteinDistance} from './levenshtein.mjs';
21
12
 
22
13
  /**
23
14
  * Find the closest component names to a given (possibly misspelled) name.
@@ -0,0 +1,100 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** Type surface for `@astryxdesign/cli/xle` — the browser-safe layout language. */
4
+
5
+ export interface XLEIssue {
6
+ message: string;
7
+ line?: number;
8
+ col?: number;
9
+ formatted: string;
10
+ suggestions?: string[];
11
+ kind?: string;
12
+ }
13
+
14
+ export interface SerializedRegistry {
15
+ components: Array<{
16
+ name: string;
17
+ exportName: string;
18
+ dirName: string;
19
+ importPath: string;
20
+ undocumented?: boolean;
21
+ props: Array<Record<string, unknown>>;
22
+ }>;
23
+ aliases: Array<[string, string]>;
24
+ componentNames: string[];
25
+ }
26
+
27
+ export interface XLEBlock {
28
+ dirName: string;
29
+ description?: string;
30
+ category?: string;
31
+ }
32
+
33
+ export interface CheckOptions {
34
+ blocks?: XLEBlock[];
35
+ form?: 'auto' | 'compact' | 'outline';
36
+ loose?: boolean;
37
+ }
38
+
39
+ export interface CheckResult {
40
+ ok: boolean;
41
+ valid?: boolean;
42
+ form?: 'compact' | 'outline';
43
+ errors: XLEIssue[];
44
+ warnings: string[];
45
+ compact?: string;
46
+ outline?: string;
47
+ parseError?: {message: string; line?: number; col?: number};
48
+ }
49
+
50
+ export interface ExpandOptions extends CheckOptions {
51
+ name?: string;
52
+ }
53
+
54
+ export type ExpandResult =
55
+ | {ok: false; errors: XLEIssue[]}
56
+ | {
57
+ ok: true;
58
+ form: 'compact' | 'outline';
59
+ code: string;
60
+ componentsUsed: string[];
61
+ states: number;
62
+ todos: string[];
63
+ warnings: string[];
64
+ };
65
+
66
+ export function checkExpression(
67
+ expression: string,
68
+ registry: SerializedRegistry | object,
69
+ opts?: CheckOptions,
70
+ ): CheckResult;
71
+
72
+ export function expandExpression(
73
+ expression: string,
74
+ registry: SerializedRegistry | object,
75
+ opts?: ExpandOptions,
76
+ ): ExpandResult;
77
+
78
+ export function hydrateRegistry(json: SerializedRegistry): object;
79
+ export function serializeRegistry(registry: object): SerializedRegistry;
80
+ export function parse(source: string, opts?: {form?: string}): unknown;
81
+ export function detectForm(source: string): 'compact' | 'outline';
82
+ export function validate(
83
+ doc: unknown,
84
+ registry: object,
85
+ blocks?: XLEBlock[],
86
+ opts?: {loose?: boolean},
87
+ ): {errors: XLEIssue[]; warnings: XLEIssue[]};
88
+ export function expand(
89
+ doc: unknown,
90
+ registry: object,
91
+ opts?: {componentName?: string},
92
+ ): {code: string; componentsUsed: string[]; states: number; todos: string[]};
93
+ export function toCompact(doc: unknown): string;
94
+ export function toOutline(doc: unknown): string;
95
+ export const ALIAS_TABLE: Record<string, string>;
96
+ export const SPACING_STEPS: number[];
97
+ export class XLEParseError extends Error {
98
+ line: number;
99
+ col: number;
100
+ }
@@ -0,0 +1,120 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file XLE/XLO browser barrel — the layout language without Node deps.
5
+ *
6
+ * The CLI builds its registry by reading .doc.mjs from disk (registry.mjs,
7
+ * Node-only). The browser playground instead receives a *serialized*
8
+ * registry (built at deploy time) and hydrates it here, then runs the same
9
+ * pure parse → validate → expand → print pipeline. Importing this module
10
+ * never pulls in node:fs, so it bundles cleanly for the web.
11
+ *
12
+ * @input expression string + a serialized-or-hydrated registry (+ blocks)
13
+ * @output checkExpression / expandExpression + the raw pipeline pieces
14
+ * @position lib/xle — exported as `@astryxdesign/cli/xle`
15
+ */
16
+
17
+ import {parse, detectForm, XLEParseError} from './parse.mjs';
18
+ import {validate} from './validate.mjs';
19
+ import {expand} from './expand.mjs';
20
+ import {toCompact, toOutline} from './print.mjs';
21
+ import {hydrateRegistry} from './registry-core.mjs';
22
+
23
+ export {parse, detectForm, XLEParseError, validate, expand, toCompact, toOutline};
24
+ export {hydrateRegistry, serializeRegistry, ALIAS_TABLE, resolveComponent, SPACING_STEPS} from './registry-core.mjs';
25
+
26
+ /** Accept either a hydrated registry (Map fields) or serialized JSON. */
27
+ function asRegistry(registry) {
28
+ return registry && registry.components instanceof Map ? registry : hydrateRegistry(registry);
29
+ }
30
+
31
+ function formatIssue(issue) {
32
+ const where = issue.line != null ? `line ${issue.line}: ` : '';
33
+ return `${where}${issue.message}`;
34
+ }
35
+
36
+ /**
37
+ * Validate an expression and return both canonical surfaces — the browser
38
+ * twin of `layout check`. Never throws on invalid input; returns the errors.
39
+ *
40
+ * @param {string} expression
41
+ * @param {object} registry - serialized or hydrated
42
+ * @param {object} [opts]
43
+ * @param {Array} [opts.blocks] - block list for {hint} resolution (default [])
44
+ * @param {'compact'|'outline'|'auto'} [opts.form]
45
+ * @param {boolean} [opts.loose]
46
+ * @returns {{ok: boolean, valid?: boolean, form?: string, errors: Array, warnings: string[], compact?: string, outline?: string, parseError?: object}}
47
+ */
48
+ export function checkExpression(expression, registry, opts = {}) {
49
+ const {blocks = [], form = 'auto', loose = false} = opts;
50
+ let doc;
51
+ try {
52
+ doc = parse(expression, {form});
53
+ } catch (e) {
54
+ if (e instanceof XLEParseError) {
55
+ return {
56
+ ok: false,
57
+ valid: false,
58
+ errors: [{message: e.message, line: e.line, col: e.col, formatted: formatIssue(e), kind: 'parse'}],
59
+ warnings: [],
60
+ parseError: {message: e.message, line: e.line, col: e.col},
61
+ };
62
+ }
63
+ throw e;
64
+ }
65
+ const {errors, warnings} = validate(doc, asRegistry(registry), blocks, {loose});
66
+ return {
67
+ ok: true,
68
+ valid: errors.length === 0,
69
+ form: doc.form,
70
+ errors: errors.map(e => ({...e, formatted: formatIssue(e)})),
71
+ warnings: warnings.map(formatIssue),
72
+ compact: toCompact(doc),
73
+ outline: toOutline(doc),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Validate + expand to TSX — the browser twin of `layout expand`.
79
+ * Returns either {code, ...} or {errors} (never writes files).
80
+ *
81
+ * @param {string} expression
82
+ * @param {object} registry
83
+ * @param {object} [opts]
84
+ * @param {Array} [opts.blocks]
85
+ * @param {'compact'|'outline'|'auto'} [opts.form]
86
+ * @param {boolean} [opts.loose]
87
+ * @param {string} [opts.name] - generated component name (PascalCase)
88
+ * @param {Map} [opts.blockModules] - prepared block sources/imports for splicing;
89
+ * omit in the browser (hints stay as TODO markers without sources)
90
+ */
91
+ export function expandExpression(expression, registry, opts = {}) {
92
+ const {blocks = [], form = 'auto', loose = false, name = 'GeneratedLayout', blockModules} = opts;
93
+ const reg = asRegistry(registry);
94
+ let doc;
95
+ try {
96
+ doc = parse(expression, {form});
97
+ } catch (e) {
98
+ if (e instanceof XLEParseError) {
99
+ return {ok: false, errors: [{message: e.message, line: e.line, col: e.col, formatted: formatIssue(e), kind: 'parse'}]};
100
+ }
101
+ throw e;
102
+ }
103
+ const {errors, warnings} = validate(doc, reg, blocks, {loose});
104
+ if (errors.length > 0) {
105
+ return {ok: false, errors: errors.map(e => ({...e, formatted: formatIssue(e)}))};
106
+ }
107
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) {
108
+ return {ok: false, errors: [{message: `Component name must be PascalCase, got '${name}'`, formatted: `Component name must be PascalCase, got '${name}'`}]};
109
+ }
110
+ const result = expand(doc, reg, {componentName: name, blockModules});
111
+ return {
112
+ ok: true,
113
+ form: form === 'auto' ? detectForm(expression) : form,
114
+ code: result.code,
115
+ componentsUsed: result.componentsUsed,
116
+ states: result.states,
117
+ todos: result.todos,
118
+ warnings: warnings.map(formatIssue),
119
+ };
120
+ }