@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.
- package/CHANGELOG.md +68 -0
- package/README.md +117 -75
- package/bin/astryx.mjs +22 -7
- package/docs/getting-started.doc.mjs +11 -11
- package/docs/icons.doc.mjs +1 -1
- package/docs/migration.doc.mjs +2 -2
- package/docs/shape.doc.mjs +1 -1
- package/docs/styling.doc.mjs +3 -4
- package/docs/theme.doc.dense.mjs +2 -2
- package/docs/theme.doc.mjs +14 -0
- package/docs/theme.doc.zh.mjs +2 -2
- package/docs/working-with-ai.doc.mjs +4 -4
- package/package.json +20 -9
- package/src/api/discover.mjs +78 -26
- package/src/api/doctor.mjs +3 -3
- package/src/api/layout.mjs +301 -0
- package/src/api/layout.test.mjs +238 -0
- package/src/api/search.mjs +207 -13
- package/src/api/template.mjs +193 -51
- package/src/api/template.test.mjs +2 -0
- package/src/codemods/__tests__/registry.test.mjs +1 -0
- package/src/codemods/registry.mjs +1 -0
- package/src/codemods/runner.mjs +105 -51
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +116 -0
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +51 -0
- package/src/codemods/transforms/v0.1.0/index.mjs +28 -0
- package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +230 -0
- package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +84 -0
- package/src/commands/agent-docs.mjs +119 -66
- package/src/commands/agent-docs.path-safety.test.mjs +1 -1
- package/src/commands/agent-docs.test.mjs +87 -31
- package/src/commands/build-theme.import-path.test.mjs +1 -1
- package/src/commands/build-theme.path-safety.test.mjs +1 -1
- package/src/commands/build-theme.prose.test.mjs +1 -1
- package/src/commands/build.mjs +196 -0
- package/src/commands/component-package.test.mjs +1 -1
- package/src/commands/component.test.mjs +1 -1
- package/src/commands/docs.test.mjs +1 -1
- package/src/commands/doctor.test.mjs +1 -1
- package/src/commands/external-showcase.test.mjs +1 -1
- package/src/commands/gap-report.mjs +17 -9
- package/src/commands/gap-report.test.mjs +21 -16
- package/src/commands/init.mjs +43 -9
- package/src/commands/init.next-steps.test.mjs +46 -0
- package/src/commands/interactive-guard.test.mjs +1 -1
- package/src/commands/json-contract.test.mjs +10 -3
- package/src/commands/layout.mjs +139 -0
- package/src/commands/swizzle-gap-safety.test.mjs +1 -1
- package/src/commands/swizzle.mjs +51 -23
- package/src/commands/swizzle.path-safety.test.mjs +1 -1
- package/src/commands/template.path-safety.test.mjs +1 -1
- package/src/commands/template.test.mjs +1 -1
- package/src/commands/upgrade.mjs +292 -177
- package/src/commands/upgrade.test.mjs +41 -27
- package/src/config.mjs +31 -0
- package/src/config.test.mjs +24 -0
- package/src/index.mjs +5 -0
- package/src/lib/config-schema.mjs +119 -0
- package/src/lib/config.mjs +45 -6
- package/src/lib/config.test.mjs +91 -0
- 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 +6 -0
- package/src/lib/package-scanner.mjs +31 -7
- package/src/lib/string-utils.mjs +5 -14
- package/src/lib/xle/browser.d.ts +91 -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/types/error-codes.d.ts +1 -0
- package/src/utils/github.mjs +12 -27
- package/src/utils/interactive.mjs +1 -1
- package/src/utils/interactive.test.mjs +2 -0
- package/src/utils/package-manager.mjs +1 -1
- package/src/utils/package-manager.test.mjs +1 -1
- package/src/utils/path-safety.test.mjs +1 -1
- package/src/utils/paths.test.mjs +8 -8
- package/src/utils/update-check.mjs +4 -26
- package/src/utils/update-check.test.mjs +2 -64
- package/templates/blocks/components/AppShell/AppShellContentOnly.tsx +1 -9
- package/templates/blocks/components/AppShell/AppShellShowcase.tsx +1 -10
- package/templates/blocks/components/AppShell/AppShellSideNavOnly.tsx +1 -9
- package/templates/blocks/components/AppShell/AppShellTopNavOnly.tsx +1 -9
- package/templates/blocks/components/AppShell/AppShellTopNavWithSideNav.tsx +1 -9
- package/templates/blocks/components/AppShell/AppShellWithBanner.tsx +1 -9
- package/templates/blocks/components/AspectRatio/AspectRatioShowcase.tsx +12 -19
- package/templates/blocks/components/Banner/BannerShowcase.tsx +1 -8
- package/templates/blocks/components/Blockquote/BlockquoteShowcase.tsx +1 -8
- package/templates/blocks/components/Carousel/CarouselShowcase.tsx +2 -12
- package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerShowcase.tsx +6 -9
- package/templates/blocks/components/ChatLayout/ChatLayoutPanelChat.tsx +10 -12
- package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +1 -9
- package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -9
- package/templates/blocks/components/ChatMessageList/ChatMessageListShowcase.tsx +1 -9
- package/templates/blocks/components/ChatMessageMetadata/ChatMessageMetadataShowcase.tsx +1 -8
- package/templates/blocks/components/ChatSendButton/ChatSendButtonInComposer.tsx +1 -8
- package/templates/blocks/components/Citation/CitationInlineText.tsx +4 -4
- package/templates/blocks/components/Code/CodeInlineInParagraph.tsx +1 -8
- package/templates/blocks/components/CodeBlock/CodeBlockBashCommand.tsx +1 -1
- package/templates/blocks/components/CodeBlock/CodeBlockJSONConfig.tsx +1 -1
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemShowcase.tsx +9 -12
- package/templates/blocks/components/ContextMenu/ContextMenuShowcase.tsx +13 -15
- package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
- package/templates/blocks/components/Divider/DividerShowcase.tsx +1 -8
- package/templates/blocks/components/Divider/DividerVertical.tsx +7 -9
- package/templates/blocks/components/Field/FieldShowcase.tsx +1 -8
- package/templates/blocks/components/FormLayout/FormLayoutHorizontal.tsx +1 -6
- package/templates/blocks/components/Grid/GridResponsiveAutoFit.tsx +1 -9
- package/templates/blocks/components/HoverCard/HoverCardInlineTextHoverCard.tsx +4 -6
- package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +1 -6
- package/templates/blocks/components/HoverCard/HoverCardProfileHoverCard.tsx +2 -8
- package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -8
- package/templates/blocks/components/OverflowList/OverflowListOverflowBadges.tsx +8 -11
- package/templates/blocks/components/OverflowList/OverflowListOverflowDropdownActions.tsx +9 -12
- package/templates/blocks/components/Overlay/OverlayBottomStrip.tsx +4 -17
- package/templates/blocks/components/Overlay/OverlayHoverReveal.tsx +15 -16
- package/templates/blocks/components/Overlay/OverlayShowcase.tsx +5 -21
- package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -14
- package/templates/blocks/components/Pagination/PaginationPageSize.tsx +12 -14
- package/templates/blocks/components/Pagination/PaginationVariants.tsx +1 -8
- package/templates/blocks/components/Pagination/PaginationWithTable.tsx +2 -14
- package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
- package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
- package/templates/blocks/components/Tokenizer/TokenizerClear.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerCreatable.tsx +2 -7
- package/templates/blocks/components/Tokenizer/TokenizerEndContent.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerIcon.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerMaxEntries.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerOverflow.tsx +2 -7
- package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerStates.tsx +4 -9
- package/templates/blocks/components/Toolbar/ToolbarCardHeader.tsx +1 -10
- package/templates/blocks/components/Toolbar/ToolbarSizes.tsx +1 -8
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +1 -8
- package/templates/blocks/components/Toolbar/ToolbarThreeSlot.tsx +1 -10
- package/templates/blocks/components/Toolbar/ToolbarWithTabs.tsx +8 -11
- package/templates/pages/ai-chat/page.tsx +71 -64
- package/templates/pages/ai-chat-landing/page.tsx +8 -12
- package/templates/pages/centered-hero/page.tsx +13 -15
- package/templates/pages/classic-gallery/page.tsx +27 -34
- package/templates/pages/detail-page/page.tsx +18 -18
- package/templates/pages/documentation/page.tsx +42 -58
- package/templates/pages/documentation-design/page.tsx +82 -60
- package/templates/pages/documentation-technical/page.tsx +101 -60
- package/templates/pages/editor/page.tsx +42 -54
- package/templates/pages/file-explorer/page.tsx +13 -16
- package/templates/pages/form-two-column/page.tsx +13 -17
- package/templates/pages/gallery-hero/page.tsx +13 -15
- package/templates/pages/ide/page.tsx +188 -264
- package/templates/pages/library/page.tsx +16 -23
- package/templates/pages/login/page.tsx +14 -18
- package/templates/pages/login-card/page.tsx +14 -18
- package/templates/pages/login-split/page.tsx +50 -48
- package/templates/pages/login-sso/page.tsx +9 -13
- package/templates/pages/mixed-gallery/page.tsx +51 -45
- package/templates/pages/payment-form/page.tsx +56 -70
- package/templates/pages/product-detail/page.tsx +27 -33
- package/templates/pages/product-gallery/page.tsx +7 -13
- package/templates/pages/settings-dialog/page.tsx +35 -43
- package/templates/pages/settings-sidebar/page.tsx +39 -47
- package/templates/pages/side-gallery/page.tsx +6 -9
- package/templates/pages/table-grouped/page.tsx +11 -15
- package/templates/pages/theme-showcase/page.tsx +33 -37
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
- 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
|
+
});
|
package/src/api/search.mjs
CHANGED
|
@@ -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
|
|
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
|
|
113
|
-
const
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 =
|
|
541
|
+
const hit = scoreQuery(term, tokens, candidate);
|
|
348
542
|
if (hit) scored.push(toResult(candidate, hit.score, hit.reason));
|
|
349
543
|
}
|
|
350
544
|
|