@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.
- package/CHANGELOG.md +50 -0
- package/docs/theme.doc.mjs +2 -2
- package/docs/working-with-ai.doc.mjs +6 -6
- package/package.json +19 -8
- package/src/api/discover.mjs +78 -26
- package/src/api/index.mjs +1 -0
- package/src/api/layout.mjs +301 -0
- package/src/api/layout.test.mjs +238 -0
- package/src/api/template.mjs +191 -50
- package/src/api/template.test.mjs +2 -0
- package/src/api/theme-add.mjs +182 -0
- package/src/codemods/__tests__/registry.test.mjs +1 -0
- package/src/codemods/registry.mjs +1 -0
- package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
- package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
- package/src/commands/build-theme.import-path.test.mjs +22 -2
- package/src/commands/build-theme.mjs +185 -16
- package/src/commands/gap-report.mjs +17 -9
- package/src/commands/gap-report.test.mjs +21 -16
- package/src/commands/init.mjs +34 -8
- package/src/commands/init.next-steps.test.mjs +46 -0
- package/src/commands/layout.mjs +139 -0
- package/src/commands/swizzle.mjs +51 -23
- package/src/commands/upgrade.mjs +2 -71
- package/src/config.mjs +31 -0
- package/src/config.test.mjs +24 -0
- package/src/index.mjs +6 -0
- package/src/lib/config-schema.mjs +119 -0
- package/src/lib/config.mjs +34 -7
- package/src/lib/config.test.mjs +51 -2
- package/src/lib/error-codes.mjs +11 -0
- package/src/lib/integrations.mjs +155 -0
- package/src/lib/integrations.test.mjs +154 -0
- package/src/lib/levenshtein.mjs +29 -0
- package/src/lib/manifest.mjs +10 -0
- package/src/lib/package-scanner.mjs +31 -7
- package/src/lib/string-utils.mjs +5 -14
- package/src/lib/xle/browser.d.ts +100 -0
- package/src/lib/xle/browser.mjs +120 -0
- package/src/lib/xle/expand.mjs +622 -0
- package/src/lib/xle/parse.mjs +581 -0
- package/src/lib/xle/print.mjs +174 -0
- package/src/lib/xle/registry-core.mjs +170 -0
- package/src/lib/xle/registry.mjs +237 -0
- package/src/lib/xle/splice.mjs +137 -0
- package/src/lib/xle/validate.mjs +356 -0
- package/src/lib/xle/xle.test.mjs +333 -0
- package/src/types/config.d.ts +99 -0
- package/src/utils/github.mjs +12 -27
- package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
- package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
- package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
- package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
- package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
- package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
- package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
- package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
- package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
- package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
- package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
- package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
- package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
- package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
- package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
- package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
- package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
- package/templates/blocks/components/Text/TextColors.tsx +20 -5
- package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
- package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
- package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
- package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
- package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
- package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
- package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
- package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
- package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
- package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
- package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
- package/templates/pages/shell-nav/page.tsx +321 -0
- package/templates/pages/shell-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-side-nav/page.tsx +242 -0
- package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-top-nav/page.tsx +224 -0
- package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
- package/templates/pages/theme-showcase/page.tsx +8 -19
- package/templates/themes/butter/butterTheme.ts +916 -0
- package/templates/themes/butter/icons.tsx +77 -0
- package/templates/themes/chocolate/chocolateTheme.ts +230 -0
- package/templates/themes/chocolate/icons.tsx +77 -0
- package/templates/themes/gothic/gothicTheme.ts +657 -0
- package/templates/themes/gothic/icons.tsx +77 -0
- package/templates/themes/manifest.json +90 -0
- package/templates/themes/matcha/icons.tsx +67 -0
- package/templates/themes/matcha/matchaTheme.ts +247 -0
- package/templates/themes/neutral/icons.tsx +77 -0
- package/templates/themes/neutral/neutralTheme.ts +603 -0
- package/templates/themes/stone/icons.tsx +77 -0
- package/templates/themes/stone/stoneTheme.ts +652 -0
- package/templates/themes/y2k/icons.tsx +67 -0
- package/templates/themes/y2k/y2kTheme.ts +617 -0
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
- 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 {
|
|
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
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
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 '${
|
|
848
|
-
import '${
|
|
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 '${
|
|
872
|
+
import { ${exportName} } from '${jsImport}';
|
|
857
873
|
|
|
858
|
-
<link rel="stylesheet" href="${
|
|
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)
|
|
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}`, {
|
|
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())
|
|
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
|
-
|
|
45
|
-
)
|
|
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(
|
|
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} =
|
|
95
|
-
'../utils/github.mjs'
|
|
96
|
-
)
|
|
97
|
-
|
|
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()
|
|
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()
|
|
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',
|
package/src/commands/init.mjs
CHANGED
|
@@ -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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
}
|