flatten-tool 1.2.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/LICENSE +21 -0
- package/README.md +127 -0
- package/index.ts +216 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# flatten-tool
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/flatten-tool)
|
|
4
|
+
|
|
5
|
+
A CLI utility to flatten directory structures.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Requires [Bun](https://bun.sh) runtime (v1.1+).
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g flatten-tool
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
By default, the tool merges all file contents into a single Markdown file, with each file's content placed under a header with its relative path, followed by a code block with appropriate language highlighting based on the file extension. Ignores and filters are applied as usual.
|
|
18
|
+
|
|
19
|
+
The `<source>` argument is optional and defaults to the current directory (`.`). The `<target>` argument is also optional and defaults to `flattened.md` (or `flattened/` when using `--directory`).
|
|
20
|
+
|
|
21
|
+
### Options
|
|
22
|
+
|
|
23
|
+
- `--directory`, `-d`: Flatten to individual files in a directory instead of merging to Markdown.
|
|
24
|
+
- `--move`, `-m`: Move files instead of copying (original files will be deleted).
|
|
25
|
+
- `--overwrite`, `-o`: Overwrite existing target files.
|
|
26
|
+
- `--gitignore`, `-g`: Respect `.gitignore` files (default: true). Use `--no-gitignore` to disable.
|
|
27
|
+
- `--help`, `-h`: Show help.
|
|
28
|
+
- `--version`, `-v`: Show version.
|
|
29
|
+
|
|
30
|
+
### Examples
|
|
31
|
+
|
|
32
|
+
Flatten current directory to `flattened.md`:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
flatten-tool
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Flatten a specific directory to `flattened.md`:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
flatten-tool /path/to/source
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Flatten current directory to a custom Markdown file:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
flatten-tool output.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Flatten a specific directory to a custom Markdown file:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
flatten-tool /path/to/source output.md
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Flatten to individual files in a directory:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
flatten-tool --directory
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This creates a `flattened/` directory with flattened files.
|
|
63
|
+
|
|
64
|
+
Flatten a specific directory to a custom output directory:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
flatten-tool /path/to/source output-dir --directory
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Move files instead of copying:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
flatten-tool --move
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Overwrite existing files:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
flatten-tool --overwrite
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Combine options:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
flatten-tool /path/to/source output.md --move --overwrite
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
Run all tests:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bun test
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Run tests in watch mode:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bun test --watch
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Run a specific test:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
bun test -t "flattens a simple nested directory"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
This project uses Bun for runtime, TypeScript for type safety, and follows the guidelines in `AGENTS.md` for coding standards.
|
|
111
|
+
|
|
112
|
+
## Changelog
|
|
113
|
+
|
|
114
|
+
### v1.2.0
|
|
115
|
+
- Implemented streaming for Markdown merging to improve memory efficiency for large files/directories.
|
|
116
|
+
- Updated documentation and coding guidelines.
|
|
117
|
+
|
|
118
|
+
### v1.1.0
|
|
119
|
+
- Made source argument optional (defaults to current directory).
|
|
120
|
+
- Improved CLI defaults and options.
|
|
121
|
+
|
|
122
|
+
### v1.0.0
|
|
123
|
+
- Initial release with Markdown merging and directory flattening capabilities.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
package/index.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { copyFile, rename, rm, stat, mkdir, readdir, rmdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, sep, resolve, extname } from 'node:path';
|
|
4
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
5
|
+
import yargs from 'yargs';
|
|
6
|
+
import { hideBin } from 'yargs/helpers';
|
|
7
|
+
import { globby } from 'globby';
|
|
8
|
+
import pkg from './package.json' assert { type: 'json' };
|
|
9
|
+
|
|
10
|
+
function escapePathComponent(component: string): string {
|
|
11
|
+
return component.replace(/_/g, '__');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function removeEmptyDirs(dir: string, root?: string): Promise<void> {
|
|
15
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
await removeEmptyDirs(join(dir, entry.name), root);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (dir !== root) {
|
|
22
|
+
try {
|
|
23
|
+
await rmdir(dir);
|
|
24
|
+
} catch {
|
|
25
|
+
// Not empty, skip
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function flattenDirectory(
|
|
31
|
+
source: string,
|
|
32
|
+
target: string,
|
|
33
|
+
move: boolean = false,
|
|
34
|
+
overwrite: boolean = false,
|
|
35
|
+
ignorePatterns: string[] = [],
|
|
36
|
+
respectGitignore: boolean = true,
|
|
37
|
+
flattenToDirectory: boolean = false
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const absSource = resolve(source);
|
|
40
|
+
const absTarget = resolve(target);
|
|
41
|
+
|
|
42
|
+
if (absSource === absTarget) {
|
|
43
|
+
console.log('Source and target are the same; skipping.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const negativeIgnores = ignorePatterns.map(pattern => `!${pattern}`);
|
|
48
|
+
|
|
49
|
+
const files = await globby(['**', ...negativeIgnores], {
|
|
50
|
+
cwd: absSource,
|
|
51
|
+
gitignore: respectGitignore,
|
|
52
|
+
absolute: true,
|
|
53
|
+
dot: true,
|
|
54
|
+
onlyFiles: true,
|
|
55
|
+
ignore: ['.git'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!flattenToDirectory) {
|
|
59
|
+
// Check if target exists
|
|
60
|
+
try {
|
|
61
|
+
await stat(absTarget);
|
|
62
|
+
if (!overwrite) {
|
|
63
|
+
throw new Error(`Target file "${absTarget}" already exists. Use --overwrite to force.`);
|
|
64
|
+
}
|
|
65
|
+
console.warn(`Overwriting existing file: ${absTarget}`);
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
if (err.code !== 'ENOENT') throw err;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const writeStream = createWriteStream(absTarget);
|
|
71
|
+
|
|
72
|
+
for (const srcPath of files) {
|
|
73
|
+
const relPath = relative(absSource, srcPath).replace(/\\/g, '/');
|
|
74
|
+
let ext = extname(srcPath).slice(1) || 'text';
|
|
75
|
+
const isMd = ['md', 'markdown'].includes(ext.toLowerCase());
|
|
76
|
+
const ticks = isMd ? '````' : '```';
|
|
77
|
+
|
|
78
|
+
// Header + opening fence
|
|
79
|
+
writeStream.write(`# ${relPath}\n\n${ticks}${ext}\n`);
|
|
80
|
+
|
|
81
|
+
// Stream file content as UTF-8 text (matches previous readFile(..., 'utf8') behavior)
|
|
82
|
+
const readStream = createReadStream(srcPath, { encoding: 'utf8' });
|
|
83
|
+
|
|
84
|
+
await new Promise<void>((resolve, reject) => {
|
|
85
|
+
readStream.pipe(writeStream, { end: false });
|
|
86
|
+
|
|
87
|
+
readStream.on('end', () => {
|
|
88
|
+
// Closing fence + trailing newlines
|
|
89
|
+
writeStream.write(`\n${ticks}\n\n`);
|
|
90
|
+
resolve();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
readStream.on('error', reject);
|
|
94
|
+
writeStream.on('error', reject);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Finalise the output file
|
|
99
|
+
await new Promise<void>((resolve, reject) => {
|
|
100
|
+
writeStream.end();
|
|
101
|
+
writeStream.on('close', resolve);
|
|
102
|
+
writeStream.on('error', reject);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (move) {
|
|
106
|
+
for (const srcPath of files) {
|
|
107
|
+
await rm(srcPath);
|
|
108
|
+
}
|
|
109
|
+
await removeEmptyDirs(absSource, absSource);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
await mkdir(absTarget, { recursive: true });
|
|
113
|
+
|
|
114
|
+
for (const srcPath of files) {
|
|
115
|
+
const relPath = relative(absSource, srcPath);
|
|
116
|
+
const components = relPath.split(sep);
|
|
117
|
+
const escapedComponents = components.map(escapePathComponent);
|
|
118
|
+
const newName = escapedComponents.join('_');
|
|
119
|
+
const tgtPath = join(absTarget, newName);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await stat(tgtPath);
|
|
123
|
+
if (!overwrite) {
|
|
124
|
+
throw new Error(`Target file "${tgtPath}" already exists. Use --overwrite to force.`);
|
|
125
|
+
}
|
|
126
|
+
console.warn(`Overwriting existing file: ${tgtPath}`);
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
if (err.code !== 'ENOENT') throw err;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (move) {
|
|
132
|
+
await rename(srcPath, tgtPath);
|
|
133
|
+
} else {
|
|
134
|
+
await copyFile(srcPath, tgtPath);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (move) {
|
|
139
|
+
await removeEmptyDirs(absSource, absSource);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Main CLI logic
|
|
145
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
146
|
+
yargs(hideBin(process.argv))
|
|
147
|
+
.command('$0 [source] [target]', 'Flatten or merge a directory structure (default source: current directory)', (yargs) => {
|
|
148
|
+
yargs
|
|
149
|
+
.positional('source', {
|
|
150
|
+
describe: 'Directory to flatten (default: current directory ".")',
|
|
151
|
+
type: 'string',
|
|
152
|
+
default: '.',
|
|
153
|
+
})
|
|
154
|
+
.positional('target', {
|
|
155
|
+
describe: 'Target file or directory. Default: flattened.md or flattened/ depending on --directory',
|
|
156
|
+
type: 'string',
|
|
157
|
+
})
|
|
158
|
+
.option('move', {
|
|
159
|
+
alias: 'm',
|
|
160
|
+
describe: 'Move files instead of copying (original files will be deleted)',
|
|
161
|
+
type: 'boolean',
|
|
162
|
+
default: false,
|
|
163
|
+
})
|
|
164
|
+
.option('overwrite', {
|
|
165
|
+
alias: 'o',
|
|
166
|
+
describe: 'Overwrite existing files in target if conflicts occur',
|
|
167
|
+
type: 'boolean',
|
|
168
|
+
default: false,
|
|
169
|
+
})
|
|
170
|
+
.option('gitignore', {
|
|
171
|
+
alias: 'g',
|
|
172
|
+
describe: 'Respect .gitignore files (default: true). Use --no-gitignore to include everything',
|
|
173
|
+
type: 'boolean',
|
|
174
|
+
default: true,
|
|
175
|
+
})
|
|
176
|
+
.option('directory', {
|
|
177
|
+
alias: 'd',
|
|
178
|
+
describe: 'Flatten files into a directory structure (escaped filenames). When not set, merges everything into a single Markdown file (default behavior).',
|
|
179
|
+
type: 'boolean',
|
|
180
|
+
default: false,
|
|
181
|
+
})
|
|
182
|
+
}, async (argv) => {
|
|
183
|
+
let source = argv.source as string; // now always defined (default '.')
|
|
184
|
+
let target = argv.target as string; // may be undefined
|
|
185
|
+
|
|
186
|
+
const move: boolean = argv.move as boolean;
|
|
187
|
+
const overwrite: boolean = argv.overwrite as boolean;
|
|
188
|
+
const ignorePatterns: string[] = argv.ignore as string[] || [];
|
|
189
|
+
const respectGitignore: boolean = argv.gitignore as boolean;
|
|
190
|
+
const flattenToDirectory: boolean = argv.directory as boolean;
|
|
191
|
+
|
|
192
|
+
// If user didn't provide explicit target, choose sensible default
|
|
193
|
+
if (!target) {
|
|
194
|
+
target = flattenToDirectory
|
|
195
|
+
? join(process.cwd(), 'flattened')
|
|
196
|
+
: join(process.cwd(), 'flattened.md');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await stat(source);
|
|
201
|
+
} catch {
|
|
202
|
+
console.error(`Source directory "${source}" does not exist.`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const action = move ? 'moved' : 'copied';
|
|
207
|
+
const mode = flattenToDirectory ? 'directory' : 'Markdown file';
|
|
208
|
+
await flattenDirectory(source, target, move, overwrite, ignorePatterns, respectGitignore, flattenToDirectory);
|
|
209
|
+
console.log(`Directory flattened successfully (${action}) into ${target} (${mode}).`);
|
|
210
|
+
})
|
|
211
|
+
.help('h')
|
|
212
|
+
.alias('h', 'help')
|
|
213
|
+
.version(pkg.version ?? '0.0.0')
|
|
214
|
+
.alias('v', 'version')
|
|
215
|
+
.parse();
|
|
216
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flatten-tool",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "CLI tool to flatten directory structures: merge files into a single Markdown file (default) or copy/move to a flat directory with escaped filenames. Respects .gitignore, supports move/overwrite, and more.",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"flatten-tool": "index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun test"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"bun": ">=1.1.0"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"flatten",
|
|
18
|
+
"directory",
|
|
19
|
+
"cli",
|
|
20
|
+
"markdown",
|
|
21
|
+
"merge",
|
|
22
|
+
"filesystem",
|
|
23
|
+
"bun",
|
|
24
|
+
"gitignore"
|
|
25
|
+
],
|
|
26
|
+
"author": "Your Name <your.email@example.com>",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/yourusername/flatten-tool.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/yourusername/flatten-tool/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/yourusername/flatten-tool#readme",
|
|
36
|
+
"files": [
|
|
37
|
+
"index.ts",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/bun": "latest",
|
|
43
|
+
"@types/yargs": "^17.0.35"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"typescript": "^5"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"globby": "^16.1.0",
|
|
50
|
+
"ignore": "^7.0.5",
|
|
51
|
+
"minimatch": "^10.1.1",
|
|
52
|
+
"yargs": "^18.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|