@astryxdesign/cli 0.1.1-canary.a514b99 → 0.1.1-canary.ac73e47

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 (49) hide show
  1. package/package.json +19 -8
  2. package/src/api/discover.mjs +78 -26
  3. package/src/api/layout.mjs +301 -0
  4. package/src/api/layout.test.mjs +238 -0
  5. package/src/api/template.mjs +191 -50
  6. package/src/api/template.test.mjs +2 -0
  7. package/src/commands/gap-report.mjs +17 -9
  8. package/src/commands/gap-report.test.mjs +21 -16
  9. package/src/commands/init.mjs +34 -8
  10. package/src/commands/init.next-steps.test.mjs +46 -0
  11. package/src/commands/layout.mjs +139 -0
  12. package/src/commands/swizzle.mjs +51 -23
  13. package/src/commands/upgrade.mjs +1 -70
  14. package/src/config.mjs +31 -0
  15. package/src/config.test.mjs +24 -0
  16. package/src/index.mjs +4 -0
  17. package/src/lib/config-schema.mjs +119 -0
  18. package/src/lib/config.mjs +34 -7
  19. package/src/lib/config.test.mjs +51 -2
  20. package/src/lib/error-codes.mjs +8 -0
  21. package/src/lib/integrations.mjs +155 -0
  22. package/src/lib/integrations.test.mjs +154 -0
  23. package/src/lib/levenshtein.mjs +29 -0
  24. package/src/lib/manifest.mjs +6 -0
  25. package/src/lib/package-scanner.mjs +31 -7
  26. package/src/lib/string-utils.mjs +5 -14
  27. package/src/lib/xle/browser.d.ts +91 -0
  28. package/src/lib/xle/browser.mjs +120 -0
  29. package/src/lib/xle/expand.mjs +622 -0
  30. package/src/lib/xle/parse.mjs +581 -0
  31. package/src/lib/xle/print.mjs +174 -0
  32. package/src/lib/xle/registry-core.mjs +170 -0
  33. package/src/lib/xle/registry.mjs +237 -0
  34. package/src/lib/xle/splice.mjs +137 -0
  35. package/src/lib/xle/validate.mjs +356 -0
  36. package/src/lib/xle/xle.test.mjs +333 -0
  37. package/src/types/config.d.ts +99 -0
  38. package/src/utils/github.mjs +12 -27
  39. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  40. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  41. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  42. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  43. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  44. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  45. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  46. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  47. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  48. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  49. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
package/src/config.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {validateConfig, validateIntegration} from './lib/config-schema.mjs';
4
+
5
+ /**
6
+ * Type-preserving helpers for Astryx config and integration manifests.
7
+ *
8
+ * These are intentionally tiny runtime identity functions. Their value is the
9
+ * exported TypeScript surface from `@astryxdesign/cli/config`, so config files
10
+ * can get editor/type feedback without coupling to CLI internals.
11
+ */
12
+
13
+ /**
14
+ * @template {import('./types/config').AstryxConfig} T
15
+ * @param {T} config
16
+ * @returns {T}
17
+ */
18
+ export function createConfig(config) {
19
+ validateConfig(config);
20
+ return config;
21
+ }
22
+
23
+ /**
24
+ * @template {import('./types/config').AstryxIntegration} T
25
+ * @param {T} integration
26
+ * @returns {T}
27
+ */
28
+ export function createIntegration(integration) {
29
+ validateIntegration(integration);
30
+ return integration;
31
+ }
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import {createConfig, createIntegration} from './config.mjs';
5
+
6
+ describe('config helpers', () => {
7
+ it('return config and integration objects unchanged', () => {
8
+ const config = {integrations: ['@acme/widgets']};
9
+ const integration = {name: '@acme/widgets', docs: './docs'};
10
+ expect(createConfig(config)).toBe(config);
11
+ expect(createIntegration(integration)).toBe(integration);
12
+ });
13
+
14
+ it('validates config and integration shapes', () => {
15
+ expect(() => createConfig({integrations: [42]})).toThrow(/integrations/);
16
+ expect(() => createIntegration({docs: './docs'})).toThrow(/name/);
17
+ expect(() =>
18
+ createIntegration({
19
+ name: '@acme/widgets',
20
+ postCodemod: [{name: 'empty'}],
21
+ }),
22
+ ).toThrow(/postCodemod/);
23
+ });
24
+ });
package/src/index.mjs CHANGED
@@ -63,6 +63,9 @@ export const JSON_SUPPORTED = new Set([
63
63
  'upgrade',
64
64
  'manifest',
65
65
  'doctor',
66
+ 'layout expand',
67
+ 'layout check',
68
+ 'layout grammar',
66
69
  ]);
