@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,135 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import {Project} from './project.mjs';
7
+ import {loadIntegrations} from './integrations.mjs';
8
+ import {discover} from '../api/discover.mjs';
9
+
10
+ let tmpDir;
11
+ let originalCwd;
12
+
13
+ function writeManifestPackage(dir, {basename = 'astryx.integration.mjs', body}) {
14
+ const pkgDir = path.join(dir, 'node_modules', '@acme', 'widgets');
15
+ fs.mkdirSync(pkgDir, {recursive: true});
16
+ fs.writeFileSync(
17
+ path.join(pkgDir, 'package.json'),
18
+ JSON.stringify({
19
+ name: '@acme/widgets',
20
+ version: '1.2.3',
21
+ }),
22
+ );
23
+ fs.writeFileSync(path.join(pkgDir, basename), body);
24
+ return pkgDir;
25
+ }
26
+
27
+ beforeEach(() => {
28
+ originalCwd = process.cwd();
29
+ tmpDir = fs.mkdtempSync(
30
+ path.join(process.cwd(), '.astryx-integration-test-'),
31
+ );
32
+ fs.writeFileSync(
33
+ path.join(tmpDir, 'package.json'),
34
+ JSON.stringify({name: 'consumer'}),
35
+ );
36
+ fs.writeFileSync(
37
+ path.join(tmpDir, 'astryx.config.mjs'),
38
+ `export default { integrations: ['@acme/widgets'] };\n`,
39
+ );
40
+ process.chdir(tmpDir);
41
+ });
42
+
43
+ afterEach(() => {
44
+ process.chdir(originalCwd);
45
+ fs.rmSync(tmpDir, {recursive: true, force: true});
46
+ });
47
+
48
+ describe('configured integrations', () => {
49
+ it('resolves identity from package.json and contribution roots to absolute paths', async () => {
50
+ const pkgDir = writeManifestPackage(tmpDir, {
51
+ body: `export default {
52
+ components: './docs',
53
+ templates: './blocks',
54
+ codemods: './codemods',
55
+ issuesUrl: 'https://example.com/issues',
56
+ };\n`,
57
+ });
58
+ fs.mkdirSync(path.join(pkgDir, 'docs'));
59
+ fs.writeFileSync(
60
+ path.join(pkgDir, 'docs', 'Widget.doc.mjs'),
61
+ `export const doc = {
62
+ name: 'Widget',
63
+ usage: {description: 'Acme widget'},
64
+ props: [],
65
+ };\n`,
66
+ );
67
+
68
+ const project = await Project.load(tmpDir);
69
+ expect(project.integrations).toEqual(['@acme/widgets']);
70
+ const loaded = project.loadedIntegrations[0];
71
+ // Identity comes from package.json, not the manifest.
72
+ expect(loaded.name).toBe('@acme/widgets');
73
+ expect(loaded.version).toBe('1.2.3');
74
+ expect(loaded.components).toBe(path.join(pkgDir, 'docs'));
75
+ expect(loaded.templates).toBe(path.join(pkgDir, 'blocks'));
76
+ expect(loaded.codemods).toBe(path.join(pkgDir, 'codemods'));
77
+ expect(loaded.issuesUrl).toBe('https://example.com/issues');
78
+ });
79
+
80
+ it('makes integration components discoverable', async () => {
81
+ const pkgDir = writeManifestPackage(tmpDir, {
82
+ body: `export default { components: './docs' };\n`,
83
+ });
84
+ fs.mkdirSync(path.join(pkgDir, 'docs'));
85
+ fs.writeFileSync(
86
+ path.join(pkgDir, 'docs', 'Widget.doc.mjs'),
87
+ `export const doc = {
88
+ name: 'Widget',
89
+ usage: {description: 'Acme widget'},
90
+ props: [],
91
+ };\n`,
92
+ );
93
+
94
+ const result = await discover(undefined, {});
95
+ expect(result.type).toBe('discover.list');
96
+ expect(result.data).toEqual([
97
+ expect.objectContaining({
98
+ name: '@acme/widgets',
99
+ category: '@acme/widgets',
100
+ components: ['Widget'],
101
+ }),
102
+ ]);
103
+ });
104
+
105
+ it('errors when the package has no conventional root manifest', async () => {
106
+ const pkgDir = path.join(tmpDir, 'node_modules', '@acme', 'widgets');
107
+ fs.mkdirSync(pkgDir, {recursive: true});
108
+ fs.writeFileSync(
109
+ path.join(pkgDir, 'package.json'),
110
+ JSON.stringify({name: '@acme/widgets'}),
111
+ );
112
+ await expect(loadIntegrations(['@acme/widgets'], {cwd: tmpDir})).rejects.toThrow(
113
+ /no conventional root manifest/,
114
+ );
115
+ });
116
+
117
+ it('errors when the package has multiple root manifests', async () => {
118
+ const pkgDir = writeManifestPackage(tmpDir, {
119
+ body: `export default {};\n`,
120
+ });
121
+ fs.writeFileSync(
122
+ path.join(pkgDir, 'astryx.integration.js'),
123
+ `module.exports = {};\n`,
124
+ );
125
+ await expect(loadIntegrations(['@acme/widgets'], {cwd: tmpDir})).rejects.toThrow(
126
+ /multiple root manifests/,
127
+ );
128
+ });
129
+
130
+ it('errors when the package is not installed', async () => {
131
+ await expect(loadIntegrations(['@acme/missing'], {cwd: tmpDir})).rejects.toThrow(
132
+ /Could not find installed integration package/,
133
+ );
134
+ });
135
+ });
@@ -0,0 +1,29 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Levenshtein edit distance — pure, dependency-free.
5
+ *
6
+ * Lives apart from string-utils.mjs (which dynamically imports node:fs/path
7
+ * for component search) so browser-bundled code — the XLE/XLO layout
8
+ * language — can use fuzzy matching without dragging node: schemes into the
9
+ * webpack graph.
10
+ *
11
+ * @input two strings
12
+ * @output edit distance (number)
13
+ * @position lib — shared by string-utils.mjs and lib/xle/validate.mjs
14
+ */
15
+
16
+ export function levenshteinDistance(a, b) {
17
+ const m = a.length, n = b.length;
18
+ const dp = Array.from({length: m + 1}, () => Array(n + 1).fill(0));
19
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
20
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
21
+ for (let i = 1; i <= m; i++) {
22
+ for (let j = 1; j <= n; j++) {
23
+ dp[i][j] = a[i-1] === b[j-1]
24
+ ? dp[i-1][j-1]
25
+ : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
26
+ }
27
+ }
28
+ return dp[m][n];
29
+ }
@@ -56,14 +56,18 @@ export const RESPONSE_TYPES = {
56
56
  'template.show',
57
57
  'template.skeleton',
58
58
  'template.copy',
59
- 'template.get',
60
59
  ],
