@astryxdesign/cli 0.4.7 → 0.5.0-canary.32a62fd
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/template.doc.mjs +2 -2
- package/api/theme/build/build.mjs +12 -42
- 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/mixed-gallery/page.tsx +12 -3
- package/assets/templates/pages/table-grouped/page.tsx +151 -144
- 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/clients/cli/commands/build-theme.mjs +85 -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/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/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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file CommandDoc for `astryx theme targets`. The terminal binding of the
|
|
5
|
+
* `themeTargets()` function (referenced via `fn`); its args map to that
|
|
6
|
+
* function's params so a converter can build Commander config + --help from one
|
|
7
|
+
* source of truth.
|
|
8
|
+
* @position packages/cli/clients/cli/commands — command documentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @type {import('@astryxdesign/cli/authoring').CommandDoc} */
|
|
12
|
+
export const doc = {
|
|
13
|
+
type: 'command',
|
|
14
|
+
name: 'theme targets',
|
|
15
|
+
displayName: 'astryx theme targets',
|
|
16
|
+
namespace: 'cli',
|
|
17
|
+
summary: 'List the component theming targets a theme can override',
|
|
18
|
+
description:
|
|
19
|
+
'Prints every `defineTheme` components key across the system: the stable class it paints, ' +
|
|
20
|
+
'the component that declares it, and the props and states that are legal override keys ' +
|
|
21
|
+
'under it. This is the whole themeable surface in one command — what auditing a theme, or ' +
|
|
22
|
+
'answering "which key paints this pixel?", used to need one `astryx component <Name>` per ' +
|
|
23
|
+
'component to assemble. Pass a component name to scope it; pass any substring to search ' +
|
|
24
|
+
'keys. `--json` for a list a repo can lint its own theme against.',
|
|
25
|
+
fn: 'themeTargets',
|
|
26
|
+
args: [{name: 'filter', param: 'filter', required: false}],
|
|
27
|
+
examples: [
|
|
28
|
+
{label: 'The whole themeable surface', cli: 'astryx theme targets'},
|
|
29
|
+
{label: "One component's targets", cli: 'astryx theme targets Switch'},
|
|
30
|
+
{label: 'Search keys', cli: 'astryx theme targets thumb'},
|
|
31
|
+
{label: 'For a lint or an audit script', cli: 'astryx --json theme targets'},
|
|
32
|
+
],
|
|
33
|
+
exitCodes: [
|
|
34
|
+
{code: 0, when: 'success'},
|
|
35
|
+
{code: 1, when: 'a filter matches no target, or core cannot be resolved'},
|
|
36
|
+
],
|
|
37
|
+
related: ['component', 'theme build', 'theme template'],
|
|
38
|
+
};
|
|
@@ -16,9 +16,9 @@ export const doc = {
|
|
|
16
16
|
namespace: 'cli',
|
|
17
17
|
summary: 'Write the annotated theme template into your project',
|
|
18
18
|
description:
|
|
19
|
-
'Writes theme.template.ts: the annotated reference for the whole theme surface
|
|
19
|
+
'Writes theme.template.ts: the annotated reference for the whole theme surface, covering every ' +
|
|
20
20
|
'defineTheme field, the token families, the component override syntax, and how a theme is ' +
|
|
21
|
-
'consumed
|
|
21
|
+
'consumed, naming the CLI command that prints the authoritative reference for each. Read ' +
|
|
22
22
|
'it, copy what you need into your own theme file, delete it. Use `theme add <slug>` instead ' +
|
|
23
23
|
'to start from a theme we ship. Leaves an existing file untouched unless --overwrite.',
|
|
24
24
|
fn: 'themeTemplate',
|
|
@@ -17,11 +17,13 @@ export const doc = {
|
|
|
17
17
|
description:
|
|
18
18
|
'The theme command group. Running astryx theme with no subcommand prints the ' +
|
|
19
19
|
'subcommand list; the work happens in the subcommands: compile a theme (build), ' +
|
|
20
|
-
'scaffold one into your project (add), start a custom one from the annotated template (template),
|
|
21
|
-
|
|
20
|
+
'scaffold one into your project (add), start a custom one from the annotated template (template), ' +
|
|
21
|
+
'list the bundled themes (list), or list the component theming targets a theme can override (targets).',
|
|
22
|
+
subcommands: ['build', 'add', 'list', 'template', 'targets'],
|
|
22
23
|
examples: [
|
|
23
24
|
{label: 'List bundled themes', cli: 'astryx theme list'},
|
|
24
25
|
{label: 'Scaffold a theme', cli: 'astryx theme add matcha'},
|
|
26
|
+
{label: 'See what a theme can override', cli: 'astryx theme targets'},
|
|
25
27
|
],
|
|
26
28
|
exitCodes: [
|
|
27
29
|
{code: 0, when: 'success (help shown, or a subcommand succeeded)'},
|
package/clients/cli/index.mjs
CHANGED
|
@@ -13,8 +13,8 @@ import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
|
|
|
13
13
|
*
|
|
14
14
|
* Override keys are the component's stable class name with the `astryx-`
|
|
15
15
|
* namespace prefix stripped — `generateThemeRules` re-adds the prefix when it
|
|
16
|
-
* builds the `.astryx-*` selector. So `astryx-
|
|
17
|
-
* (→ `.astryx-
|
|
16
|
+
* builds the `.astryx-*` selector. So `astryx-table-cell` → key `table-cell`
|
|
17
|
+
* (→ `.astryx-table-cell`), and `astryx-button` → key `button`.
|
|
18
18
|
*
|
|
19
19
|
* Keep the `astryx-` literal in sync with packages/core/src/naming.ts
|
|
20
20
|
* (NAMESPACE / classPrefix), the same way build-theme.mjs mirrors it.
|
|
@@ -77,6 +77,7 @@ export const RESPONSE_TYPES = {
|
|
|
77
77
|
'theme list': ['theme.list'],
|
|
78
78
|
'theme add': ['theme.list', 'theme.add'],
|
|
79
79
|
'theme template': ['theme.template'],
|
|
80
|
+
'theme targets': ['theme.targets'],
|
|
80
81
|
upgrade: ['upgrade.list', 'upgrade.status', 'upgrade.run'],
|
|
81
82
|
manifest: ['manifest'],
|
|
82
83
|
doctor: ['doctor'],
|
|
@@ -116,6 +117,7 @@ const EXAMPLES = {
|
|
|
116
117
|
'astryx theme add matcha ./src/themes/matcha',
|
|
117
118
|
],
|
|
118
119
|
'theme template': ['astryx theme template', 'astryx theme template --json'],
|
|
120
|
+
'theme targets': ['astryx theme targets Switch', 'astryx --json theme targets'],
|
|
119
121
|
upgrade: ['astryx upgrade --json'],
|
|
120
122
|
manifest: ['astryx manifest --json', 'astryx --json'],
|
|
121
123
|
doctor: ['astryx doctor', 'astryx doctor --json'],
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every theming target declared under a core `src` directory, sorted by key
|
|
6
|
+
* then component. A key can appear more than once: a shared sub-element (the
|
|
7
|
+
* radio indicator, say) is documented by every component that renders it.
|
|
8
|
+
*
|
|
9
|
+
* Unreadable docs are skipped rather than fatal — a single malformed doc must
|
|
10
|
+
* not take out theme validation or the listing.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} coreSrc - absolute path to `<core>/src`
|
|
13
|
+
* @returns {Promise<ThemingTarget[]>}
|
|
14
|
+
*/
|
|
15
|
+
export function collectThemingTargets(coreSrc: string): Promise<ThemingTarget[]>;
|
|
16
|
+
/**
|
|
17
|
+
* One public custom property a theme may set on a component's target.
|
|
18
|
+
* @typedef {object} ThemingVar
|
|
19
|
+
* @property {string} name - the custom property, e.g. `--tree-list-indent`
|
|
20
|
+
* @property {string} component - the component whose doc declares it
|
|
21
|
+
* @property {string} dir - absolute path to the directory the doc lives in
|
|
22
|
+
* @property {string} default - the documented default value
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Every PUBLIC theming var declared under a core `src` directory, sorted by
|
|
26
|
+
* name. Private `--_*` vars are a component's own plumbing, not a theme's to
|
|
27
|
+
* set, so they are not enumerated here.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} coreSrc - absolute path to `<core>/src`
|
|
30
|
+
* @returns {Promise<ThemingVar[]>}
|
|
31
|
+
*/
|
|
32
|
+
export function collectThemingVars(coreSrc: string): Promise<ThemingVar[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Collapse the enumeration into the `{key: [props and states]}` map theme
|
|
35
|
+
* validation checks override keys against — both are legal override keys, so
|
|
36
|
+
* they share one list.
|
|
37
|
+
* @param {ThemingTarget[]} targets
|
|
38
|
+
* @returns {Record<string, string[]>}
|
|
39
|
+
*/
|
|
40
|
+
export function targetsByKey(targets: ThemingTarget[]): Record<string, string[]>;
|
|
41
|
+
/**
|
|
42
|
+
* One theming target, as a theme author has to write it.
|
|
43
|
+
*/
|
|
44
|
+
export type ThemingTarget = {
|
|
45
|
+
/**
|
|
46
|
+
* - the `defineTheme` `components` key (class minus the `astryx-` prefix)
|
|
47
|
+
*/
|
|
48
|
+
key: string;
|
|
49
|
+
/**
|
|
50
|
+
* - the stable class the component renders
|
|
51
|
+
*/
|
|
52
|
+
className: string;
|
|
53
|
+
/**
|
|
54
|
+
* - the component whose doc declares it
|
|
55
|
+
*/
|
|
56
|
+
component: string;
|
|
57
|
+
/**
|
|
58
|
+
* - visual props the target reflects (`variant:value` keys)
|
|
59
|
+
*/
|
|
60
|
+
props: string[];
|
|
61
|
+
/**
|
|
62
|
+
* - runtime states the target reflects (bare-name keys)
|
|
63
|
+
*/
|
|
64
|
+
states: string[];
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* One public custom property a theme may set on a component's target.
|
|
68
|
+
*/
|
|
69
|
+
export type ThemingVar = {
|
|
70
|
+
/**
|
|
71
|
+
* - the custom property, e.g. `--tree-list-indent`
|
|
72
|
+
*/
|
|
73
|
+
name: string;
|
|
74
|
+
/**
|
|
75
|
+
* - the component whose doc declares it
|
|
76
|
+
*/
|
|
77
|
+
component: string;
|
|
78
|
+
/**
|
|
79
|
+
* - absolute path to the directory the doc lives in
|
|
80
|
+
*/
|
|
81
|
+
dir: string;
|
|
82
|
+
/**
|
|
83
|
+
* - the documented default value
|
|
84
|
+
*/
|
|
85
|
+
default: string;
|
|
86
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file The one enumeration of component theming targets.
|
|
5
|
+
*
|
|
6
|
+
* @input a core `src` directory
|
|
7
|
+
* @output every `theming.targets` entry authored in a component `.doc.mjs`,
|
|
8
|
+
* flattened into the `defineTheme` component key a theme author writes
|
|
9
|
+
* @position packages/cli/foundation/discovery — shared by `theme targets` (the
|
|
10
|
+
* listing) and `theme build` (override validation). Both read the
|
|
11
|
+
* component docs, which are the source of truth `astryx component
|
|
12
|
+
* <Name>` prints; nothing here is a second registry, so the list a
|
|
13
|
+
* theme author can enumerate and the set the compiler accepts cannot
|
|
14
|
+
* drift from the components or from each other.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import {loadComponentDoc} from './component-loader.mjs';
|
|
20
|
+
|
|
21
|
+
const SKIP_DIRS = new Set(['node_modules', '__tests__']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One theming target, as a theme author has to write it.
|
|
25
|
+
* @typedef {object} ThemingTarget
|
|
26
|
+
* @property {string} key - the `defineTheme` `components` key (class minus the `astryx-` prefix)
|
|
27
|
+
* @property {string} className - the stable class the component renders
|
|
28
|
+
* @property {string} component - the component whose doc declares it
|
|
29
|
+
* @property {string[]} props - visual props the target reflects (`variant:value` keys)
|
|
30
|
+
* @property {string[]} states - runtime states the target reflects (bare-name keys)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Strip the namespace prefix to get the `defineTheme` key for a class name.
|
|
35
|
+
*
|
|
36
|
+
* Keep the `astryx-` literal in sync with packages/core/src/naming.ts
|
|
37
|
+
* (NAMESPACE / classPrefix), the same way component-format.mjs does.
|
|
38
|
+
* <!-- SYNC: packages/core/src/naming.ts (namespace prefix source of truth) -->
|
|
39
|
+
* @param {string} className
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function targetKey(className) {
|
|
43
|
+
return className.replace(/^astryx-/, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every theming target declared under a core `src` directory, sorted by key
|
|
48
|
+
* then component. A key can appear more than once: a shared sub-element (the
|
|
49
|
+
* radio indicator, say) is documented by every component that renders it.
|
|
50
|
+
*
|
|
51
|
+
* Unreadable docs are skipped rather than fatal — a single malformed doc must
|
|
52
|
+
* not take out theme validation or the listing.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} coreSrc - absolute path to `<core>/src`
|
|
55
|
+
* @returns {Promise<ThemingTarget[]>}
|
|
56
|
+
*/
|
|
57
|
+
export async function collectThemingTargets(coreSrc) {
|
|
58
|
+
if (!coreSrc || !fs.existsSync(coreSrc)) return [];
|
|
59
|
+
|
|
60
|
+
/** @type {ThemingTarget[]} */
|
|
61
|
+
const targets = [];
|
|
62
|
+
|
|
63
|
+
/** @param {string} dir */
|
|
64
|
+
async function scan(dir) {
|
|
65
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
66
|
+
const full = path.join(dir, entry.name);
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
69
|
+
await scan(full);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!entry.name.endsWith('.doc.mjs')) continue;
|
|
73
|
+
|
|
74
|
+
/** @type {any} */
|
|
75
|
+
let doc;
|
|
76
|
+
try {
|
|
77
|
+
doc = await loadComponentDoc(full);
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const component =
|
|
83
|
+
typeof doc?.name === 'string' && doc.name
|
|
84
|
+
? doc.name
|
|
85
|
+
: path.basename(path.dirname(full));
|
|
86
|
+
|
|
87
|
+
for (const target of doc?.theming?.targets || []) {
|
|
88
|
+
const className = target?.className;
|
|
89
|
+
if (typeof className !== 'string') continue;
|
|
90
|
+
const key = targetKey(className);
|
|
91
|
+
if (!key) continue;
|
|
92
|
+
targets.push({
|
|
93
|
+
key,
|
|
94
|
+
className,
|
|
95
|
+
component,
|
|
96
|
+
props: stringList(target.visualProps),
|
|
97
|
+
states: stringList(target.states),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await scan(coreSrc);
|
|
104
|
+
|
|
105
|
+
targets.sort(
|
|
106
|
+
(a, b) => a.key.localeCompare(b.key) || a.component.localeCompare(b.component),
|
|
107
|
+
);
|
|
108
|
+
return targets;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One public custom property a theme may set on a component's target.
|
|
113
|
+
* @typedef {object} ThemingVar
|
|
114
|
+
* @property {string} name - the custom property, e.g. `--tree-list-indent`
|
|
115
|
+
* @property {string} component - the component whose doc declares it
|
|
116
|
+
* @property {string} dir - absolute path to the directory the doc lives in
|
|
117
|
+
* @property {string} default - the documented default value
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every PUBLIC theming var declared under a core `src` directory, sorted by
|
|
122
|
+
* name. Private `--_*` vars are a component's own plumbing, not a theme's to
|
|
123
|
+
* set, so they are not enumerated here.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} coreSrc - absolute path to `<core>/src`
|
|
126
|
+
* @returns {Promise<ThemingVar[]>}
|
|
127
|
+
*/
|
|
128
|
+
export async function collectThemingVars(coreSrc) {
|
|
129
|
+
if (!coreSrc || !fs.existsSync(coreSrc)) return [];
|
|
130
|
+
|
|
131
|
+
/** @type {Map<string, ThemingVar>} */
|
|
132
|
+
const vars = new Map();
|
|
133
|
+
|
|
134
|
+
/** @param {string} dir */
|
|
135
|
+
async function scan(dir) {
|
|
136
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
137
|
+
const full = path.join(dir, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
140
|
+
await scan(full);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!entry.name.endsWith('.doc.mjs')) continue;
|
|
144
|
+
|
|
145
|
+
/** @type {any} */
|
|
146
|
+
let doc;
|
|
147
|
+
try {
|
|
148
|
+
doc = await loadComponentDoc(full);
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const component =
|
|
154
|
+
typeof doc?.name === 'string' && doc.name
|
|
155
|
+
? doc.name
|
|
156
|
+
: path.basename(path.dirname(full));
|
|
157
|
+
|
|
158
|
+
for (const entryVar of doc?.theming?.vars || []) {
|
|
159
|
+
const name = entryVar?.name;
|
|
160
|
+
if (typeof name !== 'string') continue;
|
|
161
|
+
if (entryVar.private === true || name.startsWith('--_')) continue;
|
|
162
|
+
if (vars.has(name)) continue;
|
|
163
|
+
vars.set(name, {
|
|
164
|
+
name,
|
|
165
|
+
component,
|
|
166
|
+
dir: path.dirname(full),
|
|
167
|
+
default: typeof entryVar.default === 'string' ? entryVar.default : '',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await scan(coreSrc);
|
|
174
|
+
|
|
175
|
+
return [...vars.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Collapse the enumeration into the `{key: [props and states]}` map theme
|
|
180
|
+
* validation checks override keys against — both are legal override keys, so
|
|
181
|
+
* they share one list.
|
|
182
|
+
* @param {ThemingTarget[]} targets
|
|
183
|
+
* @returns {Record<string, string[]>}
|
|
184
|
+
*/
|
|
185
|
+
export function targetsByKey(targets) {
|
|
186
|
+
/** @type {Record<string, string[]>} */
|
|
187
|
+
const byKey = {};
|
|
188
|
+
for (const t of targets) {
|
|
189
|
+
byKey[t.key] = [...new Set([...(byKey[t.key] || []), ...t.props, ...t.states])];
|
|
190
|
+
}
|
|
191
|
+
return byKey;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {unknown} value
|
|
196
|
+
* @returns {string[]}
|
|
197
|
+
*/
|
|
198
|
+
function stringList(value) {
|
|
199
|
+
return Array.isArray(value)
|
|
200
|
+
? value.filter((/** @type {unknown} */ v) => typeof v === 'string')
|
|
201
|
+
: [];
|
|
202
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file The enumerability guard for component theming targets.
|
|
5
|
+
*
|
|
6
|
+
* A theming target is only useful if a theme author can find it. These tests
|
|
7
|
+
* run against the REAL core docs and fail if any component's targets stop
|
|
8
|
+
* being enumerable — a doc that moves out of the scanned tree, a target shape
|
|
9
|
+
* that stops being read, or a component whose Theming table says one thing
|
|
10
|
+
* while `theme targets` says another. That divergence is the failure the
|
|
11
|
+
* listing exists to prevent: a target list that can drift from the components
|
|
12
|
+
* is worse than no list.
|
|
13
|
+
*
|
|
14
|
+
* The public vars a target carries get the same treatment, one step further:
|
|
15
|
+
* being enumerable is not the same as being settable. A documented var no
|
|
16
|
+
* component reads compiles to a declaration that never applies (#5012), and a
|
|
17
|
+
* var the component writes inline outranks every cascade layer, so no theme can
|
|
18
|
+
* reach it (#4530). Both shipped. Neither is visible in the generated theme CSS
|
|
19
|
+
* — the artifact the jsdom suites assert on — so the wiring is checked here
|
|
20
|
+
* against source. Whether the cascade then lands the value on the element is a
|
|
21
|
+
* browser fact and no jsdom test can stand in for it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {describe, it, expect} from 'vitest';
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import {findCoreDir} from '../fs/paths.mjs';
|
|
28
|
+
import {
|
|
29
|
+
discoverComponents,
|
|
30
|
+
findComponentReadme,
|
|
31
|
+
} from './component-discovery.mjs';
|
|
32
|
+
import {loadComponentDoc} from './component-loader.mjs';
|
|
33
|
+
import {
|
|
34
|
+
collectThemingTargets,
|
|
35
|
+
collectThemingVars,
|
|
36
|
+
targetsByKey,
|
|
37
|
+
} from './theming-targets.mjs';
|
|
38
|
+
|
|
39
|
+
const coreDir = /** @type {string} */ (findCoreDir(process.cwd()));
|
|
40
|
+
const coreSrc = path.join(coreDir, 'src');
|
|
41
|
+
|
|
42
|
+
/** @type {Promise<import('./theming-targets.mjs').ThemingTarget[]>} */
|
|
43
|
+
const enumerated = collectThemingTargets(coreSrc);
|
|
44
|
+
|
|
45
|
+
describe('collectThemingTargets', () => {
|
|
46
|
+
it('enumerates the whole surface, not a handful', async () => {
|
|
47
|
+
const targets = await enumerated;
|
|
48
|
+
expect(targets.length).toBeGreaterThan(100);
|
|
49
|
+
expect(new Set(targets.map(t => t.component)).size).toBeGreaterThan(50);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('drops the namespace prefix so each key is what defineTheme takes', async () => {
|
|
53
|
+
for (const t of await enumerated) {
|
|
54
|
+
expect(t.className).toBe(`astryx-${t.key}`);
|
|
55
|
+
expect(t.component).toBeTruthy();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('carries the props and states a target reflects', async () => {
|
|
60
|
+
const targets = await enumerated;
|
|
61
|
+
expect(targets.find(t => t.key === 'switch-thumb')).toEqual({
|
|
62
|
+
key: 'switch-thumb',
|
|
63
|
+
className: 'astryx-switch-thumb',
|
|
64
|
+
component: 'Switch',
|
|
65
|
+
props: ['size'],
|
|
66
|
+
states: ['checked'],
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is sorted by key, so a diff of two runs is readable', async () => {
|
|
71
|
+
const keys = (await enumerated).map(t => t.key);
|
|
72
|
+
expect(keys).toEqual([...keys].sort((a, b) => a.localeCompare(b)));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// The listing and `theme build`'s override validation read this one
|
|
76
|
+
// enumeration. `targetsByKey` is the shape validation wants: props and
|
|
77
|
+
// states merged, because both are legal override keys.
|
|
78
|
+
it('collapses to the override keys, merging the components that share one', async () => {
|
|
79
|
+
const byKey = targetsByKey(await enumerated);
|
|
80
|
+
expect(byKey['switch']).toEqual(['size', 'checked', 'disabled']);
|
|
81
|
+
// `radio` is documented by both Indicator and RadioList.
|
|
82
|
+
const radio = (await enumerated).filter(t => t.key === 'radio');
|
|
83
|
+
expect(radio.length).toBeGreaterThan(1);
|
|
84
|
+
for (const t of radio) {
|
|
85
|
+
for (const name of [...t.props, ...t.states]) {
|
|
86
|
+
expect(byKey['radio']).toContain(name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('every component doc that declares targets has them enumerated', async () => {
|
|
92
|
+
const targets = await enumerated;
|
|
93
|
+
/** @type {Map<string, Set<string>>} key -> props+states */
|
|
94
|
+
const byKey = new Map(
|
|
95
|
+
Object.entries(targetsByKey(targets)).map(([k, v]) => [k, new Set(v)]),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const names = Object.values(discoverComponents(coreDir)).flat();
|
|
99
|
+
/** @type {string[]} */
|
|
100
|
+
const missing = [];
|
|
101
|
+
/** @type {Set<string>} */
|
|
102
|
+
const seenDocs = new Set();
|
|
103
|
+
let checked = 0;
|
|
104
|
+
|
|
105
|
+
for (const name of names) {
|
|
106
|
+
const docPath = findComponentReadme(coreDir, name);
|
|
107
|
+
if (!docPath || seenDocs.has(docPath)) continue;
|
|
108
|
+
seenDocs.add(docPath);
|
|
109
|
+
|
|
110
|
+
/** @type {any} */
|
|
111
|
+
let doc;
|
|
112
|
+
try {
|
|
113
|
+
doc = await loadComponentDoc(docPath);
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const target of doc?.theming?.targets || []) {
|
|
119
|
+
if (typeof target?.className !== 'string') continue;
|
|
120
|
+
checked++;
|
|
121
|
+
const key = target.className.replace(/^astryx-/, '');
|
|
122
|
+
const known = byKey.get(key);
|
|
123
|
+
if (!known) {
|
|
124
|
+
missing.push(`${name}: ${target.className} is not enumerable`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const prop of [
|
|
128
|
+
...(target.visualProps || []),
|
|
129
|
+
...(target.states || []),
|
|
130
|
+
]) {
|
|
131
|
+
if (!known.has(prop)) {
|
|
132
|
+
missing.push(`${name}: ${target.className} lost "${prop}"`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
expect(checked).toBeGreaterThan(100);
|
|
139
|
+
expect(missing).toEqual([]);
|
|
140
|
+
}, 60_000);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Public vars — enumerable is not the same as settable
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** @type {Promise<import('./theming-targets.mjs').ThemingVar[]>} */
|
|
148
|
+
const enumeratedVars = collectThemingVars(coreSrc);
|
|
149
|
+
|
|
150
|
+
/** Every non-test source file under a component directory. */
|
|
151
|
+
function sourcesIn(dir) {
|
|
152
|
+
/** @type {string[]} */
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
155
|
+
if (entry.isDirectory()) {
|
|
156
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
|
|
157
|
+
out.push(...sourcesIn(path.join(dir, entry.name)));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!/\.tsx?$/.test(entry.name)) continue;
|
|
161
|
+
if (/\.(test|stories)\.tsx?$/.test(entry.name)) continue;
|
|
162
|
+
out.push(path.join(dir, entry.name));
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The text of every inline style a file writes — `style={{…}}` objects and
|
|
169
|
+
* `setProperty` calls. A custom property written from either outranks every
|
|
170
|
+
* cascade layer, so a theme cannot reach it.
|
|
171
|
+
*/
|
|
172
|
+
function inlineStyleText(src) {
|
|
173
|
+
const chunks = [];
|
|
174
|
+
for (const m of src.matchAll(/style=\{\{/g)) {
|
|
175
|
+
const end = src.indexOf('}}', m.index);
|
|
176
|
+
chunks.push(src.slice(m.index, end === -1 ? src.length : end));
|
|
177
|
+
}
|
|
178
|
+
for (const m of src.matchAll(/setProperty\(\s*'[^']+'/g)) chunks.push(m[0]);
|
|
179
|
+
return chunks.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
describe('collectThemingVars', () => {
|
|
183
|
+
it('enumerates the public vars and drops the private plumbing', async () => {
|
|
184
|
+
const names = (await enumeratedVars).map(v => v.name);
|
|
185
|
+
expect(names.length).toBeGreaterThan(0);
|
|
186
|
+
expect(names.every(n => !n.startsWith('--_'))).toBe(true);
|
|
187
|
+
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
|
188
|
+
expect(names).toEqual([...new Set(names)]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('carries the component and the documented default', async () => {
|
|
192
|
+
const indent = (await enumeratedVars).find(
|
|
193
|
+
v => v.name === '--tree-list-indent',
|
|
194
|
+
);
|
|
195
|
+
expect(indent).toMatchObject({
|
|
196
|
+
name: '--tree-list-indent',
|
|
197
|
+
component: 'TreeList',
|
|
198
|
+
default: 'var(--spacing-4)',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// #5012: the theme docs advertised `--button-press-scale`, which no component
|
|
203
|
+
// ever read. A theme setting it compiled to a declaration nothing consumed,
|
|
204
|
+
// and nothing failed — the var was in the docs, so every existence check
|
|
205
|
+
// passed. Reading it is the minimum that makes a documented var mean anything.
|
|
206
|
+
it('every documented var is read by the component that documents it', async () => {
|
|
207
|
+
/** @type {string[]} */
|
|
208
|
+
const unread = [];
|
|
209
|
+
for (const v of await enumeratedVars) {
|
|
210
|
+
const read = sourcesIn(v.dir).some(f =>
|
|
211
|
+
fs.readFileSync(f, 'utf-8').includes(`var(${v.name}`),
|
|
212
|
+
);
|
|
213
|
+
if (!read) unread.push(`${v.component}: nothing reads var(${v.name})`);
|
|
214
|
+
}
|
|
215
|
+
expect(
|
|
216
|
+
unread,
|
|
217
|
+
`A documented public var no component reads compiles to a declaration ` +
|
|
218
|
+
`that never applies (#5012). Either wire it up or drop it from the doc.`,
|
|
219
|
+
).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// #4530: TreeList's indent was an inline `margin-inline-start` on the element
|
|
223
|
+
// carrying the theme target. An inline declaration outranks every cascade
|
|
224
|
+
// layer, so `@layer astryx-theme` could not reach it — the var was real, read,
|
|
225
|
+
// and documented, and still unsettable. The fix moved it into a StyleX rule.
|
|
226
|
+
it('no documented var is written inline, where no theme can outrank it', async () => {
|
|
227
|
+
/** @type {string[]} */
|
|
228
|
+
const clobbered = [];
|
|
229
|
+
for (const v of await enumeratedVars) {
|
|
230
|
+
for (const f of sourcesIn(v.dir)) {
|
|
231
|
+
if (inlineStyleText(fs.readFileSync(f, 'utf-8')).includes(v.name)) {
|
|
232
|
+
clobbered.push(
|
|
233
|
+
`${v.component}: ${path.basename(f)} sets ${v.name} inline`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
expect(
|
|
239
|
+
clobbered,
|
|
240
|
+
`An inline custom property beats every cascade layer, so a theme setting ` +
|
|
241
|
+
`it through @layer astryx-theme is silently ignored (#4530). Declare it ` +
|
|
242
|
+
`in a StyleX rule instead.`,
|
|
243
|
+
).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -153,7 +153,7 @@ export const doc = {
|
|
|
153
153
|
{
|
|
154
154
|
value: 'template.cdn',
|
|
155
155
|
description:
|
|
156
|
-
'A write receipt for the no-build-step CDN starter page: the path (relative to cwd), the Astryx version every CDN URL was pinned to, whether it was written, and the reason it was not
|
|
156
|
+
'A write receipt for the no-build-step CDN starter page: the path (relative to cwd), the Astryx version every CDN URL was pinned to, whether it was written, and the reason it was not. `exists` when a file was already there, which is a success.',
|
|
157
157
|
},
|
|
158
158
|
|
|
159
159
|
// hook
|
|
@@ -197,7 +197,12 @@ export const doc = {
|
|
|
197
197
|
{
|
|
198
198
|
value: 'theme.template',
|
|
199
199
|
description:
|
|
200
|
-
'A write receipt for the annotated theme template: the path (relative to cwd), whether it was written, and the reason it was not
|
|
200
|
+
'A write receipt for the annotated theme template: 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.',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
value: 'theme.targets',
|
|
204
|
+
description:
|
|
205
|
+
'The whole themeable surface: the echoed filter, the component count, and one entry per theming target — {key, className, component, props, states}, where props and states are its legal override keys.',
|
|
201
206
|
},
|
|
202
207
|
|
|
203
208
|
// upgrade
|