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 +21 -0
- package/README.md +62 -0
- package/bin/cli.js +98 -0
- package/package.json +37 -0
- package/src/html-generator.js +712 -0
- package/src/md-generator.js +65 -0
- package/src/scanner.js +346 -0
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, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''');
|
|
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)">✕</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→Z</option>
|
|
616
|
+
<option value="name-desc">Name Z←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 · <kbd>Esc</kbd> clear</div>
|
|
627
|
+
</div>
|
|
628
|
+
<div class="grid" id="grid"></div>
|
|
629
|
+
<div class="footer">Generated by <strong>project-catalog</strong> · <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 };
|