@astryxdesign/cli 0.1.1 → 0.1.2-canary.2149fa3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/docs/theme.doc.mjs +2 -2
- package/docs/working-with-ai.doc.mjs +6 -6
- package/package.json +19 -8
- package/src/api/discover.mjs +78 -26
- package/src/api/index.mjs +1 -0
- package/src/api/layout.mjs +301 -0
- package/src/api/layout.test.mjs +238 -0
- package/src/api/template.mjs +191 -50
- package/src/api/template.test.mjs +2 -0
- package/src/api/theme-add.mjs +182 -0
- package/src/codemods/__tests__/registry.test.mjs +1 -0
- package/src/codemods/registry.mjs +1 -0
- package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
- package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
- package/src/commands/build-theme.import-path.test.mjs +22 -2
- package/src/commands/build-theme.mjs +185 -16
- package/src/commands/gap-report.mjs +17 -9
- package/src/commands/gap-report.test.mjs +21 -16
- package/src/commands/init.mjs +34 -8
- package/src/commands/init.next-steps.test.mjs +46 -0
- package/src/commands/layout.mjs +139 -0
- package/src/commands/swizzle.mjs +51 -23
- package/src/commands/upgrade.mjs +2 -71
- package/src/config.mjs +31 -0
- package/src/config.test.mjs +24 -0
- package/src/index.mjs +6 -0
- package/src/lib/config-schema.mjs +119 -0
- package/src/lib/config.mjs +34 -7
- package/src/lib/config.test.mjs +51 -2
- package/src/lib/error-codes.mjs +11 -0
- package/src/lib/integrations.mjs +155 -0
- package/src/lib/integrations.test.mjs +154 -0
- package/src/lib/levenshtein.mjs +29 -0
- package/src/lib/manifest.mjs +10 -0
- package/src/lib/package-scanner.mjs +31 -7
- package/src/lib/string-utils.mjs +5 -14
- package/src/lib/xle/browser.d.ts +100 -0
- package/src/lib/xle/browser.mjs +120 -0
- package/src/lib/xle/expand.mjs +622 -0
- package/src/lib/xle/parse.mjs +581 -0
- package/src/lib/xle/print.mjs +174 -0
- package/src/lib/xle/registry-core.mjs +170 -0
- package/src/lib/xle/registry.mjs +237 -0
- package/src/lib/xle/splice.mjs +137 -0
- package/src/lib/xle/validate.mjs +356 -0
- package/src/lib/xle/xle.test.mjs +333 -0
- package/src/types/config.d.ts +99 -0
- package/src/utils/github.mjs +12 -27
- package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
- package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
- package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
- package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
- package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
- package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
- package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
- package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
- package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
- package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
- package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
- package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
- package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
- package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
- package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
- package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
- package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
- package/templates/blocks/components/Text/TextColors.tsx +20 -5
- package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
- package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
- package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
- package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
- package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
- package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
- package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
- package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
- package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
- package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
- package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
- package/templates/pages/shell-nav/page.tsx +321 -0
- package/templates/pages/shell-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-side-nav/page.tsx +242 -0
- package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-top-nav/page.tsx +224 -0
- package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
- package/templates/pages/theme-showcase/page.tsx +8 -19
- package/templates/themes/butter/butterTheme.ts +916 -0
- package/templates/themes/butter/icons.tsx +77 -0
- package/templates/themes/chocolate/chocolateTheme.ts +230 -0
- package/templates/themes/chocolate/icons.tsx +77 -0
- package/templates/themes/gothic/gothicTheme.ts +657 -0
- package/templates/themes/gothic/icons.tsx +77 -0
- package/templates/themes/manifest.json +90 -0
- package/templates/themes/matcha/icons.tsx +67 -0
- package/templates/themes/matcha/matchaTheme.ts +247 -0
- package/templates/themes/neutral/icons.tsx +77 -0
- package/templates/themes/neutral/neutralTheme.ts +603 -0
- package/templates/themes/stone/icons.tsx +77 -0
- package/templates/themes/stone/stoneTheme.ts +652 -0
- package/templates/themes/y2k/icons.tsx +67 -0
- package/templates/themes/y2k/y2kTheme.ts +617 -0
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
|
@@ -0,0 +1,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/template.mjs
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import {CLI_ROOT, discoverExternalPackages} from '../utils/paths.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
assertWithin,
|
|
12
|
+
isFilePathArg,
|
|
13
|
+
PathSafetyError,
|
|
14
|
+
} from '../utils/path-safety.mjs';
|
|
11
15
|
import {AstryxError} from './error.mjs';
|
|
12
16
|
import {ERROR_CODES} from '../lib/error-codes.mjs';
|
|
13
17
|
import {loadConfig} from '../lib/config.mjs';
|
|
@@ -23,7 +27,7 @@ const BLOCKS_DIR = path.join(TEMPLATES_DIR, 'blocks');
|
|
|
23
27
|
* or not. Mirrors apps/docsite/public/template-assets/placeholder.svg.
|
|
24
28
|
*/
|
|
25
29
|
const PLACEHOLDER_IMAGE =
|
|
26
|
-
|
|
30
|
+
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E';
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
33
|
* Demo-image sources to strip from scaffolded projects — Meta's lookaside CDN
|
|
@@ -60,9 +64,12 @@ export {discoverAll as discoverTemplates};
|
|
|
60
64
|
export function listTemplates() {
|
|
61
65
|
const all = [];
|
|
62
66
|
if (fs.existsSync(PAGES_DIR)) {
|
|
63
|
-
all.push(
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
all.push(
|
|
68
|
+
...fs
|
|
69
|
+
.readdirSync(PAGES_DIR, {withFileTypes: true})
|
|
70
|
+
.filter(e => e.isDirectory())
|
|
71
|
+
.map(e => e.name),
|
|
72
|
+
);
|
|
66
73
|
}
|
|
67
74
|
return all.sort();
|
|
68
75
|
}
|
|
@@ -83,7 +90,8 @@ function findDocFiles(dir, pattern) {
|
|
|
83
90
|
|
|
84
91
|
async function discoverPages() {
|
|
85
92
|
if (!fs.existsSync(PAGES_DIR)) return [];
|
|
86
|
-
const dirs = fs
|
|
93
|
+
const dirs = fs
|
|
94
|
+
.readdirSync(PAGES_DIR, {withFileTypes: true})
|
|
87
95
|
.filter(e => e.isDirectory());
|
|
88
96
|
|
|
89
97
|
const templates = [];
|
|
@@ -138,7 +146,11 @@ async function discoverBlocks() {
|
|
|
138
146
|
* @param {string} [cwd]
|
|
139
147
|
*/
|
|
140
148
|
async function discoverExternalBlocks(cwd = process.cwd()) {
|
|
141
|
-
const
|
|
149
|
+
const config = await loadConfig(cwd);
|
|
150
|
+
const integrationPackages = (config.loadedIntegrations ?? [])
|
|
151
|
+
.map(integration => integration.package)
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
const externals = [...discoverExternalPackages(cwd), ...integrationPackages];
|
|
142
154
|
const blocks = [];
|
|
143
155
|
|
|
144
156
|
for (const ext of externals) {
|
|
@@ -193,9 +205,7 @@ async function discoverAll() {
|
|
|
193
205
|
export async function findRelatedBlocks(componentName, cwd) {
|
|
194
206
|
const blocks = await discoverAllBlocks(cwd);
|
|
195
207
|
return blocks.filter(b =>
|
|
196
|
-
b.componentsUsed.some(c =>
|
|
197
|
-
c.toLowerCase() === componentName.toLowerCase(),
|
|
198
|
-
),
|
|
208
|
+
b.componentsUsed.some(c => c.toLowerCase() === componentName.toLowerCase()),
|
|
199
209
|
);
|
|
200
210
|
}
|
|
201
211
|
|
|
@@ -218,7 +228,7 @@ export async function findShowcase(componentName, cwd, options) {
|
|
|
218
228
|
return true;
|
|
219
229
|
});
|
|
220
230
|
|
|
221
|
-
const toResult =
|
|
231
|
+
const toResult = b => ({
|
|
222
232
|
name: b.name,
|
|
223
233
|
aspectRatio: b.aspectRatio,
|
|
224
234
|
filePath: b.filePath,
|
|
@@ -242,8 +252,14 @@ export async function findShowcase(componentName, cwd, options) {
|
|
|
242
252
|
}
|
|
243
253
|
|
|
244
254
|
const UBIQUITOUS = new Set([
|
|
245
|
-
'Text',
|
|
246
|
-
'
|
|
255
|
+
'Text',
|
|
256
|
+
'Heading',
|
|
257
|
+
'Button',
|
|
258
|
+
'HStack',
|
|
259
|
+
'VStack',
|
|
260
|
+
'Link',
|
|
261
|
+
'StackItem',
|
|
262
|
+
'Icon',
|
|
247
263
|
]);
|
|
248
264
|
|
|
249
265
|
export function extractComponents(pagePath) {
|
|
@@ -259,22 +275,115 @@ export function extractComponents(pagePath) {
|
|
|
259
275
|
while ((m = tagRegex.exec(src)) !== null) {
|
|
260
276
|
matches.push(m[2]);
|
|
261
277
|
}
|
|
262
|
-
return [
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
278
|
+
return [
|
|
279
|
+
...new Set(
|
|
280
|
+
matches
|
|
281
|
+
.filter(n => !['Theme', 'ThemeProvider'].includes(n))
|
|
282
|
+
.filter(n => !UBIQUITOUS.has(n))
|
|
283
|
+
.map(n =>
|
|
284
|
+
n.replace(
|
|
285
|
+
/(Item|Section|Header|Content|Footer|Panel|Heading|CollapseButton|Column|Sortable|Selection|Group|Source)$/,
|
|
286
|
+
'',
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
.filter(Boolean),
|
|
290
|
+
),
|
|
291
|
+
].sort();
|
|
269
292
|
}
|
|
270
293
|
|
|
271
294
|
const STRUCTURAL = new Set([
|
|
272
|
-
'AppShell',
|
|
273
|
-
'
|
|
274
|
-
'
|
|
275
|
-
'
|
|
295
|
+
'AppShell',
|
|
296
|
+
'Layout',
|
|
297
|
+
'LayoutHeader',
|
|
298
|
+
'LayoutContent',
|
|
299
|
+
'LayoutPanel',
|
|
300
|
+
'LayoutFooter',
|
|
301
|
+
'Card',
|
|
302
|
+
'Section',
|
|
303
|
+
'Grid',
|
|
304
|
+
'GridSpan',
|
|
305
|
+
'List',
|
|
306
|
+
'Table',
|
|
307
|
+
'TabList',
|
|
308
|
+
'Toolbar',
|
|
309
|
+
'SideNav',
|
|
310
|
+
'TopNav',
|
|
311
|
+
'Dialog',
|
|
312
|
+
'FormLayout',
|
|
313
|
+
'Center',
|
|
276
314
|
]);
|
|
277
315
|
|
|
316
|
+
const SPATIAL_PROPS = [
|
|
317
|
+
'padding',
|
|
318
|
+
'contentPadding',
|
|
319
|
+
'gap',
|
|
320
|
+
'rowGap',
|
|
321
|
+
'columnGap',
|
|
322
|
+
'columns',
|
|
323
|
+
'minChildWidth',
|
|
324
|
+
'hasDivider',
|
|
325
|
+
'defaultHasDividers',
|
|
326
|
+
'variant',
|
|
327
|
+
'density',
|
|
328
|
+
'role',
|
|
329
|
+
'height',
|
|
330
|
+
'width',
|
|
331
|
+
'maxWidth',
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Copy allowlisted layout props verbatim from a JSX opening-tag fragment.
|
|
336
|
+
* Uses quote/brace matching so object literals and spaced strings stay intact.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} tagText
|
|
339
|
+
* @returns {string[]}
|
|
340
|
+
*/
|
|
341
|
+
function extractSpatialAttrs(tagText) {
|
|
342
|
+
const attrs = [];
|
|
343
|
+
for (const name of SPATIAL_PROPS) {
|
|
344
|
+
const eqMatch = tagText.match(new RegExp(`\\b${name}\\s*=\\s*`));
|
|
345
|
+
if (eqMatch) {
|
|
346
|
+
const start = eqMatch.index;
|
|
347
|
+
let i = eqMatch.index + eqMatch[0].length;
|
|
348
|
+
const rest = tagText.slice(i);
|
|
349
|
+
|
|
350
|
+
if (rest[0] === '"' || rest[0] === "'") {
|
|
351
|
+
const q = rest[0];
|
|
352
|
+
i += 1;
|
|
353
|
+
while (i < tagText.length && tagText[i] !== q) {
|
|
354
|
+
if (tagText[i] === '\\') i += 1;
|
|
355
|
+
i += 1;
|
|
356
|
+
}
|
|
357
|
+
if (i < tagText.length) i += 1;
|
|
358
|
+
} else if (rest[0] === '{') {
|
|
359
|
+
let depth = 0;
|
|
360
|
+
while (i < tagText.length) {
|
|
361
|
+
if (tagText[i] === '{') depth += 1;
|
|
362
|
+
else if (tagText[i] === '}') {
|
|
363
|
+
depth -= 1;
|
|
364
|
+
if (depth === 0) {
|
|
365
|
+
i += 1;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
i += 1;
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
const bare = rest.match(/^[^\s/>]+/);
|
|
373
|
+
i += bare ? bare[0].length : 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
attrs.push(tagText.slice(start, i).trim());
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (new RegExp(`\\b${name}(?=[\\s/>])`).test(tagText)) {
|
|
381
|
+
attrs.push(name);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return attrs;
|
|
385
|
+
}
|
|
386
|
+
|
|
278
387
|
function extractSkeleton(source) {
|
|
279
388
|
const lines = source.split('\n');
|
|
280
389
|
const out = [];
|
|
@@ -287,11 +396,18 @@ function extractSkeleton(source) {
|
|
|
287
396
|
for (let i = 0; i < lines.length; i++) {
|
|
288
397
|
const t = lines[i].trim();
|
|
289
398
|
|
|
290
|
-
if (t.match(/^export\s+default\s+function/)) {
|
|
291
|
-
|
|
399
|
+
if (t.match(/^export\s+default\s+function/)) {
|
|
400
|
+
inDefaultExport = true;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (inDefaultExport && t.match(/^return\s*\(/)) {
|
|
404
|
+
capturing = true;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
292
407
|
if (!capturing) continue;
|
|
293
408
|
if (out.length >= MAX_LINES) {
|
|
294
|
-
if (!out[out.length - 1]?.includes('...'))
|
|
409
|
+
if (!out[out.length - 1]?.includes('...'))
|
|
410
|
+
out.push(' '.repeat(depth) + '...');
|
|
295
411
|
continue;
|
|
296
412
|
}
|
|
297
413
|
|
|
@@ -309,20 +425,13 @@ function extractSkeleton(source) {
|
|
|
309
425
|
if (lines[j].includes('>')) break;
|
|
310
426
|
}
|
|
311
427
|
|
|
312
|
-
const props =
|
|
313
|
-
const propRegex = /\b(padding|contentPadding|gap|rowGap|columnGap|columns|minChildWidth|hasDivider|defaultHasDividers|variant|density|role|height|width|maxWidth)\s*[=]\s*\{?\s*['"]?([^}'"\s,/>]+)/g;
|
|
314
|
-
let m;
|
|
315
|
-
while ((m = propRegex.exec(tagText)) !== null) {
|
|
316
|
-
const val = m[2];
|
|
317
|
-
if (val === 'true') props.push(m[1]);
|
|
318
|
-
else if (/^\d+$/.test(val)) props.push(`${m[1]}={${val}}`);
|
|
319
|
-
else props.push(`${m[1]}="${val}"`);
|
|
320
|
-
}
|
|
321
|
-
|
|
428
|
+
const props = extractSpatialAttrs(tagText);
|
|
322
429
|
const hasSpatialProps = props.length > 0;
|
|
323
430
|
const propStr = hasSpatialProps ? ' ' + props.join(' ') : '';
|
|
324
431
|
const isVStack = comp === 'VStack' || comp === 'HStack';
|
|
325
|
-
const isSelfClosing = tagText.match(
|
|
432
|
+
const isSelfClosing = tagText.match(
|
|
433
|
+
new RegExp('<' + tagName + '[^>]*/>', 's'),
|
|
434
|
+
);
|
|
326
435
|
|
|
327
436
|
if (isVStack && !hasSpatialProps) continue;
|
|
328
437
|
|
|
@@ -349,13 +458,18 @@ function extractSkeleton(source) {
|
|
|
349
458
|
continue;
|
|
350
459
|
}
|
|
351
460
|
|
|
352
|
-
const slotMatch = t.match(
|
|
461
|
+
const slotMatch = t.match(
|
|
462
|
+
/^(header|content|footer|start|end|sideNav|topNav)\s*=\s*\{/,
|
|
463
|
+
);
|
|
353
464
|
if (slotMatch) {
|
|
354
465
|
out.push(' '.repeat(depth) + `/* ${slotMatch[1]}: */`);
|
|
355
466
|
continue;
|
|
356
467
|
}
|
|
357
468
|
|
|
358
|
-
if (
|
|
469
|
+
if (
|
|
470
|
+
t.startsWith('<div') &&
|
|
471
|
+
(t.includes('padding') || t.includes('maxWidth') || t.includes('gap:'))
|
|
472
|
+
) {
|
|
359
473
|
const styleProps = [];
|
|
360
474
|
const divText = lines.slice(i, Math.min(i + 5, lines.length)).join(' ');
|
|
361
475
|
const pp = divText.match(/padding[^:]*:\s*['"]?([^'"},)]+)/);
|
|
@@ -395,7 +509,7 @@ export async function getTemplateById(id, options = {}) {
|
|
|
395
509
|
'Add a template.get function to astryx.config.mjs:\n\n' +
|
|
396
510
|
' export default {\n' +
|
|
397
511
|
' template: {\n' +
|
|
398
|
-
|
|
512
|
+
' get: async (id) => { /* return template source string */ },\n' +
|
|
399
513
|
' },\n' +
|
|
400
514
|
' };',
|
|
401
515
|
undefined,
|
|
@@ -408,7 +522,11 @@ export async function getTemplateById(id, options = {}) {
|
|
|
408
522
|
source = await getter(id);
|
|
409
523
|
} catch (err) {
|
|
410
524
|
const detail = err instanceof Error ? err.message : String(err);
|
|
411
|
-
throw new AstryxError(
|
|
525
|
+
throw new AstryxError(
|
|
526
|
+
`template.get("${id}") threw an error: ${detail}`,
|
|
527
|
+
undefined,
|
|
528
|
+
ERROR_CODES.ERR_TEMPLATE_GET,
|
|
529
|
+
);
|
|
412
530
|
}
|
|
413
531
|
|
|
414
532
|
if (source == null) {
|
|
@@ -450,7 +568,14 @@ export async function getTemplateById(id, options = {}) {
|
|
|
450
568
|
* @returns {Promise<{type: string, data: unknown}>}
|
|
451
569
|
*/
|
|
452
570
|
export async function template(name, options = {}) {
|
|
453
|
-
const {
|
|
571
|
+
const {
|
|
572
|
+
list = false,
|
|
573
|
+
skeleton = false,
|
|
574
|
+
show = false,
|
|
575
|
+
targetPath,
|
|
576
|
+
type,
|
|
577
|
+
cwd = process.cwd(),
|
|
578
|
+
} = options;
|
|
454
579
|
const templates = await discoverAll();
|
|
455
580
|
|
|
456
581
|
if (list || (!name && !skeleton)) {
|
|
@@ -487,7 +612,11 @@ export async function template(name, options = {}) {
|
|
|
487
612
|
);
|
|
488
613
|
}
|
|
489
614
|
if (!fs.existsSync(match.filePath)) {
|
|
490
|
-
throw new AstryxError(
|
|
615
|
+
throw new AstryxError(
|
|
616
|
+
`No source file found for template "${name}"`,
|
|
617
|
+
undefined,
|
|
618
|
+
ERROR_CODES.ERR_NO_SOURCE,
|
|
619
|
+
);
|
|
491
620
|
}
|
|
492
621
|
const src = fs.readFileSync(match.filePath, 'utf-8');
|
|
493
622
|
return {
|
|
@@ -502,7 +631,11 @@ export async function template(name, options = {}) {
|
|
|
502
631
|
}
|
|
503
632
|
|
|
504
633
|
if (!fs.existsSync(match.filePath)) {
|
|
505
|
-
throw new AstryxError(
|
|
634
|
+
throw new AstryxError(
|
|
635
|
+
`No source file found for template "${name}"`,
|
|
636
|
+
undefined,
|
|
637
|
+
ERROR_CODES.ERR_NO_SOURCE,
|
|
638
|
+
);
|
|
506
639
|
}
|
|
507
640
|
|
|
508
641
|
if (show || !targetPath) {
|
|
@@ -529,7 +662,11 @@ export async function template(name, options = {}) {
|
|
|
529
662
|
});
|
|
530
663
|
} catch (err) {
|
|
531
664
|
if (err instanceof PathSafetyError) {
|
|
532
|
-
throw new AstryxError(
|
|
665
|
+
throw new AstryxError(
|
|
666
|
+
err.message,
|
|
667
|
+
undefined,
|
|
668
|
+
ERROR_CODES.ERR_PATH_TRAVERSAL,
|
|
669
|
+
);
|
|
533
670
|
}
|
|
534
671
|
throw err;
|
|
535
672
|
}
|
|
@@ -546,9 +683,8 @@ export async function template(name, options = {}) {
|
|
|
546
683
|
outputFilePath = resolvedTarget;
|
|
547
684
|
} else {
|
|
548
685
|
outputDir = resolvedTarget;
|
|
549
|
-
outputFileName =
|
|
550
|
-
? path.basename(match.filePath)
|
|
551
|
-
: 'page.tsx';
|
|
686
|
+
outputFileName =
|
|
687
|
+
match.type === 'block' ? path.basename(match.filePath) : 'page.tsx';
|
|
552
688
|
outputFilePath = path.join(outputDir, outputFileName);
|
|
553
689
|
}
|
|
554
690
|
|
|
@@ -563,6 +699,11 @@ export async function template(name, options = {}) {
|
|
|
563
699
|
const relOutput = path.relative(cwd, outputDir) || '.';
|
|
564
700
|
return {
|
|
565
701
|
type: 'template.copy',
|
|
566
|
-
data: {
|
|
702
|
+
data: {
|
|
703
|
+
template: name,
|
|
704
|
+
outputDir: relOutput,
|
|
705
|
+
fileName: outputFileName,
|
|
706
|
+
filesCopied: 1,
|
|
707
|
+
},
|
|
567
708
|
};
|
|
568
709
|
}
|
|
@@ -74,5 +74,7 @@ describe('template --skeleton component extraction (prefix-agnostic)', () => {
|
|
|
74
74
|
expect(result.data.skeleton.trim().length).toBeGreaterThan(0);
|
|
75
75
|
expect(result.data.skeleton).toMatch(/<[A-Z]\w+/);
|
|
76
76
|
expect(result.data.skeleton).not.toContain('<XDS');
|
|
77
|
+
|
|
78
|
+
expect(result.data.skeleton).toContain('columns={{minWidth: 200}}');
|
|
77
79
|
});
|
|
78
80
|
});
|