Skip to content

Commit 8b94d68

Browse files
satoshai-devclaude
andauthored
feat: generate barrel index.ts for multi-contract sync (#20) (#42)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7f7c443 commit 8b94d68

File tree

5 files changed

+156
-5
lines changed

5 files changed

+156
-5
lines changed

.changeset/barrel-file.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@satoshai/abi-cli": minor
3+
---
4+
5+
Generate barrel `index.ts` file when syncing multiple contracts

src/codegen.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,26 @@ export function defaultFilename(contractId: string, format: 'ts' | 'json', nameO
3333
const name = nameOverride ?? contractId.split('.').pop() ?? contractId;
3434
return `${name}.${format}`;
3535
}
36+
37+
/**
38+
* Convert a kebab-case (or already camelCase) string to camelCase.
39+
*/
40+
export function toCamelCase(name: string): string {
41+
return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
42+
}
43+
44+
/**
45+
* Generate a barrel `index.ts` that re-exports all ABIs.
46+
*
47+
* Each entry maps to: `export { abi as <name>Abi } from './<filename>.js';`
48+
*/
49+
export function generateBarrel(entries: { name: string; filename: string }[]): string {
50+
const lines = entries
51+
.map(({ name, filename }) => {
52+
const exportName = `${toCamelCase(name)}Abi`;
53+
const modulePath = `./${filename.replace(/\.ts$/, '.js')}`;
54+
return `export { abi as ${exportName} } from '${modulePath}';`;
55+
});
56+
57+
return [...lines, ''].join('\n');
58+
}

src/commands/sync.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
33
import { resolve, join } from 'node:path';
44
import { loadConfig } from '../config.js';
55
import { parseContractId, fetchContractAbi } from '../fetcher.js';
6-
import { generateTypescript, generateJson, defaultFilename } from '../codegen.js';
6+
import { generateTypescript, generateJson, defaultFilename, generateBarrel } from '../codegen.js';
77

88
export const syncCommand = defineCommand({
99
meta: {
@@ -25,6 +25,7 @@ export const syncCommand = defineCommand({
2525
await mkdir(outDir, { recursive: true });
2626

2727
const failed: string[] = [];
28+
const barrelEntries: { name: string; filename: string }[] = [];
2829
let synced = 0;
2930

3031
for (const contract of config.contracts) {
@@ -40,18 +41,27 @@ export const syncCommand = defineCommand({
4041
? generateTypescript(contract.id, abi)
4142
: generateJson(abi);
4243

44+
const resolvedName = contract.name ?? name;
4345
const filename = defaultFilename(contract.id, format, contract.name);
4446
const filepath = join(outDir, filename);
4547
await writeFile(filepath, output, 'utf-8');
4648
console.error(`Wrote ${filepath}`);
4749
synced++;
50+
barrelEntries.push({ name: resolvedName, filename });
4851
} catch (err) {
4952
const message = err instanceof Error ? err.message : String(err);
5053
console.error(`Failed to sync ${contract.id}: ${message}`);
5154
failed.push(contract.id);
5255
}
5356
}
5457

58+
if (format === 'ts' && barrelEntries.length > 0) {
59+
const barrelContent = generateBarrel(barrelEntries);
60+
const barrelPath = join(outDir, 'index.ts');
61+
await writeFile(barrelPath, barrelContent, 'utf-8');
62+
console.error(`Wrote ${barrelPath}`);
63+
}
64+
5565
console.error(`\n${synced}/${config.contracts.length} contracts synced.`);
5666

5767
if (failed.length > 0) {

tests/unit/codegen.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { generateTypescript, generateJson, defaultFilename } from '../../src/codegen.js';
2+
import { generateTypescript, generateJson, defaultFilename, toCamelCase, generateBarrel } from '../../src/codegen.js';
33
import { sampleAbi } from './fixtures.js';
44

55
describe('generateTypescript', () => {
@@ -70,3 +70,49 @@ describe('defaultFilename', () => {
7070
expect(defaultFilename('standalone', 'ts')).toBe('standalone.ts');
7171
});
7272
});
73+
74+
describe('toCamelCase', () => {
75+
it('converts kebab-case to camelCase', () => {
76+
expect(toCamelCase('amm-pool')).toBe('ammPool');
77+
});
78+
79+
it('converts multi-segment kebab-case', () => {
80+
expect(toCamelCase('nft-trait-v2')).toBe('nftTraitV2');
81+
});
82+
83+
it('leaves already camelCase unchanged', () => {
84+
expect(toCamelCase('ammPool')).toBe('ammPool');
85+
});
86+
87+
it('leaves single word unchanged', () => {
88+
expect(toCamelCase('token')).toBe('token');
89+
});
90+
});
91+
92+
describe('generateBarrel', () => {
93+
it('generates re-exports with camelCase + Abi suffix', () => {
94+
const result = generateBarrel([
95+
{ name: 'amm-pool', filename: 'amm-pool.ts' },
96+
{ name: 'nft-trait', filename: 'nft-trait.ts' },
97+
]);
98+
99+
expect(result).toBe(
100+
[
101+
"export { abi as ammPoolAbi } from './amm-pool.js';",
102+
"export { abi as nftTraitAbi } from './nft-trait.js';",
103+
'',
104+
].join('\n'),
105+
);
106+
});
107+
108+
it('uses .js extension in import paths', () => {
109+
const result = generateBarrel([{ name: 'token', filename: 'token.ts' }]);
110+
expect(result).toContain("from './token.js'");
111+
expect(result).not.toContain('.ts');
112+
});
113+
114+
it('handles single entry', () => {
115+
const result = generateBarrel([{ name: 'my-contract', filename: 'my-contract.ts' }]);
116+
expect(result).toBe("export { abi as myContractAbi } from './my-contract.js';\n");
117+
});
118+
});

tests/unit/sync.test.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ describe('syncCommand', () => {
5454
await runSync({ config: '/tmp/abi.config.json' });
5555

5656
expect(mkdir).toHaveBeenCalledWith(resolve('./abis'), { recursive: true });
57-
expect(writeFile).toHaveBeenCalledTimes(2);
57+
// 2 ABI files + 1 barrel index.ts
58+
expect(writeFile).toHaveBeenCalledTimes(3);
5859
expect(writeFile).toHaveBeenCalledWith(
5960
join(resolve('./abis'), 'token-a.ts'),
6061
expect.stringContaining('export const abi ='),
@@ -152,8 +153,8 @@ describe('syncCommand', () => {
152153
'Failed to sync: SP2.token-b',
153154
);
154155

155-
// Still wrote 2 successful contracts
156-
expect(writeFile).toHaveBeenCalledTimes(2);
156+
// 2 successful ABI files + 1 barrel
157+
expect(writeFile).toHaveBeenCalledTimes(3);
157158
});
158159

159160
it('throws when config not found', async () => {
@@ -192,4 +193,70 @@ describe('syncCommand', () => {
192193
'utf-8',
193194
);
194195
});
196+
197+
it('generates barrel index.ts for ts format', async () => {
198+
mockConfig({
199+
outDir: './abis',
200+
contracts: [{ id: 'SP1.amm-pool' }, { id: 'SP2.nft-trait' }],
201+
});
202+
mockFetchSuccess(2);
203+
204+
await runSync({ config: '/tmp/abi.config.json' });
205+
206+
// 2 ABI files + 1 barrel
207+
expect(writeFile).toHaveBeenCalledTimes(3);
208+
expect(writeFile).toHaveBeenCalledWith(
209+
join(resolve('./abis'), 'index.ts'),
210+
expect.stringContaining("export { abi as ammPoolAbi } from './amm-pool.js';"),
211+
'utf-8',
212+
);
213+
expect(writeFile).toHaveBeenCalledWith(
214+
join(resolve('./abis'), 'index.ts'),
215+
expect.stringContaining("export { abi as nftTraitAbi } from './nft-trait.js';"),
216+
'utf-8',
217+
);
218+
});
219+
220+
it('does NOT generate barrel for json format', async () => {
221+
mockConfig({
222+
outDir: './abis',
223+
format: 'json',
224+
contracts: [{ id: 'SP1.token-a' }, { id: 'SP2.token-b' }],
225+
});
226+
mockFetchSuccess(2);
227+
228+
await runSync({ config: '/tmp/abi.config.json' });
229+
230+
// Only 2 ABI files, no barrel
231+
expect(writeFile).toHaveBeenCalledTimes(2);
232+
expect(writeFile).not.toHaveBeenCalledWith(
233+
join(resolve('./abis'), 'index.ts'),
234+
expect.any(String),
235+
'utf-8',
236+
);
237+
});
238+
239+
it('barrel uses name alias when available', async () => {
240+
mockConfig({
241+
outDir: './abis',
242+
contracts: [
243+
{ id: 'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01', name: 'amm-pool' },
244+
{ id: 'SP2.nft-trait' },
245+
],
246+
});
247+
mockFetchSuccess(2);
248+
249+
await runSync({ config: '/tmp/abi.config.json' });
250+
251+
expect(writeFile).toHaveBeenCalledWith(
252+
join(resolve('./abis'), 'index.ts'),
253+
expect.stringContaining("export { abi as ammPoolAbi } from './amm-pool.js';"),
254+
'utf-8',
255+
);
256+
expect(writeFile).toHaveBeenCalledWith(
257+
join(resolve('./abis'), 'index.ts'),
258+
expect.stringContaining("export { abi as nftTraitAbi } from './nft-trait.js';"),
259+
'utf-8',
260+
);
261+
});
195262
});

0 commit comments

Comments
 (0)