@astryxdesign/cli 0.4.7-canary.dfdd778 → 0.4.7-canary.e17d7f0
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/api/search/search.mjs +50 -4
- package/api/search/search.test.mjs +71 -0
- package/assets/templates/pages/table-grouped/page.tsx +151 -144
- package/foundation/discovery/theming-targets.d.mts +38 -0
- package/foundation/discovery/theming-targets.mjs +67 -0
- package/foundation/discovery/theming-targets.test.mjs +119 -1
- package/package.json +9 -9
package/api/search/search.mjs
CHANGED
|
@@ -34,10 +34,12 @@ import {pathToFileURL} from 'node:url';
|
|
|
34
34
|
import {findCoreDir} from '../../foundation/fs/paths.mjs';
|
|
35
35
|
import {
|
|
36
36
|
discoverComponents,
|
|
37
|
+
discoverIntegrationComponents,
|
|
37
38
|
findComponentReadme,
|
|
38
39
|
resolveImportPath,
|
|
39
40
|
} from '../../foundation/discovery/component-discovery.mjs';
|
|
40
41
|
import {discoverHooks, findHookDoc} from '../../foundation/discovery/hook-discovery.mjs';
|
|
42
|
+
import {loadIntegrationsSafely} from '../component/_adapter.mjs';
|
|
41
43
|
import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
|
|
42
44
|
import {discoverTemplates, extractComponents} from '../template/template.mjs';
|
|
43
45
|
import {loadDocsCatalog, loadTopicDoc} from '../docs/_adapter.mjs';
|
|
@@ -350,12 +352,12 @@ async function loadModuleDoc(docPath, exportName = 'docs') {
|
|
|
350
352
|
}
|
|
351
353
|
|
|
352
354
|
/**
|
|
353
|
-
* Build component candidates: name + keywords +
|
|
354
|
-
* component's .doc.mjs.
|
|
355
|
+
* Build component candidates from core's own tree: name + keywords +
|
|
356
|
+
* usage/description from the component's .doc.mjs.
|
|
355
357
|
* @param {string} coreDir
|
|
356
358
|
* @returns {Promise<Candidate[]>}
|
|
357
359
|
*/
|
|
358
|
-
async function
|
|
360
|
+
async function gatherCoreComponents(coreDir) {
|
|
359
361
|
const grouped = discoverComponents(coreDir);
|
|
360
362
|
const names = Object.values(grouped).flat();
|
|
361
363
|
/** @type {Candidate[]} */
|
|
@@ -383,6 +385,50 @@ async function gatherComponents(coreDir) {
|
|
|
383
385
|
return candidates;
|
|
384
386
|
}
|
|
385
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Build component candidates contributed by the project's configured
|
|
390
|
+
* integrations (astryx.config's `integrations`): name + keywords +
|
|
391
|
+
* usage/description from each component's .doc.mjs, same as core. Without
|
|
392
|
+
* this, an integration component is invisible to `search`/`build` even
|
|
393
|
+
* though `component --list`/`component <Name>` already resolve it — the two
|
|
394
|
+
* discovery paths silently disagreed.
|
|
395
|
+
* @param {string} cwd
|
|
396
|
+
* @returns {Promise<Candidate[]>}
|
|
397
|
+
*/
|
|
398
|
+
async function gatherIntegrationComponents(cwd) {
|
|
399
|
+
const loadedIntegrations = await loadIntegrationsSafely(cwd);
|
|
400
|
+
/** @type {Candidate[]} */
|
|
401
|
+
const candidates = [];
|
|
402
|
+
for (const integration of loadedIntegrations) {
|
|
403
|
+
for (const rec of discoverIntegrationComponents(integration)) {
|
|
404
|
+
const doc = await loadModuleDoc(rec.docPath);
|
|
405
|
+
candidates.push({
|
|
406
|
+
domain: 'component',
|
|
407
|
+
name: rec.name,
|
|
408
|
+
keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
|
|
409
|
+
description: doc ? doc.usage?.description || doc.description || '' : '',
|
|
410
|
+
_import: rec.package,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return candidates;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Build component candidates: core's own tree plus every configured
|
|
419
|
+
* integration's components.
|
|
420
|
+
* @param {string} coreDir
|
|
421
|
+
* @param {string} cwd
|
|
422
|
+
* @returns {Promise<Candidate[]>}
|
|
423
|
+
*/
|
|
424
|
+
async function gatherComponents(coreDir, cwd) {
|
|
425
|
+
const [core, integrations] = await Promise.all([
|
|
426
|
+
gatherCoreComponents(coreDir),
|
|
427
|
+
gatherIntegrationComponents(cwd),
|
|
428
|
+
]);
|
|
429
|
+
return [...core, ...integrations];
|
|
430
|
+
}
|
|
431
|
+
|
|
386
432
|
/**
|
|
387
433
|
* Build hook candidates: name + keywords + usage/description from the hook's
|
|
388
434
|
* .doc.mjs.
|
|
@@ -611,7 +657,7 @@ export async function search(query, options = {}) {
|
|
|
611
657
|
/** @param {string} d */
|
|
612
658
|
const wants = d => !type || type === d;
|
|
613
659
|
const [components, hooks, docTopics, templates] = await Promise.all([
|
|
614
|
-
wants('component') ? gatherComponents(coreDir) : [],
|
|
660
|
+
wants('component') ? gatherComponents(coreDir, cwd) : [],
|
|
615
661
|
wants('hook') ? gatherHooks(coreDir) : [],
|
|
616
662
|
wants('doc') ? gatherDocs(cwd) : [],
|
|
617
663
|
wants('template') ? gatherTemplates(cwd) : [],
|
|
@@ -10,9 +10,17 @@
|
|
|
10
10
|
* `limit`, an empty query, and a bad `--type` all throw AstryxError with the
|
|
11
11
|
* ERR_INVALID_ARGUMENT code, so a direct `@astryxdesign/cli/api` caller gets the
|
|
12
12
|
* same contract as `astryx search` on the command line.
|
|
13
|
+
*
|
|
14
|
+
* The last describe block covers integration-contributed components, using the
|
|
15
|
+
* same temp-consumer harness as template-integration.test.mjs. Before this,
|
|
16
|
+
* `search`/`build` only ever scanned @astryxdesign/core — an integration's own
|
|
17
|
+
* components were invisible to both, even though `component --list` and
|
|
18
|
+
* `component <Name>` already resolved them. The two discovery paths silently
|
|
19
|
+
* disagreed.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import {describe, it, expect} from 'vitest';
|
|
23
|
+
import * as fs from 'node:fs';
|
|
16
24
|
import * as path from 'node:path';
|
|
17
25
|
import {fileURLToPath} from 'node:url';
|
|
18
26
|
import {search, SEARCH_DOMAINS} from './search.mjs';
|
|
@@ -92,3 +100,66 @@ describe('search leaf — limit validation (API matches the CLI contract)', () =
|
|
|
92
100
|
});
|
|
93
101
|
}, SLOW);
|
|
94
102
|
});
|
|
103
|
+
|
|
104
|
+
describe('search leaf — integration components', () => {
|
|
105
|
+
/**
|
|
106
|
+
* A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
|
|
107
|
+
* resolves without needing the real package) plus an installed
|
|
108
|
+
* `@acme/widgets` integration that contributes one component.
|
|
109
|
+
*/
|
|
110
|
+
function makeConsumerWithIntegrationComponent() {
|
|
111
|
+
const dir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-search-it-'));
|
|
112
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name: 'consumer'}));
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(dir, 'astryx.config.mjs'),
|
|
115
|
+
`export default { integrations: ['@acme/widgets'] };\n`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Stub core: just needs to exist with an (empty) src/ so discoverComponents
|
|
119
|
+
// doesn't throw. Its own component list is irrelevant to this test.
|
|
120
|
+
const coreDir = path.join(dir, 'node_modules', '@astryxdesign', 'core');
|
|
121
|
+
fs.mkdirSync(path.join(coreDir, 'src'), {recursive: true});
|
|
122
|
+
|
|
123
|
+
const widgetsDir = path.join(dir, 'node_modules', '@acme', 'widgets');
|
|
124
|
+
fs.mkdirSync(path.join(widgetsDir, 'components'), {recursive: true});
|
|
125
|
+
fs.writeFileSync(
|
|
126
|
+
path.join(widgetsDir, 'package.json'),
|
|
127
|
+
JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
|
|
128
|
+
);
|
|
129
|
+
fs.writeFileSync(
|
|
130
|
+
path.join(widgetsDir, 'astryx.integration.mjs'),
|
|
131
|
+
`export default { components: './components' };\n`,
|
|
132
|
+
);
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(widgetsDir, 'components', 'FancyGizmo.doc.mjs'),
|
|
135
|
+
`export const docs = {
|
|
136
|
+
name: 'FancyGizmo',
|
|
137
|
+
keywords: ['gizmo', 'widget'],
|
|
138
|
+
usage: {description: 'A fancy gizmo widget.'},
|
|
139
|
+
};\n`,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return dir;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('includes a component contributed by a configured integration', async () => {
|
|
146
|
+
const dir = makeConsumerWithIntegrationComponent();
|
|
147
|
+
try {
|
|
148
|
+
const r = await search('gizmo', {cwd: dir, type: 'component'});
|
|
149
|
+
expect(r.data.results.some(x => x.name === 'FancyGizmo')).toBe(true);
|
|
150
|
+
} finally {
|
|
151
|
+
fs.rmSync(dir, {recursive: true, force: true});
|
|
152
|
+
}
|
|
153
|
+
}, SLOW);
|
|
154
|
+
|
|
155
|
+
it('reports the contributing package as the import hint', async () => {
|
|
156
|
+
const dir = makeConsumerWithIntegrationComponent();
|
|
157
|
+
try {
|
|
158
|
+
const r = await search('FancyGizmo', {cwd: dir, type: 'component'});
|
|
159
|
+
const hit = r.data.results.find(x => x.name === 'FancyGizmo');
|
|
160
|
+
expect(hit?.import).toBe('@acme/widgets');
|
|
161
|
+
} finally {
|
|
162
|
+
fs.rmSync(dir, {recursive: true, force: true});
|
|
163
|
+
}
|
|
164
|
+
}, SLOW);
|
|
165
|
+
});
|
|
@@ -37,6 +37,7 @@ import {Divider} from '@astryxdesign/core/Divider';
|
|
|
37
37
|
import {MetadataList, MetadataListItem} from '@astryxdesign/core/MetadataList';
|
|
38
38
|
import {
|
|
39
39
|
Table,
|
|
40
|
+
TableBody,
|
|
40
41
|
TableRow,
|
|
41
42
|
TableCell,
|
|
42
43
|
proportional,
|
|
@@ -963,161 +964,167 @@ export default function DataTableTemplate() {
|
|
|
963
964
|
/>
|
|
964
965
|
))}
|
|
965
966
|
</colgroup>
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
967
|
+
<TableBody>
|
|
968
|
+
{groupKeys.map(key => {
|
|
969
|
+
const tasks = grouped.get(key);
|
|
970
|
+
if (!tasks || tasks.length === 0) {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
const isExpanded = expandedGroups.has(key);
|
|
972
974
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
<TableRow
|
|
977
|
-
role="button"
|
|
978
|
-
tabIndex={0}
|
|
979
|
-
onClick={() => toggleGroup(key)}
|
|
980
|
-
onKeyDown={e => {
|
|
981
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
982
|
-
e.preventDefault();
|
|
983
|
-
toggleGroup(key);
|
|
984
|
-
}
|
|
985
|
-
}}>
|
|
986
|
-
<TableCell colSpan={COL_COUNT} style={groupHeaderCell}>
|
|
987
|
-
<HStack gap={2} vAlign="center">
|
|
988
|
-
<Icon
|
|
989
|
-
icon={
|
|
990
|
-
isExpanded ? ChevronDownIcon : ChevronRightIcon
|
|
991
|
-
}
|
|
992
|
-
size="sm"
|
|
993
|
-
color="secondary"
|
|
994
|
-
/>
|
|
995
|
-
<Text type="body" weight="bold">
|
|
996
|
-
{getGroupLabel(groupBy, key)}
|
|
997
|
-
</Text>
|
|
998
|
-
<Badge
|
|
999
|
-
variant="neutral"
|
|
1000
|
-
label={String(tasks.length)}
|
|
1001
|
-
/>
|
|
1002
|
-
</HStack>
|
|
1003
|
-
</TableCell>
|
|
1004
|
-
</TableRow>
|
|
1005
|
-
)}
|
|
1006
|
-
{(groupBy === 'none' || isExpanded) &&
|
|
1007
|
-
tasks.map(task => (
|
|
975
|
+
return (
|
|
976
|
+
<React.Fragment key={key}>
|
|
977
|
+
{groupBy !== 'none' && (
|
|
1008
978
|
<TableRow
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
979
|
+
role="button"
|
|
980
|
+
tabIndex={0}
|
|
981
|
+
onClick={() => toggleGroup(key)}
|
|
982
|
+
onKeyDown={e => {
|
|
983
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
984
|
+
e.preventDefault();
|
|
985
|
+
toggleGroup(key);
|
|
986
|
+
}
|
|
987
|
+
}}>
|
|
988
|
+
<TableCell
|
|
989
|
+
colSpan={COL_COUNT}
|
|
990
|
+
style={groupHeaderCell}>
|
|
991
|
+
<HStack gap={2} vAlign="center">
|
|
1021
992
|
<Icon
|
|
1022
|
-
icon={
|
|
993
|
+
icon={
|
|
994
|
+
isExpanded
|
|
995
|
+
? ChevronDownIcon
|
|
996
|
+
: ChevronRightIcon
|
|
997
|
+
}
|
|
1023
998
|
size="sm"
|
|
1024
|
-
color=
|
|
999
|
+
color="secondary"
|
|
1025
1000
|
/>
|
|
1026
|
-
<Text type="
|
|
1027
|
-
{
|
|
1001
|
+
<Text type="body" weight="bold">
|
|
1002
|
+
{getGroupLabel(groupBy, key)}
|
|
1028
1003
|
</Text>
|
|
1029
|
-
<
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
<Text
|
|
1034
|
-
type="body"
|
|
1035
|
-
color="secondary"
|
|
1036
|
-
maxLines={1}>
|
|
1037
|
-
› {task.subtitle}
|
|
1038
|
-
</Text>
|
|
1039
|
-
)}
|
|
1004
|
+
<Badge
|
|
1005
|
+
variant="neutral"
|
|
1006
|
+
label={String(tasks.length)}
|
|
1007
|
+
/>
|
|
1040
1008
|
</HStack>
|
|
1041
1009
|
</TableCell>
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1010
|
+
</TableRow>
|
|
1011
|
+
)}
|
|
1012
|
+
{(groupBy === 'none' || isExpanded) &&
|
|
1013
|
+
tasks.map(task => (
|
|
1014
|
+
<TableRow
|
|
1015
|
+
key={task.id}
|
|
1016
|
+
onClick={() => setSelectedTask(task)}>
|
|
1017
|
+
<TableCell>
|
|
1018
|
+
<Center axis="horizontal">
|
|
1019
|
+
<StatusDot
|
|
1020
|
+
variant={STATUS_DOT_VARIANT[task.status]}
|
|
1021
|
+
label={STATUS_LABEL[task.status]}
|
|
1022
|
+
/>
|
|
1023
|
+
</Center>
|
|
1024
|
+
</TableCell>
|
|
1025
|
+
<TableCell>
|
|
1026
|
+
<HStack gap={3} vAlign="center">
|
|
1027
|
+
<Icon
|
|
1028
|
+
icon={ChartBarIcon}
|
|
1029
|
+
size="sm"
|
|
1030
|
+
color={PRIORITY_COLOR[task.priority]}
|
|
1031
|
+
/>
|
|
1032
|
+
<Text type="supporting" color="secondary">
|
|
1033
|
+
{task.taskId}
|
|
1034
|
+
</Text>
|
|
1035
|
+
<Text type="body" maxLines={1}>
|
|
1036
|
+
{task.title}
|
|
1037
|
+
</Text>
|
|
1038
|
+
{task.subtitle && (
|
|
1039
|
+
<Text
|
|
1040
|
+
type="body"
|
|
1041
|
+
color="secondary"
|
|
1042
|
+
maxLines={1}>
|
|
1043
|
+
› {task.subtitle}
|
|
1044
|
+
</Text>
|
|
1045
|
+
)}
|
|
1046
|
+
</HStack>
|
|
1047
|
+
</TableCell>
|
|
1048
|
+
<TableCell>
|
|
1049
|
+
{task.project ? (
|
|
1050
|
+
<Text type="body" maxLines={1}>
|
|
1051
|
+
{task.project}
|
|
1052
|
+
</Text>
|
|
1053
|
+
) : (
|
|
1054
|
+
<Text type="supporting" color="secondary">
|
|
1055
|
+
—
|
|
1056
|
+
</Text>
|
|
1057
|
+
)}
|
|
1058
|
+
</TableCell>
|
|
1059
|
+
<TableCell>
|
|
1060
|
+
<Text type="supporting" color="secondary">
|
|
1061
|
+
{task.created}
|
|
1046
1062
|
</Text>
|
|
1047
|
-
|
|
1063
|
+
</TableCell>
|
|
1064
|
+
<TableCell>
|
|
1048
1065
|
<Text type="supporting" color="secondary">
|
|
1049
|
-
|
|
1066
|
+
{task.updated}
|
|
1050
1067
|
</Text>
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
onClick: () => {},
|
|
1112
|
-
},
|
|
1113
|
-
]}
|
|
1114
|
-
/>
|
|
1115
|
-
</TableCell>
|
|
1116
|
-
</TableRow>
|
|
1117
|
-
))}
|
|
1118
|
-
</React.Fragment>
|
|
1119
|
-
);
|
|
1120
|
-
})}
|
|
1068
|
+
</TableCell>
|
|
1069
|
+
<TableCell>
|
|
1070
|
+
<Avatar name={task.assignee} size="sm" />
|
|
1071
|
+
</TableCell>
|
|
1072
|
+
<TableCell>
|
|
1073
|
+
<DropdownMenu
|
|
1074
|
+
button={{
|
|
1075
|
+
label: 'Actions',
|
|
1076
|
+
variant: 'ghost',
|
|
1077
|
+
size: 'sm',
|
|
1078
|
+
icon: (
|
|
1079
|
+
<Icon
|
|
1080
|
+
icon={EllipsisHorizontalIcon}
|
|
1081
|
+
size="sm"
|
|
1082
|
+
/>
|
|
1083
|
+
),
|
|
1084
|
+
isIconOnly: true,
|
|
1085
|
+
}}
|
|
1086
|
+
hasChevron={false}
|
|
1087
|
+
items={[
|
|
1088
|
+
{
|
|
1089
|
+
label: 'Edit issue',
|
|
1090
|
+
icon: PencilIcon,
|
|
1091
|
+
onClick: () => {},
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
label: 'Assign to...',
|
|
1095
|
+
icon: UserIcon,
|
|
1096
|
+
onClick: () => {},
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
label: 'Add label',
|
|
1100
|
+
icon: TagIcon,
|
|
1101
|
+
onClick: () => {},
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
label: 'Duplicate',
|
|
1105
|
+
icon: DocumentDuplicateIcon,
|
|
1106
|
+
onClick: () => {},
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
label: 'Move to project',
|
|
1110
|
+
icon: ArrowRightIcon,
|
|
1111
|
+
onClick: () => {},
|
|
1112
|
+
},
|
|
1113
|
+
{type: 'divider' as const},
|
|
1114
|
+
{
|
|
1115
|
+
label: 'Delete issue',
|
|
1116
|
+
icon: TrashIcon,
|
|
1117
|
+
onClick: () => {},
|
|
1118
|
+
},
|
|
1119
|
+
]}
|
|
1120
|
+
/>
|
|
1121
|
+
</TableCell>
|
|
1122
|
+
</TableRow>
|
|
1123
|
+
))}
|
|
1124
|
+
</React.Fragment>
|
|
1125
|
+
);
|
|
1126
|
+
})}
|
|
1127
|
+
</TableBody>
|
|
1121
1128
|
</Table>
|
|
1122
1129
|
</LayoutContent>
|
|
1123
1130
|
}
|
|
@@ -13,6 +13,23 @@
|
|
|
13
13
|
* @returns {Promise<ThemingTarget[]>}
|
|
14
14
|
*/
|
|
15
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[]>;
|
|
16
33
|
/**
|
|
17
34
|
* Collapse the enumeration into the `{key: [props and states]}` map theme
|
|
18
35
|
* validation checks override keys against — both are legal override keys, so
|
|
@@ -46,3 +63,24 @@ export type ThemingTarget = {
|
|
|
46
63
|
*/
|
|
47
64
|
states: string[];
|
|
48
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
|
+
};
|
|
@@ -108,6 +108,73 @@ export async function collectThemingTargets(coreSrc) {
|
|
|
108
108
|
return targets;
|
|
109
109
|
}
|
|
110
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
|
+
|
|
111
178
|
/**
|
|
112
179
|
* Collapse the enumeration into the `{key: [props and states]}` map theme
|
|
113
180
|
* validation checks override keys against — both are legal override keys, so
|
|
@@ -10,9 +10,19 @@
|
|
|
10
10
|
* while `theme targets` says another. That divergence is the failure the
|
|
11
11
|
* listing exists to prevent: a target list that can drift from the components
|
|
12
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.
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
import {describe, it, expect} from 'vitest';
|
|
25
|
+
import * as fs from 'node:fs';
|
|
16
26
|
import * as path from 'node:path';
|
|
17
27
|
import {findCoreDir} from '../fs/paths.mjs';
|
|
18
28
|
import {
|
|
@@ -20,7 +30,11 @@ import {
|
|
|
20
30
|
findComponentReadme,
|
|
21
31
|
} from './component-discovery.mjs';
|
|
22
32
|
import {loadComponentDoc} from './component-loader.mjs';
|
|
23
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
collectThemingTargets,
|
|
35
|
+
collectThemingVars,
|
|
36
|
+
targetsByKey,
|
|
37
|
+
} from './theming-targets.mjs';
|
|
24
38
|
|
|
25
39
|
const coreDir = /** @type {string} */ (findCoreDir(process.cwd()));
|
|
26
40
|
const coreSrc = path.join(coreDir, 'src');
|
|
@@ -125,3 +139,107 @@ describe('collectThemingTargets', () => {
|
|
|
125
139
|
expect(missing).toEqual([]);
|
|
126
140
|
}, 60_000);
|
|
127
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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.4.7-canary.
|
|
3
|
+
"version": "0.4.7-canary.e17d7f0",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -87,10 +87,10 @@
|
|
|
87
87
|
"zod": "^4.4.3"
|
|
88
88
|
},
|
|
89
89
|
"peerDependencies": {
|
|
90
|
-
"@astryxdesign/charts": "0.4.7-canary.
|
|
91
|
-
"@astryxdesign/core": "0.4.7-canary.
|
|
92
|
-
"@astryxdesign/lab": "0.4.7-canary.
|
|
93
|
-
"@astryxdesign/theme-neutral": "0.4.7-canary.
|
|
90
|
+
"@astryxdesign/charts": "0.4.7-canary.e17d7f0",
|
|
91
|
+
"@astryxdesign/core": "0.4.7-canary.e17d7f0",
|
|
92
|
+
"@astryxdesign/lab": "0.4.7-canary.e17d7f0",
|
|
93
|
+
"@astryxdesign/theme-neutral": "0.4.7-canary.e17d7f0",
|
|
94
94
|
"gpt-tokenizer": "^3.4.0"
|
|
95
95
|
},
|
|
96
96
|
"peerDependenciesMeta": {
|
|
@@ -108,10 +108,10 @@
|
|
|
108
108
|
}
|
|
109
109
|
},
|
|
110
110
|
"devDependencies": {
|
|
111
|
-
"@astryxdesign/charts": "0.4.7-canary.
|
|
112
|
-
"@astryxdesign/core": "0.4.7-canary.
|
|
113
|
-
"@astryxdesign/lab": "0.4.7-canary.
|
|
114
|
-
"@astryxdesign/theme-neutral": "0.4.7-canary.
|
|
111
|
+
"@astryxdesign/charts": "0.4.7-canary.e17d7f0",
|
|
112
|
+
"@astryxdesign/core": "0.4.7-canary.e17d7f0",
|
|
113
|
+
"@astryxdesign/lab": "0.4.7-canary.e17d7f0",
|
|
114
|
+
"@astryxdesign/theme-neutral": "0.4.7-canary.e17d7f0",
|
|
115
115
|
"gpt-tokenizer": "^3.4.0"
|
|
116
116
|
},
|
|
117
117
|
"scripts": {
|