@astryxdesign/cli 0.1.0 → 0.1.1-canary.080d887

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 (176) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +117 -75
  3. package/bin/astryx.mjs +22 -7
  4. package/docs/getting-started.doc.mjs +11 -11
  5. package/docs/icons.doc.mjs +1 -1
  6. package/docs/migration.doc.mjs +2 -2
  7. package/docs/shape.doc.mjs +1 -1
  8. package/docs/styling.doc.mjs +3 -4
  9. package/docs/theme.doc.dense.mjs +2 -2
  10. package/docs/theme.doc.mjs +14 -0
  11. package/docs/theme.doc.zh.mjs +2 -2
  12. package/docs/working-with-ai.doc.mjs +4 -4
  13. package/package.json +20 -9
  14. package/src/api/discover.mjs +78 -26
  15. package/src/api/doctor.mjs +3 -3
  16. package/src/api/layout.mjs +301 -0
  17. package/src/api/layout.test.mjs +238 -0
  18. package/src/api/search.mjs +207 -13
  19. package/src/api/template.mjs +193 -51
  20. package/src/api/template.test.mjs +2 -0
  21. package/src/codemods/__tests__/registry.test.mjs +1 -0
  22. package/src/codemods/registry.mjs +1 -0
  23. package/src/codemods/runner.mjs +105 -51
  24. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +116 -0
  25. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +51 -0
  26. package/src/codemods/transforms/v0.1.0/index.mjs +28 -0
  27. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +230 -0
  28. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +84 -0
  29. package/src/commands/agent-docs.mjs +119 -66
  30. package/src/commands/agent-docs.path-safety.test.mjs +1 -1
  31. package/src/commands/agent-docs.test.mjs +87 -31
  32. package/src/commands/build-theme.import-path.test.mjs +1 -1
  33. package/src/commands/build-theme.path-safety.test.mjs +1 -1
  34. package/src/commands/build-theme.prose.test.mjs +1 -1
  35. package/src/commands/build.mjs +196 -0
  36. package/src/commands/component-package.test.mjs +1 -1
  37. package/src/commands/component.test.mjs +1 -1
  38. package/src/commands/docs.test.mjs +1 -1
  39. package/src/commands/doctor.test.mjs +1 -1
  40. package/src/commands/external-showcase.test.mjs +1 -1
  41. package/src/commands/gap-report.mjs +17 -9
  42. package/src/commands/gap-report.test.mjs +21 -16
  43. package/src/commands/init.mjs +43 -9
  44. package/src/commands/init.next-steps.test.mjs +46 -0
  45. package/src/commands/interactive-guard.test.mjs +1 -1
  46. package/src/commands/json-contract.test.mjs +10 -3
  47. package/src/commands/layout.mjs +139 -0
  48. package/src/commands/swizzle-gap-safety.test.mjs +1 -1
  49. package/src/commands/swizzle.mjs +51 -23
  50. package/src/commands/swizzle.path-safety.test.mjs +1 -1
  51. package/src/commands/template.path-safety.test.mjs +1 -1
  52. package/src/commands/template.test.mjs +1 -1
  53. package/src/commands/upgrade.mjs +292 -177
  54. package/src/commands/upgrade.test.mjs +41 -27
  55. package/src/config.mjs +31 -0
  56. package/src/config.test.mjs +24 -0
  57. package/src/index.mjs +5 -0
  58. package/src/lib/config-schema.mjs +119 -0
  59. package/src/lib/config.mjs +45 -6
  60. package/src/lib/config.test.mjs +91 -0
  61. package/src/lib/error-codes.mjs +11 -0
  62. package/src/lib/integrations.mjs +155 -0
  63. package/src/lib/integrations.test.mjs +154 -0
  64. package/src/lib/levenshtein.mjs +29 -0
  65. package/src/lib/manifest.mjs +6 -0
  66. package/src/lib/package-scanner.mjs +31 -7
  67. package/src/lib/string-utils.mjs +5 -14
  68. package/src/lib/xle/browser.d.ts +91 -0
  69. package/src/lib/xle/browser.mjs +120 -0
  70. package/src/lib/xle/expand.mjs +622 -0
  71. package/src/lib/xle/parse.mjs +581 -0
  72. package/src/lib/xle/print.mjs +174 -0
  73. package/src/lib/xle/registry-core.mjs +170 -0
  74. package/src/lib/xle/registry.mjs +237 -0
  75. package/src/lib/xle/splice.mjs +137 -0
  76. package/src/lib/xle/validate.mjs +356 -0
  77. package/src/lib/xle/xle.test.mjs +333 -0
  78. package/src/types/config.d.ts +99 -0
  79. package/src/types/error-codes.d.ts +1 -0
  80. package/src/utils/github.mjs +12 -27
  81. package/src/utils/interactive.mjs +1 -1
  82. package/src/utils/interactive.test.mjs +2 -0
  83. package/src/utils/package-manager.mjs +1 -1
  84. package/src/utils/package-manager.test.mjs +1 -1
  85. package/src/utils/path-safety.test.mjs +1 -1
  86. package/src/utils/paths.test.mjs +8 -8
  87. package/src/utils/update-check.mjs +4 -26
  88. package/src/utils/update-check.test.mjs +2 -64
  89. package/templates/blocks/components/AppShell/AppShellContentOnly.tsx +1 -9
  90. package/templates/blocks/components/AppShell/AppShellShowcase.tsx +1 -10
  91. package/templates/blocks/components/AppShell/AppShellSideNavOnly.tsx +1 -9
  92. package/templates/blocks/components/AppShell/AppShellTopNavOnly.tsx +1 -9
  93. package/templates/blocks/components/AppShell/AppShellTopNavWithSideNav.tsx +1 -9
  94. package/templates/blocks/components/AppShell/AppShellWithBanner.tsx +1 -9
  95. package/templates/blocks/components/AspectRatio/AspectRatioShowcase.tsx +12 -19
  96. package/templates/blocks/components/Banner/BannerShowcase.tsx +1 -8
  97. package/templates/blocks/components/Blockquote/BlockquoteShowcase.tsx +1 -8
  98. package/templates/blocks/components/Carousel/CarouselShowcase.tsx +2 -12
  99. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerShowcase.tsx +6 -9
  100. package/templates/blocks/components/ChatLayout/ChatLayoutPanelChat.tsx +10 -12
  101. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +1 -9
  102. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -9
  103. package/templates/blocks/components/ChatMessageList/ChatMessageListShowcase.tsx +1 -9
  104. package/templates/blocks/components/ChatMessageMetadata/ChatMessageMetadataShowcase.tsx +1 -8
  105. package/templates/blocks/components/ChatSendButton/ChatSendButtonInComposer.tsx +1 -8
  106. package/templates/blocks/components/Citation/CitationInlineText.tsx +4 -4
  107. package/templates/blocks/components/Code/CodeInlineInParagraph.tsx +1 -8
  108. package/templates/blocks/components/CodeBlock/CodeBlockBashCommand.tsx +1 -1
  109. package/templates/blocks/components/CodeBlock/CodeBlockJSONConfig.tsx +1 -1
  110. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  111. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  112. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemShowcase.tsx +9 -12
  113. package/templates/blocks/components/ContextMenu/ContextMenuShowcase.tsx +13 -15
  114. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  115. package/templates/blocks/components/Divider/DividerShowcase.tsx +1 -8
  116. package/templates/blocks/components/Divider/DividerVertical.tsx +7 -9
  117. package/templates/blocks/components/Field/FieldShowcase.tsx +1 -8
  118. package/templates/blocks/components/FormLayout/FormLayoutHorizontal.tsx +1 -6
  119. package/templates/blocks/components/Grid/GridResponsiveAutoFit.tsx +1 -9
  120. package/templates/blocks/components/HoverCard/HoverCardInlineTextHoverCard.tsx +4 -6
  121. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +1 -6
  122. package/templates/blocks/components/HoverCard/HoverCardProfileHoverCard.tsx +2 -8
  123. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -8
  124. package/templates/blocks/components/OverflowList/OverflowListOverflowBadges.tsx +8 -11
  125. package/templates/blocks/components/OverflowList/OverflowListOverflowDropdownActions.tsx +9 -12
  126. package/templates/blocks/components/Overlay/OverlayBottomStrip.tsx +4 -17
  127. package/templates/blocks/components/Overlay/OverlayHoverReveal.tsx +15 -16
  128. package/templates/blocks/components/Overlay/OverlayShowcase.tsx +5 -21
  129. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -14
  130. package/templates/blocks/components/Pagination/PaginationPageSize.tsx +12 -14
  131. package/templates/blocks/components/Pagination/PaginationVariants.tsx +1 -8
  132. package/templates/blocks/components/Pagination/PaginationWithTable.tsx +2 -14
  133. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  134. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  135. package/templates/blocks/components/Tokenizer/TokenizerClear.tsx +1 -6
  136. package/templates/blocks/components/Tokenizer/TokenizerCreatable.tsx +2 -7
  137. package/templates/blocks/components/Tokenizer/TokenizerEndContent.tsx +1 -6
  138. package/templates/blocks/components/Tokenizer/TokenizerIcon.tsx +1 -6
  139. package/templates/blocks/components/Tokenizer/TokenizerMaxEntries.tsx +1 -6
  140. package/templates/blocks/components/Tokenizer/TokenizerOverflow.tsx +2 -7
  141. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +1 -6
  142. package/templates/blocks/components/Tokenizer/TokenizerStates.tsx +4 -9
  143. package/templates/blocks/components/Toolbar/ToolbarCardHeader.tsx +1 -10
  144. package/templates/blocks/components/Toolbar/ToolbarSizes.tsx +1 -8
  145. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +1 -8
  146. package/templates/blocks/components/Toolbar/ToolbarThreeSlot.tsx +1 -10
  147. package/templates/blocks/components/Toolbar/ToolbarWithTabs.tsx +8 -11
  148. package/templates/pages/ai-chat/page.tsx +71 -64
  149. package/templates/pages/ai-chat-landing/page.tsx +8 -12
  150. package/templates/pages/centered-hero/page.tsx +13 -15
  151. package/templates/pages/classic-gallery/page.tsx +27 -34
  152. package/templates/pages/detail-page/page.tsx +18 -18
  153. package/templates/pages/documentation/page.tsx +42 -58
  154. package/templates/pages/documentation-design/page.tsx +82 -60
  155. package/templates/pages/documentation-technical/page.tsx +101 -60
  156. package/templates/pages/editor/page.tsx +42 -54
  157. package/templates/pages/file-explorer/page.tsx +13 -16
  158. package/templates/pages/form-two-column/page.tsx +13 -17
  159. package/templates/pages/gallery-hero/page.tsx +13 -15
  160. package/templates/pages/ide/page.tsx +188 -264
  161. package/templates/pages/library/page.tsx +16 -23
  162. package/templates/pages/login/page.tsx +14 -18
  163. package/templates/pages/login-card/page.tsx +14 -18
  164. package/templates/pages/login-split/page.tsx +50 -48
  165. package/templates/pages/login-sso/page.tsx +9 -13
  166. package/templates/pages/mixed-gallery/page.tsx +51 -45
  167. package/templates/pages/payment-form/page.tsx +56 -70
  168. package/templates/pages/product-detail/page.tsx +27 -33
  169. package/templates/pages/product-gallery/page.tsx +7 -13
  170. package/templates/pages/settings-dialog/page.tsx +35 -43
  171. package/templates/pages/settings-sidebar/page.tsx +39 -47
  172. package/templates/pages/side-gallery/page.tsx +6 -9
  173. package/templates/pages/table-grouped/page.tsx +11 -15
  174. package/templates/pages/theme-showcase/page.tsx +33 -37
  175. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  176. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -67
