@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
@@ -17,10 +17,15 @@ import * as path from 'node:path';
17
17
  import {pathToFileURL, fileURLToPath} from 'node:url';
18
18
  import {createJiti} from 'jiti';
19
19
  import {getRunPrefix} from '../utils/package-manager.mjs';
20
- import {sanitizeName, PathSafetyError} from '../utils/path-safety.mjs';
20
+ import {
21
+ sanitizeName,
22
+ PathSafetyError,
23
+ isNonInteractive,
24
+ } from '../utils/path-safety.mjs';
21
25
  import {jsonOut, humanLog} from '../lib/json.mjs';
22
26
  import {cliError} from '../lib/cli-error.mjs';
23
27
  import {ERROR_CODES} from '../lib/error-codes.mjs';
28
+ import {themeAdd, listThemes} from '../api/theme-add.mjs';
24
29
 
25
30
  // Import shared theme processing from core. `astryx theme build` MUST produce the
26
31
  // exact same CSS as the `<Theme>` runtime, so it has exactly one generation
@@ -75,6 +80,18 @@ function toIdentifier(name) {
75
80
  return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
76
81
  }
77
82
 
83
+ /**
84
+ * Import specifier for install/scaffold instructions. Drops a leading `src/`
85
+ * from the cwd-relative dir (most consumers import from a file under src/) but
86
+ * keeps the rest of the path (e.g. `themes/gothic`). Callers note the path is
87
+ * relative to the consumer's file.
88
+ */
89
+ function importSpecifier(relDir, base) {
90
+ const normalized = relDir === '.' ? '' : relDir;
91
+ const withinSrc = normalized.replace(/^src\/?/, '').replace(/\/+$/, '');
92
+ return withinSrc ? `./${withinSrc}/${base}` : `./${base}`;
93
+ }
94
+
78
95
  /**
79
96
  * Convert a kebab-case component name to PascalCase.
80
97
  * e.g. 'button' → 'Button', 'progress-bar' → 'ProgressBar', 'avatar-status-dot' → 'AvatarStatusDot'
@@ -443,7 +460,7 @@ const KNOWN_COMPONENTS = {
443
460
  breadcrumbs: ['variant'],
444
461
  button: ['variant', 'size'],
445
462
  calendar: [],
446
- card: [],
463
+ card: ['variant'],
447
464
  center: [],
448
465
  checkboxinput: [],
449
466
  collapsible: [],
@@ -737,10 +754,12 @@ export function registerTheme(program) {
737
754
  const sourceRelative = path.relative(process.cwd(), filePath);
738
755
  const buildCommand = `astryx theme build ${sourceRelative}${options.out ? ' --out ' + path.relative(process.cwd(), path.resolve(process.cwd(), options.out)) : ''}`;
739
756
 
740
- // Determine output path
757
+ // Derive the default CSS name from the theme name so .css/.js/.d.ts
758
+ // share one scheme; an explicit --out still wins.
759
+ const baseName = themeDef.name;
741
760
  const outPath = options.out
742
761
  ? path.resolve(process.cwd(), options.out)
743
- : filePath.replace(/\.(ts|tsx|js|jsx|mjs)$/, '.css');
762
+ : path.join(path.dirname(filePath), `${baseName}.css`);
744
763
 
745
764
  const displayTheme = resolvedTheme || themeDef;
746
765
  const tokenCount = displayTheme.tokens ? Object.keys(displayTheme.tokens).length : 0;
@@ -753,7 +772,6 @@ export function registerTheme(program) {
753
772
  // was left as orphaned half-built output. Stage-then-commit avoids
754
773
  // that.
755
774
  const outDir = path.dirname(outPath);
756
- const baseName = themeDef.name;
757
775
  const jsPath = path.join(outDir, `${baseName}.js`);
758
776
  const dtsPath = path.join(outDir, `${baseName}.d.ts`);
759
777
 
@@ -834,18 +852,16 @@ export function registerTheme(program) {
834
852
  });
835
853
  }
836
854
 
837
- // Print install instructions
838
- const relDir = path.relative(process.cwd(), outDir);
839
- // When the output dir is the cwd, relDir is empty — avoid emitting a
840
- // double-slash import path like './/<name>'. Build a './<relDir>/'
841
- // prefix that collapses to './' when relDir is empty.
842
- const importPrefix = relDir ? `./${relDir}/` : './';
855
+ const relOutDir = path.relative(process.cwd(), outDir) || '.';
856
+ const cssBase = path.basename(outPath, '.css');
857
+ const jsImport = importSpecifier(relOutDir, baseName);
858
+ const cssImport = importSpecifier(relOutDir, cssBase) + '.css';
843
859
  const exportName = `${toIdentifier(baseName)}Theme`;
844
860
  humanLog(`
845
- Install in your app:
861
+ Install in your app (paths are relative to a file in src/ — adjust if yours lives elsewhere):
846
862
 
847
- import { ${exportName} } from '${importPrefix}${baseName}';
848
- import '${importPrefix}${baseName}.css';
863
+ import { ${exportName} } from '${jsImport}';
864
+ import '${cssImport}';
849
865
 
850
866
  <Theme theme={${exportName}}>
851
867
  <App />
@@ -853,9 +869,9 @@ Install in your app:
853
869
 
854
870
  Or with a <link> tag:
855
871
 
856
- import { ${exportName} } from '${importPrefix}${baseName}';
872
+ import { ${exportName} } from '${jsImport}';
857
873
 
858
- <link rel="stylesheet" href="${importPrefix}${baseName}.css" />
874
+ <link rel="stylesheet" href="${cssImport}" />
859
875
  <Theme theme={${exportName}}>
860
876
  <App />
861
877
  </Theme>
@@ -871,4 +887,157 @@ Or with a <link> tag:
871
887
  humanLog('');
872
888
  }
873
889
  });
890
+
891
+ theme
892
+ .command('list')
893
+ .description('List themes available to add')
894
+ .action(async () => {
895
+ const json = program.opts().json || false;
896
+ let result;
897
+ try {
898
+ result = await themeAdd(undefined, {list: true, cwd: process.cwd()});
899
+ } catch (e) {
900
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
901
+ return;
902
+ }
903
+
904
+ if (json) return jsonOut(result.type, result.data);
905
+
906
+ const themes = result.data;
907
+ if (themes.length === 0) {
908
+ humanLog('\nNo themes are bundled with this CLI build.\n');
909
+ return;
910
+ }
911
+ humanLog('\nThemes:\n');
912
+ for (const t of themes) {
913
+ const tag = t.maintained ? ' (maintained)' : '';
914
+ humanLog(` ${t.slug}${tag}`);
915
+ if (t.description) humanLog(` ${t.description}`);
916
+ }
917
+ humanLog('\nUsage:');
918
+ humanLog(' astryx theme add <slug> [target-path] Scaffold a theme file you own\n');
919
+ });
920
+
921
+ theme
922
+ .command('add [slug] [path]')
923
+ .description('Scaffold a theme into your project as editable source')
924
+ .option('-f, --overwrite', 'Overwrite existing files without prompting')
925
+ .option('--list', 'List available themes')
926
+ .action(async (slug, targetPath, options) => {
927
+ const json = program.opts().json || false;
928
+
929
+ // Only prompt with a real TTY on stdin — a piped/redirected stdin would
930
+ // make clack hang. Non-interactive callers fall through to the API's
931
+ // ERR_FILE_EXISTS guard.
932
+ const interactive =
933
+ !json && !isNonInteractive({json}) && Boolean(process.stdin.isTTY);
934
+ if (slug && !options.list && !options.overwrite && interactive) {
935
+ const collision = await detectThemeCollision(slug, targetPath);
936
+ if (collision) {
937
+ const rel = path.relative(process.cwd(), collision) || collision;
938
+ const p = await import('@clack/prompts');
939
+ const confirmed = await p.confirm({
940
+ message: `Overwrite existing file ${rel}?`,
941
+ initialValue: false,
942
+ });
943
+ if (p.isCancel(confirmed)) {
944
+ p.cancel('Cancelled.');
945
+ return;
946
+ }
947
+ if (!confirmed) {
948
+ humanLog('Aborted. Re-run with --overwrite to replace the file.');
949
+ return;
950
+ }
951
+ options.overwrite = true;
952
+ }
953
+ }
954
+
955
+ let result;
956
+ try {
957
+ result = await themeAdd(slug, {
958
+ list: options.list,
959
+ targetPath,
960
+ overwrite: options.overwrite,
961
+ cwd: process.cwd(),
962
+ });
963
+ } catch (e) {
964
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
965
+ return;
966
+ }
967
+
968
+ if (json) return jsonOut(result.type, result.data);
969
+
970
+ if (result.type === 'theme.list') {
971
+ const themes = result.data;
972
+ humanLog('\nThemes:\n');
973
+ for (const t of themes) {
974
+ const tag = t.maintained ? ' (maintained)' : '';
975
+ humanLog(` ${t.slug}${tag}`);
976
+ if (t.description) humanLog(` ${t.description}`);
977
+ }
978
+ humanLog('\nUsage:');
979
+ humanLog(' astryx theme add <slug> [target-path] Scaffold a theme file you own\n');
980
+ return;
981
+ }
982
+
983
+ // theme.add — print where files landed + how to use the theme.
984
+ const {displayName, outputDir, entry, exportName, files} = result.data;
985
+ humanLog(`\n✓ Added ${displayName} theme to ${outputDir}/`);
986
+ for (const f of files) {
987
+ humanLog(` ${outputDir}/${f}`);
988
+ }
989
+ const entryModule = importSpecifier(
990
+ outputDir,
991
+ entry.replace(/\.tsx?$/, ''),
992
+ );
993
+ humanLog(`
994
+ Use it in your app (import path is relative to a file in src/ — adjust if yours lives elsewhere):
995
+
996
+ import { ${exportName} } from '${entryModule}';
997
+
998
+ <Theme theme={${exportName}}>
999
+ <App />
1000
+ </Theme>
1001
+
1002
+ This is your copy of the ${displayName} theme — edit ${entry} to make it your own.
1003
+ `);
1004
+ });
1005
+ }
1006
+
1007
+ /**
1008
+ * First existing file that scaffolding <slug> into <targetPath> would clobber,
1009
+ * or null. Used to prompt before invoking the API; the API re-validates and
1010
+ * owns any authoritative error.
1011
+ *
1012
+ * @param {string} slug
1013
+ * @param {string} [targetPath]
1014
+ * @returns {Promise<string|null>}
1015
+ */
1016
+ async function detectThemeCollision(slug, targetPath) {
1017
+ let themes;
1018
+ try {
1019
+ themes = listThemes();
1020
+ } catch {
1021
+ return null;
1022
+ }
1023
+ const match = themes.find(t => t.slug.toLowerCase() === slug.toLowerCase());
1024
+ if (!match) return null;
1025
+
1026
+ const rawTarget = targetPath || path.join('src', 'themes', match.slug);
1027
+ let resolvedDir;
1028
+ try {
1029
+ // Fail soft (null) on traversal; the API surfaces the real error.
1030
+ const {assertWithin} = await import('../utils/path-safety.mjs');
1031
+ resolvedDir = assertWithin(rawTarget, process.cwd(), {
1032
+ label: 'theme target path',
1033
+ });
1034
+ } catch {
1035
+ return null;
1036
+ }
1037
+
1038
+ for (const name of match.files) {
1039
+ const dest = path.join(resolvedDir, name);
1040
+ if (fs.existsSync(dest)) return dest;
1041
+ }
1042
+ return null;
874
1043
  }