61
60
  hook: ['hook.list', 'hook.brief', 'hook.full', 'hook.detail', 'hook.detail.params'],
62
61
  'theme build': ['theme.build'],
62
+ 'theme list': ['theme.list'],
63
+ 'theme add': ['theme.list', 'theme.add'],
63
64
  upgrade: ['upgrade.list', 'upgrade.status', 'upgrade.run'],
64
- 'gap-report': ['gap-report.categories', 'gap-report.dryRun', 'gap-report.file'],
65
65
  manifest: ['manifest'],
66
66
  doctor: ['doctor'],
67
+ 'validate-integration': ['integration.validate'],
68
+ 'layout expand': ['layout.expand'],
69
+ 'layout check': ['layout.check'],
70
+ 'layout grammar': ['layout.grammar'],
67
71
  };
68
72
 
69
73
  /**
@@ -79,10 +83,19 @@ const EXAMPLES = {
79
83
  template: ['astryx template --json', 'astryx template dashboard ./src/app'],
80
84
  hook: ['astryx hook', 'astryx hook useToggle --json'],
81
85
  'theme build': ['astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css'],
86
+ 'theme list': ['astryx theme list --json'],
87
+ 'theme add': ['astryx theme add matcha', 'astryx theme add matcha ./src/themes/matcha'],
82
88
  upgrade: ['astryx upgrade --json'],
83
89
  manifest: ['astryx manifest --json', 'astryx --json'],
84
90
  doctor: ['astryx doctor', 'astryx doctor --json'],
91
+ 'validate-integration': [
92
+ 'astryx validate-integration',
93
+ 'astryx validate-integration @acme/widgets --json',
94
+ ],
85
95
  init: ['astryx init'],
96
+ 'layout expand': [`astryx layout expand 'V[g6] > C{card-callout}*4' ./src/Page.tsx`],
97
+ 'layout check': [`astryx layout check 'A[cp6] > L > LC > S[p6]' --json`],
98
+ 'layout grammar': ['astryx layout grammar'],
86
99
  };
87
100
 
88
101
  /**
@@ -0,0 +1,80 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Shared module-loading + conventional-file-discovery primitives.
5
+ *
6
+ * Both config loading and integration loading need to (a) import a
7
+ * user-authored module (`.ts` via jiti, `.mjs`/`.js` via native dynamic
8
+ * import) and (b) find conventional files by basename in a fixed
9
+ * load-precedence order. These helpers centralize that so the two callers stay
10
+ * in lockstep.
11
+ *
12
+ * `loadModuleWithSchema` builds on these primitives to provide the single
13
+ * load/validation boundary shared by config, integration, codemod, and
14
+ * template discovery: import the module, take its default export, and validate
15
+ * it against the expected zod schema.
16
+ */
17
+
18
+ import * as path from 'node:path';
19
+ import {pathToFileURL} from 'node:url';
20
+ import * as fs from 'node:fs';
21
+ import {createJiti} from 'jiti';
22
+ import {formatZodError} from './config-schema.mjs';
23
+
24
+ let jitiInstance;
25
+ function getJiti() {
26
+ if (!jitiInstance) {
27
+ jitiInstance = createJiti(import.meta.url);
28
+ }
29
+ return jitiInstance;
30
+ }
31
+
32
+ /**
33
+ * Import a user-authored module. `.ts` is loaded via jiti; `.mjs`/`.js` via
34
+ * native dynamic import (file:// URL). Returns the full module namespace.
35
+ * @param {string} file absolute path
36
+ * @returns {Promise<Record<string, unknown>>}
37
+ */
38
+ export async function importUserModule(file) {
39
+ if (file.endsWith('.ts')) {
40
+ return await getJiti().import(file);
41
+ }
42
+ return await import(pathToFileURL(file).href);
43
+ }
44
+
45
+ /**
46
+ * Return the conventional files (by basename, in the given precedence order)
47
+ * that exist directly in `dir`, as absolute paths. Never throws.
48
+ * @param {string} dir
49
+ * @param {string[]} basenames precedence-ordered
50
+ * @returns {string[]} absolute paths of present files, in basenames order
51
+ */
52
+ export function findPresentFiles(dir, basenames) {
53
+ return basenames
54
+ .filter(name => fs.existsSync(path.join(dir, name)))
55
+ .map(name => path.join(dir, name));
56
+ }
57
+
58
+ /**
59
+ * Import a user-authored module, take its default export, and validate it
60
+ * against a zod schema. This is the single load/validation boundary for all
61
+ * user-authored modules (config, integration, codemod, template): execute the
62
+ * module, take the default export, validate it against the expected schema.
63
+ * Throws a clear, readable error if the default export is missing or fails
64
+ * validation.
65
+ *
66
+ * @template {import('zod').ZodType} S
67
+ * @param {string} file absolute path
68
+ * @param {S} schema
69
+ * @param {{label?: string}} [opts] label used in error messages
70
+ * @returns {Promise<import('zod').infer<S>>} parsed + typed value
71
+ */
72
+ export async function loadModuleWithSchema(file, schema, {label} = {}) {
73
+ const mod = await importUserModule(file);
74
+ const exported = mod?.default;
75
+ const result = schema.safeParse(exported);
76
+ if (!result.success) {
77
+ throw new Error(formatZodError(label ?? file, result.error));
78
+ }
79
+ return result.data;
80
+ }
@@ -0,0 +1,106 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import {z} from 'zod';
7
+ import {
8
+ findPresentFiles,
9
+ importUserModule,
10
+ loadModuleWithSchema,
11
+ } from './module-loader.mjs';
12
+
13
+ let tmpDir;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-module-loader-test-'));
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(tmpDir, {recursive: true, force: true});
21
+ });
22
+
23
+ describe('findPresentFiles', () => {
24
+ const basenames = ['a.ts', 'b.mjs', 'c.js'];
25
+
26
+ it('returns [] when none of the basenames are present', () => {
27
+ expect(findPresentFiles(tmpDir, basenames)).toEqual([]);
28
+ });
29
+
30
+ it('returns only the present files, in basenames precedence order', () => {
31
+ // Create out of order to prove the result follows `basenames`, not disk order.
32
+ fs.writeFileSync(path.join(tmpDir, 'c.js'), '');
33
+ fs.writeFileSync(path.join(tmpDir, 'a.ts'), '');
34
+ const present = findPresentFiles(tmpDir, basenames);
35
+ expect(present.map(p => path.basename(p))).toEqual(['a.ts', 'c.js']);
36
+ });
37
+
38
+ it('returns absolute paths joined to the given directory', () => {
39
+ fs.writeFileSync(path.join(tmpDir, 'b.mjs'), '');
40
+ const present = findPresentFiles(tmpDir, basenames);
41
+ expect(present).toEqual([path.join(tmpDir, 'b.mjs')]);
42
+ expect(path.isAbsolute(present[0])).toBe(true);
43
+ });
44
+
45
+ it('does not match files in nested subdirectories', () => {
46
+ const nested = path.join(tmpDir, 'nested');
47
+ fs.mkdirSync(nested);
48
+ fs.writeFileSync(path.join(nested, 'a.ts'), '');
49
+ expect(findPresentFiles(tmpDir, basenames)).toEqual([]);
50
+ });
51
+ });
52
+
53
+ describe('importUserModule', () => {
54
+ it('imports a .mjs file and returns its module namespace', async () => {
55
+ const file = path.join(tmpDir, 'mod.mjs');
56
+ fs.writeFileSync(
57
+ file,
58
+ `export default {answer: 42};\nexport const named = 'hi';\n`,
59
+ );
60
+ const mod = await importUserModule(file);
61
+ expect(mod.default).toEqual({answer: 42});
62
+ expect(mod.named).toBe('hi');
63
+ });
64
+ });
65
+
66
+ describe('loadModuleWithSchema', () => {
67
+ // Temp module files live under a repo-local dir (not /tmp): Vite's dynamic
68
+ // import blocks /tmp, so we mirror the existing repo-local temp pattern.
69
+ const schema = z
70
+ .object({
71
+ name: z.string(),
72
+ count: z.number().optional(),
73
+ })
74
+ .strict();
75
+
76
+ it('returns the parsed default export when it satisfies the schema', async () => {
77
+ const file = path.join(tmpDir, 'valid.mjs');
78
+ fs.writeFileSync(file, `export default {name: 'ok', count: 3};\n`);
79
+ const value = await loadModuleWithSchema(file, schema, {label: 'thing'});
80
+ expect(value).toEqual({name: 'ok', count: 3});
81
+ });
82
+
83
+ it('throws a readable error when there is no default export', async () => {
84
+ const file = path.join(tmpDir, 'no-default.mjs');
85
+ fs.writeFileSync(file, `export const named = {name: 'x'};\n`);
86
+ await expect(
87
+ loadModuleWithSchema(file, schema, {label: 'thing'}),
88
+ ).rejects.toThrow(/thing is invalid/i);
89
+ });
90
+
91
+ it('throws a readable error when the default export fails the schema', async () => {
92
+ const file = path.join(tmpDir, 'invalid.mjs');
93
+ fs.writeFileSync(file, `export default {count: 'not-a-number'};\n`);
94
+ await expect(
95
+ loadModuleWithSchema(file, schema, {label: 'thing'}),
96
+ ).rejects.toThrow(/thing is invalid:.*name/i);
97
+ });
98
+
99
+ it('falls back to the file path in the message when no label is given', async () => {
100
+ const file = path.join(tmpDir, 'unlabeled.mjs');
101
+ fs.writeFileSync(file, `export default {bogus: true};\n`);
102
+ await expect(loadModuleWithSchema(file, schema)).rejects.toThrow(
103
+ new RegExp(`${path.basename(file)} is invalid`),
104
+ );
105
+ });
106
+ });
@@ -15,7 +15,11 @@ export function scanDirectory(scanDir) {
15
15
  const pkgPath = path.join(scanDir, entry.name, 'package.json');
16
16
  if (!fs.existsSync(pkgPath)) continue;
17
17
  let pkg;
18
- try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { continue; }
18
+ try {
19
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
20
+ } catch {
21
+ continue;
22
+ }
19
23
  if (!pkg.astryx || !pkg.astryx.docs) continue;
20
24
  const pkgDir = path.join(scanDir, entry.name);
21
25
  const docsDir = path.resolve(pkgDir, pkg.astryx.docs);
@@ -36,9 +40,18 @@ export function scanDirectory(scanDir) {
36
40
  return packages;
37
41
  }
38
42
 
39
- export function scanAllPackages(packageDirs) {
43
+ export function scanAllPackages(packageDirs, explicitPackages = []) {
40
44
  const all = [];
41
45
  const seen = new Set();
46
+
47
+ for (const pkg of explicitPackages) {
48
+ if (!pkg || seen.has(pkg.name)) continue;
49
+ const components = discoverDocComponents(pkg.docsDir);
50
+ if (components.length === 0) continue;
51
+ seen.add(pkg.name);
52
+ all.push({...pkg, components});
53
+ }
54
+
42
55
  for (const dir of packageDirs) {
43
56
  for (const pkg of scanDirectory(dir)) {
44
57
  if (seen.has(pkg.name)) continue;
@@ -54,12 +67,17 @@ function discoverDocComponents(docsDir) {
54
67
  const components = [];
55
68
  function walk(dir) {
56
69
  let entries;
57
- try { entries = fs.readdirSync(dir, {withFileTypes: true}); } catch { return; }
70
+ try {
71
+ entries = fs.readdirSync(dir, {withFileTypes: true});
72
+ } catch {
73
+ return;
74
+ }
58
75
  for (const entry of entries) {
59
76
  if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
60
77
  const full = path.join(dir, entry.name);
61
78
  if (entry.isDirectory()) walk(full);
62
- else if (entry.name.endsWith('.doc.mjs')) components.push(entry.name.replace('.doc.mjs', ''));
79
+ else if (entry.name.endsWith('.doc.mjs'))
80
+ components.push(entry.name.replace('.doc.mjs', ''));
63
81
  }
64
82
  }
65
83
  walk(docsDir);
@@ -81,12 +99,18 @@ function findDocFile(docsDir, name) {
81
99
  const target = name + '.doc.mjs';
82
100
  function walk(dir) {
83
101
  let entries;
84
- try { entries = fs.readdirSync(dir, {withFileTypes: true}); } catch { return null; }
102
+ try {
103
+ entries = fs.readdirSync(dir, {withFileTypes: true});
104
+ } catch {
105
+ return null;
106
+ }
85
107
  for (const entry of entries) {
86
108
  if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
87
109
  const full = path.join(dir, entry.name);
88
- if (entry.isDirectory()) { const f = walk(full); if (f) return f; }
89
- else if (entry.name === target) return full;
110
+ if (entry.isDirectory()) {
111
+ const f = walk(full);
112
+ if (f) return f;
113
+ } else if (entry.name === target) return full;
90
114
  }
91
115
  return null;
92
116
  }