unbarrelify 0.0.0 → 1.0.0

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/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # unbarrelify
2
+
3
+ Barrel file removal tool for JavaScript & TypeScript projects (ESM-only).
4
+
5
+ ## What are barrel files?
6
+
7
+ Barrel files are index files that only contain re-exports from other modules:
8
+
9
+ ```ts
10
+ export { formatDate } from "./date.ts";
11
+ export { formatCurrency } from "./currency.ts";
12
+ export { capitalize } from "./string.ts";
13
+ ```
14
+
15
+ ## Why avoid barrel files?
16
+
17
+ Barrel files are convenient, but they often come with trade-offs including:
18
+
19
+ * Performance and memory: they artificially inflate the module graph and slow down startup times, HMR, and CI pipelines.
20
+ * Tree-shaking failures: they often confuse tree-shakers, risk entire libraries to be bundled when only a single function is needed.
21
+ * Circular dependencies: they frequently create "import cycles", crashing bundlers or causing confusing errors.
22
+
23
+ ## Resources
24
+
25
+ * [Speeding up the JavaScript ecosystem - The barrel file debacle][1] (Marvin Hagemeister, 2023-10-08)
26
+ * [How we optimized package imports in Next.js][2] (Shu Ding, 2023-10-13)
27
+ * [A practical guide against barrel files for library authors][3] (Pascal Schilp, 2024-06-01)
28
+ * [Please Stop Using Barrel Files][4] (Dominik Dorfmeister, 2024-07-26)
29
+
30
+ ## Features
31
+
32
+ * Automated rewiring of consumers to import directly from source
33
+ * Preserves path aliases
34
+ * Skips barrel files that are entry points (`package.json#exports` etc.)
35
+ * Auto-detects or enforces file extensions to match project style
36
+ * Optional `--organize-imports` to dedupe and clean up after rewrites
37
+ * Granular control with `--skip`, `--only` or add `--barrel`-like files
38
+ * Use `--check` for CI to fail if barrel files are detected
39
+ * Go all out with `--unsafe-namespace` to namespace imports (warning: naive)
40
+ * [Verified on non-trivial repositories][5] to not break the build/tests
41
+
42
+ ## Usage
43
+
44
+ ### Without installation
45
+
46
+ ```sh
47
+ npx unbarrelify
48
+ npx unbarrelify --help
49
+ ```
50
+
51
+ It runs safe in dry-mode until you add `--write`
52
+
53
+ ### Installation
54
+
55
+ ```bash
56
+ npm install -D unbarrelify
57
+ ```
58
+
59
+ ## CLI Usage
60
+
61
+ ```
62
+ unbarrelify - Remove barrel files and rewire imports
63
+
64
+ Usage: unbarrelify [options]
65
+
66
+ Options:
67
+ -c, --cwd <dir> Working directory (default: ".")
68
+ -o, --only <file> Process only selected barrel file (can be repeated)
69
+ -s, --skip <pattern> Barrel files to skip (glob, can be repeated)
70
+ -b, --barrel <pattern> Extra files to treat as barrels (glob, can be repeated)
71
+ -f, --files <pattern> Set file coverage (glob, can be repeated, default: use tsconfig.json)
72
+ -e, --ext <ext> Extension for rewritten imports (default: auto-detect)
73
+ -w, --write Write changes to disk (default: false/dry-run)
74
+ --organize-imports Run TypeScript's "Organize Imports" after rewrites to dedupe imports
75
+ --check, --ci Check mode for CI; non-zero exit if there are changes
76
+ --unsafe-namespace Rewrite namespace imports; may include types (bad) and cause identifier collisions (also bad)
77
+ -h, --help Show this help message
78
+
79
+ Examples:
80
+ unbarrelify
81
+ unbarrelify --cwd ./src
82
+ unbarrelify --only ./src/utils/index.ts
83
+ unbarrelify --skip ./public-api.ts
84
+ unbarrelify --barrel looks/like/barrel.ts
85
+ unbarrelify --files "src/**/*.ts" --files "lib/**/*.ts"
86
+ unbarrelify --ext .js
87
+ unbarrelify --write
88
+ unbarrelify --check
89
+ unbarrelify --unsafe-namespace
90
+ ```
91
+
92
+ ## Programmatic API
93
+
94
+ ```ts
95
+ import { unbarrelify } from "unbarrelify";
96
+
97
+ const result = await unbarrelify({
98
+ cwd: "./src",
99
+ files: ["**/*.ts"],
100
+ skip: [],
101
+ ext: ".ts",
102
+ write: false,
103
+ });
104
+
105
+ console.log(`Modified ${result.modified.length} files`);
106
+ console.log(`Deleted ${result.deleted.length} barrel files`);
107
+ ```
108
+
109
+ ### API Reference
110
+
111
+ #### `unbarrelify(options: Options): Promise<Result>`
112
+
113
+ The main function that processes files and removes barrel files.
114
+
115
+ #### `Options`
116
+
117
+ ```ts
118
+ interface Options {
119
+ cwd: string;
120
+ only?: string[];
121
+ files?: string[];
122
+ skip?: string[];
123
+ barrel?: string[];
124
+ ext?: string;
125
+ write: boolean;
126
+ check?: boolean;
127
+ unsafeNamespace?: boolean;
128
+ organizeImports?: boolean;
129
+ progress?: (event: ProgressEvent) => void;
130
+ }
131
+ ```
132
+
133
+ #### `Result`
134
+
135
+ ```ts
136
+ interface Result {
137
+ modified: string[];
138
+ deleted: string[];
139
+ preserved: PreservedBarrel[];
140
+ }
141
+
142
+ interface PreservedBarrel {
143
+ path: string;
144
+ reason: "skip" | "namespace-import" | "non-ts-import" | "dynamic-import";
145
+ consumers: string[];
146
+ }
147
+ ```
148
+
149
+ ## How does it work?
150
+
151
+ 1. Identify barrel files (files containing only re-export statements)
152
+ 2. Build import/export maps to track dependencies
153
+ 3. Rewire imports in consuming files to point directly to exporting source files
154
+ 4. Remove barrel files
155
+
156
+ ### Before
157
+
158
+ ```ts
159
+ // utils/index.ts
160
+ export { formatDate } from "./date.ts";
161
+ export { capitalize } from "./string.ts";
162
+
163
+ // module.ts
164
+ import { formatDate, capitalize } from "./utils/index.ts";
165
+ ```
166
+
167
+ ### After
168
+
169
+ ```ts
170
+ // utils/index.ts - DELETED
171
+
172
+ // module.ts
173
+ import { formatDate } from "./utils/date.ts";
174
+ import { capitalize } from "./utils/string.ts";
175
+ ```
176
+
177
+ ## License
178
+
179
+ ISC
180
+
181
+ [1]: https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-7/
182
+
183
+ [2]: https://vercel.com/blog/how-we-optimized-package-imports-in-next-js
184
+
185
+ [3]: https://dev.to/thepassle/a-practical-guide-against-barrel-files-for-library-authors-118c
186
+
187
+ [4]: https://tkdodo.eu/blog/please-stop-using-barrel-files
188
+
189
+ [5]: https://github.com/webpro/unbarrelify/blob/main/.github/workflows/integration.yml
@@ -0,0 +1,14 @@
1
+ import ts from "typescript";
2
+ import type { Context, ExportMap, File, ImportMap, PathAliases } from "./types.ts";
3
+ interface ParsedSpecifier {
4
+ prefix: string;
5
+ path: string;
6
+ suffix: string;
7
+ }
8
+ export declare function parseSpecifier(specifier: string): ParsedSpecifier;
9
+ export declare function analyzeFile(filePath: string, ctx: Context): Promise<File>;
10
+ export declare function checkIsBarrel(sourceFile: ts.SourceFile): boolean;
11
+ export declare function getExportedNames(filePath: string): Promise<Set<string>>;
12
+ export declare function buildExportMap(sourceFile: ts.SourceFile, aliases: PathAliases | null): Promise<ExportMap>;
13
+ export declare function buildImportMap(sourceFile: ts.SourceFile, aliases: PathAliases | null): ImportMap;
14
+ export {};
@@ -0,0 +1,324 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import ts from "typescript";
4
+ import { resolveModule } from "./resolver.js";
5
+ const CHAR_EXCLAMATION = 33; // '!'
6
+ const CHAR_QUESTION = 63; // '?'
7
+ const CHAR_HASH = 35; // '#'
8
+ export function parseSpecifier(specifier) {
9
+ const len = specifier.length;
10
+ let lastBang = -1;
11
+ let suffixStart = len;
12
+ for (let i = 0; i < len; i++) {
13
+ const ch = specifier.charCodeAt(i);
14
+ if (ch === CHAR_EXCLAMATION) {
15
+ lastBang = i;
16
+ }
17
+ else if (ch === CHAR_QUESTION || ch === CHAR_HASH) {
18
+ suffixStart = i;
19
+ break;
20
+ }
21
+ }
22
+ const pathStart = lastBang + 1;
23
+ return {
24
+ prefix: specifier.slice(0, pathStart),
25
+ path: specifier.slice(pathStart, suffixStart),
26
+ suffix: specifier.slice(suffixStart),
27
+ };
28
+ }
29
+ export async function analyzeFile(filePath, ctx) {
30
+ const cached = ctx.fileCache.get(filePath);
31
+ if (cached)
32
+ return cached;
33
+ const content = await readFile(filePath, "utf-8");
34
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest);
35
+ const isBarrel = checkIsBarrel(sourceFile);
36
+ const exports = await buildExportMap(sourceFile, ctx.aliases);
37
+ const imports = buildImportMap(sourceFile, ctx.aliases);
38
+ const dynamicImports = isBarrel ? new Set() : findDynamicImports(sourceFile, ctx.aliases);
39
+ const file = { isBarrel, exports, imports, sourceFile, dynamicImports };
40
+ ctx.fileCache.set(filePath, file);
41
+ return file;
42
+ }
43
+ export function checkIsBarrel(sourceFile) {
44
+ return sourceFile.statements.length > 0 && sourceFile.statements.every(ts.isExportDeclaration);
45
+ }
46
+ function extractExportedNames(sourceFile) {
47
+ const names = new Set();
48
+ for (const node of sourceFile.statements) {
49
+ if (ts.canHaveModifiers(node)) {
50
+ const modifiers = ts.getModifiers(node);
51
+ if (modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
52
+ if (modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) {
53
+ names.add("default");
54
+ }
55
+ else {
56
+ extractExportedName(node, names);
57
+ }
58
+ }
59
+ }
60
+ if (ts.isExportAssignment(node)) {
61
+ names.add("default");
62
+ }
63
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
64
+ for (const element of node.exportClause.elements) {
65
+ names.add(element.name.text);
66
+ }
67
+ }
68
+ }
69
+ return names;
70
+ }
71
+ const sourceFileCache = new Map();
72
+ export async function getExportedNames(filePath) {
73
+ let sourceFile = sourceFileCache.get(filePath);
74
+ if (!sourceFile) {
75
+ const content = await readFile(filePath, "utf-8");
76
+ sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest);
77
+ sourceFileCache.set(filePath, sourceFile);
78
+ }
79
+ return extractExportedNames(sourceFile);
80
+ }
81
+ function extractExportedName(node, names) {
82
+ if (ts.isVariableStatement(node)) {
83
+ for (const decl of node.declarationList.declarations) {
84
+ if (ts.isIdentifier(decl.name))
85
+ names.add(decl.name.text);
86
+ }
87
+ }
88
+ else if ((ts.isFunctionDeclaration(node) ||
89
+ ts.isClassDeclaration(node) ||
90
+ ts.isInterfaceDeclaration(node) ||
91
+ ts.isTypeAliasDeclaration(node) ||
92
+ ts.isEnumDeclaration(node) ||
93
+ ts.isModuleDeclaration(node)) &&
94
+ node.name &&
95
+ ts.isIdentifier(node.name)) {
96
+ names.add(node.name.text);
97
+ }
98
+ }
99
+ export async function buildExportMap(sourceFile, aliases) {
100
+ const exports = new Map();
101
+ for (const node of sourceFile.statements) {
102
+ if (!ts.isExportDeclaration(node) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier)) {
103
+ continue;
104
+ }
105
+ const specifier = node.moduleSpecifier.text;
106
+ const resolvedPath = resolveModule(sourceFile.fileName, specifier, aliases);
107
+ if (!resolvedPath)
108
+ continue;
109
+ const pos = { start: node.getStart(sourceFile), end: node.end };
110
+ if (isAbsolute(resolvedPath)) {
111
+ await processLocalExport(exports, node, resolvedPath, specifier, pos);
112
+ }
113
+ else {
114
+ processExternalExport(exports, node, specifier, pos);
115
+ }
116
+ }
117
+ return exports;
118
+ }
119
+ async function processLocalExport(exports, node, resolvedPath, specifier, pos) {
120
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
121
+ const exportedNames = new Set();
122
+ const aliasedDefaults = new Map();
123
+ let exportedAsDefault;
124
+ for (const element of node.exportClause.elements) {
125
+ exportedNames.add(element.name.text);
126
+ if (element.propertyName?.text === "default") {
127
+ aliasedDefaults.set(element.name.text, "default");
128
+ }
129
+ if (element.name.text === "default" && element.propertyName) {
130
+ exportedAsDefault = element.propertyName.text;
131
+ }
132
+ }
133
+ mergeExport(exports, resolvedPath, {
134
+ specifier,
135
+ pos,
136
+ exportedNames,
137
+ aliasedDefaults: aliasedDefaults.size > 0 ? aliasedDefaults : undefined,
138
+ exportedAsDefault,
139
+ });
140
+ }
141
+ else {
142
+ const namespace = node.exportClause && ts.isNamespaceExport(node.exportClause) ? node.exportClause.name.text : undefined;
143
+ const exportedNames = await getExportedNames(resolvedPath);
144
+ mergeExport(exports, resolvedPath, {
145
+ specifier,
146
+ pos,
147
+ exportedNames,
148
+ reExportedNs: namespace,
149
+ externalSpecifier: specifier.startsWith(".") ? undefined : specifier,
150
+ });
151
+ }
152
+ }
153
+ function processExternalExport(exports, node, specifier, pos) {
154
+ const namespace = node.exportClause && ts.isNamespaceExport(node.exportClause) ? node.exportClause.name.text : undefined;
155
+ const exportedNames = new Set();
156
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
157
+ for (const element of node.exportClause.elements) {
158
+ exportedNames.add(element.name.text);
159
+ }
160
+ }
161
+ exports.set(specifier, {
162
+ specifier,
163
+ pos,
164
+ exportedNames,
165
+ reExportedNs: namespace,
166
+ externalSpecifier: specifier,
167
+ });
168
+ }
169
+ function mergeExport(exports, path, data) {
170
+ const existing = exports.get(path);
171
+ if (!existing) {
172
+ exports.set(path, data);
173
+ return;
174
+ }
175
+ for (const name of data.exportedNames) {
176
+ existing.exportedNames.add(name);
177
+ }
178
+ if (data.aliasedDefaults) {
179
+ existing.aliasedDefaults = existing.aliasedDefaults || new Map();
180
+ for (const [k, v] of data.aliasedDefaults) {
181
+ existing.aliasedDefaults.set(k, v);
182
+ }
183
+ }
184
+ if (data.exportedAsDefault && !existing.exportedAsDefault) {
185
+ existing.exportedAsDefault = data.exportedAsDefault;
186
+ }
187
+ if (data.reExportedNs && !existing.reExportedNs) {
188
+ existing.reExportedNs = data.reExportedNs;
189
+ }
190
+ }
191
+ export function buildImportMap(sourceFile, aliases) {
192
+ const imports = new Map();
193
+ for (const node of sourceFile.statements) {
194
+ if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
195
+ processImportDeclaration(imports, node, sourceFile, aliases);
196
+ }
197
+ else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
198
+ processReExportDeclaration(imports, node, sourceFile, aliases);
199
+ }
200
+ }
201
+ return imports;
202
+ }
203
+ function processImportDeclaration(imports, node, sourceFile, aliases) {
204
+ const originalSpecifier = node.moduleSpecifier.text;
205
+ const { prefix, path, suffix } = parseSpecifier(originalSpecifier);
206
+ const resolvedPath = resolveModule(sourceFile.fileName, path, aliases);
207
+ if (!resolvedPath || !isAbsolute(resolvedPath))
208
+ return;
209
+ const clause = node.importClause;
210
+ if (!clause)
211
+ return;
212
+ const pos = { start: node.getStart(sourceFile), end: node.end };
213
+ const specifierPrefix = prefix || undefined;
214
+ const specifierSuffix = suffix || undefined;
215
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
216
+ addToImportMap(imports, resolvedPath, {
217
+ name: clause.namedBindings.name.text,
218
+ type: "ns",
219
+ pos,
220
+ members: [],
221
+ originalSpecifier: path,
222
+ specifierPrefix,
223
+ specifierSuffix,
224
+ });
225
+ }
226
+ else if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
227
+ for (const element of clause.namedBindings.elements) {
228
+ const isType = element.isTypeOnly || clause.isTypeOnly;
229
+ const propertyName = element.propertyName;
230
+ const hasAlias = propertyName && ts.isIdentifier(propertyName);
231
+ addToImportMap(imports, resolvedPath, {
232
+ names: [
233
+ {
234
+ name: hasAlias ? propertyName.text : element.name.text,
235
+ alias: hasAlias ? element.name.text : undefined,
236
+ isType,
237
+ },
238
+ ],
239
+ type: hasAlias ? "as" : "named",
240
+ pos,
241
+ originalSpecifier: path,
242
+ specifierPrefix,
243
+ specifierSuffix,
244
+ });
245
+ }
246
+ }
247
+ if (clause.name && ts.isIdentifier(clause.name)) {
248
+ addToImportMap(imports, resolvedPath, {
249
+ name: clause.name.text,
250
+ type: "default",
251
+ pos,
252
+ originalSpecifier: path,
253
+ specifierPrefix,
254
+ specifierSuffix,
255
+ });
256
+ }
257
+ }
258
+ function processReExportDeclaration(imports, node, sourceFile, aliases) {
259
+ const originalSpecifier = node.moduleSpecifier.text;
260
+ const { prefix, path, suffix } = parseSpecifier(originalSpecifier);
261
+ const resolvedPath = resolveModule(sourceFile.fileName, path, aliases);
262
+ if (!resolvedPath || !isAbsolute(resolvedPath))
263
+ return;
264
+ const pos = { start: node.getStart(sourceFile), end: node.end };
265
+ const specifierPrefix = prefix || undefined;
266
+ const specifierSuffix = suffix || undefined;
267
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
268
+ for (const element of node.exportClause.elements) {
269
+ const isType = element.isTypeOnly || node.isTypeOnly;
270
+ const hasAlias = element.propertyName && ts.isIdentifier(element.propertyName);
271
+ const propertyName = hasAlias ? element.propertyName : undefined;
272
+ addToImportMap(imports, resolvedPath, {
273
+ names: [
274
+ {
275
+ name: propertyName ? propertyName.text : element.name.text,
276
+ alias: hasAlias ? element.name.text : undefined,
277
+ isType,
278
+ },
279
+ ],
280
+ type: "export",
281
+ pos,
282
+ originalSpecifier: path,
283
+ specifierPrefix,
284
+ specifierSuffix,
285
+ });
286
+ }
287
+ }
288
+ else {
289
+ addToImportMap(imports, resolvedPath, {
290
+ name: "*",
291
+ type: "export",
292
+ pos,
293
+ originalSpecifier: path,
294
+ specifierPrefix,
295
+ specifierSuffix,
296
+ });
297
+ }
298
+ }
299
+ function addToImportMap(imports, filePath, item) {
300
+ const existing = imports.get(filePath);
301
+ if (existing) {
302
+ existing.add(item);
303
+ }
304
+ else {
305
+ imports.set(filePath, new Set([item]));
306
+ }
307
+ }
308
+ function findDynamicImports(sourceFile, aliases) {
309
+ const dynamicImports = new Set();
310
+ function visit(node) {
311
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
312
+ const arg = node.arguments[0];
313
+ if (arg && ts.isStringLiteral(arg)) {
314
+ const resolvedPath = resolveModule(sourceFile.fileName, arg.text, aliases);
315
+ if (resolvedPath && isAbsolute(resolvedPath)) {
316
+ dynamicImports.add(resolvedPath);
317
+ }
318
+ }
319
+ }
320
+ ts.forEachChild(node, visit);
321
+ }
322
+ visit(sourceFile);
323
+ return dynamicImports;
324
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ export interface ParsedArgs {
3
+ only: string[];
4
+ cwd: string;
5
+ files?: string[];
6
+ skip: string[];
7
+ barrel: string[];
8
+ ext?: string;
9
+ write: boolean;
10
+ check: boolean;
11
+ unsafeNamespace: boolean;
12
+ organizeImports: boolean;
13
+ help: boolean;
14
+ }
15
+ export declare function parseCliArgs(args: string[]): ParsedArgs;
16
+ export declare function printHelp(): void;
17
+ export declare function main(): Promise<void>;