@@ -132,7 +132,7 @@ export function registerGapReport(program) {
132
132
  .action(async () => {
133
133
  p.intro('Gap report setup');
134
134
 
135
- const config = loadGapReportConfig();
135
+ const config = await loadGapReportConfig();
136
136
  const currentMode = !config.enabled
137
137
  ? 'disabled'
138
138
  : config.command
@@ -252,9 +252,14 @@ export function registerGapReport(program) {
252
252
  return;
253
253
  }
254
254
 
255
- const config = loadGapReportConfig();
255
+ const config = await loadGapReportConfig();
256
256
  if (!config.enabled) {
257
- if (json) return jsonError('Gap reporting is disabled', undefined, ERROR_CODES.ERR_GAP_REPORT_FAILED);
257
+ if (json)
258
+ return jsonError(
259
+ 'Gap reporting is disabled',
260
+ undefined,
261
+ ERROR_CODES.ERR_GAP_REPORT_FAILED,
262
+ );
258
263
  humanLog(
259
264
  `Gap reporting is disabled (ASTRYX_GAP_REPORT=off or astryx.config.mjs).\n` +
260
265
  `Run \`${getRunPrefix()} astryx gap-report setup\` to configure.`,
@@ -295,7 +300,7 @@ export function registerGapReport(program) {
295
300
  return;
296
301
  }
297
302
 
298
- const preview = buildGapReportPreview({
303
+ const preview = await buildGapReportPreview({
299
304
  component: options.component,
300
305
  category: options.category,
301
306
  intention: options.reason,
@@ -326,7 +331,7 @@ export function registerGapReport(program) {
326
331
  }
327
332
 
328
333
  try {
329
- const url = createGapReport({
334
+ const url = await createGapReport({
330
335
  component: options.component,
331
336
  category: options.category,
332
337
  intention: options.reason,
@@ -343,7 +348,9 @@ export function registerGapReport(program) {
343
348
  humanLog('\nGap reporting is disabled via configuration.\n');
344
349
  }
345
350
  } catch (err) {
346
- cliError(`Filing gap report failed: ${err.message}`, {code: ERROR_CODES.ERR_GAP_REPORT_FAILED});
351
+ cliError(`Filing gap report failed: ${err.message}`, {
352
+ code: ERROR_CODES.ERR_GAP_REPORT_FAILED,
353
+ });
347
354
  return;
348
355
  }
349
356
  return;
@@ -386,7 +393,8 @@ export function registerGapReport(program) {
386
393
  placeholder:
387
394
  'e.g. "Need a compact variant for use in dense data tables"',
388
395
  validate: val => {
389
- if (!val.trim()) return 'Please describe what you were trying to do';
396
+ if (!val.trim())
397
+ return 'Please describe what you were trying to do';
390
398
  },
391
399
  }),
392
400
  );
@@ -406,7 +414,7 @@ export function registerGapReport(program) {
406
414
  source: 'interactive',
407
415
  };
408
416
 
409
- const preview = buildGapReportPreview(previewArgs);
417
+ const preview = await buildGapReportPreview(previewArgs);
410
418
 
411
419
  // Always show the user exactly what would be filed before sending.
412
420
  p.note(
@@ -440,7 +448,7 @@ export function registerGapReport(program) {
440
448
  s.start('Filing gap report');
441
449
 
442
450
  try {
443
- const url = createGapReport(previewArgs);
451
+ const url = await createGapReport(previewArgs);
444
452
  s.stop(url ? 'Gap report filed' : 'Gap reporting is disabled');
445
453
  if (url) {
446
454
  p.log.success(url);
@@ -40,9 +40,9 @@ describe('shouldActuallyFile', () => {
40
40
  });
41
41
 
42
42
  it('--dry-run wins over --commit (safety)', () => {
43
- expect(
44
- shouldActuallyFile({isTTY: true, commit: true, dryRun: true}),
45
- ).toBe(false);
43
+ expect(shouldActuallyFile({isTTY: true, commit: true, dryRun: true})).toBe(
44
+ false,
45
+ );
46
46
  });
47
47
  });
48
48
 
@@ -54,7 +54,9 @@ describe('formatPreview', () => {
54
54
  title: '[gap] Button: missing compact variant',
55
55
  body: '## User Intention\n\nNeed a compact variant',
56
56
  });
57
- expect(out).toContain('Would file GitHub issue on facebookexperimental/xds');
57
+ expect(out).toContain(
58
+ 'Would file GitHub issue on facebookexperimental/xds',
59
+ );
58
60
  expect(out).toContain('[gap] Button');
59
61
  expect(out).toContain('Need a compact variant');
60
62
  expect(out).toContain('Labels: gap-report');
@@ -91,13 +93,14 @@ describe('ASTRYX_GAP_REPORT=off env var', () => {
91
93
  it('disables gap reporting when set to "off"', async () => {
92
94
  process.env.ASTRYX_GAP_REPORT = 'off';
93
95
  vi.resetModules();
94
- const {loadGapReportConfig, createGapReport} = await import(
95
- '../utils/github.mjs'
96
- );
97
- expect(loadGapReportConfig().enabled).toBe(false);
96
+ const {loadGapReportConfig, createGapReport} =
97
+ await import('../utils/github.mjs');
98
+ await expect(loadGapReportConfig()).resolves.toMatchObject({
99
+ enabled: false,
100
+ });
98
101
 
99
102
  // createGapReport must return null and never invoke gh.
100
- const result = createGapReport({
103
+ const result = await createGapReport({
101
104
  component: 'Button',
102
105
  category: 'other',
103
106
  intention: 'test',
@@ -109,12 +112,16 @@ describe('ASTRYX_GAP_REPORT=off env var', () => {
109
112
  process.env.ASTRYX_GAP_REPORT = 'false';
110
113
  vi.resetModules();
111
114
  let mod = await import('../utils/github.mjs');
112
- expect(mod.loadGapReportConfig().enabled).toBe(false);
115
+ await expect(mod.loadGapReportConfig()).resolves.toMatchObject({
116
+ enabled: false,
117
+ });
113
118
 
114
119
  process.env.ASTRYX_GAP_REPORT = '0';
115
120
  vi.resetModules();
116
121
  mod = await import('../utils/github.mjs');
117
- expect(mod.loadGapReportConfig().enabled).toBe(false);
122
+ await expect(mod.loadGapReportConfig()).resolves.toMatchObject({
123
+ enabled: false,
124
+ });
118
125
  });
119
126
  });
120
127
 
@@ -133,16 +140,14 @@ describe('buildGapReportPreview', () => {
133
140
  it('renders title and body without invoking gh', async () => {
134
141
  vi.resetModules();
135
142
  const {buildGapReportPreview} = await import('../utils/github.mjs');
136
- const preview = buildGapReportPreview({
143
+ const preview = await buildGapReportPreview({
137
144
  component: 'Button',
138
145
  category: 'missing_variant',
139
146
  intention: 'Need compact variant for tables',
140
147
  });
141
148
  expect(preview.mode).toBe('github');
142
149
  expect(preview.enabled).toBe(true);
143
- expect(preview.title).toBe(
144
- '[gap] Button: Need compact variant for tables',
145
- );
150
+ expect(preview.title).toBe('[gap] Button: Need compact variant for tables');
146
151
  expect(preview.body).toContain('| **Component** | Button |');
147
152
  expect(preview.body).toContain('Need compact variant for tables');
148
153
  expect(preview.repo).toBe('facebookexperimental/xds');
@@ -152,7 +157,7 @@ describe('buildGapReportPreview', () => {
152
157
  process.env.ASTRYX_GAP_REPORT = 'off';
153
158
  vi.resetModules();
154
159
  const {buildGapReportPreview} = await import('../utils/github.mjs');
155
- const preview = buildGapReportPreview({
160
+ const preview = await buildGapReportPreview({
156
161
  component: 'Button',
157
162
  category: 'other',
158
163
  intention: 'x',
@@ -29,6 +29,37 @@ import {requireInteractive} from '../utils/interactive.mjs';
29
29
  const VALID_FEATURES = ['agents', 'theme', 'template'];
30
30
  const run = getRunPrefix();
31
31
 
32
+ /**
33
+ * Build the "Next steps" lines printed at the end of `astryx init`.
34
+ *
35
+ * Theme guidance must match the runtime recommendation emitted by core's
36
+ * <Theme> component (packages/core/src/theme/Theme.tsx): the pre-built theme
37
+ * path (`/built` import + `theme.css`) plus the base CSS import, so users
38
+ * don't end up with an unstyled app or the slower runtime style-injection
39
+ * path. See https://github.com/facebook/astryx/issues/3080.
40
+ *
41
+ * Exported for testing.
42
+ *
43
+ * @param {string} runPrefix package-manager run prefix (e.g. `npx`)
44
+ * @returns {string[]} ordered list of human-facing lines
45
+ */
46
+ export function getNextSteps(runPrefix) {
47
+ return [
48
+ '',
49
+ ' Next steps:',
50
+ " 1. Import base styles: import '@astryxdesign/core/reset.css'",
51
+ " and import '@astryxdesign/core/astryx.css'",
52
+ " 2. Import components: import { Button } from '@astryxdesign/core'",
53
+ ' 3. Optionally add a theme (use the pre-built path for performance):',
54
+ " import { neutralTheme } from '@astryxdesign/theme-neutral/built'",
55
+ " import '@astryxdesign/theme-neutral/theme.css'",
56
+ ' <Theme theme={neutralTheme}>...</Theme>',
57
+ ` For custom themes, run \`${runPrefix} astryx theme build <file>\` to generate the built artifacts.`,
58
+ ` 4. ${runPrefix} astryx --help for all commands`,
59
+ '',
60
+ ];
61
+ }
62
+
32
63
  function isCancel(value) {
33
64
  if (p.isCancel(value)) {
34
65
  p.cancel('Setup cancelled.');
@@ -250,13 +281,8 @@ export function registerInit(program) {
250
281
  // Outro
251
282
  p.outro('Design system initialized!');
252
283
 
253
- humanLog('');
254
- humanLog(' Next steps:');
255
- humanLog(" 1. Import components: import { Button } from '@astryxdesign/core'");
256
- humanLog(' 2. Optionally add a theme:');
257
- humanLog(" import { neutralTheme } from '@astryxdesign/theme-neutral'");
258
- humanLog(' <Theme theme={neutralTheme}>...</Theme>');
259
- humanLog(` 3. ${run} astryx --help for all commands`);
260
- humanLog('');
284
+ for (const line of getNextSteps(run)) {
285
+ humanLog(line);
286
+ }
261
287
  });
262
288
  }
@@ -0,0 +1,46 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Regression test for `astryx init` "Next steps" theme guidance.
5
+ *
6
+ * The init command previously steered users toward the slower runtime
7
+ * style-injection path:
8
+ *
9
+ * import { neutralTheme } from '@astryxdesign/theme-neutral'
10
+ * <Theme theme={neutralTheme}>...</Theme>
11
+ *
12
+ * ...which contradicted the runtime console warning emitted by core's
13
+ * <Theme> component (packages/core/src/theme/Theme.tsx) recommending the
14
+ * pre-built path, and left users with an unstyled app because the base CSS
15
+ * imports were never mentioned (facebook/astryx#3080).
16
+ *
17
+ * These assertions lock in the corrected guidance: base CSS imports, the
18
+ * pre-built (`/built` + `theme.css`) theme path, and the custom-theme build
19
+ * command.
20
+ */
21
+
22
+ import {describe, it, expect} from 'vitest';
23
+ import {getNextSteps} from './init.mjs';
24
+
25
+ describe('init Next steps theme guidance', () => {
26
+ const text = getNextSteps('npx').join('\n');
27
+
28
+ it('mentions the base CSS imports so the app is not left unstyled', () => {
29
+ expect(text).toContain("'@astryxdesign/core/reset.css'");
30
+ expect(text).toContain("'@astryxdesign/core/astryx.css'");
31
+ });
32
+
33
+ it('uses the pre-built theme path matching the runtime recommendation', () => {
34
+ expect(text).toContain("'@astryxdesign/theme-neutral/built'");
35
+ expect(text).toContain("'@astryxdesign/theme-neutral/theme.css'");
36
+ });
37
+
38
+ it('mentions building custom themes via `astryx theme build`', () => {
39
+ expect(text).toContain('astryx theme build <file>');
40
+ });
41
+
42
+ it('does not steer users to the runtime style-injection import', () => {
43
+ // The bare source import (no `/built`) is the slow runtime-injection path.
44
+ expect(text).not.toContain("from '@astryxdesign/theme-neutral'");
45
+ });
46
+ });
@@ -0,0 +1,139 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file layout command — thin CLI wrapper around api/layout.mjs.
5
+ *
6
+ * Subcommands:
7
+ * astryx layout expand "<expr>" [path] compressed expression → validated TSX
8
+ * astryx layout check "<expr>" validate + echo both canonical surfaces
9
+ * astryx layout grammar agent cheatsheet (alias table is branch-generated)
10
+ *
11
+ * The expression argument may also come from --file or stdin (`-`),
12
+ * which is how multi-line outline (XLO) input usually arrives.
13
+ */
14
+
15
+ import * as fs from 'node:fs';
16
+ import {jsonOut, humanLog} from '../lib/json.mjs';
17
+ import {cliError} from '../lib/cli-error.mjs';
18
+ import {layoutExpand, layoutCheck, layoutGrammar} from '../api/layout.mjs';
19
+
20
+ /** Resolve the expression from arg, --file, or stdin ('-'). */
21
+ async function readExpression(expr, options) {
22
+ if (options.file) return fs.readFileSync(options.file, 'utf-8');
23
+ if (expr === '-' || (!expr && !process.stdin.isTTY)) {
24
+ const chunks = [];
25
+ for await (const chunk of process.stdin) chunks.push(chunk);
26
+ return Buffer.concat(chunks).toString('utf-8');
27
+ }
28
+ return expr;
29
+ }
30
+
31
+ export function registerLayout(program) {
32
+ const layoutCmd = program
33
+ .command('layout')
34
+ .description('Generate XDS layouts from compressed expressions (XLE/XLO)');
35
+
36
+ layoutCmd
37
+ .command('expand [expression] [path]')
38
+ .description('Expand a layout expression into validated XDS TSX')
39
+ .option('--file <file>', 'Read the expression from a file')
40
+ .option('--form <form>', 'Input surface: compact, outline, or auto', 'auto')
41
+ .option('--name <name>', 'Generated component name (PascalCase)', 'GeneratedLayout')
42
+ .option('--loose', 'Downgrade unknown {block} hints to TODO placeholders')
43
+ .action(async (expression, targetPath, options) => {
44
+ const json = program.opts().json || false;
45
+ const source = await readExpression(expression, options);
46
+ if (!source || source.trim() === '') {
47
+ cliError('No layout expression given — pass it as an argument, via --file, or on stdin');
48
+ return;
49
+ }
50
+ let result;
51
+ try {
52
+ result = await layoutExpand(source, {
53
+ targetPath,
54
+ form: options.form,
55
+ loose: options.loose || false,
56
+ name: options.name,
57
+ cwd: process.cwd(),
58
+ });
59
+ } catch (e) {
60
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
61
+ return;
62
+ }
63
+ if (json) return jsonOut(result.type, result.data);
64
+
65
+ for (const warning of result.data.warnings) humanLog(`⚠ ${warning}`);
66
+ if (result.data.written) {
67
+ humanLog(`\n✓ Expanded to ${result.data.written}`);
68
+ humanLog(` Components: ${result.data.componentsUsed.join(', ')}`);
69
+ if (result.data.todos.length > 0) {
70
+ humanLog(` TODOs: ${result.data.todos.length} (search for "TODO(xle)")`);
71
+ }
72
+ humanLog('');
73
+ } else {
74
+ humanLog(result.data.code);
75
+ }
76
+ });
77
+
78
+ layoutCmd
79
+ .command('check [expression]')
80
+ .description('Validate a layout expression and echo canonical compact/outline forms')
81
+ .option('--file <file>', 'Read the expression from a file')
82
+ .option('--form <form>', 'Input surface: compact, outline, or auto', 'auto')
83
+ .option('--loose', 'Downgrade unknown {block} hints to TODO placeholders')
84
+ .action(async (expression, options) => {
85
+ const json = program.opts().json || false;
86
+ const source = await readExpression(expression, options);
87
+ if (!source || source.trim() === '') {
88
+ cliError('No layout expression given — pass it as an argument, via --file, or on stdin');
89
+ return;
90
+ }
91
+ let result;
92
+ try {
93
+ result = await layoutCheck(source, {
94
+ form: options.form,
95
+ loose: options.loose || false,
96
+ cwd: process.cwd(),
97
+ });
98
+ } catch (e) {
99
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
100
+ return;
101
+ }
102
+ if (json) return jsonOut(result.type, result.data);
103
+
104
+ const {valid, form, errors, warnings, compact, outline} = result.data;
105
+ if (!valid) {
106
+ humanLog(`\n✗ Invalid (${errors.length} error${errors.length === 1 ? '' : 's'}):`);
107
+ for (const e of errors) {
108
+ humanLog(` - ${e.formatted}`);
109
+ if (e.suggestions?.length > 0) humanLog(` did you mean: ${e.suggestions.join(', ')}?`);
110
+ }
111
+ humanLog('');
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ humanLog(`\n✓ Valid (parsed as ${form})`);
116
+ for (const warning of warnings) humanLog(`⚠ ${warning}`);
117
+ humanLog('\ncompact:');
118
+ humanLog(` ${compact}`);
119
+ humanLog('\noutline:');
120
+ humanLog(outline.split('\n').map(l => ` ${l}`).join('\n'));
121
+ humanLog('');
122
+ });
123
+
124
+ layoutCmd
125
+ .command('grammar')
126
+ .description('Print the XLE/XLO cheatsheet (alias table generated from this branch)')
127
+ .action(async () => {
128
+ const json = program.opts().json || false;
129
+ let result;
130
+ try {
131
+ result = await layoutGrammar({cwd: process.cwd()});
132
+ } catch (e) {
133
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
134
+ return;
135
+ }
136
+ if (json) return jsonOut(result.type, result.data);
137
+ humanLog(result.data.text);
138
+ });
139
+ }