@astryxdesign/cli 0.1.1 → 0.1.2-canary.043f3fd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/docs/theme.doc.mjs +2 -2
- package/docs/working-with-ai.doc.mjs +6 -6
- package/package.json +31 -8
- package/src/api/component.mjs +253 -12
- package/src/api/discover.mjs +87 -28
- package/src/api/doctor.mjs +7 -22
- package/src/api/index.mjs +1 -0
- package/src/api/layout.mjs +302 -0
- package/src/api/layout.test.mjs +241 -0
- package/src/api/template-integration.test.mjs +225 -0
- package/src/api/template.mjs +421 -122
- package/src/api/template.test.mjs +2 -0
- package/src/api/theme-add.mjs +182 -0
- package/src/api/validate-integration.mjs +370 -0
- package/src/api/validate-integration.test.mjs +222 -0
- package/src/codemod.mjs +93 -0
- package/src/codemod.test.mjs +134 -0
- package/src/codemods/__tests__/registry.test.mjs +2 -0
- package/src/codemods/__tests__/runner.test.mjs +103 -0
- package/src/codemods/integration-discovery.mjs +168 -0
- package/src/codemods/integration-discovery.test.mjs +234 -0
- package/src/codemods/integration-runner.mjs +109 -0
- package/src/codemods/registry.mjs +2 -0
- package/src/codemods/run-codemod.mjs +207 -0
- package/src/codemods/runner.mjs +73 -96
- package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
- package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +81 -0
- package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
- package/src/codemods/transforms/v0.1.0/index.mjs +15 -6
- package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
- package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
- package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
- package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
- package/src/commands/build-theme.import-path.test.mjs +22 -2
- package/src/commands/build-theme.mjs +185 -16
- package/src/commands/component/index.mjs +44 -10
- package/src/commands/component-ownership.test.mjs +227 -0
- package/src/commands/discover.mjs +5 -16
- package/src/commands/doctor.test.mjs +3 -3
- package/src/commands/init.mjs +34 -8
- package/src/commands/init.next-steps.test.mjs +46 -0
- package/src/commands/json-contract.test.mjs +0 -32
- package/src/commands/layout.mjs +139 -0
- package/src/commands/swizzle.mjs +241 -224
- package/src/commands/swizzle.path-safety.test.mjs +3 -3
- package/src/commands/swizzle.routing.test.mjs +279 -0
- package/src/commands/template.mjs +33 -37
- package/src/commands/upgrade.config-ordering.test.mjs +273 -0
- package/src/commands/upgrade.integration-policy.test.mjs +204 -0
- package/src/commands/upgrade.mjs +349 -239
- package/src/commands/validate-integration.mjs +110 -0
- package/src/commands/validate-integration.test.mjs +124 -0
- package/src/config.mjs +18 -0
- package/src/config.test.mjs +100 -0
- package/src/index.mjs +13 -3
- package/src/integration.mjs +19 -0
- package/src/lib/component-discovery.mjs +177 -0
- package/src/lib/config-cache.mjs +91 -0
- package/src/lib/config-cache.test.mjs +83 -0
- package/src/lib/config-schema.mjs +105 -0
- package/src/lib/error-codes.mjs +18 -12
- package/src/lib/integration-warnings.mjs +62 -0
- package/src/lib/integration-warnings.test.mjs +102 -0
- package/src/lib/integrations.mjs +132 -0
- package/src/lib/integrations.test.mjs +135 -0
- package/src/lib/levenshtein.mjs +29 -0
- package/src/lib/manifest.mjs +15 -2
- package/src/lib/module-loader.mjs +80 -0
- package/src/lib/module-loader.test.mjs +106 -0
- package/src/lib/package-scanner.mjs +31 -7
- package/src/lib/project.mjs +502 -0
- package/src/lib/project.test.mjs +308 -0
- package/src/lib/string-utils.mjs +5 -14
- package/src/lib/xle/browser.d.ts +100 -0
- package/src/lib/xle/browser.mjs +120 -0
- package/src/lib/xle/expand.mjs +622 -0
- package/src/lib/xle/parse.mjs +581 -0
- package/src/lib/xle/print.mjs +174 -0
- package/src/lib/xle/registry-core.mjs +170 -0
- package/src/lib/xle/registry.mjs +237 -0
- package/src/lib/xle/splice.mjs +137 -0
- package/src/lib/xle/validate.mjs +356 -0
- package/src/lib/xle/xle.test.mjs +333 -0
- package/src/template.mjs +73 -0
- package/src/template.test.mjs +127 -0
- package/src/types/api.d.ts +2 -6
- package/src/types/base.d.ts +3 -9
- package/src/types/codemod.d.ts +81 -0
- package/src/types/component.d.ts +27 -2
- package/src/types/config.d.ts +70 -0
- package/src/types/error-codes.d.ts +5 -3
- package/src/types/index.d.ts +0 -1
- package/src/types/integration.d.ts +29 -0
- package/src/types/swizzle.d.ts +9 -2
- package/src/types/template-api.d.ts +54 -0
- package/src/types/template.d.ts +10 -7
- package/src/types/upgrade.d.ts +29 -0
- package/src/types/validate-integration.d.ts +24 -0
- package/src/utils/github.mjs +0 -252
- package/src/utils/interactive.mjs +2 -2
- package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
- package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
- package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
- package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
- package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
- package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
- package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
- package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
- package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
- package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
- package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
- package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
- package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
- package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
- package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
- package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
- package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
- package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
- package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
- package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
- package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
- package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
- package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
- package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
- package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
- package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
- package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
- package/templates/blocks/components/Text/TextColors.tsx +20 -5
- package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
- package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
- package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
- package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
- package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
- package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
- package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
- package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
- package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
- package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
- package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
- package/templates/pages/shell-nav/page.tsx +321 -0
- package/templates/pages/shell-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-side-nav/page.tsx +242 -0
- package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-top-nav/page.tsx +224 -0
- package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
- package/templates/pages/theme-showcase/page.tsx +8 -19
- package/templates/themes/butter/butterTheme.ts +916 -0
- package/templates/themes/butter/icons.tsx +77 -0
- package/templates/themes/chocolate/chocolateTheme.ts +230 -0
- package/templates/themes/chocolate/icons.tsx +77 -0
- package/templates/themes/gothic/gothicTheme.ts +657 -0
- package/templates/themes/gothic/icons.tsx +77 -0
- package/templates/themes/manifest.json +90 -0
- package/templates/themes/matcha/icons.tsx +67 -0
- package/templates/themes/matcha/matchaTheme.ts +247 -0
- package/templates/themes/neutral/icons.tsx +77 -0
- package/templates/themes/neutral/neutralTheme.ts +603 -0
- package/templates/themes/stone/icons.tsx +77 -0
- package/templates/themes/stone/stoneTheme.ts +652 -0
- package/templates/themes/y2k/icons.tsx +67 -0
- package/templates/themes/y2k/y2kTheme.ts +617 -0
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
- package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
- package/src/commands/gap-report.mjs +0 -456
- package/src/commands/gap-report.test.mjs +0 -163
- package/src/commands/swizzle-gap-safety.test.mjs +0 -273
- package/src/lib/config.mjs +0 -86
- package/src/lib/config.test.mjs +0 -42
- package/src/types/gap-report.d.ts +0 -29
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
- /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
// A package.json beside the config makes Project.load resolve it as the
|
|
200
|
+
// sibling-of-nearest-package.json (the standard config resolution).
|
|
201
|
+
const cwd = mkdtempSync(join(process.cwd(), '.xle-imp-test-'));
|
|
202
|
+
try {
|
|
203
|
+
writeFileSync(join(cwd, 'package.json'), '{"name": "xle-imp-fixture"}\n');
|
|
204
|
+
writeFileSync(
|
|
205
|
+
join(cwd, 'astryx.config.mjs'),
|
|
206
|
+
`export default {experimental: {xle: {components: {KpiCard: {from: '@/components/KpiCard'}, TimeRangePicker: {from: '@/components/TimeRangePicker'}}}}};\n`,
|
|
207
|
+
);
|
|
208
|
+
const result = await layoutExpand('S[p6] > (G[c4 g4] > {kpi-card}*4) + {time-range-picker}', {
|
|
209
|
+
name: 'Demo',
|
|
210
|
+
cwd,
|
|
211
|
+
});
|
|
212
|
+
const {code} = result.data;
|
|
213
|
+
expectValidTsx(code);
|
|
214
|
+
expect(code).toContain("import {KpiCard} from '@/components/KpiCard';");
|
|
215
|
+
expect(code).toContain("import {TimeRangePicker} from '@/components/TimeRangePicker';");
|
|
216
|
+
expect((code.match(/<KpiCard \/>/g) || [])).toHaveLength(4);
|
|
217
|
+
expect(code).toContain('<TimeRangePicker />');
|
|
218
|
+
expect(result.data.blocksReferenced).toContainEqual({name: 'KpiCard', mode: 'import'});
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(cwd, {recursive: true, force: true});
|
|
221
|
+
}
|
|
222
|
+
}, SLOW);
|
|
223
|
+
|
|
224
|
+
it('parses a standalone {block} in both surfaces', async () => {
|
|
225
|
+
const check = await layoutCheck('G[c4 g4] > {card-callout}*2');
|
|
226
|
+
expect(check.data.valid).toBe(true);
|
|
227
|
+
expect(check.data.compact).toContain('{card-callout}*2');
|
|
228
|
+
expect(check.data.outline).toMatch(/\{card-callout\} x2/);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('layoutGrammar', () => {
|
|
233
|
+
it('emits the cheatsheet with branch-generated aliases', async () => {
|
|
234
|
+
const result = await layoutGrammar();
|
|
235
|
+
expect(result.data.text).toContain('TWO SURFACES');
|
|
236
|
+
expect(result.data.aliases.V).toBe('VStack');
|
|
237
|
+
expect(result.data.aliases.TB).toBe('TableBody');
|
|
238
|
+
// every alias target must exist — the table is registry-filtered
|
|
239
|
+
expect(Object.values(result.data.aliases)).not.toContain(undefined);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Integration-provided template discovery (same-stem + type-driven).
|
|
5
|
+
*
|
|
6
|
+
* These tests stand up a temp consumer project with an astryx.config and an
|
|
7
|
+
* installed integration package that contributes templates, then exercise the
|
|
8
|
+
* public `template()` API to verify discovery, package/type scoping, ambiguity
|
|
9
|
+
* errors, and copy-to-dir naming.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import {template} from './template.mjs';
|
|
16
|
+
|
|
17
|
+
let tmpDir;
|
|
18
|
+
let originalCwd;
|
|
19
|
+
|
|
20
|
+
/** Absolute path to the CLI package so integrations can import /template. */
|
|
21
|
+
const CLI_PKG = path.resolve(import.meta.dirname, '..', '..');
|
|
22
|
+
|
|
23
|
+
function makeConsumer() {
|
|
24
|
+
const dir = fs.mkdtempSync(
|
|
25
|
+
path.join(process.cwd(), '.astryx-template-it-'),
|
|
26
|
+
);
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
path.join(dir, 'package.json'),
|
|
29
|
+
JSON.stringify({name: 'consumer'}),
|
|
30
|
+
);
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
path.join(dir, 'astryx.config.mjs'),
|
|
33
|
+
`export default { integrations: ['@acme/widgets'] };\n`,
|
|
34
|
+
);
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Install an @acme/widgets integration package that declares a templates root.
|
|
40
|
+
* @returns the package dir.
|
|
41
|
+
*/
|
|
42
|
+
function installWidgets(consumerDir) {
|
|
43
|
+
const pkgDir = path.join(consumerDir, 'node_modules', '@acme', 'widgets');
|
|
44
|
+
fs.mkdirSync(pkgDir, {recursive: true});
|
|
45
|
+
fs.writeFileSync(
|
|
46
|
+
path.join(pkgDir, 'package.json'),
|
|
47
|
+
JSON.stringify({name: '@acme/widgets', version: '2.0.0'}),
|
|
48
|
+
);
|
|
49
|
+
fs.writeFileSync(
|
|
50
|
+
path.join(pkgDir, 'astryx.integration.mjs'),
|
|
51
|
+
`export default { templates: './templates' };\n`,
|
|
52
|
+
);
|
|
53
|
+
fs.mkdirSync(path.join(pkgDir, 'templates'));
|
|
54
|
+
return pkgDir;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Write a template doc + same-stem source under the templates root. */
|
|
58
|
+
function writeTemplate(pkgDir, id, {kind, body, withSource = true}) {
|
|
59
|
+
const docPath = path.join(pkgDir, 'templates', `${id}.doc.mjs`);
|
|
60
|
+
fs.mkdirSync(path.dirname(docPath), {recursive: true});
|
|
61
|
+
const create = kind === 'page' ? 'createPageTemplate' : 'createBlockTemplate';
|
|
62
|
+
fs.writeFileSync(
|
|
63
|
+
docPath,
|
|
64
|
+
body ??
|
|
65
|
+
`import {${create}} from '${CLI_PKG}/src/template.mjs';\n` +
|
|
66
|
+
`export default ${create}({name: '${id} name', description: '${id} desc'});\n`,
|
|
67
|
+
);
|
|
68
|
+
if (withSource) {
|
|
69
|
+
fs.writeFileSync(
|
|
70
|
+
path.join(pkgDir, 'templates', `${id}.tsx`),
|
|
71
|
+
`export default function ${id.replace(/[^a-zA-Z0-9]/g, '')}() { return null; }\n`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
originalCwd = process.cwd();
|
|
78
|
+
tmpDir = makeConsumer();
|
|
79
|
+
process.chdir(tmpDir);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
process.chdir(originalCwd);
|
|
84
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('integration template discovery', () => {
|
|
88
|
+
it('discovers and lists an integration template with package + type', async () => {
|
|
89
|
+
const pkgDir = installWidgets(tmpDir);
|
|
90
|
+
writeTemplate(pkgDir, 'pricing', {kind: 'page'});
|
|
91
|
+
|
|
92
|
+
const result = await template(undefined, {list: true, cwd: tmpDir});
|
|
93
|
+
expect(result.type).toBe('template.list');
|
|
94
|
+
const entry = result.data.find(t => t.id === 'pricing');
|
|
95
|
+
expect(entry).toBeTruthy();
|
|
96
|
+
expect(entry.type).toBe('page');
|
|
97
|
+
expect(entry.package).toBe('@acme/widgets');
|
|
98
|
+
expect(entry.name).toBe('pricing name');
|
|
99
|
+
expect(entry.description).toBe('pricing desc');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('lists nested-id templates (kebab path under root)', async () => {
|
|
103
|
+
const pkgDir = installWidgets(tmpDir);
|
|
104
|
+
writeTemplate(pkgDir, 'marketing/hero', {kind: 'block'});
|
|
105
|
+
|
|
106
|
+
const result = await template(undefined, {list: true, cwd: tmpDir});
|
|
107
|
+
const entry = result.data.find(t => t.id === 'marketing/hero');
|
|
108
|
+
expect(entry).toBeTruthy();
|
|
109
|
+
expect(entry.type).toBe('block');
|
|
110
|
+
expect(entry.package).toBe('@acme/widgets');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('always reports core templates under @astryxdesign/core', async () => {
|
|
114
|
+
const result = await template(undefined, {list: true, cwd: tmpDir});
|
|
115
|
+
const core = result.data.filter(t => t.package === '@astryxdesign/core');
|
|
116
|
+
expect(core.length).toBeGreaterThan(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('--package narrows the listing', async () => {
|
|
120
|
+
const pkgDir = installWidgets(tmpDir);
|
|
121
|
+
writeTemplate(pkgDir, 'pricing', {kind: 'page'});
|
|
122
|
+
|
|
123
|
+
const result = await template(undefined, {
|
|
124
|
+
list: true,
|
|
125
|
+
package: '@acme/widgets',
|
|
126
|
+
cwd: tmpDir,
|
|
127
|
+
});
|
|
128
|
+
expect(result.data.length).toBe(1);
|
|
129
|
+
expect(result.data[0].id).toBe('pricing');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('skips a template whose same-stem source is missing', async () => {
|
|
133
|
+
const pkgDir = installWidgets(tmpDir);
|
|
134
|
+
writeTemplate(pkgDir, 'orphan', {kind: 'page', withSource: false});
|
|
135
|
+
|
|
136
|
+
const result = await template(undefined, {list: true, cwd: tmpDir});
|
|
137
|
+
expect(result.data.find(t => t.id === 'orphan')).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('skips a raw doc that is missing a type', async () => {
|
|
141
|
+
const pkgDir = installWidgets(tmpDir);
|
|
142
|
+
writeTemplate(pkgDir, 'untyped', {
|
|
143
|
+
kind: 'page',
|
|
144
|
+
body: `export default {name: 'Untyped', description: 'no type'};\n`,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const result = await template(undefined, {list: true, cwd: tmpDir});
|
|
148
|
+
expect(result.data.find(t => t.id === 'untyped')).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('errors with candidates when an id is ambiguous across type/package', async () => {
|
|
152
|
+
const pkgDir = installWidgets(tmpDir);
|
|
153
|
+
// Same id "hero" as both a page and a block within the integration.
|
|
154
|
+
writeTemplate(pkgDir, 'hero', {kind: 'page'});
|
|
155
|
+
// Add a sibling block doc with the same stem in a different file is not
|
|
156
|
+
// possible (same file). Instead install a second package with a "hero".
|
|
157
|
+
const pkg2 = path.join(tmpDir, 'node_modules', '@acme', 'extra');
|
|
158
|
+
fs.mkdirSync(path.join(pkg2, 'templates'), {recursive: true});
|
|
159
|
+
fs.writeFileSync(
|
|
160
|
+
path.join(pkg2, 'package.json'),
|
|
161
|
+
JSON.stringify({name: '@acme/extra', version: '1.0.0'}),
|
|
162
|
+
);
|
|
163
|
+
fs.writeFileSync(
|
|
164
|
+
path.join(pkg2, 'astryx.integration.mjs'),
|
|
165
|
+
`export default { templates: './templates' };\n`,
|
|
166
|
+
);
|
|
167
|
+
fs.writeFileSync(
|
|
168
|
+
path.join(pkg2, 'templates', 'hero.doc.mjs'),
|
|
169
|
+
`import {createBlockTemplate} from '${CLI_PKG}/src/template.mjs';\n` +
|
|
170
|
+
`export default createBlockTemplate({name: 'Hero block', description: 'b'});\n`,
|
|
171
|
+
);
|
|
172
|
+
fs.writeFileSync(
|
|
173
|
+
path.join(pkg2, 'templates', 'hero.tsx'),
|
|
174
|
+
`export default function Hero() { return null; }\n`,
|
|
175
|
+
);
|
|
176
|
+
fs.writeFileSync(
|
|
177
|
+
path.join(tmpDir, 'astryx.config.mjs'),
|
|
178
|
+
`export default { integrations: ['@acme/widgets', '@acme/extra'] };\n`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
template('hero', {targetPath: './out', cwd: tmpDir}),
|
|
183
|
+
).rejects.toMatchObject({code: 'ERR_AMBIGUOUS_TEMPLATE'});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('--type and --package narrow an ambiguous id to a single match', async () => {
|
|
187
|
+
const pkgDir = installWidgets(tmpDir);
|
|
188
|
+
writeTemplate(pkgDir, 'pricing', {kind: 'page'});
|
|
189
|
+
|
|
190
|
+
const result = await template('pricing', {
|
|
191
|
+
type: 'page',
|
|
192
|
+
package: '@acme/widgets',
|
|
193
|
+
show: true,
|
|
194
|
+
cwd: tmpDir,
|
|
195
|
+
});
|
|
196
|
+
expect(result.type).toBe('template.show');
|
|
197
|
+
expect(result.data.type).toBe('page');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('copies a page template into a directory as page.tsx', async () => {
|
|
201
|
+
const pkgDir = installWidgets(tmpDir);
|
|
202
|
+
writeTemplate(pkgDir, 'pricing', {kind: 'page'});
|
|
203
|
+
|
|
204
|
+
const result = await template('pricing', {
|
|
205
|
+
targetPath: './dest',
|
|
206
|
+
cwd: tmpDir,
|
|
207
|
+
});
|
|
208
|
+
expect(result.type).toBe('template.copy');
|
|
209
|
+
expect(result.data.fileName).toBe('page.tsx');
|
|
210
|
+
expect(fs.existsSync(path.join(tmpDir, 'dest', 'page.tsx'))).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('copies a block template into a directory as <id-basename>.tsx', async () => {
|
|
214
|
+
const pkgDir = installWidgets(tmpDir);
|
|
215
|
+
writeTemplate(pkgDir, 'marketing/hero', {kind: 'block'});
|
|
216
|
+
|
|
217
|
+
const result = await template('marketing/hero', {
|
|
218
|
+
targetPath: './dest',
|
|
219
|
+
cwd: tmpDir,
|
|
220
|
+
});
|
|
221
|
+
expect(result.type).toBe('template.copy');
|
|
222
|
+
expect(result.data.fileName).toBe('hero.tsx');
|
|
223
|
+
expect(fs.existsSync(path.join(tmpDir, 'dest', 'hero.tsx'))).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
});
|