@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +19 -8
  5. package/src/api/discover.mjs +78 -26
  6. package/src/api/index.mjs +1 -0
  7. package/src/api/layout.mjs +301 -0
  8. package/src/api/layout.test.mjs +238 -0
  9. package/src/api/template.mjs +191 -50
  10. package/src/api/template.test.mjs +2 -0
  11. package/src/api/theme-add.mjs +182 -0
  12. package/src/codemods/__tests__/registry.test.mjs +1 -0
  13. package/src/codemods/registry.mjs +1 -0
  14. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  15. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  16. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  17. package/src/commands/build-theme.import-path.test.mjs +22 -2
  18. package/src/commands/build-theme.mjs +185 -16
  19. package/src/commands/gap-report.mjs +17 -9
  20. package/src/commands/gap-report.test.mjs +21 -16
  21. package/src/commands/init.mjs +34 -8
  22. package/src/commands/init.next-steps.test.mjs +46 -0
  23. package/src/commands/layout.mjs +139 -0
  24. package/src/commands/swizzle.mjs +51 -23
  25. package/src/commands/upgrade.mjs +2 -71
  26. package/src/config.mjs +31 -0
  27. package/src/config.test.mjs +24 -0
  28. package/src/index.mjs +6 -0
  29. package/src/lib/config-schema.mjs +119 -0
  30. package/src/lib/config.mjs +34 -7
  31. package/src/lib/config.test.mjs +51 -2
  32. package/src/lib/error-codes.mjs +11 -0
  33. package/src/lib/integrations.mjs +155 -0
  34. package/src/lib/integrations.test.mjs +154 -0
  35. package/src/lib/levenshtein.mjs +29 -0
  36. package/src/lib/manifest.mjs +10 -0
  37. package/src/lib/package-scanner.mjs +31 -7
  38. package/src/lib/string-utils.mjs +5 -14
  39. package/src/lib/xle/browser.d.ts +100 -0
  40. package/src/lib/xle/browser.mjs +120 -0
  41. package/src/lib/xle/expand.mjs +622 -0
  42. package/src/lib/xle/parse.mjs +581 -0
  43. package/src/lib/xle/print.mjs +174 -0
  44. package/src/lib/xle/registry-core.mjs +170 -0
  45. package/src/lib/xle/registry.mjs +237 -0
  46. package/src/lib/xle/splice.mjs +137 -0
  47. package/src/lib/xle/validate.mjs +356 -0
  48. package/src/lib/xle/xle.test.mjs +333 -0
  49. package/src/types/config.d.ts +99 -0
  50. package/src/utils/github.mjs +12 -27
  51. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  52. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  53. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  54. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  55. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  56. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  57. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  58. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  59. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  60. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  61. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  62. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  63. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  64. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  65. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  66. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  67. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  68. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  69. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  70. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  71. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  72. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  73. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  74. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  75. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  76. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  77. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  78. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  79. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  80. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  81. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  82. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  83. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  84. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  85. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  86. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  87. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  88. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  89. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  90. package/templates/pages/shell-nav/page.tsx +321 -0
  91. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  92. package/templates/pages/shell-side-nav/page.tsx +242 -0
  93. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  94. package/templates/pages/shell-top-nav/page.tsx +224 -0
  95. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  96. package/templates/pages/theme-showcase/page.tsx +8 -19
  97. package/templates/themes/butter/butterTheme.ts +916 -0
  98. package/templates/themes/butter/icons.tsx +77 -0
  99. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  100. package/templates/themes/chocolate/icons.tsx +77 -0
  101. package/templates/themes/gothic/gothicTheme.ts +657 -0
  102. package/templates/themes/gothic/icons.tsx +77 -0
  103. package/templates/themes/manifest.json +90 -0
  104. package/templates/themes/matcha/icons.tsx +67 -0
  105. package/templates/themes/matcha/matchaTheme.ts +247 -0
  106. package/templates/themes/neutral/icons.tsx +77 -0
  107. package/templates/themes/neutral/neutralTheme.ts +603 -0
  108. package/templates/themes/stone/icons.tsx +77 -0
  109. package/templates/themes/stone/stoneTheme.ts +652 -0
  110. package/templates/themes/y2k/icons.tsx +67 -0
  111. package/templates/themes/y2k/y2kTheme.ts +617 -0
  112. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  113. 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
+ }