@@ -0,0 +1,238 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration tests for the layout API against the real @astryxdesign/core
5
+ * registry and real template blocks. Every expansion is additionally
6
+ * checked for TSX syntactic validity with the TypeScript parser, so the
7
+ * "expansion emits compilable JSX" contract is enforced, not assumed.
8
+ */
9
+
10
+ import {describe, it, expect, beforeAll} from 'vitest';
11
+ import {mkdtempSync, writeFileSync, rmSync} from 'node:fs';
12
+ import {tmpdir} from 'node:os';
13
+ import {join} from 'node:path';
14
+ import ts from 'typescript';
15
+ import {layoutExpand, layoutCheck, layoutGrammar} from './layout.mjs';
16
+ import {buildRegistry} from '../lib/xle/registry.mjs';
17
+
18
+ // The registry imports ~140 .doc.mjs modules on first use; under full-suite
19
+ // parallel load that can exceed the default 5s test timeout. Warm it once.
20
+ beforeAll(async () => {
21
+ await buildRegistry();
22
+ }, 120_000);
23
+
24
+ const SLOW = 30_000;
25
+
26
+ /** Assert TSX parses cleanly; returns the source file for inspection. */
27
+ function expectValidTsx(code) {
28
+ const sourceFile = ts.createSourceFile('generated.tsx', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
29
+ const diagnostics = sourceFile.parseDiagnostics || [];
30
+ const messages = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, ' '));
31
+ expect(messages).toEqual([]);
32
+ return sourceFile;
33
+ }
34
+
35
+ const LOGIN_COMPACT =
36
+ 'Ctr[h="100dvh"] > C[w=400 p8] > V[g6] > ' +
37
+ '(V[g1] > Tx"Welcome back" + Tx[t=supporting]"Sign in to your account") + ' +
38
+ '(F > TI"Email"[t=email req] + TI"Password"[t=password req]) + ' +
39
+ '(H[j=between a=center] > CB"Remember me" + Lk[href="/forgot"]"Forgot password?") + ' +
40
+ 'B.primary"Sign in"';
41
+
42
+ const CHAT_OUTLINE = `
43
+ AppShell
44
+ topNav: TN
45
+ Layout > LC !scroll
46
+ ChL
47
+ composer:
48
+ ChC
49
+ ChML
50
+ ChS "Today"
51
+ repeat 2:
52
+ ChM
53
+ ChB "Reply $"
54
+ Tbar "Actions"
55
+ B "Delete" opens=#confirm
56
+
57
+ overlays:
58
+ AD#confirm "Delete item?"
59
+ `;
60
+
61
+ const DASHBOARD_COMPACT =
62
+ 'A[@topNav=TN @sideNav=SN] > L > V[g6] > ' +
63
+ '(G[c4 g4] > C{card-callout}*4) + ' +
64
+ '(C[p0] > T[striped] > (TR > THC"Name" + THC"Amount") + (TR > TC"Order $" + TC"\\$12.00")*3)';
65
+
66
+ describe('layoutExpand', () => {
67
+ it('expands the login card with typed state scaffolds', async () => {
68
+ const result = await layoutExpand(LOGIN_COMPACT);
69
+ expect(result.type).toBe('layout.expand');
70
+ const {code} = result.data;
71
+ expectValidTsx(code);
72
+
73
+ expect(code).toContain(`const [email, setEmail] = useState('');`);
74
+ expect(code).toContain(`const [rememberMe, setRememberMe] = useState(false);`);
75
+ expect(code).toContain('<XDSCenter height="100dvh">');
76
+ expect(code).toContain('variant="primary"');
77
+ // axis-neutral j/a resolved onto the HStack's real props
78
+ expect(code).toContain('hAlign="between"');
79
+ expect(code).toContain('vAlign="center"');
80
+ // payload routed to label props, not duplicated as children
81
+ expect(code).not.toMatch(/label="Forgot password\?"[\s\S]{0,80}Forgot password\?/);
82
+ }, SLOW);
83
+
84
+ it('expands the outline chat page: slots, repeat blocks, overlays, triggers', async () => {
85
+ const result = await layoutExpand(CHAT_OUTLINE);
86
+ const {code, form} = result.data;
87
+ expect(form).toBe('outline');
88
+ expectValidTsx(code);
89
+
90
+ expect(code).toContain('composer={');
91
+ // slot satisfied required prop — no double assignment, no TODO for it
92
+ expect(code.match(/composer=/g)).toHaveLength(1);
93
+ expect(code).toContain('TODO(xle): open #confirm');
94
+ expect(code).toContain('overlays — wire open state');
95
+ expect((code.match(/<XDSChatMessage\b/g) || [])).toHaveLength(2);
96
+ }, SLOW);
97
+
98
+ it('expands the dashboard: table partition, repeats with $ counter and \\$ escape', async () => {
99
+ const result = await layoutExpand(DASHBOARD_COMPACT);
100
+ const {code} = result.data;
101
+ expectValidTsx(code);
102
+
103
+ expect(code).toContain('<XDSTableHeader>');
104
+ expect(code).toContain('<XDSTableBody>');
105
+ expect(code).toContain('Order 1');
106
+ expect(code).toContain('Order 3');
107
+ expect(code).toContain('$12.00');
108
+ expect(code).not.toContain('112.00');
109
+ // {card-callout} now splices: the block is co-defined once and referenced
110
+ // four times — no TODO marker.
111
+ expect((code.match(/^function CardCallout\(\)/gm) || [])).toHaveLength(1);
112
+ expect((code.match(/<CardCallout \/>/g) || [])).toHaveLength(4);
113
+ expect(code).not.toContain("TODO(xle): content block 'CardCallout'");
114
+ }, SLOW);
115
+
116
+ it('compact and outline surfaces expand to identical TSX', async () => {
117
+ const check = await layoutCheck(LOGIN_COMPACT);
118
+ const fromCompact = await layoutExpand(LOGIN_COMPACT);
119
+ const fromOutline = await layoutExpand(check.data.outline, {form: 'outline'});
120
+ expect(fromOutline.data.code).toEqual(fromCompact.data.code);
121
+ }, SLOW);
122
+
123
+ it('expansion is deterministic', async () => {
124
+ const a = await layoutExpand(DASHBOARD_COMPACT);
125
+ const b = await layoutExpand(DASHBOARD_COMPACT);
126
+ expect(a.data.code).toEqual(b.data.code);
127
+ }, SLOW);
128
+
129
+ it('throws structured errors with suggestions on invalid expressions', async () => {
130
+ await expect(layoutExpand('A[p6] > Grdi')).rejects.toMatchObject({
131
+ code: 'ERR_LAYOUT_INVALID',
132
+ message: expect.stringMatching(/AppShell has no prop 'padding'/),
133
+ });
134
+ });
135
+
136
+ it('rejects non-PascalCase --name', async () => {
137
+ await expect(layoutExpand('V > C', {name: 'not pascal'})).rejects.toMatchObject({
138
+ code: 'ERR_INVALID_ARGUMENT',
139
+ });
140
+ });
141
+
142
+ it('surfaces parse errors with positions', async () => {
143
+ await expect(layoutExpand('V > > C')).rejects.toMatchObject({
144
+ code: 'ERR_LAYOUT_PARSE',
145
+ message: expect.stringMatching(/line 1/),
146
+ });
147
+ });
148
+ });
149
+
150
+ describe('layoutCheck', () => {
151
+ it('returns both canonical surfaces for valid input', async () => {
152
+ const result = await layoutCheck('V[g6] > C{card-callout}*2');
153
+ expect(result.data.valid).toBe(true);
154
+ expect(result.data.compact).toContain('{card-callout}');
155
+ expect(result.data.outline).toContain('C {card-callout} x2');
156
+ });
157
+
158
+ it('collects all errors instead of stopping at the first', async () => {
159
+ const result = await layoutCheck('A[p6] > V[g7] > Bd.sucess"x" + C{not-a-block}');
160
+ expect(result.data.valid).toBe(false);
161
+ expect(result.data.errors.length).toBeGreaterThanOrEqual(4);
162
+ const all = result.data.errors.map(e => e.message).join('\n');
163
+ expect(all).toMatch(/no prop 'padding'/);
164
+ expect(all).toMatch(/must be one of/);
165
+ expect(all).toMatch(/Unknown block/);
166
+ });
167
+ });
168
+
169
+ describe('template referencing', () => {
170
+ it('splices a template block: co-defined once, referenced, imports merged', async () => {
171
+ const result = await layoutExpand('S[p6] > C{card-callout}*3', {name: 'Demo'});
172
+ const {code} = result.data;
173
+ expectValidTsx(code);
174
+ // co-defined exactly once, referenced three times, no TODO
175
+ expect((code.match(/^function CardCallout\(\)/gm) || [])).toHaveLength(1);
176
+ expect((code.match(/<CardCallout \/>/g) || [])).toHaveLength(3);
177
+ expect(code).not.toContain('TODO(xle)');
178
+ // the block's own import is hoisted, and shared specifiers dedupe
179
+ expect((code.match(/from '@astryxdesign\/core\/Card'/g) || [])).toHaveLength(1);
180
+ // the block lost its export — it's a local declaration now
181
+ expect(code).not.toContain('export default function CardCallout');
182
+ expect(result.data.blocksReferenced).toEqual([{name: 'CardCallout', mode: 'splice'}]);
183
+ }, SLOW);
184
+
185
+ it('merges a stateful block useState into a single react import', async () => {
186
+ const result = await layoutExpand(
187
+ 'V > TI"Search"[t=text] + {table-column-settings-table}',
188
+ {name: 'Demo'},
189
+ );
190
+ const {code} = result.data;
191
+ expectValidTsx(code);
192
+ // page TextInput scaffolds useState; block also uses useState → one import
193
+ expect((code.match(/^import \{useState\} from 'react';$/gm) || [])).toHaveLength(1);
194
+ expect(code).toContain('function TableColumnSettingsTable()');
195
+ }, SLOW);
196
+
197
+ it('imports app-registered local components (the local-component bridge)', async () => {
198
+ // Inside the workspace so @astryxdesign/core resolves; cleaned up after.
199
+ const cwd = mkdtempSync(join(process.cwd(), '.xle-imp-test-'));
200
+ try {
201
+ writeFileSync(
202
+ join(cwd, 'astryx.config.mjs'),
203
+ `export default {layout: {components: {KpiCard: '@/components/KpiCard', TimeRangePicker: {from: '@/components/TimeRangePicker'}}}};\n`,
204
+ );
205
+ const result = await layoutExpand('S[p6] > (G[c4 g4] > {kpi-card}*4) + {time-range-picker}', {
206
+ name: 'Demo',
207
+ cwd,
208
+ });
209
+ const {code} = result.data;
210
+ expectValidTsx(code);
211
+ expect(code).toContain("import {KpiCard} from '@/components/KpiCard';");
212
+ expect(code).toContain("import {TimeRangePicker} from '@/components/TimeRangePicker';");
213
+ expect((code.match(/<KpiCard \/>/g) || [])).toHaveLength(4);
214
+ expect(code).toContain('<TimeRangePicker />');
215
+ expect(result.data.blocksReferenced).toContainEqual({name: 'KpiCard', mode: 'import'});
216
+ } finally {
217
+ rmSync(cwd, {recursive: true, force: true});
218
+ }
219
+ }, SLOW);
220
+
221
+ it('parses a standalone {block} in both surfaces', async () => {
222
+ const check = await layoutCheck('G[c4 g4] > {card-callout}*2');
223
+ expect(check.data.valid).toBe(true);
224
+ expect(check.data.compact).toContain('{card-callout}*2');
225
+ expect(check.data.outline).toMatch(/\{card-callout\} x2/);
226
+ });
227
+ });
228
+
229
+ describe('layoutGrammar', () => {
230
+ it('emits the cheatsheet with branch-generated aliases', async () => {
231
+ const result = await layoutGrammar();
232
+ expect(result.data.text).toContain('TWO SURFACES');
233
+ expect(result.data.aliases.V).toBe('VStack');
234
+ expect(result.data.aliases.TB).toBe('TableBody');
235
+ // every alias target must exist — the table is registry-filtered
236
+ expect(Object.values(result.data.aliases)).not.toContain(undefined);
237
+ });
238
+ });
@@ -41,14 +41,184 @@ import {
41
41
  } from '../lib/component-discovery.mjs';