67
70
 
68
71
  program
@@ -243,6 +246,7 @@ const commands = [
243
246
  {name: 'swizzle', path: './commands/swizzle.mjs', register: 'registerSwizzle'},
244
247
  // agent-docs folded into init — functions still importable from agent-docs.mjs
245
248
  {name: 'template', path: './commands/template.mjs', register: 'registerTemplate'},
249
+ {name: 'layout', path: './commands/layout.mjs', register: 'registerLayout'},
246
250
  {name: 'gap-report', path: './commands/gap-report.mjs', register: 'registerGapReport'},
247
251
  {name: 'upgrade', path: './commands/upgrade.mjs', register: 'registerUpgrade'},
248
252
  {name: 'theme', path: './commands/build-theme.mjs', register: 'registerTheme'},
@@ -0,0 +1,119 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** Runtime schemas for Astryx config and integration manifests. */
4
+
5
+ import {z} from 'zod';
6
+
7
+ const Fn = z.custom(value => typeof value === 'function', {
8
+ message: 'Expected function',
9
+ });
10
+
11
+ const StringOrStringArray = z.union([z.string(), z.array(z.string())]);
12
+ const GapReportSchema = z.union([
13
+ z.literal(false),
14
+ z.object({command: z.string()}).passthrough(),
15
+ ]);
16
+
17
+ export const AstryxConfigSchema = z
18
+ .object({
19
+ packages: StringOrStringArray.optional(),
20
+ integrations: StringOrStringArray.optional(),
21
+ gapReport: GapReportSchema.optional(),
22
+ template: z
23
+ .object({
24
+ get: Fn.optional(),
25
+ })
26
+ .passthrough()
27
+ .optional(),
28
+ })
29
+ .passthrough();
30
+
31
+ export const AstryxIntegrationCodemodSchema = z
32
+ .object({
33
+ name: z.string(),
34
+ from: z.string().optional(),
35
+ to: z.string().optional(),
36
+ title: z.string().optional(),
37
+ description: z.string().optional(),
38
+ pr: z.string().optional(),
39
+ optional: z.boolean().optional(),
40
+ fileExtensions: z.array(z.string()).optional(),
41
+ transform: z.union([z.string(), Fn]),
42
+ })
43
+ .passthrough();
44
+
45
+ export const AstryxPostCodemodHookSchema = z
46
+ .object({
47
+ name: z.string().optional(),
48
+ run: Fn.optional(),
49
+ command: Fn.optional(),
50
+ })
51
+ .passthrough()
52
+ .refine(value => value.run || value.command, {
53
+ message: 'postCodemod hook must define run() or command()',
54
+ });
55
+
56
+ export const AstryxIntegrationSchema = z
57
+ .object({
58
+ name: z.string(),
59
+ version: z.string().optional(),
60
+ displayName: z.string().optional(),
61
+ description: z.string().optional(),
62
+ docs: z.string().optional(),
63
+ category: z.string().optional(),
64
+ blocks: z.string().optional(),
65
+ gapReport: GapReportSchema.optional(),
66
+ template: z
67
+ .object({
68
+ get: z.union([z.string(), Fn]).optional(),
69
+ })
70
+ .passthrough()
71
+ .optional(),
72
+ codemods: z.array(AstryxIntegrationCodemodSchema).optional(),
73
+ postCodemod: z.array(AstryxPostCodemodHookSchema).optional(),
74
+ })
75
+ .passthrough();
76
+
77
+ /**
78
+ * @param {string} label
79
+ * @param {import('zod').ZodError} error
80
+ */
81
+ function formatZodError(label, error) {
82
+ const issues = error.issues
83
+ .map(issue => {
84
+ const path = issue.path.length ? issue.path.join('.') : '(root)';
85
+ return `${path}: ${issue.message}`;
86
+ })
87
+ .join('; ');
88
+ return `${label} is invalid: ${issues}`;
89
+ }
90
+
91
+ /**
92
+ * @param {unknown} config
93
+ * @returns {import('../types/config').AstryxConfig}
94
+ */
95
+ export function validateConfig(config) {
96
+ const result = AstryxConfigSchema.safeParse(config);
97
+ if (!result.success) {
98
+ throw new Error(
99
+ formatZodError('astryx.config.mjs default export', result.error),
100
+ );
101
+ }
102
+ return result.data;
103
+ }
104
+
105
+ /**
106
+ * @param {unknown} integration
107
+ * @param {string} [label]
108
+ * @returns {import('../types/config').AstryxIntegration}
109
+ */
110
+ export function validateIntegration(
111
+ integration,
112
+ label = 'integration manifest',
113
+ ) {
114
+ const result = AstryxIntegrationSchema.safeParse(integration);
115
+ if (!result.success) {
116
+ throw new Error(formatZodError(label, result.error));
117
+ }
118
+ return result.data;
119
+ }
@@ -10,6 +10,8 @@
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import {pathToFileURL} from 'node:url';
13
+ import {loadIntegrations} from './integrations.mjs';
14
+ import {validateConfig} from './config-schema.mjs';
13
15
 
14
16
  const DEFAULTS = {
15
17
  packages: [],
@@ -40,18 +42,30 @@ export async function loadConfig(startDir = process.cwd()) {
40
42
  const configPath = findConfigPath(startDir);
41
43
  if (!configPath) return {...DEFAULTS};
42
44
 
45
+ let rawConfig;
43
46
  try {
44
47
  const mod = await import(pathToFileURL(configPath).href);
45
- const config = mod.default || {};
46
- return {
47
- ...DEFAULTS,
48
- ...config,
49
- packages: normalizePackages(config.packages, path.dirname(configPath)),
50
- integrations: normalizeIntegrations(config.integrations),
51
- };
48
+ rawConfig = mod.default || {};
52
49
  } catch {
53
50
  return {...DEFAULTS};
54
51
  }
52
+
53
+ const config = validateConfig(rawConfig);
54
+ const configDir = path.dirname(configPath);
55
+ const integrationSpecs = normalizeIntegrations(config.integrations);
56
+ const loadedIntegrations = await loadIntegrations(integrationSpecs, {
57
+ cwd: configDir,
58
+ });
59
+ return {
60
+ ...DEFAULTS,
61
+ ...config,
62
+ packages: normalizePackages(config.packages, configDir),
63
+ integrations: integrationSpecs,
64
+ loadedIntegrations,
65
+ gapReport:
66
+ config.gapReport ?? firstDefined(loadedIntegrations, 'gapReport'),
67
+ template: mergeTemplateConfig(config.template, loadedIntegrations),
68
+ };
55
69
  }
56
70
 
57
71
  /**
@@ -84,3 +98,16 @@ function normalizeIntegrations(integrations) {
84
98
  const arr = Array.isArray(integrations) ? integrations : [integrations];
85
99
  return arr.filter(value => typeof value === 'string' && value !== '');
86
100
  }
101
+
102
+ function firstDefined(integrations, key) {
103
+ for (const integration of integrations) {
104
+ if (integration[key] !== undefined) return integration[key];
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ function mergeTemplateConfig(template, integrations) {
110
+ if (template?.get) return template;
111
+ const integrationTemplate = firstDefined(integrations, 'template');
112
+ return integrationTemplate ?? template;
113
+ }
@@ -15,10 +15,25 @@ afterEach(() => {
15
15
  fs.rmSync(tmpDir, {recursive: true, force: true});
16
16
  });
17
17
 
18
+ function installIntegrationPackage(dir, name = '@nest/xds-meta') {
19
+ const packageDir = path.join(dir, 'node_modules', ...name.split('/'));
20
+ fs.mkdirSync(packageDir, {recursive: true});
21
+ fs.writeFileSync(
22
+ path.join(packageDir, 'package.json'),
23
+ JSON.stringify({name, astryx: {integration: './astryx.integration.mjs'}}),
24
+ );
25
+ fs.writeFileSync(
26
+ path.join(packageDir, 'astryx.integration.mjs'),
27
+ `export default { name: '${name}' };
28
+ `,
29
+ );
30
+ }
31
+
18
32
  describe('loadConfig', () => {
19
33
  it('normalizes integrations from astryx.config.mjs', async () => {
20
34
  const dir = path.join(tmpDir, 'one');
21
35
  fs.mkdirSync(dir);
36
+ installIntegrationPackage(dir);
22
37
  fs.writeFileSync(
23
38
  path.join(dir, 'astryx.config.mjs'),
24
39
  `export default { integrations: '@nest/xds-meta' };\n`,
@@ -28,15 +43,49 @@ describe('loadConfig', () => {
28
43
  });
29
44
  });
30
45
 
31
- it('normalizes integration arrays and filters non-strings', async () => {
46
+ it('rejects invalid integration package manifests', async () => {
47
+ const dir = path.join(tmpDir, 'bad');
48
+ fs.mkdirSync(dir);
49
+ const packageDir = path.join(dir, 'node_modules', '@bad', 'widgets');
50
+ fs.mkdirSync(packageDir, {recursive: true});
51
+ fs.writeFileSync(
52
+ path.join(dir, 'astryx.config.mjs'),
53
+ `export default { integrations: '@bad/widgets' };\n`,
54
+ );
55
+ fs.writeFileSync(
56
+ path.join(packageDir, 'package.json'),
57
+ JSON.stringify({
58
+ name: '@bad/widgets',
59
+ astryx: {integration: './astryx.integration.mjs'},
60
+ }),
61
+ );
62
+ fs.writeFileSync(
63
+ path.join(packageDir, 'astryx.integration.mjs'),
64
+ `export default { docs: './docs' };\n`,
65
+ );
66
+ await expect(loadConfig(dir)).rejects.toThrow(/name/);
67
+ });
68
+
69
+ it('normalizes integration arrays', async () => {
32
70
  const dir = path.join(tmpDir, 'two');
33
71
  fs.mkdirSync(dir);
72
+ installIntegrationPackage(dir);
34
73
  fs.writeFileSync(
35
74
  path.join(dir, 'astryx.config.mjs'),
36
- `export default { integrations: ['@nest/xds-meta', '', 42] };\n`,
75
+ `export default { integrations: ['@nest/xds-meta', ''] };\n`,
37
76
  );
38
77
  await expect(loadConfig(dir)).resolves.toMatchObject({
39
78
  integrations: ['@nest/xds-meta'],
40
79
  });
41
80
  });
81
+
82
+ it('rejects invalid config shapes', async () => {
83
+ const dir = path.join(tmpDir, 'invalid');
84
+ fs.mkdirSync(dir);
85
+ fs.writeFileSync(
86
+ path.join(dir, 'astryx.config.mjs'),
87
+ `export default { integrations: [42] };\n`,
88
+ );
89
+ await expect(loadConfig(dir)).rejects.toThrow(/integrations/);
90
+ });
42
91
  });
@@ -75,6 +75,8 @@
75
75
  * | 'ERR_DEP_MISSING'
76
76
  * | 'ERR_GH_CLI'
77
77
  * | 'ERR_GAP_REPORT_FAILED'
78
+ * | 'ERR_LAYOUT_PARSE'
79
+ * | 'ERR_LAYOUT_INVALID'
78
80
  * )} ErrorCode
79
81
  */
80
82
 
@@ -180,6 +182,12 @@ export const ERROR_CODES = Object.freeze({
180
182
  ERR_GH_CLI: 'ERR_GH_CLI',
181
183
  /** Filing a gap report failed at the command/integration boundary. */
182
184
  ERR_GAP_REPORT_FAILED: 'ERR_GAP_REPORT_FAILED',
185
+
186
+ // ── Layout expressions (XLE/XLO) ─────────────────────────────────
187
+ /** A layout expression failed to parse (syntax error, with line/col). */
188
+ ERR_LAYOUT_PARSE: 'ERR_LAYOUT_PARSE',
189
+ /** A layout expression parsed but failed validation (unknown component/prop/enum/block). */
190
+ ERR_LAYOUT_INVALID: 'ERR_LAYOUT_INVALID',
183
191
  });
184
192
 
185
193
  /**
@@ -0,0 +1,155 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration manifest loading for Astryx config.
5
+ *
6
+ * Integrations are package names or direct manifest paths listed in
7
+ * astryx.config.mjs. A manifest can contribute docs/discovery metadata,
8
+ * gap-report/template hooks, upgrade codemods, and post-codemod hooks.
9
+ */
10
+
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import {pathToFileURL} from 'node:url';
14
+ import {validateIntegration} from './config-schema.mjs';
15
+
16
+ export function isPathSpec(spec) {
17
+ return (
18
+ spec.startsWith('.') ||
19
+ spec.startsWith('/') ||
20
+ spec.endsWith('.mjs') ||
21
+ spec.endsWith('.js')
22
+ );
23
+ }
24
+
25
+ export function resolvePackageDir(packageName, cwd = process.cwd()) {
26
+ return path.resolve(cwd, 'node_modules', ...packageName.split('/'));
27
+ }
28
+
29
+ export function resolveIntegrationFile(spec, cwd = process.cwd()) {
30
+ if (isPathSpec(spec)) {
31
+ return path.resolve(cwd, spec);
32
+ }
33
+
34
+ const packageDir = resolvePackageDir(spec, cwd);
35
+ const pkgPath = path.join(packageDir, 'package.json');
36
+ let pkg;
37
+ try {
38
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
39
+ } catch {
40
+ throw new Error(
41
+ `Could not find installed integration package "${spec}" at ${pkgPath}. Install it first or pass a direct integration file path.`,
42
+ );
43
+ }
44
+
45
+ const manifestPath = pkg.astryx?.integration ?? pkg.xds?.integration;
46
+ if (!manifestPath) {
47
+ throw new Error(
48
+ `Package "${spec}" does not declare astryx.integration (or legacy xds.integration) in package.json.`,
49
+ );
50
+ }
51
+ return path.resolve(packageDir, manifestPath);
52
+ }
53
+
54
+ function normalizePackageContribution(integration) {
55
+ const docs = integration.docs;
56
+ if (!docs) return null;
57
+ const packageDir = integration.__packageDir ?? integration.__dir;
58
+ const docsDir = path.resolve(packageDir, docs);
59
+ return {
60
+ name: integration.name ?? integration.__spec,
61
+ version: integration.version,
62
+ description: integration.description,
63
+ displayName: integration.displayName,
64
+ dir: packageDir,
65
+ astryx: {
66
+ docs,
67
+ category: integration.category,
68
+ blocks: integration.blocks,
69
+ },
70
+ category: integration.category ?? integration.name ?? integration.__spec,
71
+ docsDir,
72
+ };
73
+ }
74
+
75
+ function resolveHookFunction(hook, integration) {
76
+ if (typeof hook === 'function') return hook;
77
+ if (typeof hook !== 'string') return hook;
78
+ const [moduleSpec, exportName = 'default'] = hook.split('#');
79
+ return async (...args) => {
80
+ const mod = isPathSpec(moduleSpec)
81
+ ? await import(
82
+ pathToFileURL(path.resolve(integration.__dir, moduleSpec)).href
83
+ )
84
+ : await import(moduleSpec);
85
+ const fn = exportName === 'default' ? mod.default : mod[exportName];
86
+ if (typeof fn !== 'function') {
87
+ throw new Error(
88
+ `Integration hook ${hook} did not resolve to a function.`,
89
+ );
90
+ }
91
+ return fn(...args);
92
+ };
93
+ }
94
+
95
+ export async function loadIntegrations(specs = [], {cwd = process.cwd()} = {}) {
96
+ const integrations = [];
97
+ const seen = new Set();
98
+
99
+ for (const spec of specs) {
100
+ if (!spec || seen.has(spec)) continue;
101
+ seen.add(spec);
102
+
103
+ const file = resolveIntegrationFile(spec, cwd);
104
+ const mod = await import(pathToFileURL(file).href);
105
+ const exported = mod.default ?? mod.integration ?? mod;
106
+ if (!exported || typeof exported !== 'object') {
107
+ throw new Error(`Integration ${spec} did not export an object.`);
108
+ }
109
+ const integration = validateIntegration(exported, `Integration ${spec}`);
110
+
111
+ const integrationDir = path.dirname(file);
112
+ const packageDir = isPathSpec(spec)
113
+ ? integrationDir
114
+ : resolvePackageDir(spec, cwd);
115
+ const normalized = {
116
+ ...integration,
117
+ __file: file,
118
+ __dir: integrationDir,
119
+ __packageDir: packageDir,
120
+ __spec: spec,
121
+ };
122
+
123
+ if (Array.isArray(normalized.codemods)) {
124
+ for (const codemod of normalized.codemods) {
125
+ if (typeof codemod.transform === 'string') {
126
+ const transformPath = path.resolve(integrationDir, codemod.transform);
127
+ const transformMod = await import(pathToFileURL(transformPath).href);
128
+ codemod.transform =
129
+ transformMod.default ?? transformMod.transform ?? transformMod;
130
+ }
131
+ }
132
+ }
133
+
134
+ normalized.package = normalizePackageContribution(normalized);
135
+ if (
136
+ normalized.gapReport?.command &&
137
+ isPathSpec(normalized.gapReport.command)
138
+ ) {
139
+ normalized.gapReport = {
140
+ ...normalized.gapReport,
141
+ command: path.resolve(packageDir, normalized.gapReport.command),
142
+ };
143
+ }
144
+ if (normalized.template?.get) {
145
+ normalized.template = {
146
+ ...normalized.template,
147
+ get: resolveHookFunction(normalized.template.get, normalized),
148
+ };
149
+ }
150
+
151
+ integrations.push(normalized);
152
+ }
153
+
154
+ return integrations;
155
+ }
@@ -0,0 +1,154 @@
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 {loadConfig} from './config.mjs';
7
+ import {discover} from '../api/discover.mjs';
8
+ import {loadGapReportConfig} from '../utils/github.mjs';
9
+ import {getTemplateById} from '../api/template.mjs';
10
+
11
+ let tmpDir;
12
+ let originalCwd;
13
+
14
+ beforeEach(() => {
15
+ originalCwd = process.cwd();
16
+ tmpDir = fs.mkdtempSync(
17
+ path.join(process.cwd(), '.astryx-integration-test-'),
18
+ );
19
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', '@acme', 'widgets'), {
20
+ recursive: true,
21
+ });
22
+ fs.writeFileSync(
23
+ path.join(tmpDir, 'astryx.config.mjs'),
24
+ `export default { integrations: '@acme/widgets' };\n`,
25
+ );
26
+ fs.writeFileSync(
27
+ path.join(tmpDir, 'node_modules', '@acme', 'widgets', 'package.json'),
28
+ JSON.stringify({
29
+ name: '@acme/widgets',
30
+ version: '1.2.3',
31
+ displayName: 'Acme Widgets',
32
+ astryx: {integration: './astryx.integration.mjs'},
33
+ }),
34
+ );
35
+ fs.writeFileSync(
36
+ path.join(
37
+ tmpDir,
38
+ 'node_modules',
39
+ '@acme',
40
+ 'widgets',
41
+ 'astryx.integration.mjs',
42
+ ),
43
+ `export default {
44
+ name: '@acme/widgets',
45
+ version: '1.2.3',
46
+ displayName: 'Acme Widgets',
47
+ docs: './docs',
48
+ category: 'Acme',
49
+ gapReport: {command: './scripts/report-gap.sh'},
50
+ template: {get: './template.mjs#getTemplate'},
51
+ codemods: [{name: 'noop', from: '0.0.0', to: '9.0.0', transform: './codemod.mjs'}],
52
+ };\n`,
53
+ );
54
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', '@acme', 'widgets', 'docs'));
55
+ fs.writeFileSync(
56
+ path.join(
57
+ tmpDir,
58
+ 'node_modules',
59
+ '@acme',
60
+ 'widgets',
61
+ 'docs',
62
+ 'Widget.doc.mjs',
63
+ ),
64
+ `export const doc = {
65
+ name: 'Widget',
66
+ usage: {description: 'Acme widget'},
67
+ props: [],
68
+ };\n`,
69
+ );
70
+ fs.mkdirSync(
71
+ path.join(tmpDir, 'node_modules', '@acme', 'widgets', 'scripts'),
72
+ );
73
+ fs.writeFileSync(
74
+ path.join(
75
+ tmpDir,
76
+ 'node_modules',
77
+ '@acme',
78
+ 'widgets',
79
+ 'scripts',
80
+ 'report-gap.sh',
81
+ ),
82
+ '#!/bin/sh\ncat >/dev/null\necho ok\n',
83
+ );
84
+ fs.writeFileSync(
85
+ path.join(tmpDir, 'node_modules', '@acme', 'widgets', 'template.mjs'),
86
+ `export async function getTemplate(id) { return 'template:' + id; }\n`,
87
+ );
88
+ fs.writeFileSync(
89
+ path.join(tmpDir, 'node_modules', '@acme', 'widgets', 'codemod.mjs'),
90
+ `export default function transform() { return undefined; }\n`,
91
+ );
92
+ process.chdir(tmpDir);
93
+ });
94
+
95
+ afterEach(() => {
96
+ process.chdir(originalCwd);
97
+ fs.rmSync(tmpDir, {recursive: true, force: true});
98
+ });
99
+
100
+ describe('configured integrations', () => {
101
+ it('load docs/gap/template/codemod metadata from integration manifests', async () => {
102
+ const config = await loadConfig(tmpDir);
103
+ expect(config.integrations).toEqual(['@acme/widgets']);
104
+ expect(config.loadedIntegrations[0].name).toBe('@acme/widgets');
105
+ expect(config.loadedIntegrations[0].package).toMatchObject({
106
+ name: '@acme/widgets',
107
+ category: 'Acme',
108
+ });
109
+ expect(config.loadedIntegrations[0].codemods[0].transform).toEqual(
110
+ expect.any(Function),
111
+ );
112
+ expect(config.gapReport.command).toBe(
113
+ path.join(
114
+ tmpDir,
115
+ 'node_modules',
116
+ '@acme',
117
+ 'widgets',
118
+ 'scripts',
119
+ 'report-gap.sh',
120
+ ),
121
+ );
122
+ await expect(config.template.get('P123')).resolves.toBe('template:P123');
123
+ });
124
+
125
+ it('makes integration docs discoverable without packages config', async () => {
126
+ const result = await discover(undefined, {});
127
+ expect(result.type).toBe('discover.list');
128
+ expect(result.data).toEqual([
129
+ expect.objectContaining({
130
+ name: '@acme/widgets',
131
+ category: 'Acme',
132
+ components: ['Widget'],
133
+ }),
134
+ ]);
135
+ });
136
+
137
+ it('uses integration gap-report and template hooks', async () => {
138
+ await expect(loadGapReportConfig()).resolves.toMatchObject({
139
+ enabled: true,
140
+ command: path.join(
141
+ tmpDir,
142
+ 'node_modules',
143
+ '@acme',
144
+ 'widgets',
145
+ 'scripts',
146
+ 'report-gap.sh',
147
+ ),
148
+ });
149
+ await expect(getTemplateById('P123', {cwd: tmpDir})).resolves.toEqual({
150
+ type: 'template.get',
151
+ data: {id: 'P123', source: 'template:P123'},
152
+ });
153
+ });
154
+ });
@@ -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
+ }