@astryxdesign/cli 0.1.1 → 0.1.2-canary.2149fa3

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 (113) 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 +19 -8
  5. package/src/api/discover.mjs +78 -26
  6. package/src/api/index.mjs +1 -0
  7. package/src/api/layout.mjs +301 -0
  8. package/src/api/layout.test.mjs +238 -0
  9. package/src/api/template.mjs +191 -50
  10. package/src/api/template.test.mjs +2 -0
  11. package/src/api/theme-add.mjs +182 -0
  12. package/src/codemods/__tests__/registry.test.mjs +1 -0
  13. package/src/codemods/registry.mjs +1 -0
  14. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  15. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  16. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  17. package/src/commands/build-theme.import-path.test.mjs +22 -2
  18. package/src/commands/build-theme.mjs +185 -16
  19. package/src/commands/gap-report.mjs +17 -9
  20. package/src/commands/gap-report.test.mjs +21 -16
  21. package/src/commands/init.mjs +34 -8
  22. package/src/commands/init.next-steps.test.mjs +46 -0
  23. package/src/commands/layout.mjs +139 -0
  24. package/src/commands/swizzle.mjs +51 -23
  25. package/src/commands/upgrade.mjs +2 -71
  26. package/src/config.mjs +31 -0
  27. package/src/config.test.mjs +24 -0
  28. package/src/index.mjs +6 -0
  29. package/src/lib/config-schema.mjs +119 -0
  30. package/src/lib/config.mjs +34 -7
  31. package/src/lib/config.test.mjs +51 -2
  32. package/src/lib/error-codes.mjs +11 -0
  33. package/src/lib/integrations.mjs +155 -0
  34. package/src/lib/integrations.test.mjs +154 -0
  35. package/src/lib/levenshtein.mjs +29 -0
  36. package/src/lib/manifest.mjs +10 -0
  37. package/src/lib/package-scanner.mjs +31 -7
  38. package/src/lib/string-utils.mjs +5 -14
  39. package/src/lib/xle/browser.d.ts +100 -0
  40. package/src/lib/xle/browser.mjs +120 -0
  41. package/src/lib/xle/expand.mjs +622 -0
  42. package/src/lib/xle/parse.mjs +581 -0
  43. package/src/lib/xle/print.mjs +174 -0
  44. package/src/lib/xle/registry-core.mjs +170 -0
  45. package/src/lib/xle/registry.mjs +237 -0
  46. package/src/lib/xle/splice.mjs +137 -0
  47. package/src/lib/xle/validate.mjs +356 -0
  48. package/src/lib/xle/xle.test.mjs +333 -0
  49. package/src/types/config.d.ts +99 -0
  50. package/src/utils/github.mjs +12 -27
  51. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  52. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  53. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  54. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  55. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  56. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  57. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  58. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  59. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  60. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  61. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  62. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  63. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  64. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  65. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  66. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  67. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  68. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  69. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  70. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  71. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  72. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  73. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  74. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  75. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  76. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  77. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  78. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  79. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  80. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  81. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  82. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  83. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  84. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  85. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  86. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  87. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  88. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  89. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  90. package/templates/pages/shell-nav/page.tsx +321 -0
  91. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  92. package/templates/pages/shell-side-nav/page.tsx +242 -0
  93. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  94. package/templates/pages/shell-top-nav/page.tsx +224 -0
  95. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  96. package/templates/pages/theme-showcase/page.tsx +8 -19
  97. package/templates/themes/butter/butterTheme.ts +916 -0
  98. package/templates/themes/butter/icons.tsx +77 -0
  99. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  100. package/templates/themes/chocolate/icons.tsx +77 -0
  101. package/templates/themes/gothic/gothicTheme.ts +657 -0
  102. package/templates/themes/gothic/icons.tsx +77 -0
  103. package/templates/themes/manifest.json +90 -0
  104. package/templates/themes/matcha/icons.tsx +67 -0
  105. package/templates/themes/matcha/matchaTheme.ts +247 -0
  106. package/templates/themes/neutral/icons.tsx +77 -0
  107. package/templates/themes/neutral/neutralTheme.ts +603 -0
  108. package/templates/themes/stone/icons.tsx +77 -0
  109. package/templates/themes/stone/stoneTheme.ts +652 -0
  110. package/templates/themes/y2k/icons.tsx +67 -0
  111. package/templates/themes/y2k/y2kTheme.ts +617 -0
  112. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  113. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
@@ -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
+ }
@@ -60,10 +60,15 @@ export const RESPONSE_TYPES = {
60
60
  ],
61
61
  hook: ['hook.list', 'hook.brief', 'hook.full', 'hook.detail', 'hook.detail.params'],
62
62
  'theme build': ['theme.build'],
63
+ 'theme list': ['theme.list'],
64
+ 'theme add': ['theme.list', 'theme.add'],
63
65
  upgrade: ['upgrade.list', 'upgrade.status', 'upgrade.run'],
64
66
  'gap-report': ['gap-report.categories', 'gap-report.dryRun', 'gap-report.file'],
65
67
  manifest: ['manifest'],
66
68
  doctor: ['doctor'],
69
+ 'layout expand': ['layout.expand'],
70
+ 'layout check': ['layout.check'],
71
+ 'layout grammar': ['layout.grammar'],
67
72
  };
68
73
 
69
74
  /**
@@ -79,10 +84,15 @@ const EXAMPLES = {
79
84
  template: ['astryx template --json', 'astryx template dashboard ./src/app'],
80
85
  hook: ['astryx hook', 'astryx hook useToggle --json'],
81
86
  'theme build': ['astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css'],
87
+ 'theme list': ['astryx theme list --json'],
88
+ 'theme add': ['astryx theme add matcha', 'astryx theme add matcha ./src/themes/matcha'],
82
89
  upgrade: ['astryx upgrade --json'],
83
90
  manifest: ['astryx manifest --json', 'astryx --json'],
84
91
  doctor: ['astryx doctor', 'astryx doctor --json'],
85
92
  init: ['astryx init'],
93
+ 'layout expand': [`astryx layout expand 'V[g6] > C{card-callout}*4' ./src/Page.tsx`],
94
+ 'layout check': [`astryx layout check 'A[cp6] > L > LC > S[p6]' --json`],
95
+ 'layout grammar': ['astryx layout grammar'],
86
96
  };
87
97
 
88
98
  /**
@@ -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
  }
@@ -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
+ }