42
42
  import {discoverHooks, findHookDoc} from '../lib/hook-discovery.mjs';
43
43
  import {levenshteinDistance} from '../lib/string-utils.mjs';
44
- import {discoverTemplates} from './template.mjs';
44
+ import {discoverTemplates, extractComponents} from './template.mjs';
45
45
  import {AstryxError} from './error.mjs';
46
46
 
47
47
  const DOCS_DIR = path.join(CLI_ROOT, 'docs');
48
48
 
49
+ /**
50
+ * Synonym / intent map: product-language terms an agent is likely to type,
51
+ * expanded to the catalog's vocabulary so oblique queries still rank. Keys and
52
+ * values are matched bidirectionally (typing any value also pulls in the key
53
+ * and its siblings). Lowercase, single words or short phrases.
54
+ */
55
+ const SYNONYMS = {
56
+ dashboard: ['overview', 'analytics', 'kpi', 'kpis', 'metrics', 'stats', 'reporting', 'insights', 'control'],
57
+ login: ['signin', 'auth', 'authentication', 'sso', 'credentials', 'account'],
58
+ signup: ['register', 'registration', 'onboarding'],
59
+ payment: ['checkout', 'billing', 'card', 'pay', 'purchase', 'order'],
60
+ pricing: ['plans', 'plan', 'tiers', 'tier', 'subscription', 'subscriptions'],
61
+ chat: ['messaging', 'message', 'messages', 'conversation', 'inbox', 'dm'],
62
+ settings: ['preferences', 'config', 'configuration', 'account'],
63
+ calendar: ['schedule', 'scheduling', 'events', 'event', 'month', 'agenda'],
64
+ table: ['list', 'rows', 'records', 'grid', 'spreadsheet', 'datatable'],
65
+ gallery: ['photos', 'photo', 'images', 'image', 'pictures'],
66
+ hero: ['banner', 'splash', 'headline', 'landing'],
67
+ form: ['fields', 'input', 'inputs', 'survey'],
68
+ profile: ['bio', 'avatar', 'user'],
69
+ documentation: ['docs', 'reference', 'guide', 'api'],
70
+ navigation: ['nav', 'menu', 'sidebar'],
71
+ };
72
+
73
+ // Flatten into a token -> Set(expansions) lookup (bidirectional).
74
+ const SYNONYM_INDEX = (() => {
75
+ const idx = new Map();
76
+ const add = (a, b) => {
77
+ if (!idx.has(a)) idx.set(a, new Set());
78
+ idx.get(a).add(b);
79
+ };
80
+ for (const [key, vals] of Object.entries(SYNONYMS)) {
81
+ for (const v of vals) {
82
+ add(key, v);
83
+ add(v, key);
84
+ for (const v2 of vals) if (v2 !== v) add(v, v2);
85
+ }
86
+ }
87
+ return idx;
88
+ })();
89
+
90
+ /**
91
+ * Light stemmer: strips common English suffixes so "charts"/"charting" and
92
+ * "chart" share a root. Deliberately crude (no Porter) — good enough to bridge
93
+ * plural/gerund gaps without a dependency.
94
+ * @param {string} w
95
+ * @returns {string}
96
+ */
97
+ export function stem(w) {
98
+ let s = w;
99
+ for (const suf of ['ing', 'ed', 'ies', 'es', 's']) {
100
+ if (s.length > suf.length + 2 && s.endsWith(suf)) {
101
+ s = suf === 'ies' ? s.slice(0, -3) + 'y' : s.slice(0, -suf.length);
102
+ break;
103
+ }
104
+ }
105
+ return s;
106
+ }
107
+
108
+
49
109
  /** Valid domain filters for `--type`. */
