@astryxdesign/cli 0.1.1 → 0.1.2-canary.2149fa3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/docs/theme.doc.mjs +2 -2
- package/docs/working-with-ai.doc.mjs +6 -6
- package/package.json +19 -8
- package/src/api/discover.mjs +78 -26
- package/src/api/index.mjs +1 -0
- package/src/api/layout.mjs +301 -0
- package/src/api/layout.test.mjs +238 -0
- package/src/api/template.mjs +191 -50
- package/src/api/template.test.mjs +2 -0
- package/src/api/theme-add.mjs +182 -0
- package/src/codemods/__tests__/registry.test.mjs +1 -0
- package/src/codemods/registry.mjs +1 -0
- package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
- package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
- package/src/commands/build-theme.import-path.test.mjs +22 -2
- package/src/commands/build-theme.mjs +185 -16
- package/src/commands/gap-report.mjs +17 -9
- package/src/commands/gap-report.test.mjs +21 -16
- package/src/commands/init.mjs +34 -8
- package/src/commands/init.next-steps.test.mjs +46 -0
- package/src/commands/layout.mjs +139 -0
- package/src/commands/swizzle.mjs +51 -23
- package/src/commands/upgrade.mjs +2 -71
- package/src/config.mjs +31 -0
- package/src/config.test.mjs +24 -0
- package/src/index.mjs +6 -0
- package/src/lib/config-schema.mjs +119 -0
- package/src/lib/config.mjs +34 -7
- package/src/lib/config.test.mjs +51 -2
- package/src/lib/error-codes.mjs +11 -0
- package/src/lib/integrations.mjs +155 -0
- package/src/lib/integrations.test.mjs +154 -0
- package/src/lib/levenshtein.mjs +29 -0
- package/src/lib/manifest.mjs +10 -0
- package/src/lib/package-scanner.mjs +31 -7
- package/src/lib/string-utils.mjs +5 -14
- package/src/lib/xle/browser.d.ts +100 -0
- package/src/lib/xle/browser.mjs +120 -0
- package/src/lib/xle/expand.mjs +622 -0
- package/src/lib/xle/parse.mjs +581 -0
- package/src/lib/xle/print.mjs +174 -0
- package/src/lib/xle/registry-core.mjs +170 -0
- package/src/lib/xle/registry.mjs +237 -0
- package/src/lib/xle/splice.mjs +137 -0
- package/src/lib/xle/validate.mjs +356 -0
- package/src/lib/xle/xle.test.mjs +333 -0
- package/src/types/config.d.ts +99 -0
- package/src/utils/github.mjs +12 -27
- package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
- package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
- package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
- package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
- package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
- package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
- package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
- package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
- package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
- package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
- package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
- package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
- package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
- package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
- package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
- package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
- package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
- package/templates/blocks/components/Text/TextColors.tsx +20 -5
- package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
- package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
- package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
- package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
- package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
- package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
- package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
- package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
- package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
- package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
- package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
- package/templates/pages/shell-nav/page.tsx +321 -0
- package/templates/pages/shell-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-side-nav/page.tsx +242 -0
- package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-top-nav/page.tsx +224 -0
- package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
- package/templates/pages/theme-showcase/page.tsx +8 -19
- package/templates/themes/butter/butterTheme.ts +916 -0
- package/templates/themes/butter/icons.tsx +77 -0
- package/templates/themes/chocolate/chocolateTheme.ts +230 -0
- package/templates/themes/chocolate/icons.tsx +77 -0
- package/templates/themes/gothic/gothicTheme.ts +657 -0
- package/templates/themes/gothic/icons.tsx +77 -0
- package/templates/themes/manifest.json +90 -0
- package/templates/themes/matcha/icons.tsx +67 -0
- package/templates/themes/matcha/matchaTheme.ts +247 -0
- package/templates/themes/neutral/icons.tsx +77 -0
- package/templates/themes/neutral/neutralTheme.ts +603 -0
- package/templates/themes/stone/icons.tsx +77 -0
- package/templates/themes/stone/stoneTheme.ts +652 -0
- package/templates/themes/y2k/icons.tsx +67 -0
- package/templates/themes/y2k/y2kTheme.ts +617 -0
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file XLE canonical printers — AST → compact (XLE) or outline (XLO) text.
|
|
5
|
+
*
|
|
6
|
+
* Used by `astryx layout check --form …` so either party (agent or human)
|
|
7
|
+
* can request the other surface, and by round-trip conformance tests.
|
|
8
|
+
* Printing is best-effort canonical: spelling variants the lexer
|
|
9
|
+
* normalized are emitted in canonical form.
|
|
10
|
+
*
|
|
11
|
+
* @input parse() AST (binding not required)
|
|
12
|
+
* @output toCompact(doc) / toOutline(doc)
|
|
13
|
+
* @position lib/xle — inverse of parse.mjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function valueText(value) {
|
|
17
|
+
if (typeof value === 'string') return /[\s,'"]/.test(value) ? `'${value}'` : value;
|
|
18
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
19
|
+
if (value && value.idref) return `#${value.idref}`;
|
|
20
|
+
if (Array.isArray(value)) return `[${value.map(valueText).join(',')}]`;
|
|
21
|
+
if (typeof value === 'object' && value !== null) {
|
|
22
|
+
return `{${Object.entries(value)
|
|
23
|
+
.map(([k, v]) => (v === true ? k : `${k}:${valueText(v)}`))
|
|
24
|
+
.join(',')}}`;
|
|
25
|
+
}
|
|
26
|
+
return String(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hintText(hint) {
|
|
30
|
+
let out = hint.name;
|
|
31
|
+
for (const flag of hint.flags || []) out += ` +${flag}`;
|
|
32
|
+
if (hint.arg) out += `:${hint.arg}`;
|
|
33
|
+
return `{${out}}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function attrTokens(node) {
|
|
37
|
+
const tokens = [];
|
|
38
|
+
for (const attr of node.attrs) {
|
|
39
|
+
if (attr.kind === 'kv') tokens.push(`${attr.key}=${valueText(attr.value)}`);
|
|
40
|
+
else if (attr.kind === 'flag') tokens.push(attr.key);
|
|
41
|
+
else if (attr.kind === 'neg') tokens.push(`!${attr.key}`);
|
|
42
|
+
}
|
|
43
|
+
return tokens;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function slotTokensCompact(node) {
|
|
47
|
+
return node.slots.map(slot => {
|
|
48
|
+
if (slot.value == null) return `@${slot.key}`;
|
|
49
|
+
if (typeof slot.value === 'string') return `@${slot.key}='${slot.value}'`;
|
|
50
|
+
if (slot.value.hint) return `@${slot.key}=${hintText(slot.value.hint)}`;
|
|
51
|
+
if (slot.value.idref) return `@${slot.key}=#${slot.value.idref}`;
|
|
52
|
+
if (slot.value.subexpr) return `@${slot.key}=(${siblingsCompact(slot.value.subexpr)})`;
|
|
53
|
+
return `@${slot.key}`;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nodeCompact(node) {
|
|
58
|
+
let out = node.name || '';
|
|
59
|
+
if (node.id) out += `#${node.id}`;
|
|
60
|
+
for (const mod of node.enumMods) out += `.${mod}`;
|
|
61
|
+
if (node.payload != null) out += `"${node.payload}"`;
|
|
62
|
+
if (node.payload2 != null) out += `:"${node.payload2}"`;
|
|
63
|
+
const attrs = [...attrTokens(node), ...slotTokensCompact(node)];
|
|
64
|
+
if (attrs.length > 0) out += `[${attrs.join(' ')}]`;
|
|
65
|
+
if (node.hint) out += hintText(node.hint);
|
|
66
|
+
if (node.selected) out += '!';
|
|
67
|
+
if (node.repeat) out += `*${node.repeat}`;
|
|
68
|
+
if (node.children.length > 0) {
|
|
69
|
+
const inner = siblingsCompact(node.children);
|
|
70
|
+
out += node.children.length === 1 ? ` > ${inner}` : ` > ${inner}`;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function termCompact(item) {
|
|
76
|
+
if (item.kind === 'group') {
|
|
77
|
+
let out = `(${siblingsCompact(item.children)})`;
|
|
78
|
+
if (item.repeat) out += `*${item.repeat}`;
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
// Multi-sibling children need grouping when this node is itself a sibling.
|
|
82
|
+
return nodeCompact(item);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function siblingsCompact(items) {
|
|
86
|
+
return items
|
|
87
|
+
.map((item, i) => {
|
|
88
|
+
const text = termCompact(item);
|
|
89
|
+
// `a > b + c` binds b and c to a; if a itself has a following
|
|
90
|
+
// sibling, the next `+` would rebind into a's children — wrap any
|
|
91
|
+
// non-last sibling that opens a `>` chain in a group.
|
|
92
|
+
const needsGroup =
|
|
93
|
+
i < items.length - 1 && item.kind === 'node' && item.children.length > 0;
|
|
94
|
+
return needsGroup ? `(${text})` : text;
|
|
95
|
+
})
|
|
96
|
+
.join(' + ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function toCompact(doc) {
|
|
100
|
+
let out = siblingsCompact(doc.roots);
|
|
101
|
+
for (const overlay of doc.overlays) {
|
|
102
|
+
out += ` ;; ${termCompact(overlay)}`;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── outline ────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function nodeOutlineLines(node, depth) {
|
|
110
|
+
const pad = ' '.repeat(depth);
|
|
111
|
+
let line = node.name || '';
|
|
112
|
+
if (node.id) line += `#${node.id}`;
|
|
113
|
+
for (const mod of node.enumMods) line += `.${mod}`;
|
|
114
|
+
if (node.payload != null) line += ` "${node.payload}"`;
|
|
115
|
+
if (node.payload2 != null) line += ` :"${node.payload2}"`;
|
|
116
|
+
const attrs = attrTokens(node);
|
|
117
|
+
if (attrs.length > 0) line += ' ' + attrs.join(' ');
|
|
118
|
+
if (node.hint) line += ' ' + hintText(node.hint);
|
|
119
|
+
if (node.selected) line += ' !';
|
|
120
|
+
if (node.repeat) line += ` x${node.repeat}`;
|
|
121
|
+
|
|
122
|
+
const lines = [pad + line.trimStart()];
|
|
123
|
+
for (const slot of node.slots) {
|
|
124
|
+
if (slot.value && slot.value.subexpr) {
|
|
125
|
+
if (slot.value.subexpr.length === 1 && isSimpleNode(slot.value.subexpr[0])) {
|
|
126
|
+
const sub = nodeOutlineLines(slot.value.subexpr[0], 0)[0];
|
|
127
|
+
lines.push(`${pad} ${slot.key}: ${sub}`);
|
|
128
|
+
// simple = no children, so the single line is complete
|
|
129
|
+
} else {
|
|
130
|
+
lines.push(`${pad} ${slot.key}:`);
|
|
131
|
+
for (const sub of slot.value.subexpr) lines.push(...itemOutlineLines(sub, depth + 2));
|
|
132
|
+
}
|
|
133
|
+
} else if (typeof slot.value === 'string') {
|
|
134
|
+
lines.push(`${pad} ${slot.key}: "${slot.value}"`);
|
|
135
|
+
} else if (slot.value && slot.value.hint) {
|
|
136
|
+
lines.push(`${pad} ${slot.key}: ${hintText(slot.value.hint)}`);
|
|
137
|
+
} else if (slot.value && slot.value.idref) {
|
|
138
|
+
lines.push(`${pad} ${slot.key}: #${slot.value.idref}`);
|
|
139
|
+
} else {
|
|
140
|
+
lines.push(`${pad} ${slot.key}:`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const child of node.children) lines.push(...itemOutlineLines(child, depth + 1));
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isSimpleNode(item) {
|
|
148
|
+
return item.kind === 'node' && item.children.length === 0 && item.slots.length === 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function itemOutlineLines(item, depth) {
|
|
152
|
+
if (item.kind === 'group') {
|
|
153
|
+
// A repeat-less group exists only to disambiguate compact-form
|
|
154
|
+
// sibling chains — indentation already does that, so inline it.
|
|
155
|
+
if (!item.repeat || item.repeat === 1) {
|
|
156
|
+
return item.children.flatMap(child => itemOutlineLines(child, depth));
|
|
157
|
+
}
|
|
158
|
+
const pad = ' '.repeat(depth);
|
|
159
|
+
const lines = [`${pad}repeat ${item.repeat}:`];
|
|
160
|
+
for (const child of item.children) lines.push(...itemOutlineLines(child, depth + 1));
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
return nodeOutlineLines(item, depth);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function toOutline(doc) {
|
|
167
|
+
const lines = [];
|
|
168
|
+
for (const item of doc.roots) lines.push(...itemOutlineLines(item, 0));
|
|
169
|
+
if (doc.overlays.length > 0) {
|
|
170
|
+
lines.push('', 'overlays:');
|
|
171
|
+
for (const item of doc.overlays) lines.push(...itemOutlineLines(item, 1));
|
|
172
|
+
}
|
|
173
|
+
return lines.join('\n');
|
|
174
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file XLE registry — pure core (no Node deps).
|
|
5
|
+
*
|
|
6
|
+
* The fs-bound builder lives in registry.mjs; everything here is
|
|
7
|
+
* environment-agnostic so it can run in the browser playground too.
|
|
8
|
+
* This holds the alias table, enum parsing, the component-entry shape,
|
|
9
|
+
* name resolution, and the serialize/hydrate pair that lets a Node
|
|
10
|
+
* build step ship a registry to the browser as plain JSON.
|
|
11
|
+
*
|
|
12
|
+
* @input doc.mjs-shaped prop arrays (from registry.mjs) or serialized JSON
|
|
13
|
+
* @output ALIAS_TABLE, parseEnumValues, toComponentEntry, resolveComponent,
|
|
14
|
+
* serializeRegistry, hydrateRegistry
|
|
15
|
+
* @position lib/xle — imported by registry.mjs (Node) and the browser barrel
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Curated alias table (from the XLE research, paste P2376666892 §2).
|
|
20
|
+
* Single letters reserved for the highest-frequency structural set.
|
|
21
|
+
* Collision policy: case-only pairs forbidden; HTML mnemonics win the
|
|
22
|
+
* Table family; form-frequency wins contested pairs (CB, NI).
|
|
23
|
+
*
|
|
24
|
+
* Aliases are validated against the discovered registry — an alias whose
|
|
25
|
+
* component doesn't exist on this branch is silently dropped.
|
|
26
|
+
*/
|
|
27
|
+
export const ALIAS_TABLE = {
|
|
28
|
+
// Layout core
|
|
29
|
+
A: 'AppShell', L: 'Layout', LH: 'LayoutHeader', LC: 'LayoutContent',
|
|
30
|
+
LF: 'LayoutFooter', LP: 'LayoutPanel', V: 'VStack', H: 'HStack',
|
|
31
|
+
SI: 'StackItem', G: 'Grid', GS: 'GridSpan', S: 'Section', Ctr: 'Center',
|
|
32
|
+
F: 'FormLayout', D: 'Divider', Tbar: 'Toolbar', AR: 'AspectRatio',
|
|
33
|
+
// Navigation
|
|
34
|
+
TN: 'TopNav', TNH: 'TopNavHeading', TNI: 'TopNavItem',
|
|
35
|
+
SN: 'SideNav', SNI: 'SideNavItem', SNS: 'SideNavSection', SNH: 'SideNavHeading',
|
|
36
|
+
MN: 'MobileNav', MNT: 'MobileNavToggle',
|
|
37
|
+
BC: 'Breadcrumbs', BCI: 'BreadcrumbItem',
|
|
38
|
+
TL: 'TabList', Tab: 'Tab', PG: 'Pagination',
|
|
39
|
+
SG: 'SegmentedControl', SGI: 'SegmentedControlItem',
|
|
40
|
+
// Data display
|
|
41
|
+
T: 'Table', TH: 'TableHeader', TB: 'TableBody', TF: 'TableFooter',
|
|
42
|
+
TR: 'TableRow', TC: 'TableCell', TD: 'TableCell', THC: 'TableHeaderCell',
|
|
43
|
+
UL: 'List', LI: 'ListItem', ML: 'MetadataList', MLI: 'MetadataListItem',
|
|
44
|
+
C: 'Card', CC: 'ClickableCard', ES: 'EmptyState', Bd: 'Badge',
|
|
45
|
+
SD: 'StatusDot', Av: 'Avatar', AvG: 'AvatarGroup', Tmb: 'Thumbnail',
|
|
46
|
+
Ts: 'Timestamp', OFL: 'OverflowList', Cs: 'Carousel', It: 'Item',
|
|
47
|
+
// Forms & inputs
|
|
48
|
+
Fd: 'Field', IG: 'InputGroup', IGT: 'InputGroupText',
|
|
49
|
+
TI: 'TextInput', TA: 'TextArea', NI: 'NumberInput',
|
|
50
|
+
DI: 'DateInput', DR: 'DateRangeInput', DT: 'DateTimeInput', TM: 'TimeInput',
|
|
51
|
+
FI: 'FileInput', CB: 'CheckboxInput', CL: 'CheckboxList', CLI: 'CheckboxListItem',
|
|
52
|
+
RL: 'RadioList', RLI: 'RadioListItem', SW: 'Switch', SL: 'Slider',
|
|
53
|
+
SE: 'Selector', MS: 'MultiSelector', TY: 'Typeahead', Tkz: 'Tokenizer',
|
|
54
|
+
PS: 'PowerSearch', CAL: 'Calendar',
|
|
55
|
+
// Overlay & feedback
|
|
56
|
+
Dlg: 'Dialog', DH: 'DialogHeader', AD: 'AlertDialog',
|
|
57
|
+
Po: 'Popover', HC: 'HoverCard', Tt: 'Tooltip', Bn: 'Banner', Ov: 'Overlay',
|
|
58
|
+
CM: 'ContextMenu', DM: 'DropdownMenu', MM: 'MoreMenu', CP: 'CommandPalette',
|
|
59
|
+
Col: 'Collapsible', ColG: 'CollapsibleGroup',
|
|
60
|
+
Sp: 'Spinner', PB: 'ProgressBar', Sk: 'Skeleton',
|
|
61
|
+
// Content & chat
|
|
62
|
+
Tx: 'Text', Hd: 'Heading', MD: 'Markdown', Cd: 'CodeBlock', BQ: 'Blockquote',
|
|
63
|
+
K: 'Kbd', Ic: 'Icon', Lk: 'Link', Tk: 'Token',
|
|
64
|
+
B: 'Button', IB: 'IconButton', BG: 'ButtonGroup', Tg: 'ToggleButton', TgG: 'ToggleButtonGroup',
|
|
65
|
+
ChL: 'ChatLayout', ChML: 'ChatMessageList', ChM: 'ChatMessage',
|
|
66
|
+
ChB: 'ChatMessageBubble', ChC: 'ChatComposer', ChCD: 'ChatComposerDrawer',
|
|
67
|
+
ChS: 'ChatSystemMessage', ChT: 'ChatToolCalls',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** SpacingStep — the canonical spacing enum (matches the doc.mjs prose). */
|
|
71
|
+
export const SPACING_STEPS = [0, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 8, 10];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse enum values from a doc.mjs prop type string.
|
|
75
|
+
* "'a' | 'b' | 'c'" → ['a','b','c']; "1|2|3" → [1,2,3];
|
|
76
|
+
* "SpacingStep" → SPACING_STEPS. Mixed/non-enum types → null.
|
|
77
|
+
*/
|
|
78
|
+
export function parseEnumValues(type) {
|
|
79
|
+
if (!type || typeof type !== 'string') return null;
|
|
80
|
+
const t = type.trim();
|
|
81
|
+
if (t === 'SpacingStep' || /^0\|0\.5\|/.test(t.replace(/\s+/g, ''))) {
|
|
82
|
+
return SPACING_STEPS;
|
|
83
|
+
}
|
|
84
|
+
const parts = t.split('|').map(p => p.trim());
|
|
85
|
+
if (parts.length < 2) return null;
|
|
86
|
+
const values = [];
|
|
87
|
+
for (const part of parts) {
|
|
88
|
+
const str = part.match(/^'([^']*)'$/) || part.match(/^"([^"]*)"$/);
|
|
89
|
+
if (str) { values.push(str[1]); continue; }
|
|
90
|
+
if (/^\d+(\.\d+)?$/.test(part)) { values.push(Number(part)); continue; }
|
|
91
|
+
return null; // union of non-literal types (e.g. number|string) — not an enum
|
|
92
|
+
}
|
|
93
|
+
return values;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function normalizeName(name) {
|
|
97
|
+
return name.replace(/^XDS/, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Index one doc entry's props into the registry component shape.
|
|
102
|
+
*/
|
|
103
|
+
export function toComponentEntry(name, props, dirName, importPath) {
|
|
104
|
+
const propMap = new Map();
|
|
105
|
+
for (const p of props || []) {
|
|
106
|
+
propMap.set(p.name, {
|
|
107
|
+
name: p.name,
|
|
108
|
+
type: p.type || '',
|
|
109
|
+
required: p.required === true,
|
|
110
|
+
enumValues: parseEnumValues(p.type),
|
|
111
|
+
isBoolean: (p.type || '').trim() === 'boolean',
|
|
112
|
+
isFunction: /^\(/.test((p.type || '').trim()),
|
|
113
|
+
isNode: /ReactNode|ReactElement/.test(p.type || ''),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return {name, exportName: `XDS${name}`, dirName, importPath, props: propMap};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a node name (alias, bare name, or XDS-prefixed name) to a
|
|
121
|
+
* registry component entry, or null.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveComponent(registry, name) {
|
|
124
|
+
const viaAlias = registry.aliases.get(name);
|
|
125
|
+
if (viaAlias) return registry.components.get(viaAlias);
|
|
126
|
+
const bare = normalizeName(name);
|
|
127
|
+
return registry.components.get(bare) || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Serialize a built registry (with Map fields) to a plain JSON-safe object
|
|
132
|
+
* so a Node build step can ship it to the browser.
|
|
133
|
+
*/
|
|
134
|
+
export function serializeRegistry(registry) {
|
|
135
|
+
return {
|
|
136
|
+
components: [...registry.components.values()].map(c => ({
|
|
137
|
+
name: c.name,
|
|
138
|
+
exportName: c.exportName,
|
|
139
|
+
dirName: c.dirName,
|
|
140
|
+
importPath: c.importPath,
|
|
141
|
+
undocumented: c.undocumented || false,
|
|
142
|
+
props: [...c.props.values()],
|
|
143
|
+
})),
|
|
144
|
+
aliases: [...registry.aliases.entries()],
|
|
145
|
+
componentNames: registry.componentNames,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Hydrate a serialized registry back into the Map-bearing shape that
|
|
151
|
+
* validate()/expand() expect. Inverse of serializeRegistry.
|
|
152
|
+
*/
|
|
153
|
+
export function hydrateRegistry(json) {
|
|
154
|
+
const components = new Map();
|
|
155
|
+
for (const c of json.components) {
|
|
156
|
+
components.set(c.name, {
|
|
157
|
+
name: c.name,
|
|
158
|
+
exportName: c.exportName,
|
|
159
|
+
dirName: c.dirName,
|
|
160
|
+
importPath: c.importPath,
|
|
161
|
+
undocumented: c.undocumented || false,
|
|
162
|
+
props: new Map(c.props.map(p => [p.name, p])),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
components,
|
|
167
|
+
aliases: new Map(json.aliases),
|
|
168
|
+
componentNames: json.componentNames || [...components.keys()].sort(),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file XLE component registry — built from @astryxdesign/core .doc.mjs metadata.
|
|
5
|
+
*
|
|
6
|
+
* Everything the layout language knows about components (valid names,
|
|
7
|
+
* aliases, props, enums, slots) is derived from the same .doc.mjs files
|
|
8
|
+
* that power `xds component`, so the notation can never drift from the
|
|
9
|
+
* branch's actual API. The pure pieces (alias table, enum parsing,
|
|
10
|
+
* resolution, serialize/hydrate) live in registry-core.mjs so they can
|
|
11
|
+
* run in the browser; this module adds the fs-bound builder.
|
|
12
|
+
*
|
|
13
|
+
* @input packages/core/src/(star)/(star).doc.mjs via component-discovery
|
|
14
|
+
* @output buildRegistry() → { components, aliases, componentNames }
|
|
15
|
+
* @position lib/xle — shared by parse/validate/expand; no CLI concerns here
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import {findCoreDir} from '../../utils/paths.mjs';
|
|
21
|
+
import {
|
|
22
|
+
discoverComponents,
|
|
23
|
+
findComponentReadme,
|
|
24
|
+
resolveImportPath,
|
|
25
|
+
} from '../component-discovery.mjs';
|
|
26
|
+
import {loadDocs} from '../component-loader.mjs';
|
|
27
|
+
import {
|
|
28
|
+
ALIAS_TABLE,
|
|
29
|
+
normalizeName,
|
|
30
|
+
toComponentEntry,
|
|
31
|
+
} from './registry-core.mjs';
|
|
32
|
+
|
|
33
|
+
// Re-export the pure surface so existing importers (and tests) keep working.
|
|
34
|
+
export {
|
|
35
|
+
ALIAS_TABLE,
|
|
36
|
+
SPACING_STEPS,
|
|
37
|
+
parseEnumValues,
|
|
38
|
+
resolveComponent,
|
|
39
|
+
serializeRegistry,
|
|
40
|
+
hydrateRegistry,
|
|
41
|
+
} from './registry-core.mjs';
|
|
42
|
+
|
|
43
|
+
function findDirFor(grouped, member) {
|
|
44
|
+
for (const [dir, members] of Object.entries(grouped)) {
|
|
45
|
+
if (members.includes(member)) return dir;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Match bare `export {Name}` / `export {Name, Other}` named re-exports in a
|
|
51
|
+
// component directory's index.ts. PascalCase only — hooks (useX), lowercase
|
|
52
|
+
// helpers, and `type` exports are skipped via the caller's filters.
|
|
53
|
+
const NAMED_EXPORT_RE = /export\s*\{([^}]*)\}/g;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the PascalCase component names re-exported from a component directory's
|
|
57
|
+
* `index.ts`. Used to recover structural sub-components that ship without their
|
|
58
|
+
* own `.doc.mjs` (e.g. TableHeader, TableBody, TableFooter) — the doc-driven
|
|
59
|
+
* discoverComponents() intentionally skips those, but the layout language still
|
|
60
|
+
* needs to name and emit them.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} coreDir
|
|
63
|
+
* @param {string} dirName
|
|
64
|
+
* @returns {string[]} bare PascalCase export names
|
|
65
|
+
*/
|
|
66
|
+
function readDirExportedComponents(coreDir, dirName) {
|
|
67
|
+
const indexPath = path.join(coreDir, 'src', dirName, 'index.ts');
|
|
68
|
+
let content;
|
|
69
|
+
try {
|
|
70
|
+
content = fs.readFileSync(indexPath, 'utf-8');
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const names = new Set();
|
|
75
|
+
for (const match of content.matchAll(NAMED_EXPORT_RE)) {
|
|
76
|
+
for (const raw of match[1].split(',')) {
|
|
77
|
+
// `Name`, `Name as Alias`, ` type Name ` — keep the exported (left) name,
|
|
78
|
+
// drop `type` and aliases.
|
|
79
|
+
const token = raw.trim();
|
|
80
|
+
if (!token || token.startsWith('type ')) continue;
|
|
81
|
+
const name = token.split(/\s+as\s+/)[0].trim().replace(/^XDS/, '');
|
|
82
|
+
// PascalCase components only — skip hooks (useX), lowercase utils, and
|
|
83
|
+
// non-rendering exports (Context/Provider) that discoverComponents()
|
|
84
|
+
// also excludes.
|
|
85
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) continue;
|
|
86
|
+
if (/(?:Context|Provider)$/.test(name)) continue;
|
|
87
|
+
names.add(name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...names];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let cachedRegistry = null;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build (and cache) the registry: every documented component keyed by its
|
|
97
|
+
* un-prefixed name, plus the validated alias map.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} [options]
|
|
100
|
+
* @param {string} [options.cwd]
|
|
101
|
+
* @returns {Promise<{components: Map<string, object>, aliases: Map<string, string>, componentNames: string[]}>}
|
|
102
|
+
*/
|
|
103
|
+
export async function buildRegistry({cwd = process.cwd()} = {}) {
|
|
104
|
+
if (cachedRegistry) return cachedRegistry;
|
|
105
|
+
|
|
106
|
+
const coreDir = findCoreDir(cwd);
|
|
107
|
+
if (!coreDir) {
|
|
108
|
+
throw new Error('Could not find @astryxdesign/core package — run from an XDS workspace');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const components = new Map();
|
|
112
|
+
const grouped = discoverComponents(coreDir);
|
|
113
|
+
const dirNames = [...new Set(Object.values(grouped).flat())];
|
|
114
|
+
|
|
115
|
+
for (const dirName of dirNames) {
|
|
116
|
+
const readme = findComponentReadme(coreDir, dirName);
|
|
117
|
+
if (!readme || !readme.endsWith('.doc.mjs')) continue;
|
|
118
|
+
let docs;
|
|
119
|
+
try {
|
|
120
|
+
docs = await loadDocs(readme, {});
|
|
121
|
+
} catch {
|
|
122
|
+
continue; // a malformed doc must not take down the whole language
|
|
123
|
+
}
|
|
124
|
+
const importPath = resolveImportPath(coreDir, dirName);
|
|
125
|
+
|
|
126
|
+
const register = (rawName, props) => {
|
|
127
|
+
const name = normalizeName(rawName);
|
|
128
|
+
const entry = toComponentEntry(name, props, dirName, importPath);
|
|
129
|
+
const existing = components.get(name);
|
|
130
|
+
// Some docs list related components with empty prop arrays
|
|
131
|
+
// (e.g. Layout's references to Card) — prefer the richer entry.
|
|
132
|
+
if (!existing || entry.props.size > existing.props.size) {
|
|
133
|
+
components.set(name, entry);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (docs.props) register(docs.name || dirName, docs.props);
|
|
138
|
+
for (const sub of docs.components || []) {
|
|
139
|
+
if (sub?.name) register(sub.name, sub.props);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Some components are documented in a sibling `<Name>.doc.mjs` inside another
|
|
144
|
+
// component's dir (e.g. Text/Heading.doc.mjs documents Heading, with its own
|
|
145
|
+
// props like `level`). The per-dir main-doc read above only sees the bare
|
|
146
|
+
// `{name: 'Heading'}` cross-reference stub (no props), so without this those
|
|
147
|
+
// props go missing and valid attrs (e.g. Heading[level=2]) get rejected.
|
|
148
|
+
// Read every .doc.mjs in each contributing dir and upgrade props, keyed to
|
|
149
|
+
// each component's own export subpath. Props-only: never adds empty entries.
|
|
150
|
+
const upgradeFromDoc = (rawName, props, fallbackDir) => {
|
|
151
|
+
if (!props || props.length === 0) return;
|
|
152
|
+
const name = normalizeName(rawName);
|
|
153
|
+
// Prefer the component's own export subpath (Heading → @astryxdesign/core/Heading);
|
|
154
|
+
// fall back to the dir it was found in, then any existing entry's path.
|
|
155
|
+
const ip =
|
|
156
|
+
resolveImportPath(coreDir, name) ||
|
|
157
|
+
resolveImportPath(coreDir, fallbackDir) ||
|
|
158
|
+
components.get(name)?.importPath;
|
|
159
|
+
const entry = toComponentEntry(name, props, name, ip);
|
|
160
|
+
const existing = components.get(name);
|
|
161
|
+
if (!existing || entry.props.size > existing.props.size) components.set(name, entry);
|
|
162
|
+
};
|
|
163
|
+
for (const dirName of dirNames) {
|
|
164
|
+
const dirPath = path.join(coreDir, 'src', dirName);
|
|
165
|
+
let files;
|
|
166
|
+
try {
|
|
167
|
+
files = fs.readdirSync(dirPath).filter(f => f.endsWith('.doc.mjs'));
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (const file of files) {
|
|
172
|
+
let docs;
|
|
173
|
+
try {
|
|
174
|
+
docs = await loadDocs(path.join(dirPath, file), {});
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (docs.props) upgradeFromDoc(docs.name || file.replace(/\.doc\.mjs$/, ''), docs.props, dirName);
|
|
179
|
+
for (const sub of docs.components || []) {
|
|
180
|
+
if (sub?.name && sub.props?.length) upgradeFromDoc(sub.name, sub.props, dirName);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Exported components without their own doc entry (e.g. TableHeader,
|
|
186
|
+
// TableBody) still get minimal registry entries so they can be named in
|
|
187
|
+
// expressions — the validator warns rather than validates their props.
|
|
188
|
+
//
|
|
189
|
+
// Two sources:
|
|
190
|
+
// 1. Members discovered alongside a documented sibling (legacy path).
|
|
191
|
+
// 2. Bare PascalCase re-exports in each component dir's index.ts. The
|
|
192
|
+
// doc-driven discoverComponents() skips doc-less structural pieces
|
|
193
|
+
// (TableHeader/Body/Footer became bare files with no own .doc.mjs after
|
|
194
|
+
// the un-prefix migration), so we recover them from the real export
|
|
195
|
+
// surface here.
|
|
196
|
+
const registerUndocumented = (name, dirName) => {
|
|
197
|
+
if (components.has(name)) return;
|
|
198
|
+
const entry = toComponentEntry(name, [], dirName, resolveImportPath(coreDir, name));
|
|
199
|
+
entry.undocumented = true;
|
|
200
|
+
components.set(name, entry);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (const [, members] of Object.entries(grouped)) {
|
|
204
|
+
for (const member of members) {
|
|
205
|
+
registerUndocumented(normalizeName(member), findDirFor(grouped, member) || member);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Walk every component directory (those that contributed at least one
|
|
210
|
+
// documented component) and backfill its index.ts exports.
|
|
211
|
+
const docDirs = new Set();
|
|
212
|
+
for (const c of components.values()) {
|
|
213
|
+
if (c.dirName) docDirs.add(c.dirName);
|
|
214
|
+
}
|
|
215
|
+
for (const dirName of docDirs) {
|
|
216
|
+
for (const name of readDirExportedComponents(coreDir, dirName)) {
|
|
217
|
+
registerUndocumented(name, dirName);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const aliases = new Map();
|
|
222
|
+
for (const [alias, target] of Object.entries(ALIAS_TABLE)) {
|
|
223
|
+
if (components.has(target)) aliases.set(alias, target);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
cachedRegistry = {
|
|
227
|
+
components,
|
|
228
|
+
aliases,
|
|
229
|
+
componentNames: [...components.keys()].sort(),
|
|
230
|
+
};
|
|
231
|
+
return cachedRegistry;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Test seam — drop the module-level cache. */
|
|
235
|
+
export function resetRegistryCache() {
|
|
236
|
+
cachedRegistry = null;
|
|
237
|
+
}
|