kandar-project-catalog 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kandar Lubis
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,62 @@
1
+ # project-catalog
2
+
3
+ Scan any directory, detect tech stacks automatically, and generate a beautiful interactive HTML dashboard + Markdown catalog of all your projects.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npx project-catalog scan .
9
+ ```
10
+
11
+ This will scan the current directory, detect all projects and their tech stacks, and generate:
12
+ - `PROJECTS-CATALOG.md` — Markdown catalog of all projects
13
+ - `projects-dashboard.html` — Interactive HTML dashboard with search, filter, and sort
14
+
15
+ ## Install Globally
16
+
17
+ ```bash
18
+ npm install -g project-catalog
19
+ project-catalog scan ./my-projects
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ | Command | Description |
25
+ |---------|-------------|
26
+ | `project-catalog scan <dir>` | Scan directory and generate catalog + dashboard |
27
+ | `project-catalog scan <dir> --md-only` | Generate only the Markdown catalog |
28
+ | `project-catalog scan <dir> --html-only` | Generate only the HTML dashboard |
29
+ | `project-catalog scan <dir> --output <path>` | Custom output directory |
30
+
31
+ ## How It Works
32
+
33
+ 1. Walks the target directory recursively
34
+ 2. Detects tech stacks by reading `package.json`, `requirements.txt`, `composer.json`, `pubspec.yaml`, `build.gradle`, etc.
35
+ 3. Extracts project names, descriptions, and directory structure
36
+ 4. Generates a polished HTML dashboard with search, filter, sort, and keyboard shortcuts
37
+ 5. Generates a comprehensive Markdown catalog
38
+
39
+ ## Features
40
+
41
+ - Automatic tech stack detection (Node.js, Python, PHP, Java, Flutter, Go, Rust, etc.)
42
+ - Interactive HTML dashboard with search, category filters, type filters, and sorting
43
+ - Markdown catalog for documentation
44
+ - Keyboard shortcuts (`/` to search, `Esc` to clear)
45
+ - Notable project highlighting
46
+ - Zero dependencies — runs with just Node.js
47
+
48
+ ## Example
49
+
50
+ ```bash
51
+ # Scan a folder
52
+ npx project-catalog scan ~/Desktop/projects
53
+
54
+ # Output:
55
+ # ✓ Scanned 42 projects across 8 categories
56
+ # → PROJECTS-CATALOG.md
57
+ # → projects-dashboard.html
58
+ ```
59
+
60
+ ## License
61
+
62
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { scanDirectory } = require('../src/scanner');
6
+ const { generateHTML } = require('../src/html-generator');
7
+ const { generateMD } = require('../src/md-generator');
8
+
9
+ const VERSION = '1.0.0';
10
+
11
+ function printHelp() {
12
+ console.log(`
13
+ project-catalog v${VERSION}
14
+
15
+ Usage:
16
+ project-catalog scan <directory> Scan and generate catalog + dashboard
17
+ project-catalog scan <dir> --md-only Generate only Markdown catalog
18
+ project-catalog scan <dir> --html-only Generate only HTML dashboard
19
+ project-catalog scan <dir> --output <path> Custom output directory
20
+ project-catalog --help Show this help
21
+ project-catalog --version Show version
22
+
23
+ Examples:
24
+ project-catalog scan .
25
+ project-catalog scan ~/projects
26
+ project-catalog scan ./src --output ./docs
27
+ `);
28
+ }
29
+
30
+ function parseArgs(args) {
31
+ const result = { dir: null, mdOnly: false, htmlOnly: false, output: null };
32
+
33
+ for (let i = 2; i < args.length; i++) {
34
+ const arg = args[i];
35
+ if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); }
36
+ if (arg === '--version' || arg === '-v') { console.log(VERSION); process.exit(0); }
37
+ if (arg === '--md-only') { result.mdOnly = true; continue; }
38
+ if (arg === '--html-only') { result.htmlOnly = true; continue; }
39
+ if (arg === '--output' || arg === '-o') { result.output = args[++i]; continue; }
40
+ if (!arg.startsWith('-')) { result.dir = arg; }
41
+ }
42
+
43
+ if (!result.dir) {
44
+ console.error('Error: No directory specified.\n');
45
+ printHelp();
46
+ process.exit(1);
47
+ }
48
+
49
+ return result;
50
+ }
51
+
52
+ function main() {
53
+ const opts = parseArgs(process.argv);
54
+ const targetDir = path.resolve(opts.dir);
55
+ const outDir = opts.output ? path.resolve(opts.output) : targetDir;
56
+
57
+ if (!fs.existsSync(targetDir)) {
58
+ console.error(`Error: Directory not found: ${targetDir}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ console.log(`\n Scanning: ${targetDir}\n`);
63
+
64
+ const projects = scanDirectory(targetDir);
65
+
66
+ if (projects.length === 0) {
67
+ console.log(' No projects found in this directory.');
68
+ process.exit(0);
69
+ }
70
+
71
+ // Count categories and tech stacks
72
+ const categories = [...new Set(projects.map(p => p.category))];
73
+ const techSet = new Set();
74
+ projects.forEach(p => p.stack.forEach(t => techSet.add(t)));
75
+
76
+ console.log(` Found ${projects.length} projects across ${categories.length} categories`);
77
+ console.log(` Detected ${techSet.size} technologies\n`);
78
+
79
+ if (!fs.existsSync(outDir)) {
80
+ fs.mkdirSync(outDir, { recursive: true });
81
+ }
82
+
83
+ if (!opts.htmlOnly) {
84
+ const mdPath = path.join(outDir, 'PROJECTS-CATALOG.md');
85
+ fs.writeFileSync(mdPath, generateMD(projects, targetDir));
86
+ console.log(` -> ${mdPath}`);
87
+ }
88
+
89
+ if (!opts.mdOnly) {
90
+ const htmlPath = path.join(outDir, 'projects-dashboard.html');
91
+ fs.writeFileSync(htmlPath, generateHTML(projects, targetDir));
92
+ console.log(` -> ${htmlPath}`);
93
+ }
94
+
95
+ console.log(`\n Done! ${projects.length} projects cataloged.\n`);
96
+ }
97
+
98
+ main();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "kandar-project-catalog",
3
+ "version": "1.0.0",
4
+ "description": "Scan directories, detect tech stacks, and generate beautiful project catalogs with interactive dashboards",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "project-catalog": "bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/cli.js",
11
+ "test": "node bin/cli.js scan ."
12
+ },
13
+ "keywords": [
14
+ "project",
15
+ "catalog",
16
+ "scanner",
17
+ "dashboard",
18
+ "tech-stack",
19
+ "developer-tools",
20
+ "portfolio"
21
+ ],
22
+ "author": "Kandar Lubis",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/kandarlubis31/project-catalog.git"
27
+ },
28
+ "files": [
29
+ "bin/",
30
+ "src/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=16.0.0"
36
+ }
37
+ }
@@ -0,0 +1,712 @@
1
+ const path = require('path');
2
+
3
+ function esc(s) {
4
+ return String(s)
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&#039;');
10
+ }
11
+
12
+ // JS-safe escaping for data injected into <script> tags
13
+ function jsEsc(s) {
14
+ return String(s)
15
+ .replace(/\\/g, '\\\\') // backslash first
16
+ .replace(/"/g, '\\"') // double quote
17
+ .replace(/\n/g, '\\n') // newline
18
+ .replace(/\r/g, '\\r') // carriage return
19
+ .replace(/</g, '\x3c') // prevent </script>
20
+ .replace(/>/g, '\x3e'); // angle brackets
21
+ }
22
+
23
+ function getTagClass(stack) {
24
+ const first = (stack[0] || '').toLowerCase();
25
+ if (first.includes('type') || first.includes('node') || first.includes('astro') || first.includes('next')) return 'tag-ts';
26
+ if (first.includes('python') || first.includes('django') || first.includes('flask')) return 'tag-py';
27
+ if (first.includes('php') || first.includes('laravel') || first.includes('code')) return 'tag-php';
28
+ if (first.includes('react') || first.includes('vue') || first.includes('svelte')) return 'tag-frontend';
29
+ return 'tag-default';
30
+ }
31
+
32
+ function generateHTML(projects, rootDir) {
33
+ const categories = [...new Set(projects.map(p => p.category))];
34
+ const techSet = new Set();
35
+ projects.forEach(p => p.stack.forEach(t => techSet.add(t)));
36
+
37
+ const projectData = projects.map(p => {
38
+ const features = p.features.join(', ') || 'N/A';
39
+ return ` {n:"${jsEsc(p.name)}",c:"${jsEsc(p.category)}",p:"${jsEsc(p.path)}",s:${JSON.stringify(p.stack).replace(/</g, '\x3c')},t:"${getTagClass(p.stack)}",d:"${jsEsc(p.description || 'No description')}",f:"${jsEsc(features)}",type:"${p.type}"${p.notable ? ',notable:true' : ''}}`;
40
+ }).join(',\n');
41
+
42
+ const rootName = path.basename(rootDir);
43
+ const rootPath = path.resolve(rootDir);
44
+
45
+ return `<!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="UTF-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
50
+ <title>Project Catalog</title>
51
+ <link rel="preconnect" href="https://fonts.googleapis.com">
52
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
53
+ <style>
54
+ :root {
55
+ --bg: #05050a;
56
+ --bg-elevated: #0a0a12;
57
+ --surface: #0f0f1a;
58
+ --surface-raised: #15152a;
59
+ --surface-hover: #1a1a35;
60
+ --border: rgba(255,255,255,0.06);
61
+ --border-bright: rgba(255,255,255,0.1);
62
+ --text: #f0f0f5;
63
+ --text-secondary: #9090a8;
64
+ --text-muted: #5a5a72;
65
+ --accent: #8b5cf6;
66
+ --accent-glow: rgba(139,92,246,0.15);
67
+ --accent-border: rgba(139,92,246,0.3);
68
+ --green: #10b981;
69
+ --green-bg: rgba(16,185,129,0.1);
70
+ --blue: #3b82f6;
71
+ --blue-bg: rgba(59,130,246,0.1);
72
+ --rose: #f43f5e;
73
+ --rose-bg: rgba(244,63,94,0.1);
74
+ --cyan: #06b6d4;
75
+ --cyan-bg: rgba(6,182,212,0.1);
76
+ --amber: #f59e0b;
77
+ --amber-bg: rgba(245,158,11,0.1);
78
+ --radius: 10px;
79
+ --radius-sm: 6px;
80
+ }
81
+
82
+ * { margin: 0; padding: 0; box-sizing: border-box; }
83
+
84
+ body {
85
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
86
+ background: var(--bg);
87
+ color: var(--text);
88
+ min-height: 100vh;
89
+ -webkit-font-smoothing: antialiased;
90
+ }
91
+
92
+ /* ── Hero Header ── */
93
+ .hero {
94
+ position: relative;
95
+ padding: 3rem 2.5rem 2rem;
96
+ background: linear-gradient(135deg, #0a0a18 0%, #0f0f2a 40%, #141432 100%);
97
+ border-bottom: 1px solid var(--border);
98
+ overflow: hidden;
99
+ }
100
+
101
+ .hero::before {
102
+ content: '';
103
+ position: absolute;
104
+ top: -50%;
105
+ right: -10%;
106
+ width: 500px;
107
+ height: 500px;
108
+ background: radial-gradient(circle, rgba(139,92,246,0.08) 0%, transparent 70%);
109
+ pointer-events: none;
110
+ }
111
+
112
+ .hero::after {
113
+ content: '';
114
+ position: absolute;
115
+ bottom: -30%;
116
+ left: 10%;
117
+ width: 400px;
118
+ height: 400px;
119
+ background: radial-gradient(circle, rgba(6,182,212,0.05) 0%, transparent 70%);
120
+ pointer-events: none;
121
+ }
122
+
123
+ .hero-content {
124
+ position: relative;
125
+ z-index: 1;
126
+ max-width: 1400px;
127
+ margin: 0 auto;
128
+ }
129
+
130
+ .hero-row {
131
+ display: flex;
132
+ align-items: flex-end;
133
+ justify-content: space-between;
134
+ margin-bottom: 1.5rem;
135
+ flex-wrap: wrap;
136
+ gap: 1rem;
137
+ }
138
+
139
+ .hero-title {
140
+ font-size: 2rem;
141
+ font-weight: 800;
142
+ letter-spacing: -0.03em;
143
+ background: linear-gradient(135deg, #f0f0f5 0%, #a0a0c0 100%);
144
+ -webkit-background-clip: text;
145
+ -webkit-text-fill-color: transparent;
146
+ }
147
+
148
+ .hero-sub {
149
+ font-size: 0.85rem;
150
+ color: var(--text-muted);
151
+ margin-top: 0.3rem;
152
+ }
153
+
154
+ .hero-sub code {
155
+ font-family: 'JetBrains Mono', monospace;
156
+ background: rgba(255,255,255,0.05);
157
+ padding: 0.15rem 0.5rem;
158
+ border-radius: 4px;
159
+ font-size: 0.78rem;
160
+ }
161
+
162
+ .stats-row {
163
+ display: flex;
164
+ gap: 2.5rem;
165
+ }
166
+
167
+ .stat-item { text-align: right; }
168
+
169
+ .stat-num {
170
+ font-size: 2rem;
171
+ font-weight: 800;
172
+ letter-spacing: -0.04em;
173
+ line-height: 1;
174
+ background: linear-gradient(135deg, var(--accent) 0%, var(--cyan) 100%);
175
+ -webkit-background-clip: text;
176
+ -webkit-text-fill-color: transparent;
177
+ }
178
+
179
+ .stat-label {
180
+ font-size: 0.65rem;
181
+ color: var(--text-muted);
182
+ text-transform: uppercase;
183
+ letter-spacing: 0.1em;
184
+ margin-top: 0.3rem;
185
+ }
186
+
187
+ /* ── Controls ── */
188
+ .controls {
189
+ display: flex;
190
+ gap: 0.5rem;
191
+ align-items: center;
192
+ flex-wrap: wrap;
193
+ position: relative;
194
+ z-index: 1;
195
+ }
196
+
197
+ .search-wrap {
198
+ flex: 1;
199
+ min-width: 260px;
200
+ position: relative;
201
+ }
202
+
203
+ .search-input {
204
+ width: 100%;
205
+ padding: 0.7rem 2.5rem 0.7rem 1rem;
206
+ background: rgba(255,255,255,0.04);
207
+ border: 1px solid var(--border);
208
+ border-radius: var(--radius);
209
+ color: var(--text);
210
+ font-size: 0.85rem;
211
+ font-family: inherit;
212
+ outline: none;
213
+ transition: all 0.2s;
214
+ }
215
+
216
+ .search-input::placeholder { color: var(--text-muted); }
217
+ .search-input:focus {
218
+ border-color: var(--accent-border);
219
+ background: rgba(139,92,246,0.04);
220
+ box-shadow: 0 0 0 3px rgba(139,92,246,0.08);
221
+ }
222
+
223
+ .search-icon {
224
+ position: absolute;
225
+ left: 0.85rem;
226
+ top: 50%;
227
+ transform: translateY(-50%);
228
+ color: var(--text-muted);
229
+ pointer-events: none;
230
+ }
231
+
232
+ .search-clear {
233
+ position: absolute;
234
+ right: 0.6rem;
235
+ top: 50%;
236
+ transform: translateY(-50%);
237
+ width: 20px;
238
+ height: 20px;
239
+ border: none;
240
+ background: rgba(255,255,255,0.08);
241
+ color: var(--text-muted);
242
+ border-radius: 5px;
243
+ font-size: 0.65rem;
244
+ cursor: pointer;
245
+ display: none;
246
+ align-items: center;
247
+ justify-content: center;
248
+ transition: all 0.15s;
249
+ }
250
+
251
+ .search-clear:hover { background: rgba(255,255,255,0.12); color: var(--text); }
252
+ .search-clear.visible { display: flex; }
253
+
254
+ .pill {
255
+ padding: 0.5rem 0.9rem;
256
+ background: rgba(255,255,255,0.04);
257
+ border: 1px solid var(--border);
258
+ border-radius: var(--radius);
259
+ color: var(--text-secondary);
260
+ font-size: 0.78rem;
261
+ font-family: inherit;
262
+ font-weight: 500;
263
+ cursor: pointer;
264
+ transition: all 0.2s;
265
+ white-space: nowrap;
266
+ user-select: none;
267
+ }
268
+
269
+ .pill:hover {
270
+ border-color: var(--border-bright);
271
+ color: var(--text);
272
+ background: rgba(255,255,255,0.06);
273
+ }
274
+
275
+ .pill.active {
276
+ background: var(--accent);
277
+ border-color: var(--accent);
278
+ color: white;
279
+ box-shadow: 0 2px 8px rgba(139,92,246,0.3);
280
+ }
281
+
282
+ .sort-select {
283
+ padding: 0.5rem 0.8rem;
284
+ background: rgba(255,255,255,0.04);
285
+ border: 1px solid var(--border);
286
+ border-radius: var(--radius);
287
+ color: var(--text-secondary);
288
+ font-size: 0.78rem;
289
+ font-family: inherit;
290
+ font-weight: 500;
291
+ cursor: pointer;
292
+ outline: none;
293
+ appearance: none;
294
+ -webkit-appearance: none;
295
+ background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%235a5a72' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
296
+ background-repeat: no-repeat;
297
+ background-position: right 0.7rem center;
298
+ padding-right: 2rem;
299
+ transition: all 0.2s;
300
+ }
301
+
302
+ .sort-select:hover { border-color: var(--border-bright); color: var(--text); }
303
+ .sort-select option { background: var(--surface); color: var(--text); }
304
+
305
+ /* ── Category bar ── */
306
+ .cat-bar {
307
+ display: flex;
308
+ gap: 0.4rem;
309
+ padding: 0.8rem 2.5rem;
310
+ overflow-x: auto;
311
+ border-bottom: 1px solid var(--border);
312
+ background: var(--bg-elevated);
313
+ position: sticky;
314
+ top: 0;
315
+ z-index: 50;
316
+ scrollbar-width: none;
317
+ }
318
+
319
+ .cat-bar::-webkit-scrollbar { display: none; }
320
+
321
+ .cat-pill {
322
+ padding: 0.35rem 0.75rem;
323
+ background: transparent;
324
+ border: 1px solid var(--border);
325
+ border-radius: 20px;
326
+ color: var(--text-muted);
327
+ font-size: 0.72rem;
328
+ font-family: inherit;
329
+ font-weight: 500;
330
+ cursor: pointer;
331
+ transition: all 0.2s;
332
+ white-space: nowrap;
333
+ }
334
+
335
+ .cat-pill:hover {
336
+ border-color: var(--border-bright);
337
+ color: var(--text-secondary);
338
+ }
339
+
340
+ .cat-pill.active {
341
+ background: rgba(139,92,246,0.12);
342
+ border-color: var(--accent-border);
343
+ color: var(--accent);
344
+ }
345
+
346
+ .cat-pill .cnt {
347
+ display: inline-block;
348
+ margin-left: 0.3rem;
349
+ font-size: 0.6rem;
350
+ opacity: 0.5;
351
+ }
352
+
353
+ /* ── Result bar ── */
354
+ .result-bar {
355
+ display: flex;
356
+ align-items: center;
357
+ justify-content: space-between;
358
+ padding: 0.7rem 2.5rem;
359
+ border-bottom: 1px solid var(--border);
360
+ background: var(--bg);
361
+ }
362
+
363
+ .result-count {
364
+ font-size: 0.75rem;
365
+ color: var(--text-muted);
366
+ }
367
+
368
+ .result-count strong {
369
+ color: var(--text-secondary);
370
+ font-weight: 600;
371
+ }
372
+
373
+ .shortcut-hint {
374
+ font-size: 0.65rem;
375
+ color: var(--text-muted);
376
+ opacity: 0.5;
377
+ }
378
+
379
+ .shortcut-hint kbd {
380
+ display: inline-block;
381
+ padding: 0.1rem 0.4rem;
382
+ background: rgba(255,255,255,0.05);
383
+ border: 1px solid var(--border);
384
+ border-radius: 4px;
385
+ font-family: 'JetBrains Mono', monospace;
386
+ font-size: 0.6rem;
387
+ margin: 0 0.1rem;
388
+ }
389
+
390
+ /* ── Grid ── */
391
+ .grid {
392
+ display: grid;
393
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
394
+ gap: 1px;
395
+ background: var(--border);
396
+ max-width: 1400px;
397
+ margin: 0 auto;
398
+ }
399
+
400
+ .card {
401
+ background: var(--bg);
402
+ padding: 1.4rem 1.6rem;
403
+ position: relative;
404
+ transition: all 0.25s ease;
405
+ animation: fadeIn 0.3s ease both;
406
+ }
407
+
408
+ @keyframes fadeIn {
409
+ from { opacity: 0; transform: translateY(8px); }
410
+ to { opacity: 1; transform: translateY(0); }
411
+ }
412
+
413
+ .card:hover {
414
+ background: var(--surface);
415
+ z-index: 1;
416
+ }
417
+
418
+ .card-type-bar {
419
+ position: absolute;
420
+ top: 0;
421
+ left: 0;
422
+ width: 3px;
423
+ height: 100%;
424
+ opacity: 0;
425
+ transition: opacity 0.2s;
426
+ }
427
+
428
+ .card:hover .card-type-bar { opacity: 1; }
429
+
430
+ .card-type-bar.type-web { background: var(--blue); }
431
+ .card-type-bar.type-api { background: var(--cyan); }
432
+ .card-type-bar.type-mobile { background: var(--green); }
433
+ .card-type-bar.type-desktop { background: var(--amber); }
434
+ .card-type-bar.type-cli { background: var(--accent); }
435
+
436
+ .card-top {
437
+ display: flex;
438
+ justify-content: space-between;
439
+ align-items: flex-start;
440
+ margin-bottom: 0.6rem;
441
+ }
442
+
443
+ .card-name {
444
+ font-size: 0.95rem;
445
+ font-weight: 650;
446
+ letter-spacing: -0.01em;
447
+ line-height: 1.3;
448
+ }
449
+
450
+ .card-category {
451
+ font-size: 0.68rem;
452
+ color: var(--text-muted);
453
+ margin-top: 0.2rem;
454
+ }
455
+
456
+ .card-badge {
457
+ font-size: 0.58rem;
458
+ font-weight: 600;
459
+ padding: 0.2rem 0.5rem;
460
+ border-radius: var(--radius-sm);
461
+ background: rgba(139,92,246,0.12);
462
+ color: var(--accent);
463
+ border: 1px solid rgba(139,92,246,0.2);
464
+ white-space: nowrap;
465
+ flex-shrink: 0;
466
+ margin-left: 0.75rem;
467
+ letter-spacing: 0.02em;
468
+ }
469
+
470
+ .card-desc {
471
+ font-size: 0.8rem;
472
+ color: var(--text-secondary);
473
+ line-height: 1.6;
474
+ margin-bottom: 0.75rem;
475
+ }
476
+
477
+ .card-tags {
478
+ display: flex;
479
+ flex-wrap: wrap;
480
+ gap: 0.3rem;
481
+ margin-bottom: 0.65rem;
482
+ }
483
+
484
+ .tag {
485
+ padding: 0.18rem 0.5rem;
486
+ border-radius: var(--radius-sm);
487
+ font-size: 0.62rem;
488
+ font-weight: 500;
489
+ font-family: 'JetBrains Mono', monospace;
490
+ letter-spacing: 0.01em;
491
+ }
492
+
493
+ .tag-ts { background: var(--blue-bg); color: var(--blue); }
494
+ .tag-py { background: var(--green-bg); color: var(--green); }
495
+ .tag-php { background: var(--rose-bg); color: var(--rose); }
496
+ .tag-frontend { background: var(--cyan-bg); color: var(--cyan); }
497
+ .tag-default { background: rgba(255,255,255,0.04); color: var(--text-secondary); }
498
+
499
+ .card-features {
500
+ font-size: 0.72rem;
501
+ color: var(--text-muted);
502
+ line-height: 1.6;
503
+ }
504
+
505
+ .card-path {
506
+ margin-top: 0.75rem;
507
+ padding: 0.35rem 0.6rem;
508
+ background: rgba(255,255,255,0.02);
509
+ border-radius: var(--radius-sm);
510
+ font-family: 'JetBrains Mono', monospace;
511
+ font-size: 0.62rem;
512
+ color: var(--text-muted);
513
+ word-break: break-all;
514
+ border: 1px solid var(--border);
515
+ transition: border-color 0.2s;
516
+ }
517
+
518
+ .card:hover .card-path {
519
+ border-color: var(--border-bright);
520
+ }
521
+
522
+ /* ── Empty state ── */
523
+ .empty {
524
+ text-align: center;
525
+ padding: 6rem 2rem;
526
+ grid-column: 1 / -1;
527
+ background: var(--bg);
528
+ }
529
+
530
+ .empty-icon {
531
+ width: 56px;
532
+ height: 56px;
533
+ margin: 0 auto 1.25rem;
534
+ border-radius: 14px;
535
+ background: var(--surface);
536
+ border: 1px solid var(--border);
537
+ display: flex;
538
+ align-items: center;
539
+ justify-content: center;
540
+ color: var(--text-muted);
541
+ }
542
+
543
+ .empty-title {
544
+ font-size: 1rem;
545
+ font-weight: 600;
546
+ color: var(--text-secondary);
547
+ margin-bottom: 0.3rem;
548
+ }
549
+
550
+ .empty-desc {
551
+ font-size: 0.82rem;
552
+ color: var(--text-muted);
553
+ }
554
+
555
+ /* ── Footer ── */
556
+ .footer {
557
+ text-align: center;
558
+ padding: 2rem;
559
+ color: var(--text-muted);
560
+ font-size: 0.7rem;
561
+ border-top: 1px solid var(--border);
562
+ }
563
+
564
+ .footer code {
565
+ font-family: 'JetBrains Mono', monospace;
566
+ background: rgba(255,255,255,0.04);
567
+ padding: 0.15rem 0.45rem;
568
+ border-radius: 4px;
569
+ font-size: 0.68rem;
570
+ }
571
+
572
+ /* ── Responsive ── */
573
+ @media (max-width: 768px) {
574
+ .hero { padding: 2rem 1.25rem 1.5rem; }
575
+ .hero-title { font-size: 1.5rem; }
576
+ .grid { grid-template-columns: 1fr; }
577
+ .cat-bar { padding: 0.6rem 1.25rem; top: 0; }
578
+ .result-bar { padding: 0.5rem 1.25rem; }
579
+ .stats-row { gap: 1.5rem; }
580
+ .stat-num { font-size: 1.5rem; }
581
+ .shortcut-hint { display: none; }
582
+ }
583
+ </style>
584
+ </head>
585
+ <body>
586
+
587
+ <div class="hero">
588
+ <div class="hero-content">
589
+ <div class="hero-row">
590
+ <div>
591
+ <div class="hero-title">Project Catalog</div>
592
+ <div class="hero-sub">Scanned from <code>${esc(rootName)}</code></div>
593
+ </div>
594
+ <div class="stats-row">
595
+ <div class="stat-item"><div class="stat-num" id="total">0</div><div class="stat-label">Projects</div></div>
596
+ <div class="stat-item"><div class="stat-num" id="cats">0</div><div class="stat-label">Categories</div></div>
597
+ <div class="stat-item"><div class="stat-num" id="techs">0</div><div class="stat-label">Technologies</div></div>
598
+ </div>
599
+ </div>
600
+ <div class="controls">
601
+ <div class="search-wrap">
602
+ <svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
603
+ <input type="text" class="search-input" id="search" placeholder="Search projects, stacks, features..." />
604
+ <button class="search-clear" id="clearSearch" title="Clear (Esc)">&#x2715;</button>
605
+ </div>
606
+ <button class="pill active" data-filter="all">All</button>
607
+ <button class="pill" data-filter="notable">Notable</button>
608
+ <button class="pill" data-filter="web">Web</button>
609
+ <button class="pill" data-filter="mobile">Mobile</button>
610
+ <button class="pill" data-filter="cli">CLI</button>
611
+ <button class="pill" data-filter="desktop">Desktop</button>
612
+ <button class="pill" data-filter="api">API</button>
613
+ <select class="sort-select" id="sortSelect">
614
+ <option value="default">Sort: Default</option>
615
+ <option value="name-asc">Name A&#8594;Z</option>
616
+ <option value="name-desc">Name Z&#8592;A</option>
617
+ <option value="category">Category</option>
618
+ </select>
619
+ </div>
620
+ </div>
621
+ </div>
622
+
623
+ <div class="cat-bar" id="catBar"></div>
624
+ <div class="result-bar">
625
+ <div class="result-count" id="resultCount"></div>
626
+ <div class="shortcut-hint"><kbd>/</kbd> search &middot; <kbd>Esc</kbd> clear</div>
627
+ </div>
628
+ <div class="grid" id="grid"></div>
629
+ <div class="footer">Generated by <strong>project-catalog</strong> &middot; <code>${esc(rootPath)}</code></div>
630
+
631
+ <script>
632
+ const P = [
633
+ ${projectData}
634
+ ];
635
+
636
+ const cats=[...new Set(P.map(p=>p.c))];
637
+ const techSet=new Set();
638
+ P.forEach(p=>p.s.forEach(t=>techSet.add(t)));
639
+
640
+ document.getElementById('total').textContent=P.length;
641
+ document.getElementById('cats').textContent=cats.length;
642
+ document.getElementById('techs').textContent=techSet.size;
643
+
644
+ const catBar=document.getElementById('catBar');
645
+ catBar.innerHTML='<button class="cat-pill active" data-cat="all">All<span class="cnt">'+P.length+'</span></button>'+cats.map(c=>'<button class="cat-pill" data-cat="'+c+'">'+c+'<span class="cnt">'+P.filter(p=>p.c===c).length+'</span></button>').join('');
646
+
647
+ function tagClass(t){return{ts:'tag-ts',py:'tag-py',php:'tag-php',frontend:'tag-frontend'}[t]||'tag-default'}
648
+
649
+ function render(list){
650
+ const g=document.getElementById('grid');
651
+ if(!list.length){
652
+ g.innerHTML='<div class="empty"><div class="empty-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></div><div class="empty-title">No results found</div><div class="empty-desc">Try a different search term or filter.</div></div>';
653
+ return;
654
+ }
655
+ g.innerHTML=list.map((p,i)=>'<div class="card" style="animation-delay:'+Math.min(i*0.025,0.4)+'s"><div class="card-type-bar type-'+p.type+'"></div><div class="card-top"><div><div class="card-name">'+p.n+'</div><div class="card-category">'+p.c+'</div></div>'+(p.notable?'<div class="card-badge">Notable</div>':'')+'</div><div class="card-desc">'+p.d+'</div><div class="card-tags">'+p.s.map((s,j)=>'<span class="tag '+(j===0?tagClass(p.t):'tag-default')+'">'+s+'</span>').join('')+'</div><div class="card-features">'+p.f+'</div><div class="card-path">'+p.p+'</div></div>').join('');
656
+ }
657
+
658
+ let ac='all',af='all',q='',so='default';
659
+
660
+ function apply(){
661
+ let l=[...P];
662
+ if(ac!=='all')l=l.filter(p=>p.c===ac);
663
+ if(af==='notable')l=l.filter(p=>p.notable);
664
+ else if(af!=='all')l=l.filter(p=>p.type===af);
665
+ if(q){
666
+ const lo=q.toLowerCase();
667
+ l=l.filter(p=>p.n.toLowerCase().includes(lo)||p.d.toLowerCase().includes(lo)||p.f.toLowerCase().includes(lo)||p.s.some(s=>s.toLowerCase().includes(lo))||p.c.toLowerCase().includes(lo));
668
+ }
669
+ if(so==='name-asc')l.sort((a,b)=>a.n.localeCompare(b.n));
670
+ else if(so==='name-desc')l.sort((a,b)=>b.n.localeCompare(a.n));
671
+ else if(so==='category')l.sort((a,b)=>a.c.localeCompare(b.c)||a.n.localeCompare(b.n));
672
+ render(l);
673
+ const c=document.getElementById('resultCount');
674
+ if(q||ac!=='all'||af!=='all')c.innerHTML='Showing <strong>'+l.length+'</strong> of '+P.length+' projects';
675
+ else c.innerHTML='<strong>'+P.length+'</strong> projects';
676
+ }
677
+
678
+ catBar.addEventListener('click',e=>{
679
+ const b=e.target.closest('.cat-pill');
680
+ if(!b)return;
681
+ catBar.querySelectorAll('.cat-pill').forEach(x=>x.classList.remove('active'));
682
+ b.classList.add('active');
683
+ ac=b.dataset.cat;
684
+ apply();
685
+ });
686
+
687
+ document.querySelectorAll('.pill').forEach(b=>{
688
+ b.addEventListener('click',()=>{
689
+ document.querySelectorAll('.pill').forEach(x=>x.classList.remove('active'));
690
+ b.classList.add('active');
691
+ af=b.dataset.filter;
692
+ apply();
693
+ });
694
+ });
695
+
696
+ const se=document.getElementById('search'),ce=document.getElementById('clearSearch');
697
+ se.addEventListener('input',e=>{q=e.target.value;ce.classList.toggle('visible',q.length>0);apply()});
698
+ ce.addEventListener('click',()=>{se.value='';q='';ce.classList.remove('visible');se.focus();apply()});
699
+ document.getElementById('sortSelect').addEventListener('change',e=>{so=e.target.value;apply()});
700
+
701
+ document.addEventListener('keydown',e=>{
702
+ if(e.key==='/'&&document.activeElement!==se){e.preventDefault();se.focus()}
703
+ if(e.key==='Escape'){if(se.value){se.value='';q='';ce.classList.remove('visible');apply()}se.blur()}
704
+ });
705
+
706
+ apply();
707
+ </script>
708
+ </body>
709
+ </html>`;
710
+ }
711
+
712
+ module.exports = { generateHTML };
@@ -0,0 +1,65 @@
1
+ function esc(s) {
2
+ return String(s).replace(/\|/g, '\\|');
3
+ }
4
+
5
+ function generateMD(projects, rootDir) {
6
+ const categories = [...new Set(projects.map(p => p.category))];
7
+ const techSet = new Set();
8
+ projects.forEach(p => p.stack.forEach(t => techSet.add(t)));
9
+
10
+ const now = new Date().toISOString().split('T')[0];
11
+
12
+ let md = `# Project Catalog
13
+
14
+ **Scanned from:** \`${rootDir}\`
15
+ **Generated:** ${now}
16
+ **Total:** ${projects.length} projects across ${categories.length} categories
17
+
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ | Category | Count | Primary Stack |
23
+ |----------|-------|---------------|
24
+ `;
25
+
26
+ for (const cat of categories) {
27
+ const catProjects = projects.filter(p => p.category === cat);
28
+ const stacks = [...new Set(catProjects.flatMap(p => p.stack))].slice(0, 3).join(', ');
29
+ md += `| ${esc(cat)} | ${catProjects.length} | ${stacks || 'N/A'} |\n`;
30
+ }
31
+
32
+ md += `\n---\n\n`;
33
+
34
+ for (const cat of categories) {
35
+ const catProjects = projects.filter(p => p.category === cat);
36
+ md += `## ${esc(cat)}\n\n`;
37
+
38
+ for (const p of catProjects) {
39
+ md += `### ${esc(p.name)}\n`;
40
+ md += `- **Path:** \`${esc(p.path)}\`\n`;
41
+ md += `- **Stack:** ${p.stack.map(s => esc(s)).join(', ') || 'N/A'}\n`;
42
+ if (p.description) md += `- **What:** ${esc(p.description)}\n`;
43
+ if (p.features.length) md += `- **Features:** ${p.features.join(', ')}\n`;
44
+ md += `\n`;
45
+ }
46
+ }
47
+
48
+ md += `---\n\n## Technology Distribution\n\n`;
49
+ md += `| Technology | Projects |\n`;
50
+ md += `|------------|----------|\n`;
51
+
52
+ const techCounts = {};
53
+ projects.forEach(p => p.stack.forEach(t => { techCounts[t] = (techCounts[t] || 0) + 1; }));
54
+ const sorted = Object.entries(techCounts).sort((a, b) => b[1] - a[1]);
55
+
56
+ for (const [tech, count] of sorted) {
57
+ md += `| ${esc(tech)} | ${count} |\n`;
58
+ }
59
+
60
+ md += `\n---\n\n*Generated by project-catalog*\n`;
61
+
62
+ return md;
63
+ }
64
+
65
+ module.exports = { generateMD };
package/src/scanner.js ADDED
@@ -0,0 +1,346 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ // Files that indicate a project root
5
+ const PROJECT_MARKERS = [
6
+ 'package.json', 'requirements.txt', 'composer.json', 'pubspec.yaml',
7
+ 'build.gradle', 'build.gradle.kts', 'Cargo.toml', 'go.mod',
8
+ 'Gemfile', 'mix.exs', 'pom.xml', 'CMakeLists.txt',
9
+ 'Makefile', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
10
+ 'astro.config.mjs', 'astro.config.mjs',
11
+ 'next.config.js', 'next.config.ts', 'next.config.mjs',
12
+ 'nuxt.config.js', 'nuxt.config.ts',
13
+ 'vite.config.js', 'vite.config.ts', 'vite.config.mjs',
14
+ 'angular.json', 'svelte.config.js',
15
+ 'pyproject.toml', 'setup.py', 'setup.cfg',
16
+ 'app.py', 'main.py', 'manage.py', 'server.js', 'index.js',
17
+ 'artisan', 'spark',
18
+ 'index.html', // single page sites
19
+ ];
20
+
21
+ // Files to skip when walking
22
+ const SKIP_DIRS = new Set([
23
+ 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'env',
24
+ 'dist', 'build', '.next', '.nuxt', '.output', 'coverage',
25
+ '.cache', '.parcel-cache', '.turbo', '.yarn',
26
+ 'vendor', 'composer', '.composer',
27
+ 'target', 'bin', 'obj',
28
+ 'Pods', '.gradle', '.idea', '.vscode',
29
+ '.angular', '.svelte-kit',
30
+ 'android', 'ios', // skip native builds
31
+ '.wwebjs_auth', '.wwebjs_cache', // WhatsApp sessions
32
+ ]);
33
+
34
+ // Tech stack detection patterns
35
+ const STACK_PATTERNS = {
36
+ 'Node.js': ['package.json'],
37
+ 'TypeScript': ['tsconfig.json'],
38
+ 'React': ['package.json'], // checked further in content
39
+ 'Next.js': ['next.config.js', 'next.config.ts', 'next.config.mjs'],
40
+ 'Vue.js': ['vue.config.js', 'nuxt.config.js', 'nuxt.config.ts'],
41
+ 'Nuxt.js': ['nuxt.config.js', 'nuxt.config.ts'],
42
+ 'Svelte': ['svelte.config.js', 'svelte.config.ts'],
43
+ 'Astro': ['astro.config.mjs', 'astro.config.js', 'astro.config.ts'],
44
+ 'Vite': ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'],
45
+ 'Python': ['requirements.txt', 'pyproject.toml', 'setup.py', 'Pipfile'],
46
+ 'Django': ['manage.py'],
47
+ 'Flask': ['app.py', 'main.py'],
48
+ 'FastAPI': ['main.py'],
49
+ 'PHP': ['composer.json', 'artisan', 'index.php'],
50
+ 'Laravel': ['artisan'],
51
+ 'CodeIgniter': ['spark'],
52
+ 'Java': ['build.gradle', 'build.gradle.kts', 'pom.xml'],
53
+ 'Kotlin': ['build.gradle.kts'],
54
+ 'Flutter': ['pubspec.yaml'],
55
+ 'Dart': ['pubspec.yaml'],
56
+ 'Go': ['go.mod'],
57
+ 'Rust': ['Cargo.toml'],
58
+ 'Ruby': ['Gemfile'],
59
+ 'Docker': ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml'],
60
+ 'SQLite': [], // detected from file presence
61
+ 'Prisma': [], // detected from file presence
62
+ 'Tailwind CSS': [], // detected from config
63
+ 'Vitest': ['vitest.config.js', 'vitest.config.ts'],
64
+ 'Jest': ['jest.config.js', 'jest.config.ts', 'jest.config.mjs'],
65
+ 'Playwright': ['playwright.config.js', 'playwright.config.ts'],
66
+ };
67
+
68
+ function detectStack(dirPath) {
69
+ const stack = [];
70
+ const files = getFiles(dirPath);
71
+
72
+ // Basic detection from config files
73
+ for (const [tech, markers] of Object.entries(STACK_PATTERNS)) {
74
+ if (markers.some(m => files.includes(m))) {
75
+ stack.push(tech);
76
+ }
77
+ }
78
+
79
+ // package.json deep inspection
80
+ const pkgPath = path.join(dirPath, 'package.json');
81
+ if (files.includes('package.json')) {
82
+ try {
83
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
84
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
85
+
86
+ if (deps.react) stack.push('React');
87
+ if (deps.vue) stack.push('Vue.js');
88
+ if (deps.svelte) stack.push('Svelte');
89
+ if (deps.angular || deps['@angular/core']) stack.push('Angular');
90
+ if (deps.express) stack.push('Express');
91
+ if (deps.nestjs || deps['@nestjs/core']) stack.push('NestJS');
92
+ if (deps.next) stack.push('Next.js');
93
+ if (deps.nuxt) stack.push('Nuxt.js');
94
+ if (deps.astro) stack.push('Astro');
95
+ if (deps.tailwindcss) stack.push('Tailwind CSS');
96
+ if (deps.prisma || deps['@prisma/client']) stack.push('Prisma');
97
+ if (deps.vitest) stack.push('Vitest');
98
+ if (deps.jest || deps['@jest/core']) stack.push('Jest');
99
+ if (deps.typescript || deps['ts-node']) {
100
+ if (!stack.includes('TypeScript')) stack.push('TypeScript');
101
+ }
102
+ if (deps.sqlite3 || deps.betterSqlite3 || deps.sql.js) stack.push('SQLite');
103
+ if (deps.pg) stack.push('PostgreSQL');
104
+ if (deps.mysql2 || deps.mysql) stack.push('MySQL');
105
+ if (deps.mongoose) stack.push('MongoDB');
106
+ if (deps.redis) stack.push('Redis');
107
+ if (deps.socket.io || deps.ws) stack.push('WebSocket');
108
+ if (deps.puppeteer) stack.push('Puppeteer');
109
+ if (deps.cypress) stack.push('Cypress');
110
+ if (deps.playwright) stack.push('Playwright');
111
+ if (deps.ink) stack.push('Ink');
112
+ if (deps.reactNative || deps['react-native']) stack.push('React Native');
113
+ if (deps.expo) stack.push('Expo');
114
+ if (deps['whatsapp-web.js'] || deps['@whiskeysockets/baileys']) stack.push('WhatsApp.js');
115
+
116
+ // Remove duplicates
117
+ const seen = new Set();
118
+ for (let i = stack.length - 1; i >= 0; i--) {
119
+ if (seen.has(stack[i])) stack.splice(i, 1);
120
+ else seen.add(stack[i]);
121
+ }
122
+ } catch (e) { /* skip invalid json */ }
123
+ }
124
+
125
+ // requirements.txt inspection
126
+ const reqPath = path.join(dirPath, 'requirements.txt');
127
+ if (files.includes('requirements.txt')) {
128
+ try {
129
+ const reqs = fs.readFileSync(reqPath, 'utf8').toLowerCase();
130
+ if (reqs.includes('django')) stack.push('Django');
131
+ if (reqs.includes('flask')) stack.push('Flask');
132
+ if (reqs.includes('fastapi')) stack.push('FastAPI');
133
+ if (reqs.includes('sqlalchemy')) stack.push('SQLAlchemy');
134
+ if (reqs.includes('selenium')) stack.push('Selenium');
135
+ if (reqs.includes('puppeteer')) stack.push('Puppeteer');
136
+ if (reqs.includes('pyinstaller')) stack.push('PyInstaller');
137
+ if (reqs.includes('kivy')) stack.push('Kivy');
138
+ if (reqs.includes('dlib')) stack.push('dlib');
139
+ } catch (e) { /* skip */ }
140
+ }
141
+
142
+ // composer.json inspection
143
+ const compPath = path.join(dirPath, 'composer.json');
144
+ if (files.includes('composer.json')) {
145
+ try {
146
+ const comp = JSON.parse(fs.readFileSync(compPath, 'utf8'));
147
+ const allDeps = { ...comp.require, ...comp['require-dev'] };
148
+ if (allDeps['laravel/framework']) stack.push('Laravel');
149
+ if (allDeps['codeigniter4/framework']) stack.push('CodeIgniter 4');
150
+ if (allDeps['livewire/livewire']) stack.push('Livewire');
151
+ if (allDeps['laravel/jetstream']) stack.push('Jetstream');
152
+ if (allDeps['laravel/sanctum']) stack.push('Sanctum');
153
+ if (allDeps['laravel/fortify']) stack.push('Fortify');
154
+ } catch (e) { /* skip */ }
155
+ }
156
+
157
+ // pubspec.yaml inspection
158
+ if (files.includes('pubspec.yaml')) {
159
+ try {
160
+ const pub = fs.readFileSync(path.join(dirPath, 'pubspec.yaml'), 'utf8');
161
+ if (pub.includes('flutter')) stack.push('Flutter');
162
+ if (pub.includes('capacitor')) stack.push('Capacitor');
163
+ } catch (e) { /* skip */ }
164
+ }
165
+
166
+ // Detect SQLite from file presence
167
+ if (files.some(f => f.endsWith('.db') || f.endsWith('.sqlite') || f.endsWith('.sqlite3'))) {
168
+ if (!stack.includes('SQLite')) stack.push('SQLite');
169
+ }
170
+
171
+ // Deduplicate
172
+ return [...new Set(stack)];
173
+ }
174
+
175
+ function getFiles(dirPath) {
176
+ try {
177
+ return fs.readdirSync(dirPath);
178
+ } catch (e) {
179
+ if (e.code === 'EACCES') return []; // skip permission errors
180
+ return [];
181
+ }
182
+ }
183
+
184
+ function getSubDirs(dirPath) {
185
+ try {
186
+ return fs.readdirSync(dirPath).filter(f => {
187
+ try {
188
+ const stat = fs.statSync(path.join(dirPath, f));
189
+ return stat.isDirectory() && !SKIP_DIRS.has(f) && !f.startsWith('.');
190
+ } catch (e) {
191
+ return false;
192
+ }
193
+ });
194
+ } catch (e) {
195
+ return [];
196
+ }
197
+ }
198
+
199
+ function getProjectName(dirPath) {
200
+ const name = path.basename(dirPath);
201
+
202
+ // Try to get name from package.json
203
+ const pkgPath = path.join(dirPath, 'package.json');
204
+ if (fs.existsSync(pkgPath)) {
205
+ try {
206
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
207
+ if (pkg.name && pkg.name !== name.toLowerCase().replace(/\s+/g, '-')) {
208
+ return pkg.name;
209
+ }
210
+ } catch (e) { /* skip */ }
211
+ }
212
+
213
+ // Try composer.json
214
+ const compPath = path.join(dirPath, 'composer.json');
215
+ if (fs.existsSync(compPath)) {
216
+ try {
217
+ const comp = JSON.parse(fs.readFileSync(compPath, 'utf8'));
218
+ if (comp.name) return comp.name;
219
+ } catch (e) { /* skip */ }
220
+ }
221
+
222
+ return name;
223
+ }
224
+
225
+ function getProjectDescription(dirPath) {
226
+ // Try package.json description
227
+ const pkgPath = path.join(dirPath, 'package.json');
228
+ if (fs.existsSync(pkgPath)) {
229
+ try {
230
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
231
+ if (pkg.description) return pkg.description;
232
+ } catch (e) { /* skip */ }
233
+ }
234
+
235
+ // Try README
236
+ const readmePath = path.join(dirPath, 'README.md');
237
+ if (fs.existsSync(readmePath)) {
238
+ try {
239
+ const content = fs.readFileSync(readmePath, 'utf8');
240
+ // Get first non-empty, non-heading line
241
+ const lines = content.split('\n');
242
+ for (const line of lines) {
243
+ const trimmed = line.trim();
244
+ if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!') && !trimmed.startsWith('<')) {
245
+ return trimmed.substring(0, 200);
246
+ }
247
+ }
248
+ } catch (e) { /* skip */ }
249
+ }
250
+
251
+ return '';
252
+ }
253
+
254
+ function classifyProject(dirPath, stack) {
255
+ const hasMobile = stack.includes('Flutter') || stack.includes('React Native') || stack.includes('Kivy') || stack.includes('Capacitor') || stack.includes('Expo');
256
+ const hasDesktop = stack.includes('PyInstaller') || stack.includes('PyQt') || stack.includes('Kivy');
257
+ const hasCLI = stack.includes('Ink');
258
+ const hasAPI = stack.includes('NestJS') || stack.includes('FastAPI');
259
+ const hasFrontend = stack.includes('React') || stack.includes('Next.js') || stack.includes('Vue.js') || stack.includes('Astro') || stack.includes('Svelte');
260
+ const hasBackend = stack.includes('Express') || stack.includes('Django') || stack.includes('Laravel') || stack.includes('Flask') || stack.includes('CodeIgniter 4');
261
+
262
+ if (hasMobile) return 'mobile';
263
+ if (hasDesktop) return 'desktop';
264
+ if (hasCLI) return 'cli';
265
+ if (hasAPI && !hasFrontend) return 'api';
266
+ return 'web';
267
+ }
268
+
269
+ function extractFeatures(dirPath, stack) {
270
+ const features = [];
271
+
272
+ // Check for common feature indicators
273
+ const files = getFiles(dirPath);
274
+
275
+ if (files.includes('docker-compose.yml') || files.includes('docker-compose.yaml')) features.push('Docker');
276
+ if (files.some(f => f.includes('.test.') || f.includes('.spec.'))) features.push('Tests');
277
+ if (files.includes('vitest.config.js') || files.includes('vitest.config.ts')) features.push('Vitest');
278
+ if (files.includes('playwright.config.js') || files.includes('playwright.config.ts')) features.push('E2E Tests');
279
+ if (files.includes('eslint.config.js') || files.includes('.eslintrc.json') || files.includes('.eslintrc.js')) features.push('ESLint');
280
+ if (files.includes('postcss.config.js') || files.includes('postcss.config.mjs')) features.push('PostCSS');
281
+ if (files.some(f => f.includes('sw.js') || f.includes('service-worker'))) features.push('PWA');
282
+ if (files.some(f => f.includes('manifest.json'))) features.push('PWA');
283
+ if (files.some(f => f === 'prisma' || f === 'schema.prisma')) features.push('Prisma');
284
+ if (files.some(f => f.includes('.env'))) features.push('Env config');
285
+
286
+ // Check for common directories
287
+ const dirs = getSubDirs(dirPath);
288
+ if (dirs.includes('tests') || dirs.includes('test') || dirs.includes('__tests__')) features.push('Tests');
289
+ if (dirs.includes('docs')) features.push('Documentation');
290
+ if (dirs.includes('.github')) features.push('CI/CD');
291
+ if (dirs.includes('src')) features.push('Source code');
292
+ if (dirs.includes('app')) features.push('App structure');
293
+ if (dirs.includes('pages')) features.push('Pages');
294
+ if (dirs.includes('components')) features.push('Components');
295
+
296
+ return [...new Set(features)].slice(0, 6);
297
+ }
298
+
299
+ function isProjectRoot(dirPath) {
300
+ const files = getFiles(dirPath);
301
+ return PROJECT_MARKERS.some(marker => files.includes(marker));
302
+ }
303
+
304
+ function scanDirectory(rootDir, maxDepth = 3, currentDepth = 0) {
305
+ const projects = [];
306
+
307
+ if (currentDepth > maxDepth) return projects;
308
+
309
+ const entries = getFiles(rootDir);
310
+ const subDirs = getSubDirs(rootDir);
311
+
312
+ // Check if current directory is a project
313
+ if (isProjectRoot(rootDir)) {
314
+ const stack = detectStack(rootDir);
315
+ const name = getProjectName(rootDir);
316
+ const description = getProjectDescription(rootDir);
317
+ const features = extractFeatures(rootDir, stack);
318
+ const type = classifyProject(rootDir, stack);
319
+ const relativePath = path.relative(path.resolve('.'), rootDir);
320
+
321
+ projects.push({
322
+ name,
323
+ category: path.basename(rootDir),
324
+ path: relativePath + '/',
325
+ stack,
326
+ description,
327
+ features,
328
+ type,
329
+ notable: false,
330
+ });
331
+
332
+ // Don't recurse into project roots (they are leaf nodes)
333
+ return projects;
334
+ }
335
+
336
+ // Otherwise, recurse into subdirectories
337
+ for (const subDir of subDirs) {
338
+ const subPath = path.join(rootDir, subDir);
339
+ const subProjects = scanDirectory(subPath, maxDepth, currentDepth + 1);
340
+ projects.push(...subProjects);
341
+ }
342
+
343
+ return projects;
344
+ }
345
+
346
+ module.exports = { scanDirectory };