@astryxdesign/cli 0.1.1 → 0.1.2-canary.2149fa3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +19 -8
  5. package/src/api/discover.mjs +78 -26
  6. package/src/api/index.mjs +1 -0
  7. package/src/api/layout.mjs +301 -0
  8. package/src/api/layout.test.mjs +238 -0
  9. package/src/api/template.mjs +191 -50
  10. package/src/api/template.test.mjs +2 -0
  11. package/src/api/theme-add.mjs +182 -0
  12. package/src/codemods/__tests__/registry.test.mjs +1 -0
  13. package/src/codemods/registry.mjs +1 -0
  14. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  15. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  16. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  17. package/src/commands/build-theme.import-path.test.mjs +22 -2
  18. package/src/commands/build-theme.mjs +185 -16
  19. package/src/commands/gap-report.mjs +17 -9
  20. package/src/commands/gap-report.test.mjs +21 -16
  21. package/src/commands/init.mjs +34 -8
  22. package/src/commands/init.next-steps.test.mjs +46 -0
  23. package/src/commands/layout.mjs +139 -0
  24. package/src/commands/swizzle.mjs +51 -23
  25. package/src/commands/upgrade.mjs +2 -71
  26. package/src/config.mjs +31 -0
  27. package/src/config.test.mjs +24 -0
  28. package/src/index.mjs +6 -0
  29. package/src/lib/config-schema.mjs +119 -0
  30. package/src/lib/config.mjs +34 -7
  31. package/src/lib/config.test.mjs +51 -2
  32. package/src/lib/error-codes.mjs +11 -0
  33. package/src/lib/integrations.mjs +155 -0
  34. package/src/lib/integrations.test.mjs +154 -0
  35. package/src/lib/levenshtein.mjs +29 -0
  36. package/src/lib/manifest.mjs +10 -0
  37. package/src/lib/package-scanner.mjs +31 -7
  38. package/src/lib/string-utils.mjs +5 -14
  39. package/src/lib/xle/browser.d.ts +100 -0
  40. package/src/lib/xle/browser.mjs +120 -0
  41. package/src/lib/xle/expand.mjs +622 -0
  42. package/src/lib/xle/parse.mjs +581 -0
  43. package/src/lib/xle/print.mjs +174 -0
  44. package/src/lib/xle/registry-core.mjs +170 -0
  45. package/src/lib/xle/registry.mjs +237 -0
  46. package/src/lib/xle/splice.mjs +137 -0
  47. package/src/lib/xle/validate.mjs +356 -0
  48. package/src/lib/xle/xle.test.mjs +333 -0
  49. package/src/types/config.d.ts +99 -0
  50. package/src/utils/github.mjs +12 -27
  51. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  52. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  53. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  54. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  55. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  56. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  57. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  58. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  59. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  60. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  61. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  62. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  63. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  64. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  65. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  66. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  67. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  68. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  69. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  70. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  71. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  72. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  73. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  74. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  75. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  76. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  77. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  78. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  79. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  80. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  81. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  82. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  83. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  84. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  85. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  86. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  87. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  88. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  89. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  90. package/templates/pages/shell-nav/page.tsx +321 -0
  91. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  92. package/templates/pages/shell-side-nav/page.tsx +242 -0
  93. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  94. package/templates/pages/shell-top-nav/page.tsx +224 -0
  95. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  96. package/templates/pages/theme-showcase/page.tsx +8 -19
  97. package/templates/themes/butter/butterTheme.ts +916 -0
  98. package/templates/themes/butter/icons.tsx +77 -0
  99. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  100. package/templates/themes/chocolate/icons.tsx +77 -0
  101. package/templates/themes/gothic/gothicTheme.ts +657 -0
  102. package/templates/themes/gothic/icons.tsx +77 -0
  103. package/templates/themes/manifest.json +90 -0
  104. package/templates/themes/matcha/icons.tsx +67 -0
  105. package/templates/themes/matcha/matchaTheme.ts +247 -0
  106. package/templates/themes/neutral/icons.tsx +77 -0
  107. package/templates/themes/neutral/neutralTheme.ts +603 -0
  108. package/templates/themes/stone/icons.tsx +77 -0
  109. package/templates/themes/stone/stoneTheme.ts +652 -0
  110. package/templates/themes/y2k/icons.tsx +67 -0
  111. package/templates/themes/y2k/y2kTheme.ts +617 -0
  112. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  113. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
@@ -0,0 +1,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
+ });
@@ -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 {assertWithin, isFilePathArg, PathSafetyError} from '../utils/path-safety.mjs';
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
- "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";
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(...fs.readdirSync(PAGES_DIR, {withFileTypes: true})
64
- .filter(e => e.isDirectory())
65
- .map(e => e.name));
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.readdirSync(PAGES_DIR, {withFileTypes: true})
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 externals = discoverExternalPackages(cwd);
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 = (b) => ({
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', 'Heading', 'Button', 'HStack', 'VStack', 'Link',
246
- 'StackItem', 'Icon',
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 [...new Set(
263
- matches
264
- .filter(n => !['Theme', 'ThemeProvider'].includes(n))
265
- .filter(n => !UBIQUITOUS.has(n))
266
- .map(n => n.replace(/(Item|Section|Header|Content|Footer|Panel|Heading|CollapseButton|Column|Sortable|Selection|Group|Source)$/, ''))
267
- .filter(Boolean),
268
- )].sort();
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', 'Layout', 'LayoutHeader', 'LayoutContent', 'LayoutPanel',
273
- 'LayoutFooter', 'Card', 'Section', 'Grid', 'GridSpan', 'List',
274
- 'Table', 'TabList', 'Toolbar', 'SideNav', 'TopNav', 'Dialog',
275
- 'FormLayout', 'Center',
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/)) { inDefaultExport = true; continue; }
291
- if (inDefaultExport && t.match(/^return\s*\(/)) { capturing = true; continue; }
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('...')) out.push(' '.repeat(depth) + '...');
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(new RegExp('<' + tagName + '[^>]*/>', 's'));
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(/^(header|content|footer|start|end|sideNav|topNav)\s*=\s*\{/);
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 (t.startsWith('<div') && (t.includes('padding') || t.includes('maxWidth') || t.includes('gap:'))) {
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
- " get: async (id) => { /* return template source string */ },\n" +
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(`template.get("${id}") threw an error: ${detail}`, undefined, ERROR_CODES.ERR_TEMPLATE_GET);
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 {list = false, skeleton = false, show = false, targetPath, type, cwd = process.cwd()} = options;
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(`No source file found for template "${name}"`, undefined, ERROR_CODES.ERR_NO_SOURCE);
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(`No source file found for template "${name}"`, undefined, ERROR_CODES.ERR_NO_SOURCE);
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(err.message, undefined, ERROR_CODES.ERR_PATH_TRAVERSAL);
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 = match.type === 'block'
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: {template: name, outputDir: relOutput, fileName: outputFileName, filesCopied: 1},
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
  });