50
110
  export const SEARCH_DOMAINS = ['component', 'hook', 'doc', 'template'];
51
111
 
112
+ /**
113
+ * Filler words stripped from multi-word queries so natural-language phrasing
114
+ * ("a page where you can see business stats") ranks on its content words.
115
+ */
116
+ const STOPWORDS = new Set([
117
+ 'a', 'an', 'the', 'of', 'for', 'to', 'with', 'and', 'or', 'in', 'on', 'at',
118
+ 'by', 'that', 'this', 'my', 'your', 'our', 'their', 'is', 'are', 'be', 'it',
119
+ 'its', 'as', 'from', 'page', 'screen', 'app', 'application', 'view', 'where',
120
+ 'you', 'can', 'some', 'like', 'just', 'basically', 'kinda', 'want', 'wants',
121
+ 'need', 'needs', 'something', 'thing', 'things', 'build', 'make', 'create',
122
+ 'i', 'me', 'we', 'us', 'so', 'up', 'out', 'over', 'side', 'one', 'big',
123
+ ]);
124
+
125
+ /**
126
+ * Split a query into meaningful content tokens (lowercased, stopwords + very
127
+ * short words removed). Empty for single-word queries (callers fall back to
128
+ * whole-phrase scoring).
129
+ * @param {string} term - Already-lowercased query.
130
+ * @returns {string[]}
131
+ */
132
+ export function tokenizeQuery(term) {
133
+ return term
134
+ .split(/\s+/)
135
+ // Strip only leading/trailing punctuation; keep joined identifiers intact
136
+ // (e.g. "foo_bar" stays one token) so gibberish stays gibberish.
137
+ .map(t => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
138
+ .filter(t => t.length >= 2 && !STOPWORDS.has(t));
139
+ }
140
+
141
+ /**
142
+ * Score a candidate against a query, handling multi-word natural language.
143
+ * Tries the whole phrase (so exact/near matches still win) AND a per-token
144
+ * pass (so "data table with filters" matches `table-page` via table+filter),
145
+ * and returns whichever is stronger.
146
+ *
147
+ * @param {string} term - Lowercased full query.
148
+ * @param {string[]} tokens - Content tokens from tokenizeQuery(term).
149
+ * @param {object} candidate
150
+ * @returns {{score: number, reason: string} | null}
151
+ */
152
+ /**
153
+ * Minimum per-token score (in the multi-word pass) to count as a real match.
154
+ * 50 = a genuine name/keyword/description hit; below that is loose Levenshtein
155
+ * fuzz that would otherwise turn gibberish queries into noise.
156
+ */
157
+ const MIN_TOKEN_SCORE = 50;
158
+
159
+ /**
160
+ * Best score for a token against a candidate, fanning out through synonyms
161
+ * (synonym hits are discounted so a direct hit always wins).
162
+ * @returns {{score: number, reason: string} | null}
163
+ */
164
+ function bestForToken(tok, candidate) {
165
+ let best = scoreCandidate(tok, candidate);
166
+ const syns = SYNONYM_INDEX.get(tok);
167
+ if (syns) {
168
+ for (const s of syns) {
169
+ const h = scoreCandidate(s, candidate);
170
+ if (h) {
171
+ const score = Math.round(h.score * 0.85);
172
+ if (!best || score > best.score) best = {score, reason: `${h.reason} (~${tok})`};
173
+ }
174
+ }
175
+ }
176
+ return best;
177
+ }
178
+
179
+ export function scoreQuery(term, tokens, candidate) {
180
+ const full = scoreCandidate(term, candidate);
181
+
182
+ // 0–1 content tokens: keep whole-phrase fuzzy matching (typo tolerance for
183
+ // single words), but if stopwords left exactly one DIFFERENT token (e.g.
184
+ // "pricing page" → "pricing"), score that token too and take the stronger.
185
+ if (tokens.length <= 1) {
186
+ const single = tokens.length === 1 ? bestForToken(tokens[0], candidate) : null;
187
+ if (full && (!single || full.score >= single.score)) return full;
188
+ return single;
189
+ }
190
+
191
+ // Multi-word natural language: score each content token, counting only
192
+ // strong hits, then reward coverage so candidates matching more terms win.
193
+ let sum = 0;
194
+ let matched = 0;
195
+ const hitTerms = [];
196
+ for (const tok of tokens) {
197
+ const h = bestForToken(tok, candidate);
198
+ if (h && h.score >= MIN_TOKEN_SCORE) {
199
+ sum += h.score;
200
+ matched++;
201
+ hitTerms.push(tok);
202
+ }
203
+ }
204
+ if (matched === 0) return full;
205
+
206
+ // Reward the AVERAGE strength of the concepts that matched (not divided by
207
+ // total query length — that penalizes verbose / low-fidelity prompts), plus
208
+ // a bonus per additional matched concept and a coverage term. A candidate
209
+ // that matches several of the query's concepts beats one matching a single
210
+ // incidental word.
211
+ const avgMatched = sum / matched;
212
+ const coverage = matched / tokens.length;
213
+ const tokenScore = Math.round(avgMatched + Math.min(matched - 1, 3) * 12 + coverage * 15);
214
+
215
+ if (full && full.score >= tokenScore) return full;
216
+ return {
217
+ score: tokenScore,
218
+ reason: `matches ${matched}/${tokens.length} terms: ${hitTerms.join(', ')}`,
219
+ };
220
+ }
221
+
52
222
  /**
53
223
  * Score a single candidate against the search term across name, keywords,
54
224
  * and prose signals. Returns the best (highest) score plus a human reason,
@@ -107,10 +277,13 @@ export function scoreCandidate(term, {name, keywords = [], description = '', pro
107
277
  else if (dist === 2) consider(30, `keyword "${kw}" (distance ${dist})`);
108
278
  }
109
279
 
110
- // ── Prose / description signals (whole-word boundary) ───────────
280
+ // ── Prose / description signals (stem-tolerant whole word) ──────
281
+ // Match the term's stem as a whole word, tolerating plural/gerund suffixes
282
+ // so "chart" matches "charts" and "filter" matches "filtering".
111
283
  if (term.length >= 3) {
112
- const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
- const re = new RegExp(`\\b${escaped}\\b`);
284
+ const root = stem(term);
285
+ const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
286
+ const re = new RegExp(`\\b${escaped}(s|es|ing|ed|ies)?\\b`);
114
287
  if (description && re.test(description.toLowerCase())) {
115
288
  consider(50, `description mentions "${term}"`);
116
289
  } else {
@@ -240,14 +413,30 @@ async function gatherTemplates(cwd) {
240
413
  } catch {
241
414
  return [];
242
415
  }
243
- return templates.map(t => ({
244
- domain: 'template',
245
- name: t.dirName,
246
- keywords: Array.isArray(t.componentsUsed) ? t.componentsUsed : [],
247
- description: t.description || '',
248
- _displayName: t.name,
249
- _kind: t.type, // 'page' | 'block'
250
- }));
416
+ return templates.map(t => {
417
+ // Blocks ship componentsUsed; page templates don't, so derive them from the
418
+ // source. Category words (e.g. "Dashboard - Analytics") are strong intent
419
+ // signal for pages, which otherwise only index on name + description.
420
+ let keywords = Array.isArray(t.componentsUsed) ? [...t.componentsUsed] : [];
421
+ if (t.type === 'page') {
422
+ if (t.filePath) {
423
+ try {
424
+ keywords = keywords.concat(extractComponents(t.filePath));
425
+ } catch {
426
+ // Best-effort: skip keyword enrichment if the source can't be read.
427
+ }
428
+ }
429
+ if (t.category) keywords = keywords.concat(t.category.split(/[^A-Za-z0-9]+/).filter(Boolean));
430
+ }
431
+ return {
432
+ domain: 'template',
433
+ name: t.dirName,
434
+ keywords,
435
+ description: t.description || '',
436
+ _displayName: t.name,
437
+ _kind: t.type, // 'page' | 'block'
438
+ };
439
+ });
251
440
  }
252
441
 
253
442
  /**
@@ -325,6 +514,7 @@ export async function search(query, options = {}) {
325
514
  }
326
515
 
327
516
  const term = String(query).trim().toLowerCase();
517
+ const tokens = tokenizeQuery(term);
328
518
 
329
519
  const coreDir = findCoreDir(cwd);
330
520
  if (!coreDir) {
@@ -342,9 +532,13 @@ export async function search(query, options = {}) {
342
532
 
343
533
  const all = [...components, ...hooks, ...docTopics, ...templates];
344
534
 
535
+ // Score every candidate on its own merits. The consumer groups results by
536
+ // role (page / block / component) and takes the top of each, so there's no
537
+ // cross-role competition to engineer — a target page only needs to be the
538
+ // strongest PAGE, not outrank every component.
345
539
  const scored = [];
346
540
  for (const candidate of all) {
347
- const hit = scoreCandidate(term, candidate);
541
+ const hit = scoreQuery(term, tokens, candidate);
348
542
  if (hit) scored.push(toResult(candidate, hit.score, hit.reason));
349
543
  }
350
544