cartotree 0.1.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 +200 -0
- package/bin/cartotree.js +55 -0
- package/package.json +15 -0
- package/src/ai.js +113 -0
- package/src/clipboard.js +11 -0
- package/src/config.js +26 -0
- package/src/exports.js +65 -0
- package/src/ignore.js +31 -0
- package/src/sync.js +30 -0
- package/src/tree.js +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lumina
|
|
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,200 @@
|
|
|
1
|
+
# 🌲 cartotree
|
|
2
|
+
|
|
3
|
+
> AI-friendly codebase mapping CLI — annotated directory trees that save tokens, not just space.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/cartotree)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## The Problem
|
|
11
|
+
|
|
12
|
+
When you paste your project structure into an AI assistant, two things happen:
|
|
13
|
+
|
|
14
|
+
1. **Token waste** — Raw `tree` output burns hundreds of tokens on ASCII box characters with zero semantic value.
|
|
15
|
+
2. **Hallucination** — AI has no idea what each folder *does*, so it guesses wrong.
|
|
16
|
+
|
|
17
|
+
## The Solution
|
|
18
|
+
|
|
19
|
+
cartotree generates a compact, annotated map of your codebase — folder descriptions, exported functions, and an AI-optimized serialization format that fits your architecture into a fraction of the tokens.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx cartotree
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
No install required.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Output Formats
|
|
34
|
+
|
|
35
|
+
### Default — Annotated ASCII Tree
|
|
36
|
+
```
|
|
37
|
+
my-app/
|
|
38
|
+
├── src/ # Core application source
|
|
39
|
+
│ ├── components/ # Reusable UI components
|
|
40
|
+
│ │ ├── Button.tsx <Button, ButtonProps>
|
|
41
|
+
│ │ └── Modal.tsx <Modal, useModal>
|
|
42
|
+
│ ├── hooks/
|
|
43
|
+
│ │ └── useAuth.ts <useAuth, useUser>
|
|
44
|
+
│ └── services/
|
|
45
|
+
│ └── api.ts <fetchUser, postData>
|
|
46
|
+
└── package.json
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `--ai` — Token-Optimized Serialization
|
|
50
|
+
```
|
|
51
|
+
# cartotree fmt: name{desc}[children]<exports>
|
|
52
|
+
my-app{Core application source}[src{Core application source}[components{Reusable UI components}[Button.tsx<Button,ButtonProps>,Modal.tsx<Modal,useModal>],hooks[useAuth.ts<useAuth,useUser>],services[api.ts<fetchUser,postData>]],package.json]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `--json` — JSON Format
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"my-app": {
|
|
59
|
+
"src": {
|
|
60
|
+
"_desc": "Core application source",
|
|
61
|
+
"components": {
|
|
62
|
+
"Button.tsx": ["Button", "ButtonProps"],
|
|
63
|
+
"Modal.tsx": ["Modal", "useModal"]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Default annotated ASCII tree
|
|
76
|
+
npx cartotree
|
|
77
|
+
|
|
78
|
+
# AI-optimized single-line serialization
|
|
79
|
+
npx cartotree --ai
|
|
80
|
+
|
|
81
|
+
# JSON format
|
|
82
|
+
npx cartotree --json
|
|
83
|
+
|
|
84
|
+
# Copy to clipboard (paste directly into AI)
|
|
85
|
+
npx cartotree --ai --copy
|
|
86
|
+
|
|
87
|
+
# Sync into CLAUDE.md / AGENTS.md
|
|
88
|
+
npx cartotree --ai --sync
|
|
89
|
+
|
|
90
|
+
# Sync into custom file
|
|
91
|
+
npx cartotree --sync .cursorrules
|
|
92
|
+
|
|
93
|
+
# Limit depth
|
|
94
|
+
npx cartotree --depth 2
|
|
95
|
+
|
|
96
|
+
# No depth limit
|
|
97
|
+
npx cartotree --full
|
|
98
|
+
|
|
99
|
+
# Exclude patterns
|
|
100
|
+
npx cartotree --exclude "tests,*.log"
|
|
101
|
+
|
|
102
|
+
# Disable export parsing
|
|
103
|
+
npx cartotree --no-exports
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Options
|
|
109
|
+
|
|
110
|
+
| Option | Default | Description |
|
|
111
|
+
|--------|---------|-------------|
|
|
112
|
+
| `--ai` | false | AI-optimized compact serialization |
|
|
113
|
+
| `--json` | false | JSON format |
|
|
114
|
+
| `--copy` | false | Copy output to clipboard |
|
|
115
|
+
| `--sync [file]` | CLAUDE.md | Inject into AI config file |
|
|
116
|
+
| `--depth <n>` | 3 | Max traversal depth |
|
|
117
|
+
| `--full` | false | No depth limit |
|
|
118
|
+
| `--exclude <patterns>` | — | Comma-separated exclude patterns |
|
|
119
|
+
| `--no-exports` | false | Disable export parsing |
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
Create `.cartotreerc` in your project root to set persistent defaults:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"default": {
|
|
130
|
+
"ai": true,
|
|
131
|
+
"exports": false,
|
|
132
|
+
"depth": 3
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Now `npx cartotree` automatically runs with `--ai --no-exports`.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Folder Annotations
|
|
142
|
+
|
|
143
|
+
Add a `.folder.md` to any directory — the first line becomes the annotation:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
# Reusable UI components
|
|
147
|
+
Atomic design system built with Radix UI primitives.
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
cartotree also falls back to the first line of `README.md` if no `.folder.md` exists.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Ignoring Files
|
|
155
|
+
|
|
156
|
+
Create `.cartotreeignore` in your project root:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
# .cartotreeignore
|
|
160
|
+
*.log
|
|
161
|
+
temp
|
|
162
|
+
coverage
|
|
163
|
+
.env*
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`node_modules`, `.git`, `dist`, `build` are excluded by default.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## `--sync` Integration
|
|
171
|
+
|
|
172
|
+
Running `cartotree --sync` injects the tree between marker tags:
|
|
173
|
+
|
|
174
|
+
```html
|
|
175
|
+
<!-- CARTOTREE-START -->
|
|
176
|
+
(auto-updated on every sync)
|
|
177
|
+
<!-- CARTOTREE-END -->
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Works with `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, or any file.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Export Parsing
|
|
185
|
+
|
|
186
|
+
cartotree reads your source files and surfaces exported symbols directly in the tree:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
hooks/
|
|
190
|
+
└── useAuth.ts <useAuth, useUser, AuthProvider>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Supports `.js`, `.ts`, `.jsx`, `.tsx`, `.mjs`, `.cjs`.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
MIT
|
|
199
|
+
|
|
200
|
+
---
|
package/bin/cartotree.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const { generateTree } = require('../src/tree');
|
|
5
|
+
const { generateAI, generateJSON } = require('../src/ai');
|
|
6
|
+
const { syncToFile } = require('../src/sync');
|
|
7
|
+
const { copyToClipboard } = require('../src/clipboard');
|
|
8
|
+
const { loadIgnorePatterns } = require('../src/ignore');
|
|
9
|
+
const { loadConfig } = require('../src/config');
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('cartotree')
|
|
13
|
+
.description('AI-friendly annotated directory tree')
|
|
14
|
+
.version('0.1.0')
|
|
15
|
+
.option('-d, --depth <number>', 'max depth')
|
|
16
|
+
.option('-e, --exclude <patterns>', 'exclude patterns (comma-separated)')
|
|
17
|
+
.option('--ai', 'AI-optimized compact format')
|
|
18
|
+
.option('--json', 'JSON format')
|
|
19
|
+
.option('--no-exports', 'disable export parsing')
|
|
20
|
+
.option('--copy', 'copy to clipboard')
|
|
21
|
+
.option('--sync [file]', 'sync to CLAUDE.md or specified file')
|
|
22
|
+
.option('--full', 'no depth limit')
|
|
23
|
+
.parse(process.argv);
|
|
24
|
+
|
|
25
|
+
const opts = program.opts();
|
|
26
|
+
const config = loadConfig(process.cwd());
|
|
27
|
+
|
|
28
|
+
const depth = opts.full ? Infinity : parseInt(opts.depth ?? config.depth);
|
|
29
|
+
const useAI = opts.ai || config.ai;
|
|
30
|
+
const useJSON = opts.json || config.json;
|
|
31
|
+
const useCopy = opts.copy || config.copy;
|
|
32
|
+
const useSync = opts.sync || config.sync;
|
|
33
|
+
const showExports = opts.exports ?? config.exports;
|
|
34
|
+
|
|
35
|
+
const ignorePatterns = loadIgnorePatterns(process.cwd());
|
|
36
|
+
const manualExclude = opts.exclude ? opts.exclude.split(',').map(s => s.trim()) : [];
|
|
37
|
+
const exclude = [...new Set([...ignorePatterns, ...manualExclude])];
|
|
38
|
+
const target = typeof useSync === 'string' ? useSync : 'CLAUDE.md';
|
|
39
|
+
|
|
40
|
+
let output;
|
|
41
|
+
if (useAI) {
|
|
42
|
+
output = generateAI(process.cwd(), { depth, exclude, showExports });
|
|
43
|
+
} else if (useJSON) {
|
|
44
|
+
output = generateJSON(process.cwd(), { depth, exclude, showExports });
|
|
45
|
+
} else {
|
|
46
|
+
output = generateTree(process.cwd(), { depth, exclude, showExports });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (useCopy) {
|
|
50
|
+
copyToClipboard(output);
|
|
51
|
+
} else if (useSync) {
|
|
52
|
+
syncToFile(output, target);
|
|
53
|
+
} else {
|
|
54
|
+
console.log(output);
|
|
55
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cartotree",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-friendly codebase mapping CLI — annotated directory trees that save tokens",
|
|
5
|
+
"main": "src/tree.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cartotree": "./bin/cartotree.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": ["cli", "ai", "developer-tools", "tree", "claude", "cursor", "tokens"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/kingluminance/cartotree.git"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/ai.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { shouldIgnore } = require('./ignore');
|
|
4
|
+
const { parseExports } = require('./exports');
|
|
5
|
+
|
|
6
|
+
const AI_FORMAT_HEADER = '# cartotree fmt: name{desc}[children]<exports>';
|
|
7
|
+
|
|
8
|
+
function getFolderDescription(dirPath) {
|
|
9
|
+
const folderMd = path.join(dirPath, '.folder.md');
|
|
10
|
+
const readmeMd = path.join(dirPath, 'README.md');
|
|
11
|
+
|
|
12
|
+
if (fs.existsSync(folderMd)) {
|
|
13
|
+
return fs.readFileSync(folderMd, 'utf-8').trim().split('\n')[0].replace(/^#\s*/, '');
|
|
14
|
+
}
|
|
15
|
+
if (fs.existsSync(readmeMd)) {
|
|
16
|
+
const first = fs.readFileSync(readmeMd, 'utf-8').trim().split('\n')[0].replace(/^#\s*/, '');
|
|
17
|
+
if (first.length < 80) return first;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildAINode(entryPath, name, opts, isDir, currentDepth = 0) {
|
|
23
|
+
if (isDir) {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = fs.readdirSync(entryPath, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return `${name}[!]`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const filtered = entries
|
|
32
|
+
.filter(e => !shouldIgnore(e.name, opts.exclude))
|
|
33
|
+
.sort((a, b) => {
|
|
34
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
35
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
36
|
+
return a.name.localeCompare(b.name);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const desc = getFolderDescription(entryPath);
|
|
40
|
+
const descPart = desc ? `{${desc}}` : '';
|
|
41
|
+
|
|
42
|
+
if (filtered.length === 0) return `${name}${descPart}[]`;
|
|
43
|
+
|
|
44
|
+
if (currentDepth >= opts.depth) {
|
|
45
|
+
const names = filtered.map(e => e.name + (e.isDirectory() ? '/' : '')).join(',');
|
|
46
|
+
return `${name}${descPart}[${names}]`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const children = filtered.map(e => {
|
|
50
|
+
const fullPath = path.join(entryPath, e.name);
|
|
51
|
+
return buildAINode(fullPath, e.name, opts, e.isDirectory(), currentDepth + 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return `${name}${descPart}[${children.join(',')}]`;
|
|
55
|
+
} else {
|
|
56
|
+
const exports = opts.showExports ? parseExports(entryPath) : null;
|
|
57
|
+
const exportPart = exports && exports.length > 0
|
|
58
|
+
? `<${exports.slice(0, 4).join(',')}${exports.length > 4 ? `+${exports.length - 4}` : ''}>`
|
|
59
|
+
: '';
|
|
60
|
+
return `${name}${exportPart}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildJSONNode(entryPath, opts, currentDepth = 0) {
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = fs.readdirSync(entryPath, { withFileTypes: true });
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const filtered = entries
|
|
73
|
+
.filter(e => !shouldIgnore(e.name, opts.exclude))
|
|
74
|
+
.sort((a, b) => {
|
|
75
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
76
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
77
|
+
return a.name.localeCompare(b.name);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const result = {};
|
|
81
|
+
|
|
82
|
+
const desc = getFolderDescription(entryPath);
|
|
83
|
+
if (desc) result['_desc'] = desc;
|
|
84
|
+
|
|
85
|
+
filtered.forEach(entry => {
|
|
86
|
+
const fullPath = path.join(entryPath, entry.name);
|
|
87
|
+
|
|
88
|
+
if (entry.isDirectory()) {
|
|
89
|
+
result[entry.name] = currentDepth < opts.depth
|
|
90
|
+
? buildJSONNode(fullPath, opts, currentDepth + 1)
|
|
91
|
+
: {};
|
|
92
|
+
} else {
|
|
93
|
+
const exports = parseExports(fullPath);
|
|
94
|
+
result[entry.name] = exports ?? null;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function generateAI(rootPath, opts) {
|
|
102
|
+
const rootName = path.basename(rootPath);
|
|
103
|
+
const node = buildAINode(rootPath, rootName, opts, true);
|
|
104
|
+
return `${AI_FORMAT_HEADER}\n${node}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function generateJSON(rootPath, opts) {
|
|
108
|
+
const rootName = path.basename(rootPath);
|
|
109
|
+
const tree = buildJSONNode(rootPath, opts);
|
|
110
|
+
return JSON.stringify({ [rootName]: tree }, null, 2);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { generateAI, generateJSON };
|
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const clipboardy = require('clipboardy');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
|
|
4
|
+
function copyToClipboard(text) {
|
|
5
|
+
const plain = text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
|
6
|
+
clipboardy.writeSync(plain);
|
|
7
|
+
console.log(chalk.green('✔ Copied to clipboard!'));
|
|
8
|
+
console.log(chalk.gray('(Color codes stripped for clean paste)'));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = { copyToClipboard };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
ai: false,
|
|
6
|
+
json: false,
|
|
7
|
+
exports: true,
|
|
8
|
+
depth: 3,
|
|
9
|
+
copy: false,
|
|
10
|
+
sync: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function loadConfig(rootPath) {
|
|
14
|
+
const configPath = path.join(rootPath, '.cartotreerc');
|
|
15
|
+
if (!fs.existsSync(configPath)) return DEFAULTS;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
19
|
+
return { ...DEFAULTS, ...parsed.default };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.warn('âš .cartotreerc parse error, using defaults');
|
|
22
|
+
return DEFAULTS;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { loadConfig };
|
package/src/exports.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
const SUPPORTED_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'];
|
|
4
|
+
|
|
5
|
+
function parseExports(filePath) {
|
|
6
|
+
const ext = SUPPORTED_EXTENSIONS.find(e => filePath.endsWith(e));
|
|
7
|
+
if (!ext) return null;
|
|
8
|
+
|
|
9
|
+
let content;
|
|
10
|
+
try {
|
|
11
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const names = new Set();
|
|
17
|
+
|
|
18
|
+
// export function foo / export async function foo / export class foo / export const foo
|
|
19
|
+
const namedPattern = /export\s+(?:async\s+)?(?:function|const|let|var|class)\s+(\w+)/g;
|
|
20
|
+
let match;
|
|
21
|
+
while ((match = namedPattern.exec(content)) !== null) {
|
|
22
|
+
if (match[1] !== 'default') names.add(match[1]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// export { foo, bar as baz }
|
|
26
|
+
const bracketPattern = /export\s*\{([^}]+)\}/g;
|
|
27
|
+
while ((match = bracketPattern.exec(content)) !== null) {
|
|
28
|
+
match[1].split(',')
|
|
29
|
+
.map(s => s.trim().split(/\s+as\s+/).pop().trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.forEach(n => names.add(n));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// export default function foo / export default class foo
|
|
35
|
+
const defaultNamedPattern = /export\s+default\s+(?:function|class)\s+(\w+)/g;
|
|
36
|
+
while ((match = defaultNamedPattern.exec(content)) !== null) {
|
|
37
|
+
names.add(match[1]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// module.exports = { foo, bar }
|
|
41
|
+
const cjsPattern = /module\.exports\s*=\s*\{([^}]+)\}/g;
|
|
42
|
+
while ((match = cjsPattern.exec(content)) !== null) {
|
|
43
|
+
match[1].split(',')
|
|
44
|
+
.map(s => s.trim().split(':')[0].trim())
|
|
45
|
+
.filter(s => /^\w+$/.test(s))
|
|
46
|
+
.forEach(n => names.add(n));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// module.exports.foo = ...
|
|
50
|
+
const cjsDotPattern = /module\.exports\.(\w+)\s*=/g;
|
|
51
|
+
while ((match = cjsDotPattern.exec(content)) !== null) {
|
|
52
|
+
names.add(match[1]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return names.size > 0 ? [...names] : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatExports(names, max = 4) {
|
|
59
|
+
if (!names) return '';
|
|
60
|
+
const visible = names.slice(0, max);
|
|
61
|
+
const rest = names.length > max ? `, +${names.length - max}` : '';
|
|
62
|
+
return `<${visible.join(', ')}${rest}>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { parseExports, formatExports };
|
package/src/ignore.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const DEFAULTS = [
|
|
5
|
+
'node_modules', '.git', 'dist', 'build', '.next',
|
|
6
|
+
'.nuxt', 'coverage', '.cache', '.DS_Store', 'Thumbs.db'
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
function loadIgnorePatterns(rootPath) {
|
|
10
|
+
const ignorePath = path.join(rootPath, '.cartotreeignore');
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(ignorePath)) return DEFAULTS;
|
|
13
|
+
|
|
14
|
+
const lines = fs.readFileSync(ignorePath, 'utf-8')
|
|
15
|
+
.split('\n')
|
|
16
|
+
.map(l => l.trim())
|
|
17
|
+
.filter(l => l && !l.startsWith('#'));
|
|
18
|
+
|
|
19
|
+
return [...new Set([...DEFAULTS, ...lines])];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shouldIgnore(name, patterns) {
|
|
23
|
+
return patterns.some(pattern => {
|
|
24
|
+
if (pattern.startsWith('*.')) {
|
|
25
|
+
return name.endsWith(pattern.slice(1));
|
|
26
|
+
}
|
|
27
|
+
return name === pattern;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { loadIgnorePatterns, shouldIgnore };
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
|
|
4
|
+
const START_MARKER = '<!-- CARTOTREE-START -->';
|
|
5
|
+
const END_MARKER = '<!-- CARTOTREE-END -->';
|
|
6
|
+
|
|
7
|
+
function syncToFile(tree, filePath) {
|
|
8
|
+
const plain = tree.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
|
9
|
+
const block = `${START_MARKER}\n\`\`\`\n${plain}\`\`\`\n${END_MARKER}`;
|
|
10
|
+
|
|
11
|
+
if (fs.existsSync(filePath)) {
|
|
12
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
13
|
+
const startIdx = content.indexOf(START_MARKER);
|
|
14
|
+
const endIdx = content.indexOf(END_MARKER);
|
|
15
|
+
|
|
16
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
17
|
+
content = content.slice(0, startIdx) + block + content.slice(endIdx + END_MARKER.length);
|
|
18
|
+
} else {
|
|
19
|
+
content += `\n\n${block}\n`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
23
|
+
console.log(chalk.green(`✔ Synced to ${filePath}`));
|
|
24
|
+
} else {
|
|
25
|
+
fs.writeFileSync(filePath, `# Project Structure\n\n${block}\n`, 'utf-8');
|
|
26
|
+
console.log(chalk.green(`✔ Created ${filePath}`));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { syncToFile };
|
package/src/tree.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const { shouldIgnore } = require('./ignore');
|
|
5
|
+
const { parseExports, formatExports } = require('./exports');
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
function getFolderDescription(dirPath) {
|
|
9
|
+
const folderMd = path.join(dirPath, '.folder.md');
|
|
10
|
+
const readmeMd = path.join(dirPath, 'README.md');
|
|
11
|
+
|
|
12
|
+
if (fs.existsSync(folderMd)) {
|
|
13
|
+
const content = fs.readFileSync(folderMd, 'utf-8').trim();
|
|
14
|
+
return content.split('\n')[0].replace(/^#\s*/, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (fs.existsSync(readmeMd)) {
|
|
18
|
+
const content = fs.readFileSync(readmeMd, 'utf-8').trim();
|
|
19
|
+
const firstLine = content.split('\n')[0].replace(/^#\s*/, '');
|
|
20
|
+
if (firstLine.length < 80) return firstLine;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildTree(dirPath, opts, prefix = '', currentDepth = 0) {
|
|
27
|
+
if (currentDepth > opts.depth) return '';
|
|
28
|
+
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err.code === 'EACCES') {
|
|
34
|
+
return `${prefix}${chalk.red('[permission denied]')}\n`;
|
|
35
|
+
}
|
|
36
|
+
return `${prefix}${chalk.red(`[error: ${err.code}]`)}\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const filtered = entries
|
|
40
|
+
.filter(e => !shouldIgnore(e.name, opts.exclude))
|
|
41
|
+
.sort((a, b) => {
|
|
42
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
43
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
44
|
+
return a.name.localeCompare(b.name);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (filtered.length === 0) {
|
|
48
|
+
return `${prefix}${chalk.gray('(empty)')}\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let output = '';
|
|
52
|
+
|
|
53
|
+
filtered.forEach((entry, index) => {
|
|
54
|
+
const isLast = index === filtered.length - 1;
|
|
55
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
56
|
+
const childPrefix = isLast ? ' ' : '│ ';
|
|
57
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
58
|
+
|
|
59
|
+
if (entry.isSymbolicLink()) {
|
|
60
|
+
try {
|
|
61
|
+
const target = fs.readlinkSync(fullPath);
|
|
62
|
+
output += `${prefix}${connector}${chalk.magenta(entry.name)}${chalk.gray(` -> ${target}`)}\n`;
|
|
63
|
+
} catch {
|
|
64
|
+
output += `${prefix}${connector}${chalk.magenta(entry.name)}${chalk.red(' -> [broken link]')}\n`;
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
const desc = getFolderDescription(fullPath);
|
|
71
|
+
const annotation = desc ? chalk.gray(` # ${desc}`) : '';
|
|
72
|
+
output += `${prefix}${connector}${chalk.cyan(entry.name)}/${annotation}\n`;
|
|
73
|
+
if (currentDepth < opts.depth) {
|
|
74
|
+
output += buildTree(fullPath, opts, prefix + childPrefix, currentDepth + 1);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
const exports = opts.showExports ? parseExports(fullPath) : null;
|
|
78
|
+
const exportTag = exports ? chalk.yellow(` ${formatExports(exports)}`) : '';
|
|
79
|
+
output += `${prefix}${connector}${entry.name}${exportTag}\n`;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return output;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function generateTree(rootPath, opts) {
|
|
87
|
+
const rootName = path.basename(rootPath);
|
|
88
|
+
const desc = getFolderDescription(rootPath);
|
|
89
|
+
const annotation = desc ? chalk.gray(` # ${desc}`) : '';
|
|
90
|
+
|
|
91
|
+
let output = `${chalk.bold.cyan(rootName)}/${annotation}\n`;
|
|
92
|
+
output += buildTree(rootPath, opts);
|
|
93
|
+
|
|
94
|
+
return output;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { generateTree };
|