lsallcp 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/LICENSE +13 -0
- package/README.md +204 -0
- package/bin/lsallcp.js +17 -0
- package/package.json +25 -0
- package/src/binary.ts +5 -0
- package/src/cli.ts +99 -0
- package/src/clipboard.ts +37 -0
- package/src/matcher.ts +45 -0
- package/src/render.ts +26 -0
- package/src/scanner.ts +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
2
|
+
Version 2, December 2004
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
|
5
|
+
|
|
6
|
+
Everyone is permitted to copy and distribute verbatim or modified
|
|
7
|
+
copies of this license document, and changing it is allowed as long
|
|
8
|
+
as the name is changed.
|
|
9
|
+
|
|
10
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
11
|
+
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
|
12
|
+
|
|
13
|
+
0. You just DO WHAT THE FUCK YOU WANT TO.
|
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# lsallcp
|
|
2
|
+
|
|
3
|
+
> Recursively dump every file in a directory as colorized, fenced-code blocks -- with smart skip patterns, a live progress bar, and one-flag clipboard export. Built on [`cli-atelier`](https://www.npmjs.com/package/cli-atelier), runs on [Bun](https://bun.sh).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun add -g lsallcp
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
`lsallcp` walks a directory tree and prints every file it finds as a labeled, syntax-tagged code block:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
src/index.ts:
|
|
17
|
+
<code>ts
|
|
18
|
+
console.log("hello")
|
|
19
|
+
</code>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
It's built for quickly dumping a project's source into a chat window, a doc, or a clipboard -- for code review, LLM prompts, documentation, or debugging -- without manually opening and copy-pasting dozens of files.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
| Feature | Description |
|
|
29
|
+
| ------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
30
|
+
| Recursive listing | Walks the full directory tree from any starting path |
|
|
31
|
+
| Smart skip patterns | Skip by exact name, glob (`*.png`), or regex literal (`/\.test\.ts$/i`) |
|
|
32
|
+
| True recursive pruning | A matched directory is never descended into -- not just filtered afterward |
|
|
33
|
+
| Binary-safe | Detects binary files and shows a placeholder instead of dumping raw bytes |
|
|
34
|
+
| Live progress bar | Real-time progress while files are read, powered by `cli-atelier` |
|
|
35
|
+
| Colorized output | Paths, fences, and log messages are colored for readability |
|
|
36
|
+
| Clipboard export | `-c` / `--copy` copies plain-text results directly to your system clipboard |
|
|
37
|
+
| Zero runtime dependencies (besides `cli-atelier`) | No bundler, no build step -- pure Bun + TypeScript |
|
|
38
|
+
| Cross-platform | Works in Windows Terminal, Git Bash, macOS Terminal, and Linux shells |
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
| Dependency | Minimum version | Notes |
|
|
45
|
+
| ---------- | --------------- | ------------------------------------------------------------ |
|
|
46
|
+
| Bun | `>= 1.1.0` | Required at runtime -- the CLI executes as raw `.ts` via Bun |
|
|
47
|
+
| Node.js | `>= 18.0.0` | Only needed if installing globally via `npm`/`pnpm`/`yarn` |
|
|
48
|
+
|
|
49
|
+
> `lsallcp` always executes through Bun. When installed via `npm install -g`, the generated shim (`bin/lsallcp.js`) runs under Node just long enough to spawn Bun as a subprocess -- this is what makes the same package work identically in Windows Terminal, Git Bash, macOS, and Linux.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Bun (recommended -- installs and runs natively)
|
|
57
|
+
bun add -g lsallcp
|
|
58
|
+
|
|
59
|
+
# npm / pnpm / yarn (cross-platform shim, still requires Bun on PATH)
|
|
60
|
+
npm install -g lsallcp
|
|
61
|
+
pnpm add -g lsallcp
|
|
62
|
+
yarn global add lsallcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
To use it inside a single project instead of globally:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
bun add -d lsallcp
|
|
69
|
+
bunx lsallcp .
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
lsallcp <path> [skipPattern...] [--copy | -c]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
| Argument | Required | Description |
|
|
81
|
+
| -------------- | -------- | -------------------------------------------------------------- |
|
|
82
|
+
| `<path>` | Yes | Directory to scan, e.g. `.`, `./src`, `../my-project` |
|
|
83
|
+
| `skipPattern` | No | Zero or more names/globs/regexes to skip, recursively |
|
|
84
|
+
| `--copy`, `-c` | No | Copy plain-text output to the clipboard instead of printing it |
|
|
85
|
+
|
|
86
|
+
Flags can appear anywhere after the path -- order doesn't matter.
|
|
87
|
+
|
|
88
|
+
### Basic examples
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# List every file in the current directory
|
|
92
|
+
lsallcp .
|
|
93
|
+
|
|
94
|
+
# Skip an exact folder name (matched at any depth)
|
|
95
|
+
lsallcp . node_modules
|
|
96
|
+
|
|
97
|
+
# Skip multiple exact names at once
|
|
98
|
+
lsallcp . node_modules dist .env
|
|
99
|
+
|
|
100
|
+
# Skip by glob pattern
|
|
101
|
+
lsallcp . *.png *.lock
|
|
102
|
+
|
|
103
|
+
# Skip by regex literal (wrap in slashes, flags optional)
|
|
104
|
+
lsallcp . /\.test\.ts$/i
|
|
105
|
+
|
|
106
|
+
# Combine skip types freely
|
|
107
|
+
lsallcp . node_modules *.png /\.spec\.ts$/
|
|
108
|
+
|
|
109
|
+
# Copy the result to your clipboard instead of printing it
|
|
110
|
+
lsallcp . --copy
|
|
111
|
+
lsallcp . node_modules -c
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Skip patterns
|
|
117
|
+
|
|
118
|
+
Every skip argument is compiled into a matcher and checked against both the file/folder's **basename** and its **relative path**. A match on a directory prunes the entire subtree -- files inside are never even read.
|
|
119
|
+
|
|
120
|
+
| Pattern type | Syntax | Example | Matches |
|
|
121
|
+
| ------------- | ------------------- | --------------------- | ---------------------------------------- |
|
|
122
|
+
| Exact name | plain string | `node_modules` | Any file/folder named exactly this |
|
|
123
|
+
| Glob | contains `*` or `?` | `*.png`, `**/dist/**` | Shell-style wildcard match |
|
|
124
|
+
| Regex literal | `/pattern/flags` | `/\.test\.ts$/i` | Full regular expression, flags supported |
|
|
125
|
+
|
|
126
|
+
Glob rules:
|
|
127
|
+
|
|
128
|
+
- `*` matches any characters except `/`
|
|
129
|
+
- `**` matches any characters, including `/`
|
|
130
|
+
- `?` matches exactly one character (not `/`)
|
|
131
|
+
|
|
132
|
+
`.git` is **always** skipped automatically, regardless of the patterns you pass.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Clipboard export
|
|
137
|
+
|
|
138
|
+
Passing `-c` or `--copy` copies the full, ANSI-free plain-text result directly to your system clipboard and suppresses the normal terminal printout -- you'll only see the scan log, progress bar, and a final confirmation:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
lsallcp . node_modules --copy
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
ℹ Target: /Users/<nolly>/projects/my-app
|
|
146
|
+
ℹ Skip patterns: node_modules
|
|
147
|
+
ℹ Clipboard: enabled (content will not be printed)
|
|
148
|
+
✔ Found 214 file(s) after skipping
|
|
149
|
+
✔ All files read
|
|
150
|
+
✔ Listed 214 file(s) from /Users/<nolly>/projects/my-app
|
|
151
|
+
✔ Result copied to clipboard
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Clipboard support is zero-dependency and platform-native:
|
|
155
|
+
|
|
156
|
+
| Platform | Tool used |
|
|
157
|
+
| -------- | ------------------------------------------- |
|
|
158
|
+
| Windows | `clip` (built-in, also works from Git Bash) |
|
|
159
|
+
| macOS | `pbcopy` (built-in) |
|
|
160
|
+
| Linux | `wl-copy` → `xclip` → `xsel`, first found |
|
|
161
|
+
|
|
162
|
+
If none of these tools are available on Linux, `lsallcp` prints a warning and leaves the terminal output untouched.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Output format
|
|
167
|
+
|
|
168
|
+
Each file is rendered as:
|
|
169
|
+
|
|
170
|
+
<relativePath>:
|
|
171
|
+
<code><extension>
|
|
172
|
+
<file content>
|
|
173
|
+
</code>
|
|
174
|
+
|
|
175
|
+
- `<relativePath>` is colored cyan and bold, relative to the scanned root, using forward slashes on all platforms.
|
|
176
|
+
- `<extension>` is the file's extension without the leading dot (`ts`, `json`, `md`); extensionless files fall back to `text`.
|
|
177
|
+
- Binary files render as a dimmed, italic `[binary file - content skipped]` placeholder instead of raw bytes.
|
|
178
|
+
- Unreadable files (permissions, broken symlinks, etc.) render a red `[unreadable: <reason>]` placeholder instead of crashing the whole run.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Exit behavior
|
|
183
|
+
|
|
184
|
+
- Directory scanning never aborts on a single unreadable subdirectory -- it logs a warning and continues.
|
|
185
|
+
- If the target path doesn't exist or isn't a directory, `lsallcp` exits with an error and a non-zero status code.
|
|
186
|
+
- If no files remain after skip patterns are applied, `lsallcp` logs a warning and exits cleanly.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## How it works
|
|
191
|
+
|
|
192
|
+
`lsallcp` is a thin, purpose-built CLI in three stages:
|
|
193
|
+
|
|
194
|
+
1. **Scan** -- recursively walks the target directory (`src/scanner.ts`), pruning any path matched by a compiled skip matcher (`src/matcher.ts`) before ever entering it.
|
|
195
|
+
2. **Read** -- reads each remaining file, detects binary content (`src/binary.ts`), and formats it into a colorized fenced block (`src/render.ts`), updating a live progress bar as it goes.
|
|
196
|
+
3. **Output** -- either prints the assembled result to stdout, or strips ANSI codes and sends it to the system clipboard (`src/clipboard.ts`) when `-c`/`--copy` is set.
|
|
197
|
+
|
|
198
|
+
All terminal UI -- headers, spinners, progress bars, logs, boxes -- comes from [`cli-atelier`](https://www.npmjs.com/package/cli-atelier), a zero-dependency, pure-ANSI terminal framework for Bun.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Released under the [WTFPL License](./LICENSE).
|
package/bin/lsallcp.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('node:child_process')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
|
|
6
|
+
const cliEntry = path.join(__dirname, '..', 'src', 'cli.ts')
|
|
7
|
+
const args = process.argv.slice(2)
|
|
8
|
+
|
|
9
|
+
const result = spawnSync('bun', [cliEntry, ...args], { stdio: 'inherit' })
|
|
10
|
+
|
|
11
|
+
if (result.error) {
|
|
12
|
+
console.error('lsallcp requires Bun to be installed and available on your PATH.')
|
|
13
|
+
console.error('Install it from https://bun.sh/ then try again.')
|
|
14
|
+
process.exit(1)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
process.exit(result.status === null ? 1 : result.status)
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lsallcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Recursively list every file's content as relativePath: ```ext code fences```, with glob/regex/name skip patterns, progress bar and colored output",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lsallcp": "./bin/lsallcp.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["src", "bin", "package.json", "README.md", "LICENSE"],
|
|
10
|
+
"engines": {
|
|
11
|
+
"bun": ">=1.1.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "bun src/cli.ts"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["cli", "bun", "list", "files", "dump", "tree"],
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "latest",
|
|
19
|
+
"typescript": "latest"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"cli-atelier": "^1.0.2"
|
|
23
|
+
},
|
|
24
|
+
"license": "WTFPL"
|
|
25
|
+
}
|
package/src/binary.ts
ADDED
package/src/cli.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { promises as fs } from 'node:fs'
|
|
5
|
+
import { Display, Log, Header, Spinner, Progress, Box } from 'cli-atelier'
|
|
6
|
+
import { buildSkipMatchers } from './matcher'
|
|
7
|
+
import { scanDirectory } from './scanner'
|
|
8
|
+
import { renderFile } from './render'
|
|
9
|
+
import { copyToClipboard } from './clipboard'
|
|
10
|
+
|
|
11
|
+
const COPY_FLAGS = new Set(['--copy', '-c'])
|
|
12
|
+
|
|
13
|
+
function printHelp(): void {
|
|
14
|
+
Box.render(
|
|
15
|
+
[
|
|
16
|
+
Display.paint('lsallcp <path> [skipPattern...] [--copy|-c]', Display.color.white, Display.color.bold),
|
|
17
|
+
'',
|
|
18
|
+
' lsallcp . List every file under the current dir',
|
|
19
|
+
' lsallcp . node_modules Skip any file/folder named node_modules',
|
|
20
|
+
' lsallcp . *.png *.lock Skip by glob pattern',
|
|
21
|
+
' lsallcp . /\\.test\\.ts$/i Skip by regex literal',
|
|
22
|
+
" lsallcp . --copy List, copy to clipboard, don't print content",
|
|
23
|
+
" lsallcp . node_modules -c Skip + copy, flag order doesn't matter",
|
|
24
|
+
'',
|
|
25
|
+
Display.paint('Note:', Display.color.yellow) + ' .git is always skipped.',
|
|
26
|
+
],
|
|
27
|
+
{ style: 'round', color: Display.color.cyan, title: 'lsallcp' }
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function resolveRoot(targetArgument: string): Promise<string> {
|
|
32
|
+
const root = path.resolve(process.cwd(), targetArgument)
|
|
33
|
+
const stat = await fs.stat(root)
|
|
34
|
+
if (!stat.isDirectory()) throw new Error(`Target is not a directory: ${root}`)
|
|
35
|
+
return root
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv: string[]): { targetArgument: string, skipArguments: string[], copy: boolean } {
|
|
39
|
+
let copy = false
|
|
40
|
+
const rest: string[] = []
|
|
41
|
+
for (const arg of argv) {
|
|
42
|
+
if (COPY_FLAGS.has(arg)) copy = true
|
|
43
|
+
else rest.push(arg)
|
|
44
|
+
}
|
|
45
|
+
const [targetArgument, ...skipArguments] = rest
|
|
46
|
+
return { targetArgument, skipArguments, copy }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main(): Promise<void> {
|
|
50
|
+
const argv = process.argv.slice(2)
|
|
51
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
52
|
+
printHelp()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const { targetArgument, skipArguments, copy } = parseArgs(argv)
|
|
56
|
+
if (!targetArgument) {
|
|
57
|
+
printHelp()
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
Header.render('lsallcp', { style: 'banner', color: Display.color.magenta })
|
|
61
|
+
const root = await resolveRoot(targetArgument)
|
|
62
|
+
Log.info(`Target: ${Display.paint(root, Display.color.yellow)}`)
|
|
63
|
+
if (skipArguments.length > 0) Log.info(`Skip patterns: ${Display.paint(skipArguments.join(', '), Display.color.gray)}`)
|
|
64
|
+
if (copy) Log.info(`Clipboard: ${Display.paint('enabled', Display.color.green)} (content will not be printed)`)
|
|
65
|
+
const matchers = buildSkipMatchers(skipArguments)
|
|
66
|
+
const spinner = new Spinner('Walking directory tree...')
|
|
67
|
+
spinner.start()
|
|
68
|
+
const files = await scanDirectory(root, matchers)
|
|
69
|
+
spinner.stop(`Found ${files.length} file(s) after skipping`)
|
|
70
|
+
if (files.length === 0) {
|
|
71
|
+
Log.warn('No files matched. Nothing to list.')
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const bar = new Progress({ label: 'Reading files', width: 40, color: Display.color.cyan })
|
|
75
|
+
const blocks: string[] = []
|
|
76
|
+
for (let index = 0; index < files.length; index++) {
|
|
77
|
+
blocks.push(await renderFile(files[index]))
|
|
78
|
+
bar.update(Math.round(((index + 1) / files.length) * 100))
|
|
79
|
+
}
|
|
80
|
+
bar.finish('All files read')
|
|
81
|
+
const output = blocks.join('\n\n')
|
|
82
|
+
if (!copy) {
|
|
83
|
+
Header.divider()
|
|
84
|
+
console.log(output)
|
|
85
|
+
Header.divider()
|
|
86
|
+
}
|
|
87
|
+
Log.success(`Listed ${files.length} file(s) from ${Display.paint(root, Display.color.yellow)}`)
|
|
88
|
+
if (copy) {
|
|
89
|
+
const plainOutput = Display.strip(output)
|
|
90
|
+
const copied = await copyToClipboard(plainOutput)
|
|
91
|
+
if (copied) Log.success('Result copied to clipboard')
|
|
92
|
+
else Log.warn('Could not copy to clipboard (no clip/pbcopy/wl-copy/xclip/xsel found)')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
main().catch((error) => {
|
|
97
|
+
Log.error(error instanceof Error ? error.stack ?? error.message : String(error))
|
|
98
|
+
process.exit(1)
|
|
99
|
+
})
|
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
interface ClipboardCommand {
|
|
4
|
+
command: string
|
|
5
|
+
args: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function candidateCommands(): ClipboardCommand[] {
|
|
9
|
+
switch (process.platform) {
|
|
10
|
+
case 'win32': return [{ command: 'clip', args: [] }]
|
|
11
|
+
case 'darwin': return [{ command: 'pbcopy', args: [] }]
|
|
12
|
+
default: return [
|
|
13
|
+
{ command: 'wl-copy', args: [] },
|
|
14
|
+
{ command: 'xclip', args: ['-selection', 'clipboard'] },
|
|
15
|
+
{ command: 'xsel', args: ['--clipboard', '--input'] },
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function tryCommand(command: ClipboardCommand, text: string): Promise<boolean> {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const child = spawn(command.command, command.args, { stdio: ['pipe', 'ignore', 'ignore'] })
|
|
23
|
+
child.once('error', () => resolve(false))
|
|
24
|
+
child.once('close', (code) => resolve(code === 0))
|
|
25
|
+
child.stdin.on('error', () => { })
|
|
26
|
+
child.stdin.write(text)
|
|
27
|
+
child.stdin.end()
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
32
|
+
for (const command of candidateCommands()) {
|
|
33
|
+
const ok = await tryCommand(command, text)
|
|
34
|
+
if (ok) return true
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
package/src/matcher.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type SkipMatcher = (relativePath: string, baseName: string, isDirectory: boolean) => boolean
|
|
2
|
+
|
|
3
|
+
function defaultGitMatcher(baseName: string): boolean { return baseName === '.git' }
|
|
4
|
+
|
|
5
|
+
function parseRegexLiteral(raw: string): RegExp | null {
|
|
6
|
+
const match = raw.match(/^\/(.+)\/([a-z]*)$/i)
|
|
7
|
+
if (!match) return null
|
|
8
|
+
try { return new RegExp(match[1], match[2]) } catch { return null }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function globToRegExp(glob: string): RegExp {
|
|
12
|
+
let out = '^'
|
|
13
|
+
for (let index = 0; index < glob.length; index++) {
|
|
14
|
+
const char = glob[index]
|
|
15
|
+
if (char === '*') {
|
|
16
|
+
if (glob[index + 1] === '*') {
|
|
17
|
+
out += '.*'
|
|
18
|
+
index++
|
|
19
|
+
} else out += '[^/]*'
|
|
20
|
+
} else if (char === '?') out += '[^/]'
|
|
21
|
+
else if ('.+^$()[]{}|\\'.includes(char)) out += '\\' + char
|
|
22
|
+
else out += char
|
|
23
|
+
}
|
|
24
|
+
out += '$'
|
|
25
|
+
return new RegExp(out, 'i')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compilePattern(raw: string): SkipMatcher {
|
|
29
|
+
const regexLiteral = parseRegexLiteral(raw)
|
|
30
|
+
if (regexLiteral) return (relativePath, baseName) => regexLiteral.test(baseName) || regexLiteral.test(relativePath)
|
|
31
|
+
if (/[*?]/.test(raw)) {
|
|
32
|
+
const compiled = globToRegExp(raw)
|
|
33
|
+
return (relativePath, baseName) => compiled.test(baseName) || compiled.test(relativePath)
|
|
34
|
+
}
|
|
35
|
+
const lowered = raw.toLowerCase()
|
|
36
|
+
return (_relativePath, baseName) => baseName.toLowerCase() === lowered
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildSkipMatchers(patterns: string[]): SkipMatcher[] {
|
|
40
|
+
return [defaultGitMatcher, ...patterns.map(compilePattern)]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function shouldSkip(matchers: SkipMatcher[], relativePath: string, baseName: string, isDirectory: boolean): boolean {
|
|
44
|
+
return matchers.some((matcher) => matcher(relativePath, baseName, isDirectory))
|
|
45
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import { Display } from 'cli-atelier'
|
|
4
|
+
import type { ScannedFile } from './scanner'
|
|
5
|
+
import { isBinaryBuffer } from './binary'
|
|
6
|
+
|
|
7
|
+
function resolveExtension(relativePath: string): string {
|
|
8
|
+
const extension = path.extname(relativePath).slice(1)
|
|
9
|
+
return extension.length > 0 ? extension : 'text'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function renderFile(file: ScannedFile): Promise<string> {
|
|
13
|
+
const extension = resolveExtension(file.relativePath)
|
|
14
|
+
let body: string
|
|
15
|
+
try {
|
|
16
|
+
const buffer = await fs.readFile(file.absolutePath)
|
|
17
|
+
if (isBinaryBuffer(buffer)) body = Display.paint('[binary file - content skipped]', Display.color.gray, Display.color.italic)
|
|
18
|
+
else body = buffer.toString('utf8')
|
|
19
|
+
} catch (error) {
|
|
20
|
+
body = Display.paint(`[unreadable: ${(error as Error).message}]`, Display.color.red)
|
|
21
|
+
}
|
|
22
|
+
const header = Display.paint(file.relativePath, Display.color.cyan, Display.color.bold) + ':'
|
|
23
|
+
const openFence = Display.paint('```' + extension, Display.color.gray)
|
|
24
|
+
const closeFence = Display.paint('```', Display.color.gray)
|
|
25
|
+
return `${header}\n${openFence}\n${body}\n${closeFence}`
|
|
26
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import { Log } from 'cli-atelier'
|
|
4
|
+
import type { SkipMatcher } from './matcher'
|
|
5
|
+
import { shouldSkip } from './matcher'
|
|
6
|
+
|
|
7
|
+
export interface ScannedFile {
|
|
8
|
+
absolutePath: string
|
|
9
|
+
relativePath: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function scanDirectory(root: string, matchers: SkipMatcher[]): Promise<ScannedFile[]> {
|
|
13
|
+
const results: ScannedFile[] = []
|
|
14
|
+
await walk(root, '', matchers, results)
|
|
15
|
+
results.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
|
16
|
+
return results
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function walk(directory: string, relativeBase: string, matchers: SkipMatcher[], results: ScannedFile[]): Promise<void> {
|
|
20
|
+
let entries: import('node:fs').Dirent[]
|
|
21
|
+
try {
|
|
22
|
+
entries = await fs.readdir(directory, { withFileTypes: true })
|
|
23
|
+
} catch (error) {
|
|
24
|
+
Log.warn(`Skipping unreadable directory: ${directory} (${(error as Error).message})`)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name
|
|
29
|
+
const isDirectory = entry.isDirectory()
|
|
30
|
+
if (shouldSkip(matchers, relativePath, entry.name, isDirectory)) continue
|
|
31
|
+
if (isDirectory) {
|
|
32
|
+
await walk(path.join(directory, entry.name), relativePath, matchers, results)
|
|
33
|
+
} else if (entry.isFile()) {
|
|
34
|
+
results.push({ absolutePath: path.join(directory, entry.name), relativePath })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|