@astryxdesign/cli 0.4.7 → 0.5.0-canary.009bcb3
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 +64 -0
- package/README.md +53 -51
- package/api/docs/docs.doc.mjs +2 -2
- package/api/index.d.mts +1 -1
- package/api/index.mjs +1 -1
- package/api/search/search.mjs +50 -4
- package/api/search/search.test.mjs +71 -0
- package/api/template/data-token-fallbacks.test.mjs +85 -0
- package/api/template/template.doc.mjs +2 -2
- package/api/theme/build/build.mjs +43 -44
- package/api/theme/targets/targets.d.mts +18 -0
- package/api/theme/targets/targets.mjs +87 -0
- package/api/theme/targets/targets.test.mjs +66 -0
- package/api/theme/theme.d.mts +1 -0
- package/api/theme/theme.mjs +3 -1
- package/api/theme/theme.type.d.mts +23 -0
- package/api/theme/theme.type.mjs +21 -1
- package/api/theme/themeTargets.doc.d.mts +11 -0
- package/api/theme/themeTargets.doc.mjs +58 -0
- package/api/theme/themeTemplate.doc.mjs +3 -3
- package/assets/codemods/__tests__/registry.test.mjs +1 -0
- package/assets/codemods/registry.mjs +1 -0
- package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
- package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
- package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
- package/assets/docs/README.md +50 -0
- package/assets/docs/cli-integrations.doc.mjs +4 -4
- package/assets/docs/theme.doc.dense.mjs +1 -1
- package/assets/docs/theme.doc.mjs +1 -1
- package/assets/docs/typography.doc.mjs +2 -2
- package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
- package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
- package/assets/templates/blocks/components/DateInput/DateInputClearable.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +6 -1
- package/assets/templates/blocks/components/DateInput/DateInputFormats.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputShowcase.tsx +4 -1
- package/assets/templates/blocks/components/DateInput/DateInputWithDescription.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputWithValidation.tsx +5 -1
- package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.doc.mjs +25 -0
- package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.tsx +167 -0
- package/assets/templates/blocks/components/Dialog/DialogScrollingContent.tsx +1 -1
- package/assets/templates/blocks/components/HoverCard/HoverCardHookUsage.tsx +4 -1
- package/assets/templates/blocks/components/Step/StepContent.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepContent.tsx +32 -0
- package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepIndicator.tsx +60 -0
- package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/Step/StepShowcase.tsx +26 -0
- package/assets/templates/blocks/components/Step/StepStates.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepStates.tsx +46 -0
- package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +22 -0
- package/assets/templates/blocks/components/Stepper/StepperCustomContent.tsx +126 -0
- package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.doc.mjs +1 -1
- package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.tsx +17 -5
- package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.doc.mjs +14 -0
- package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.tsx +25 -0
- package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.doc.mjs +2 -2
- package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.tsx +1 -1
- package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/Stepper/StepperShowcase.tsx +6 -7
- package/assets/templates/blocks/components/Stepper/StepperStatus.tsx +1 -1
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +1 -1
- package/assets/templates/pages/dashboard-data/page.tsx +1 -1
- package/assets/templates/pages/dashboard-portfolio/page.tsx +5 -5
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1 -1
- package/assets/templates/pages/mixed-gallery/page.tsx +12 -3
- package/assets/templates/pages/settings-dialog/page.tsx +2543 -757
- package/assets/templates/pages/settings-dialog/template.doc.mjs +1 -2
- package/assets/templates/pages/table-filter/page.tsx +4093 -0
- package/assets/templates/pages/table-filter/template.doc.mjs +12 -0
- package/assets/templates/pages/table-grouped/page.tsx +151 -144
- package/assets/templates/pages/theme-showcase/page.tsx +36 -17
- package/assets/templates/themes/chocolate/chocolateTheme.ts +3 -1
- package/assets/templates/themes/matcha/matchaTheme.ts +3 -1
- package/assets/templates/themes/neutral/neutralTheme.ts +16 -9
- package/assets/templates/themes/stone/stoneTheme.ts +3 -1
- package/clients/cli/commands/build-theme.color-scheme.test.mjs +7 -6
- package/clients/cli/commands/build-theme.data-tokens.test.mjs +144 -0
- package/clients/cli/commands/build-theme.mjs +85 -0
- package/clients/cli/commands/build-theme.variants.test.mjs +3 -0
- package/clients/cli/commands/dialog-adaptive-template.test.mjs +24 -0
- package/clients/cli/commands/theme-targets.behavior.test.mjs +64 -0
- package/clients/cli/commands/theme-targets.doc.mjs +38 -0
- package/clients/cli/commands/theme-template.doc.mjs +2 -2
- package/clients/cli/commands/theme.doc.mjs +4 -2
- package/clients/cli/index.mjs +1 -0
- package/clients/cli/lib/component-format.mjs +2 -2
- package/clients/cli/lib/manifest.mjs +2 -0
- package/foundation/discovery/component-discovery.mjs +10 -9
- package/foundation/discovery/hook-discovery.mjs +2 -1
- package/foundation/discovery/hook-discovery.test.mjs +156 -37
- package/foundation/discovery/theming-targets.d.mts +86 -0
- package/foundation/discovery/theming-targets.mjs +202 -0
- package/foundation/discovery/theming-targets.test.mjs +245 -0
- package/foundation/fs/paths.d.mts +16 -0
- package/foundation/fs/paths.mjs +36 -0
- package/foundation/fs/paths.test.mjs +29 -1
- package/foundation/response/response-types.doc.mjs +7 -2
- package/package.json +9 -9
- package/assets/templates/blocks/components/Stepper/StepperHorizontal.doc.mjs +0 -14
- package/assets/templates/blocks/components/Stepper/StepperHorizontal.tsx +0 -24
- package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.doc.mjs +0 -14
- package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.tsx +0 -92
- package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.doc.mjs +0 -14
- package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.tsx +0 -40
|
@@ -43,6 +43,10 @@ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
|
|
|
43
43
|
import {AstryxError} from '../../error.mjs';
|
|
44
44
|
import {logger} from '../../logger.mjs';
|
|
45
45
|
import {loadComponentDoc} from '../../../foundation/discovery/component-loader.mjs';
|
|
46
|
+
import {
|
|
47
|
+
collectThemingTargets,
|
|
48
|
+
targetsByKey,
|
|
49
|
+
} from '../../../foundation/discovery/theming-targets.mjs';
|
|
46
50
|
import {
|
|
47
51
|
collectUnloadedFonts,
|
|
48
52
|
formatFontLoadingHelp,
|
|
@@ -58,12 +62,14 @@ import {
|
|
|
58
62
|
/** @type {any} */ let _defineTheme = null;
|
|
59
63
|
/** @type {any} */ let _generateThemeRulesSplit = null;
|
|
60
64
|
/** @type {any} */ let _generateOnMediaCSS = null;
|
|
65
|
+
/** @type {any} */ let _dataTokenDefaults = null;
|
|
61
66
|
/** @type {any} */ let _coreImportError = null;
|
|
62
67
|
try {
|
|
63
68
|
const coreTheme = await import('@astryxdesign/core/theme');
|
|
64
69
|
_defineTheme = coreTheme.defineTheme;
|
|
65
70
|
_generateThemeRulesSplit = coreTheme.generateThemeRulesSplit;
|
|
66
71
|
_generateOnMediaCSS = coreTheme.generateOnMediaCSS;
|
|
72
|
+
_dataTokenDefaults = coreTheme.dataTokenDefaults;
|
|
67
73
|
} catch (e) {
|
|
68
74
|
// Capture the reason so the theme action can surface a precise, actionable
|
|
69
75
|
// error. We don't throw here: this module is imported eagerly by the CLI
|
|
@@ -345,8 +351,8 @@ const _augmentationTargetCache = new Map();
|
|
|
345
351
|
* Resolve a rendered theme class token (the key without `astryx-`) to candidate
|
|
346
352
|
* public core subpaths and interface prefixes that may own its augmentable prop
|
|
347
353
|
* maps. Some tokens are subtargets documented by a parent component
|
|
348
|
-
* (`avatar-status-dot` augments `@astryxdesign/core/Avatar`), and some
|
|
349
|
-
*
|
|
354
|
+
* (`avatar-status-dot` augments `@astryxdesign/core/Avatar`), and some
|
|
355
|
+
* deprecated tokens still omit word separators (`progressbar`, `statusdot`)
|
|
350
356
|
* while the public API keeps `ProgressBar`/`StatusDot` casing. Component docs
|
|
351
357
|
* are the source of truth for the target token → owning component relationship.
|
|
352
358
|
*
|
|
@@ -424,8 +430,8 @@ async function resolveAugmentationTargetCandidates(componentName) {
|
|
|
424
430
|
|
|
425
431
|
// Try the exact rendered token first for documented subtargets such as
|
|
426
432
|
// avatar-status-dot → AvatarStatusDotVariantMap, then the owning public
|
|
427
|
-
// component name for unhyphenated
|
|
428
|
-
// ProgressBarVariantMap/statusdot → StatusDotVariantMap.
|
|
433
|
+
// component name for the deprecated unhyphenated tokens such as
|
|
434
|
+
// progressbar → ProgressBarVariantMap/statusdot → StatusDotVariantMap.
|
|
429
435
|
addCandidate(moduleName, toPascalCase(componentName));
|
|
430
436
|
addCandidate(moduleName, moduleName);
|
|
431
437
|
if (Array.isArray(doc?.components)) {
|
|
@@ -882,6 +888,9 @@ ${iconType}export declare const ${toIdentifier(themeDef.name)}Theme: DefinedThem
|
|
|
882
888
|
* Returns null when docs are unavailable so validation can skip unknown-key
|
|
883
889
|
* warnings rather than guessing from a second registry.
|
|
884
890
|
*
|
|
891
|
+
* Shares its enumeration with `theme targets`, so what a theme author can list
|
|
892
|
+
* is exactly what this validator accepts.
|
|
893
|
+
*
|
|
885
894
|
* @returns {Promise<Record<string, string[]> | null>}
|
|
886
895
|
*/
|
|
887
896
|
async function loadKnownComponents() {
|
|
@@ -889,44 +898,7 @@ async function loadKnownComponents() {
|
|
|
889
898
|
const coreSrc = coreRoot ? path.join(coreRoot, 'src') : null;
|
|
890
899
|
if (!coreSrc || !fs.existsSync(coreSrc)) return null;
|
|
891
900
|
|
|
892
|
-
|
|
893
|
-
const targets = {};
|
|
894
|
-
|
|
895
|
-
/** @param {string} dir */
|
|
896
|
-
async function scan(dir) {
|
|
897
|
-
const entries = fs.readdirSync(dir, {withFileTypes: true});
|
|
898
|
-
for (const entry of entries) {
|
|
899
|
-
const full = path.join(dir, entry.name);
|
|
900
|
-
if (entry.isDirectory()) {
|
|
901
|
-
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
|
|
902
|
-
await scan(full);
|
|
903
|
-
continue;
|
|
904
|
-
}
|
|
905
|
-
if (!entry.name.endsWith('.doc.mjs')) continue;
|
|
906
|
-
|
|
907
|
-
/** @type {any} */
|
|
908
|
-
let doc;
|
|
909
|
-
try {
|
|
910
|
-
doc = await loadComponentDoc(full);
|
|
911
|
-
} catch {
|
|
912
|
-
continue;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
for (const target of doc?.theming?.targets || []) {
|
|
916
|
-
const className = target?.className;
|
|
917
|
-
if (typeof className !== 'string') continue;
|
|
918
|
-
const key = className.replace(/^astryx-/, '');
|
|
919
|
-
if (!key) continue;
|
|
920
|
-
const props = [target.visualProps, target.states]
|
|
921
|
-
.filter(list => Array.isArray(list))
|
|
922
|
-
.flat()
|
|
923
|
-
.filter((/** @type {unknown} */ p) => typeof p === 'string');
|
|
924
|
-
targets[key] = [...new Set([...(targets[key] || []), ...props])];
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
await scan(coreSrc);
|
|
901
|
+
const targets = targetsByKey(await collectThemingTargets(coreSrc));
|
|
930
902
|
return Object.keys(targets).length > 0 ? targets : null;
|
|
931
903
|
}
|
|
932
904
|
|
|
@@ -1196,8 +1168,15 @@ export async function themeBuild(
|
|
|
1196
1168
|
if (component.length > 0) {
|
|
1197
1169
|
const componentInner = component.join('\n\n');
|
|
1198
1170
|
const componentScope = `@scope (${scopeSelector}) to (${scopeTo}) {\n${componentInner}\n}`;
|
|
1199
|
-
// #3658: also emit attribute-specific rules so <Theme mode> can override color-scheme
|
|
1200
|
-
|
|
1171
|
+
// #3658: also emit attribute-specific rules so <Theme mode> can override color-scheme.
|
|
1172
|
+
// Decided from the theme's own values, not the generated CSS: that CSS
|
|
1173
|
+
// also carries the data-token defaults, which are light-dark() pairs, so
|
|
1174
|
+
// a substring check on it would fire for every theme.
|
|
1175
|
+
const themeOwnValues = JSON.stringify([
|
|
1176
|
+
resolvedTheme.tokens ?? {},
|
|
1177
|
+
resolvedTheme.components ?? {},
|
|
1178
|
+
]);
|
|
1179
|
+
const colorSchemeDecl = themeOwnValues.includes('light-dark(')
|
|
1201
1180
|
? ' :root { color-scheme: light dark; }\n html[data-theme="light"] { color-scheme: light; }\n html[data-theme="dark"] { color-scheme: dark; }\n\n'
|
|
1202
1181
|
: '';
|
|
1203
1182
|
cssParts.push(
|
|
@@ -1215,6 +1194,26 @@ export async function themeBuild(
|
|
|
1215
1194
|
logger.log('No overrides found — nothing to build.');
|
|
1216
1195
|
return null;
|
|
1217
1196
|
}
|
|
1197
|
+
// The data-token defaults are theme-independent and go in @layer
|
|
1198
|
+
// astryx-base, below the theme's own overrides. Formatted here from the
|
|
1199
|
+
// public `dataTokenDefaults` export, byte for byte as the `<Theme>`
|
|
1200
|
+
// runtime emits it — build-theme.data-tokens.test.mjs is the drift guard.
|
|
1201
|
+
// Placed after the reset block and before the theme block: a layer's order
|
|
1202
|
+
// is fixed by where it is first declared, so emitting it anywhere else in
|
|
1203
|
+
// the file would invert reset < astryx-base < astryx-theme for a consumer
|
|
1204
|
+
// who imports this stylesheet on its own.
|
|
1205
|
+
const baseCss = _dataTokenDefaults
|
|
1206
|
+
? `:root {\n${Object.entries(_dataTokenDefaults)
|
|
1207
|
+
.map(([name, value]) => ` ${name}: ${value};`)
|
|
1208
|
+
.join('\n')}\n}`
|
|
1209
|
+
: '';
|
|
1210
|
+
if (baseCss) {
|
|
1211
|
+
cssParts.splice(
|
|
1212
|
+
prose.length > 0 ? 1 : 0,
|
|
1213
|
+
0,
|
|
1214
|
+
`@layer astryx-base {\n${baseCss}\n}`,
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1218
1217
|
css = cssParts.join('\n\n') + '\n';
|
|
1219
1218
|
}
|
|
1220
1219
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* List every component theming target — the `defineTheme` `components` keys,
|
|
6
|
+
* with the props and states each one accepts.
|
|
7
|
+
*
|
|
8
|
+
* A filter naming a component exactly wins over a substring search, so
|
|
9
|
+
* `theme targets Button` is Button's own set (what `astryx component Button`
|
|
10
|
+
* prints) rather than every key that happens to contain "button".
|
|
11
|
+
*
|
|
12
|
+
* @param {string} [filter] - component name, or a substring of a target key
|
|
13
|
+
* @param {{cwd?: string}} [ctx]
|
|
14
|
+
* @returns {Promise<import('../theme.type.mjs').ThemeTargetsResponse>}
|
|
15
|
+
*/
|
|
16
|
+
export function themeTargets(filter?: string, { cwd }?: {
|
|
17
|
+
cwd?: string;
|
|
18
|
+
}): Promise<import("../theme.type.mjs").ThemeTargetsResponse>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file `astryx theme targets` leaf — the whole themeable surface, enumerated.
|
|
5
|
+
*
|
|
6
|
+
* @input a cwd (to resolve the project's `@astryxdesign/core`) and an optional
|
|
7
|
+
* component/key filter
|
|
8
|
+
* @output the `theme.targets` envelope: one row per theming target
|
|
9
|
+
* @position api/theme/targets — projection over
|
|
10
|
+
* foundation/discovery/theming-targets.mjs, the same component docs
|
|
11
|
+
* `astryx component <Name>` prints its Theming table from.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import {findCoreDir} from '../../../foundation/fs/paths.mjs';
|
|
16
|
+
import {collectThemingTargets} from '../../../foundation/discovery/theming-targets.mjs';
|
|
17
|
+
import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
|
|
18
|
+
import {AstryxError} from '../../error.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a target matches the caller's filter loosely: any target whose key,
|
|
22
|
+
* class, or component contains it — so `theme targets thumb` finds the switch
|
|
23
|
+
* thumb without knowing which component owns it.
|
|
24
|
+
* @param {import('../../../foundation/discovery/theming-targets.mjs').ThemingTarget} target
|
|
25
|
+
* @param {string} filter - already lowercased
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function matchesLoosely(target, filter) {
|
|
29
|
+
return (
|
|
30
|
+
target.key.toLowerCase().includes(filter) ||
|
|
31
|
+
target.className.toLowerCase().includes(filter) ||
|
|
32
|
+
target.component.toLowerCase().includes(filter)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* List every component theming target — the `defineTheme` `components` keys,
|
|
38
|
+
* with the props and states each one accepts.
|
|
39
|
+
*
|
|
40
|
+
* A filter naming a component exactly wins over a substring search, so
|
|
41
|
+
* `theme targets Button` is Button's own set (what `astryx component Button`
|
|
42
|
+
* prints) rather than every key that happens to contain "button".
|
|
43
|
+
*
|
|
44
|
+
* @param {string} [filter] - component name, or a substring of a target key
|
|
45
|
+
* @param {{cwd?: string}} [ctx]
|
|
46
|
+
* @returns {Promise<import('../theme.type.mjs').ThemeTargetsResponse>}
|
|
47
|
+
*/
|
|
48
|
+
export async function themeTargets(filter, {cwd = process.cwd()} = {}) {
|
|
49
|
+
const coreDir = findCoreDir(cwd);
|
|
50
|
+
if (!coreDir) {
|
|
51
|
+
throw new AstryxError(
|
|
52
|
+
'Could not find @astryxdesign/core package',
|
|
53
|
+
undefined,
|
|
54
|
+
ERROR_CODES.ERR_CORE_NOT_FOUND,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const all = await collectThemingTargets(path.join(coreDir, 'src'));
|
|
59
|
+
const needle = filter ? String(filter).toLowerCase() : null;
|
|
60
|
+
let targets = all;
|
|
61
|
+
if (needle) {
|
|
62
|
+
const named = all.filter(t => t.component.toLowerCase() === needle);
|
|
63
|
+
targets = named.length > 0 ? named : all.filter(t => matchesLoosely(t, needle));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (needle && targets.length === 0) {
|
|
67
|
+
const near = [...new Set(all.map(t => t.component))]
|
|
68
|
+
.filter(name => name.toLowerCase().startsWith(needle.slice(0, 3)))
|
|
69
|
+
.sort()
|
|
70
|
+
.slice(0, 5)
|
|
71
|
+
.map(name => ({name, reason: 'has theming targets'}));
|
|
72
|
+
throw new AstryxError(
|
|
73
|
+
`No theming target matches "${filter}". Run \`theme targets\` with no filter for the whole list.`,
|
|
74
|
+
near.length > 0 ? near : undefined,
|
|
75
|
+
ERROR_CODES.ERR_UNKNOWN_COMPONENT,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
type: 'theme.targets',
|
|
81
|
+
data: {
|
|
82
|
+
filter: filter ?? null,
|
|
83
|
+
componentCount: new Set(targets.map(t => t.component)).size,
|
|
84
|
+
targets,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Direct-API tests for the `theme targets` leaf. Runs against the real
|
|
5
|
+
* core docs, so it doubles as a guard that the themeable surface stays
|
|
6
|
+
* readable and shaped as `theme.targets` promises.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {describe, it, expect} from 'vitest';
|
|
10
|
+
import {themeTargets} from './targets.mjs';
|
|
11
|
+
|
|
12
|
+
describe('themeTargets (api/theme/targets)', () => {
|
|
13
|
+
it('returns a theme.targets envelope covering the whole surface', async () => {
|
|
14
|
+
const result = await themeTargets();
|
|
15
|
+
expect(result.type).toBe('theme.targets');
|
|
16
|
+
expect(result.data.filter).toBeNull();
|
|
17
|
+
expect(result.data.targets.length).toBeGreaterThan(100);
|
|
18
|
+
expect(result.data.componentCount).toBeGreaterThan(50);
|
|
19
|
+
for (const t of result.data.targets) {
|
|
20
|
+
expect(Object.keys(t).sort()).toEqual([
|
|
21
|
+
'className',
|
|
22
|
+
'component',
|
|
23
|
+
'key',
|
|
24
|
+
'props',
|
|
25
|
+
'states',
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
}, 60_000);
|
|
29
|
+
|
|
30
|
+
it('scopes to one component by name', async () => {
|
|
31
|
+
const {data} = await themeTargets('Switch');
|
|
32
|
+
expect(data.filter).toBe('Switch');
|
|
33
|
+
expect(data.componentCount).toBe(1);
|
|
34
|
+
expect(data.targets.map(t => t.key)).toEqual([
|
|
35
|
+
'switch',
|
|
36
|
+
'switch-field',
|
|
37
|
+
'switch-label',
|
|
38
|
+
'switch-thumb',
|
|
39
|
+
]);
|
|
40
|
+
}, 60_000);
|
|
41
|
+
|
|
42
|
+
// Half the system's keys contain "button" (chat-send-button, toggle-button,
|
|
43
|
+
// …). A component name has to mean the component, or `theme targets Button`
|
|
44
|
+
// answers a different question than `component Button` and the two views
|
|
45
|
+
// look like they disagree.
|
|
46
|
+
it('prefers an exact component name over a substring match', async () => {
|
|
47
|
+
const {data} = await themeTargets('Button');
|
|
48
|
+
expect(data.targets.map(t => t.key)).toEqual(['button']);
|
|
49
|
+
}, 60_000);
|
|
50
|
+
|
|
51
|
+
// This command answers "which theme slot paints the switch thumb?" — a
|
|
52
|
+
// question you can only ask by the part, not the component, until you
|
|
53
|
+
// already know which component owns it.
|
|
54
|
+
it('searches keys by substring, across components', async () => {
|
|
55
|
+
const {data} = await themeTargets('thumb');
|
|
56
|
+
expect(data.componentCount).toBeGreaterThan(1);
|
|
57
|
+
expect(data.targets.map(t => t.key)).toContain('switch-thumb');
|
|
58
|
+
for (const t of data.targets) expect(t.key).toContain('thumb');
|
|
59
|
+
}, 60_000);
|
|
60
|
+
|
|
61
|
+
it('rejects a filter that matches nothing, with components to try', async () => {
|
|
62
|
+
await expect(themeTargets('nosuchthing')).rejects.toMatchObject({
|
|
63
|
+
code: 'ERR_UNKNOWN_COMPONENT',
|
|
64
|
+
});
|
|
65
|
+
}, 60_000);
|
|
66
|
+
});
|
package/api/theme/theme.d.mts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
export { themeAdd } from "./add/add.mjs";
|
|
5
5
|
export { themeTemplate } from "./template/template.mjs";
|
|
6
|
+
export { themeTargets } from "./targets/targets.mjs";
|
|
6
7
|
export { themeList } from "./list/list.mjs";
|
|
7
8
|
export { listThemes } from "./_adapter.mjs";
|
|
8
9
|
export { themeBuild, importSpecifier } from "./build/build.mjs";
|
package/api/theme/theme.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @file `theme` command barrel — re-exports the build/add/list
|
|
4
|
+
* @file `theme` command barrel — re-exports the build/add/list/template/targets
|
|
5
|
+
* leaves so the CLI
|
|
5
6
|
* (cli/commands/build-theme.mjs) and scripted callers import from one place.
|
|
6
7
|
* Each leaf is also importable directly (e.g. api/theme/add/add.mjs). `theme`
|
|
7
8
|
* has real subcommands, so there is no flag-dispatch here — the CLI calls the
|
|
@@ -11,5 +12,6 @@
|
|
|
11
12
|
export {themeBuild, importSpecifier} from './build/build.mjs';
|
|
12
13
|
export {themeAdd} from './add/add.mjs';
|
|
13
14
|
export {themeTemplate} from './template/template.mjs';
|
|
15
|
+
export {themeTargets} from './targets/targets.mjs';
|
|
14
16
|
export {themeList} from './list/list.mjs';
|
|
15
17
|
export {listThemes} from './_adapter.mjs';
|
|
@@ -101,3 +101,26 @@ export type ThemeTemplateResponse = {
|
|
|
101
101
|
reason: "exists" | null;
|
|
102
102
|
};
|
|
103
103
|
};
|
|
104
|
+
/**
|
|
105
|
+
* One themeable target: the `defineTheme` `components` key, the class it
|
|
106
|
+
* renders as, the component whose doc declares it, and the props and states
|
|
107
|
+
* that are legal override keys under it.
|
|
108
|
+
*/
|
|
109
|
+
export type ThemeTargetEntry = {
|
|
110
|
+
key: string;
|
|
111
|
+
className: string;
|
|
112
|
+
component: string;
|
|
113
|
+
props: string[];
|
|
114
|
+
states: string[];
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* xds --json theme targets [filter]
|
|
118
|
+
*/
|
|
119
|
+
export type ThemeTargetsResponse = {
|
|
120
|
+
type: "theme.targets";
|
|
121
|
+
data: {
|
|
122
|
+
filter: string | null;
|
|
123
|
+
componentCount: number;
|
|
124
|
+
targets: ThemeTargetEntry[];
|
|
125
|
+
};
|
|
126
|
+
};
|
package/api/theme/theme.type.mjs
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* xds --json theme list -> theme.list
|
|
15
15
|
* xds --json theme add <slug> -> theme.add
|
|
16
16
|
* xds --json theme template -> theme.template
|
|
17
|
+
* xds --json theme targets [filter] -> theme.targets
|
|
17
18
|
* (file not found / parse error) -> CLIError
|
|
18
19
|
*
|
|
19
|
-
* @position api — colocated typedefs for api/theme/{theme,build,add,list,template,_adapter}
|
|
20
|
+
* @position api — colocated typedefs for api/theme/{theme,build,add,list,template,targets,_adapter}
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -78,6 +79,25 @@
|
|
|
78
79
|
* @property {{path: string, written: boolean, reason: 'exists' | null}} data
|
|
79
80
|
*/
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* One themeable target: the `defineTheme` `components` key, the class it
|
|
84
|
+
* renders as, the component whose doc declares it, and the props and states
|
|
85
|
+
* that are legal override keys under it.
|
|
86
|
+
* @typedef {object} ThemeTargetEntry
|
|
87
|
+
* @property {string} key
|
|
88
|
+
* @property {string} className
|
|
89
|
+
* @property {string} component
|
|
90
|
+
* @property {string[]} props
|
|
91
|
+
* @property {string[]} states
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* xds --json theme targets [filter]
|
|
96
|
+
* @typedef {object} ThemeTargetsResponse
|
|
97
|
+
* @property {'theme.targets'} type
|
|
98
|
+
* @property {{filter: string | null, componentCount: number, targets: ThemeTargetEntry[]}} data
|
|
99
|
+
*/
|
|
100
|
+
|
|
81
101
|
// Make this a module so the @typedefs above are importable as types via
|
|
82
102
|
// `import('./theme.type.mjs').ThemeBuildResponse` (and re-exportable from a .d.ts).
|
|
83
103
|
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @file FunctionDoc for `themeTargets()` / `astryx theme targets`. Colocated
|
|
6
|
+
* with the API function it documents; the response-shape source of truth stays
|
|
7
|
+
* in `theme.type.mjs`.
|
|
8
|
+
* @position packages/cli/api/theme — function documentation
|
|
9
|
+
*/
|
|
10
|
+
/** @type {import('@astryxdesign/cli/authoring').FunctionDoc} */
|
|
11
|
+
export const doc: import("@astryxdesign/cli/authoring").FunctionDoc;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file FunctionDoc for `themeTargets()` / `astryx theme targets`. Colocated
|
|
5
|
+
* with the API function it documents; the response-shape source of truth stays
|
|
6
|
+
* in `theme.type.mjs`.
|
|
7
|
+
* @position packages/cli/api/theme — function documentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @type {import('@astryxdesign/cli/authoring').FunctionDoc} */
|
|
11
|
+
export const doc = {
|
|
12
|
+
type: 'function',
|
|
13
|
+
kind: 'api',
|
|
14
|
+
name: 'themeTargets',
|
|
15
|
+
displayName: 'themeTargets()',
|
|
16
|
+
summary: 'List every component theming target a theme can override.',
|
|
17
|
+
description:
|
|
18
|
+
'Enumerates the whole themeable surface: each `defineTheme` components key, the stable ' +
|
|
19
|
+
'class it paints, the component that declares it, and the props and states that are legal ' +
|
|
20
|
+
'override keys under it. Same source as the Theming table `astryx component <Name>` prints ' +
|
|
21
|
+
'(the component docs), so the list cannot drift from the components, and `theme build` ' +
|
|
22
|
+
'validates overrides against this exact set. A filter naming a component gives that ' +
|
|
23
|
+
'component\'s set; anything else is a substring search over the keys.',
|
|
24
|
+
importPath: '@astryxdesign/cli/api',
|
|
25
|
+
signature:
|
|
26
|
+
'themeTargets(filter?: string, ctx?: {cwd?: string}): Promise<ThemeTargetsResponse>',
|
|
27
|
+
keywords: ['theme', 'targets', 'defineTheme', 'components', 'override', 'class', 'states', 'audit'],
|
|
28
|
+
params: [
|
|
29
|
+
{
|
|
30
|
+
name: 'filter',
|
|
31
|
+
type: 'string',
|
|
32
|
+
description:
|
|
33
|
+
'A component name (exact, case-insensitive) or a substring of a target key. Omit for the whole surface.',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'ctx.cwd',
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'Directory the project\'s @astryxdesign/core is resolved from.',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
returns: [
|
|
42
|
+
{
|
|
43
|
+
type: 'theme.targets',
|
|
44
|
+
description:
|
|
45
|
+
'The echoed filter, how many components are represented, and the targets: each {key, className, component, props, states}.',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
throws: [
|
|
49
|
+
{code: 'ERR_CORE_NOT_FOUND', when: '@astryxdesign/core cannot be resolved from cwd'},
|
|
50
|
+
{code: 'ERR_UNKNOWN_COMPONENT', when: 'a filter matches no target'},
|
|
51
|
+
],
|
|
52
|
+
examples: [
|
|
53
|
+
{label: 'The whole themeable surface', code: 'await themeTargets();'},
|
|
54
|
+
{label: "One component's targets", code: "await themeTargets('Switch');"},
|
|
55
|
+
],
|
|
56
|
+
command: 'theme targets',
|
|
57
|
+
related: ['themeBuild', 'themeTemplate', 'component'],
|
|
58
|
+
};
|
|
@@ -15,9 +15,9 @@ export const doc = {
|
|
|
15
15
|
displayName: 'themeTemplate()',
|
|
16
16
|
summary: 'Write the annotated theme template into a project.',
|
|
17
17
|
description:
|
|
18
|
-
'Writes theme.template.ts: the annotated reference for the whole theme surface
|
|
18
|
+
'Writes theme.template.ts: the annotated reference for the whole theme surface, covering every ' +
|
|
19
19
|
'defineTheme field, the token families, the component override syntax, and how a theme is ' +
|
|
20
|
-
'consumed
|
|
20
|
+
'consumed, with the CLI command that prints the authoritative reference for each section. ' +
|
|
21
21
|
'Read it, copy what you need into your own theme file, delete it. Where `theme add` starts ' +
|
|
22
22
|
'you from a theme we ship, this starts you from a blank one. Refuses to overwrite without ' +
|
|
23
23
|
'`overwrite`, so it is safe to re-run.',
|
|
@@ -48,7 +48,7 @@ export const doc = {
|
|
|
48
48
|
{
|
|
49
49
|
type: 'theme.template',
|
|
50
50
|
description:
|
|
51
|
-
'Receipt: the path (relative to cwd), whether it was written, and the reason it was not
|
|
51
|
+
'Receipt: the path (relative to cwd), whether it was written, and the reason it was not. `exists` when a file was already there, which is a success, not a failure.',
|
|
52
52
|
},
|
|
53
53
|
],
|
|
54
54
|
throws: [{code: 'ERR_PATH_TRAVERSAL', when: 'the target path escapes cwd'}],
|
|
@@ -27,6 +27,7 @@ const registry = new Map([
|
|
|
27
27
|
['0.2.1', () => import('./transforms/v0.2.1/index.mjs')],
|
|
28
28
|
['0.3.0', () => import('./transforms/v0.3.0/index.mjs')],
|
|
29
29
|
['0.4.0', () => import('./transforms/v0.4.0/index.mjs')],
|
|
30
|
+
['0.5.0', () => import('./transforms/v0.5.0/index.mjs')],
|
|
30
31
|
]);
|
|
31
32
|
|
|
32
33
|
// Re-export from the shared utility so registry callers and other consumers
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Unit tests for the staged (next-release) codemods.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors v0.4.0/__tests__/next-codemods.test.mjs, which covers the codemods
|
|
7
|
+
* after promotion. Keeping a copy here means a staged transform is tested from
|
|
8
|
+
* the day it is written rather than the day it is released.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {describe, expect, it} from 'vitest';
|
|
12
|
+
import jscodeshift from 'jscodeshift';
|
|
13
|
+
|
|
14
|
+
const j = jscodeshift.withParser('tsx');
|
|
15
|
+
const api = {jscodeshift: j, stats: () => {}, report: () => {}};
|
|
16
|
+
|
|
17
|
+
async function apply(name, source) {
|
|
18
|
+
const {default: transform} = await import(`../${name}.mjs`);
|
|
19
|
+
return transform({source, path: 'test.tsx'}, api) ?? source;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TRANSFORM = 'banner-collapsible-content';
|
|
23
|
+
|
|
24
|
+
const IMPORT = "import {Banner} from '@astryxdesign/core/Banner';\n";
|
|
25
|
+
|
|
26
|
+
describe('banner-collapsible-content', () => {
|
|
27
|
+
it('rewrites a bare defaultIsExpanded to a starts-open config', async () => {
|
|
28
|
+
const output = await apply(
|
|
29
|
+
TRANSFORM,
|
|
30
|
+
`${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`,
|
|
31
|
+
);
|
|
32
|
+
expect(output).toContain('defaultIsOpen: true');
|
|
33
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rewrites defaultIsExpanded={true} the same way', async () => {
|
|
37
|
+
const output = await apply(
|
|
38
|
+
TRANSFORM,
|
|
39
|
+
`${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={true}><p>d</p></Banner>;`,
|
|
40
|
+
);
|
|
41
|
+
expect(output).toContain('defaultIsOpen: true');
|
|
42
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('drops defaultIsExpanded={false}, which is the default', async () => {
|
|
46
|
+
const output = await apply(
|
|
47
|
+
TRANSFORM,
|
|
48
|
+
`${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={false}><p>d</p></Banner>;`,
|
|
49
|
+
);
|
|
50
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
51
|
+
// No config needed: starting collapsed is what a Banner does by default.
|
|
52
|
+
expect(output).not.toContain('collapsible');
|
|
53
|
+
// Untouched attributes keep their original text (recast only reprints
|
|
54
|
+
// what changed), so the element is exactly the base minus the prop.
|
|
55
|
+
expect(output).toContain('<Banner status="info" title="T">');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps a dynamic default dynamic', async () => {
|
|
59
|
+
const output = await apply(
|
|
60
|
+
TRANSFORM,
|
|
61
|
+
`${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={isOpen}><p>d</p></Banner>;`,
|
|
62
|
+
);
|
|
63
|
+
expect(output).toContain('defaultIsOpen: isOpen');
|
|
64
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('leaves a banner that never set the prop alone', async () => {
|
|
68
|
+
// The default is unchanged, so this banner still behaves as it did. The
|
|
69
|
+
// migration must not touch it — that is the whole point of the shape.
|
|
70
|
+
const source = `${IMPORT}const el = <Banner status="error" title="T"><ul><li>a</li></ul></Banner>;`;
|
|
71
|
+
const output = await apply(TRANSFORM, source);
|
|
72
|
+
expect(output).toBe(source);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('leaves a childless banner alone', async () => {
|
|
76
|
+
const source = `${IMPORT}const el = <Banner status="info" title="T" />;`;
|
|
77
|
+
const output = await apply(TRANSFORM, source);
|
|
78
|
+
expect(output).toBe(source);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('leaves a banner that already uses collapsible alone', async () => {
|
|
82
|
+
const source = `${IMPORT}const el = <Banner status="info" title="T" collapsible={false} defaultIsExpanded><p>d</p></Banner>;`;
|
|
83
|
+
const output = await apply(TRANSFORM, source);
|
|
84
|
+
expect(output).toBe(source);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('does not guess around a spread', async () => {
|
|
88
|
+
const source = `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded {...rest}><p>d</p></Banner>;`;
|
|
89
|
+
const output = await apply(TRANSFORM, source);
|
|
90
|
+
expect(output).toBe(source);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("leaves another component's defaultIsExpanded alone", async () => {
|
|
94
|
+
// ChatToolCalls has a prop of the same name that this migration must not
|
|
95
|
+
// touch.
|
|
96
|
+
const source = `import {ChatToolCalls} from '@astryxdesign/core/Chat';
|
|
97
|
+
const el = <ChatToolCalls calls={calls} defaultIsExpanded />;`;
|
|
98
|
+
const output = await apply(TRANSFORM, source);
|
|
99
|
+
expect(output).toBe(source);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('leaves a Banner that is not the core Banner alone', async () => {
|
|
103
|
+
const source = `import {Banner} from './ui/Banner';
|
|
104
|
+
const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`;
|
|
105
|
+
const output = await apply(TRANSFORM, source);
|
|
106
|
+
expect(output).toBe(source);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('leaves the prop inside a props object alone', async () => {
|
|
110
|
+
// Out of scope by design: which component the object feeds is a guess,
|
|
111
|
+
// and the removed prop makes those sites a type error anyway.
|
|
112
|
+
const source = `${IMPORT}const args = {status: 'info', title: 'T', defaultIsExpanded: true};
|
|
113
|
+
const el = <Banner {...args} />;`;
|
|
114
|
+
const output = await apply(TRANSFORM, source);
|
|
115
|
+
expect(output).toBe(source);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('migrates a Banner imported from the package root', async () => {
|
|
119
|
+
const output = await apply(
|
|
120
|
+
TRANSFORM,
|
|
121
|
+
`import {Banner, Button} from '@astryxdesign/core';
|
|
122
|
+
const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`,
|
|
123
|
+
);
|
|
124
|
+
expect(output).toContain('defaultIsOpen: true');
|
|
125
|
+
expect(output).not.toContain('defaultIsExpanded');
|
|
126
|
+
});
|
|
127
|
+
});
|