@x-quantum-tech/repolens 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/examples/repolens.config.example.json +38 -0
- package/package.json +43 -0
- package/repolens.mjs +1421 -0
package/repolens.mjs
ADDED
|
@@ -0,0 +1,1421 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* repolens — zero-dependency repository mapper for humans and LLM agents.
|
|
4
|
+
*
|
|
5
|
+
* Scans a repository and produces:
|
|
6
|
+
* <out>.json — full machine-readable map (files, dirs, import graph, catalogs)
|
|
7
|
+
* <out>.md — compact human/LLM-readable index with clickable file links
|
|
8
|
+
* <out>.html — self-contained interactive dashboard (overview + treemap + catalogs)
|
|
9
|
+
*
|
|
10
|
+
* Philosophy: deterministic extraction, ZERO LLM tokens. The map is generated
|
|
11
|
+
* by code; the model only reads it. Repo-specific knowledge (custom tool
|
|
12
|
+
* definitions, skill catalogs, …) plugs in via a JSON config with regex
|
|
13
|
+
* extractors — the core stays language/framework agnostic.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* node repolens.mjs [rootDir] [--config repolens.config.json] [--out out/repo-map]
|
|
17
|
+
*
|
|
18
|
+
* No dependencies. Node 18+.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
// ───────────────────────────── CLI ─────────────────────────────
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2);
|
|
29
|
+
function argOf(flag, dflt) {
|
|
30
|
+
const i = argv.indexOf(flag);
|
|
31
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VERSION = "0.1.0";
|
|
35
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
36
|
+
console.log("repolens " + VERSION);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
40
|
+
console.log(`repolens ${VERSION} — zero-dependency repository mapper for humans and LLM agents
|
|
41
|
+
|
|
42
|
+
USAGE
|
|
43
|
+
repo-lens [dir] [options] map a single repository (default dir: .)
|
|
44
|
+
repo-lens --portfolio <dir> [options] map every subfolder of <dir> as its own project
|
|
45
|
+
|
|
46
|
+
OPTIONS
|
|
47
|
+
--config <file> JSON/JSONC config: name, lang (en|it), ignore[], aliases{}, extractors[]
|
|
48
|
+
--out <prefix> output path prefix (default: repolens-out/repo-map)
|
|
49
|
+
→ writes <prefix>.md, <prefix>.json, <prefix>.html
|
|
50
|
+
--max-file-kb <n> max file size read in full for LOC/extraction (default: 2048)
|
|
51
|
+
--portfolio <dir> portfolio mode → <out>/index.{html,md,json} + one map per project
|
|
52
|
+
--skip <a,b,c> (portfolio) project folder names to skip
|
|
53
|
+
--cap <n> (portfolio) skip projects with more than n files (default: 12000)
|
|
54
|
+
-h, --help show this help
|
|
55
|
+
-v, --version show version
|
|
56
|
+
|
|
57
|
+
OUTPUT
|
|
58
|
+
.md compact index for humans & LLMs (routes, tables, env, pages, custom catalogs)
|
|
59
|
+
.json full machine-readable map (file tree, import graph, catalogs w/ file:line)
|
|
60
|
+
.html self-contained interactive dashboard (treemap + import links + sortable tables)
|
|
61
|
+
|
|
62
|
+
Built by Maurizio Tarricone · https://xquantumtech.com · MIT`);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ROOT = path.resolve(argv[0] && !argv[0].startsWith("--") ? argv[0] : ".");
|
|
67
|
+
const CONFIG_PATH = argOf("--config", null);
|
|
68
|
+
const OUT_PREFIX = argOf("--out", "repolens-out/repo-map");
|
|
69
|
+
const MAX_READ_BYTES = Number(argOf("--max-file-kb", "2048")) * 1024;
|
|
70
|
+
|
|
71
|
+
// ─────────────────────── Portfolio mode ────────────────────────
|
|
72
|
+
// `--portfolio <dir>`: tratta ogni sottocartella di <dir> come un progetto a sé.
|
|
73
|
+
// Lancia repolens UNA VOLTA PER PROGETTO in un processo isolato (niente O(n²) né
|
|
74
|
+
// contentCache globale su decine di migliaia di file), poi cuce un indice
|
|
75
|
+
// portfolio: treemap dei progetti (dimensione = LOC) + tabella, ogni progetto
|
|
76
|
+
// linka alla propria mappa. Opzioni: --cap <maxFilesPerProject> (default 12000,
|
|
77
|
+
// salta i mostri loggando), --config <perProjectConfig> opzionale.
|
|
78
|
+
const PORTFOLIO_DIR = argOf("--portfolio", null);
|
|
79
|
+
if (PORTFOLIO_DIR) {
|
|
80
|
+
runPortfolio(path.resolve(PORTFOLIO_DIR));
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runPortfolio(rootDir) {
|
|
85
|
+
const selfPath = fileURLToPath(import.meta.url);
|
|
86
|
+
const outDir = path.resolve(argOf("--out", path.join(rootDir, "_repo-maps")));
|
|
87
|
+
const cap = Number(argOf("--cap", "12000"));
|
|
88
|
+
const cfg = argOf("--config", null);
|
|
89
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
90
|
+
|
|
91
|
+
// Skip: cartelle nascoste, output, e l'elenco utente via --skip a,b,c
|
|
92
|
+
// (es. env/vendored bundle come "nfw" = ambiente Python installato, non codice).
|
|
93
|
+
const userSkip = new Set((argOf("--skip", "") || "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
94
|
+
const SKIP_DIR = /^(\.|_repo-maps$|node_modules$)/;
|
|
95
|
+
const HEAVY = new Set(["node_modules", ".git", "target", "dist", "build", ".next", "venv", ".venv", "__pycache__", "vendor", ".turbo"]);
|
|
96
|
+
function countFilesQuick(dir, limit) {
|
|
97
|
+
let n = 0;
|
|
98
|
+
const stack = [dir];
|
|
99
|
+
while (stack.length) {
|
|
100
|
+
let entries;
|
|
101
|
+
try { entries = fs.readdirSync(stack.pop(), { withFileTypes: true }); } catch { continue; }
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
if (e.isDirectory()) { if (!HEAVY.has(e.name) && !e.name.startsWith(".")) stack.push(path.join(e.path || dir, e.name)); }
|
|
104
|
+
else if (++n > limit) return n;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return n;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const subdirs = fs.readdirSync(rootDir, { withFileTypes: true })
|
|
111
|
+
.filter((e) => e.isDirectory() && !SKIP_DIR.test(e.name) && !userSkip.has(e.name))
|
|
112
|
+
.map((e) => e.name)
|
|
113
|
+
.sort((a, b) => a.localeCompare(b));
|
|
114
|
+
|
|
115
|
+
console.error(`[repolens portfolio] ${subdirs.length} progetti in ${rootDir} → ${outDir}`);
|
|
116
|
+
const projects = [];
|
|
117
|
+
let i = 0;
|
|
118
|
+
for (const name of subdirs) {
|
|
119
|
+
i++;
|
|
120
|
+
const projDir = path.join(rootDir, name);
|
|
121
|
+
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
122
|
+
const projOut = path.join(outDir, safe);
|
|
123
|
+
const nFiles = countFilesQuick(projDir, cap + 1);
|
|
124
|
+
if (nFiles > cap) {
|
|
125
|
+
console.error(` [${i}/${subdirs.length}] SKIP ${name} (${nFiles}+ file > cap ${cap})`);
|
|
126
|
+
projects.push({ name, safe, skipped: true, files: nFiles, loc: 0, byLang: {}, catalogs: {} });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
process.stderr.write(` [${i}/${subdirs.length}] ${name} … `);
|
|
130
|
+
const args = [selfPath, projDir, "--out", projOut];
|
|
131
|
+
if (cfg) args.push("--config", cfg);
|
|
132
|
+
const r = spawnSync(process.execPath, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
133
|
+
let summary = null;
|
|
134
|
+
try { summary = JSON.parse(fs.readFileSync(projOut + ".json", "utf8")); } catch { /* failed */ }
|
|
135
|
+
if (summary) {
|
|
136
|
+
const cats = {};
|
|
137
|
+
for (const [k, c] of Object.entries(summary.catalogs || {})) cats[k] = c.rows.length;
|
|
138
|
+
const codeLoc = summary.stats.codeLoc ?? summary.stats.loc;
|
|
139
|
+
projects.push({ name, safe, files: summary.stats.files, loc: summary.stats.loc, codeLoc, byLang: summary.stats.byLang, catalogs: cats, html: safe + ".html" });
|
|
140
|
+
console.error(`${summary.stats.files} file · ${codeLoc.toLocaleString()} code LOC (${summary.stats.loc.toLocaleString()} total)`);
|
|
141
|
+
} else {
|
|
142
|
+
console.error(`FAIL${r.status ? " (exit " + r.status + ")" : ""}`);
|
|
143
|
+
projects.push({ name, safe, failed: true, files: 0, loc: 0, byLang: {}, catalogs: {} });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Ranking per CODE LOC (il segnale vero); fallback su loc per gli skip.
|
|
148
|
+
projects.sort((a, b) => (b.codeLoc ?? b.loc ?? 0) - (a.codeLoc ?? a.loc ?? 0));
|
|
149
|
+
const totals = projects.reduce((t, p) => ({ files: t.files + (p.files || 0), loc: t.loc + (p.loc || 0), codeLoc: t.codeLoc + (p.codeLoc || 0) }), { files: 0, loc: 0, codeLoc: 0 });
|
|
150
|
+
const stamp = new Date().toISOString();
|
|
151
|
+
fs.writeFileSync(path.join(outDir, "index.json"), JSON.stringify({ tool: "repolens", mode: "portfolio", root: rootDir, generatedAt: stamp, totals, projects }, null, 1));
|
|
152
|
+
|
|
153
|
+
// index.md
|
|
154
|
+
let md = `# Portfolio — ${path.basename(rootDir)}\n\n> repolens portfolio · ${stamp} · ${projects.length} projects · ${totals.files.toLocaleString()} files · ${totals.codeLoc.toLocaleString()} code LOC (${totals.loc.toLocaleString()} total incl. data/markup)\n\n`;
|
|
155
|
+
md += `| project | files | code LOC | total LOC | top language | map |\n| --- | --- | --- | --- | --- | --- |\n`;
|
|
156
|
+
for (const p of projects) {
|
|
157
|
+
const top = Object.entries(p.byLang || {}).sort((a, b) => b[1] - a[1])[0];
|
|
158
|
+
const status = p.skipped ? "_(skipped: too big)_" : p.failed ? "_(failed)_" : `[map](${p.html})`;
|
|
159
|
+
md += `| ${p.name} | ${(p.files || 0).toLocaleString()} | ${(p.codeLoc || 0).toLocaleString()} | ${(p.loc || 0).toLocaleString()} | ${top ? top[0] : "—"} | ${status} |\n`;
|
|
160
|
+
}
|
|
161
|
+
fs.writeFileSync(path.join(outDir, "index.md"), md);
|
|
162
|
+
|
|
163
|
+
// index.html (treemap progetti + tabella)
|
|
164
|
+
fs.writeFileSync(path.join(outDir, "index.html"), portfolioHtml({ root: path.basename(rootDir), generatedAt: stamp, totals, projects }));
|
|
165
|
+
console.error(`[repolens portfolio] done → ${path.join(outDir, "index.html")}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function portfolioHtml(d) {
|
|
169
|
+
const data = JSON.stringify(d);
|
|
170
|
+
const TPL = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"/>
|
|
171
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
172
|
+
<title>__ROOT__ — repolens portfolio</title>
|
|
173
|
+
<style>
|
|
174
|
+
:root{--bg:#fafafa;--card:#fff;--line:#e4e4e7;--txt:#18181b;--dim:#71717a;--acc:#65a30d;}
|
|
175
|
+
*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
|
176
|
+
header{background:var(--card);border-bottom:1px solid var(--line);border-top:4px solid;border-image:linear-gradient(90deg,#a3e635,#65a30d,#18181b)1;padding:16px 26px}
|
|
177
|
+
header .t{font-size:20px;font-weight:800}header .t em{font-style:normal;color:var(--acc)}header .s{color:var(--dim);font-size:12.5px;margin-top:2px}
|
|
178
|
+
main{flex:1;display:flex;min-height:0}
|
|
179
|
+
#left{flex:1.7;border-right:1px solid var(--line);display:flex;flex-direction:column;min-width:0}
|
|
180
|
+
#cv{flex:1;width:100%;display:block}
|
|
181
|
+
#right{flex:1;min-width:330px;max-width:560px;display:flex;flex-direction:column}
|
|
182
|
+
#search{margin:12px 14px 6px;padding:8px 12px;border:1px solid var(--line);border-radius:9px;font:inherit}
|
|
183
|
+
#list{flex:1;overflow-y:auto;padding:4px 14px 20px}
|
|
184
|
+
.row{display:grid;grid-template-columns:1fr auto;gap:8px;padding:8px 6px;border-bottom:1px solid #f1f1f3;cursor:pointer;align-items:center}
|
|
185
|
+
.row:hover{background:#f6f7f4}.row .nm{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
186
|
+
.row .meta{color:var(--dim);font-size:12px;text-align:right;font-variant-numeric:tabular-nums}
|
|
187
|
+
.row .lang{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px;vertical-align:middle}
|
|
188
|
+
.tag{font-size:10px;padding:1px 6px;border-radius:5px;background:#f4f4f5;color:var(--dim);margin-left:6px}
|
|
189
|
+
#tip{position:fixed;pointer-events:none;background:#18181b;color:#fff;padding:7px 10px;border-radius:7px;font-size:12px;display:none;z-index:9;box-shadow:0 6px 20px rgba(0,0,0,.2)}
|
|
190
|
+
</style></head><body>
|
|
191
|
+
<header><div class="t"><em>◳ repolens</em> portfolio · __ROOT__</div><div class="s" id="s"></div></header>
|
|
192
|
+
<main>
|
|
193
|
+
<div id="left"><canvas id="cv"></canvas></div>
|
|
194
|
+
<div id="right"><input id="search" placeholder="filter projects…"/><div id="list"></div></div>
|
|
195
|
+
</main><div id="tip"></div>
|
|
196
|
+
<script>
|
|
197
|
+
var D=__DATA__;
|
|
198
|
+
var PAL={JavaScript:"#f7df1e",TypeScript:"#3178c6",Python:"#3776ab",Rust:"#dea584",Go:"#00add8",HTML:"#e34c26",CSS:"#563d7c",Java:"#b07219","C++":"#f34b7d",C:"#555",Ruby:"#cc342d",PHP:"#4f5d95",Vue:"#41b883",Svelte:"#ff3e00",Shell:"#89e051",JSON:"#999",Markdown:"#a5b4fc",Other:"#cbd5e1",Binary:"#94a3b8"};
|
|
199
|
+
function topLang(p){var e=Object.entries(p.byLang||{}).sort(function(a,b){return b[1]-a[1]});return e[0]?e[0][0]:"Other";}
|
|
200
|
+
function col(p){return PAL[topLang(p)]||"#cbd5e1";}
|
|
201
|
+
function esc(s){return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");}
|
|
202
|
+
function fmt(n){return Number(n||0).toLocaleString();}
|
|
203
|
+
function CL(p){return p.codeLoc!=null?p.codeLoc:p.loc||0;} // code LOC (fallback total)
|
|
204
|
+
document.getElementById("s").textContent=D.projects.length+" projects · "+fmt(D.totals.files)+" files · "+fmt(D.totals.codeLoc!=null?D.totals.codeLoc:D.totals.loc)+" code LOC · "+D.generatedAt.slice(0,16).replace("T"," ");
|
|
205
|
+
var cv=document.getElementById("cv"),ctx=cv.getContext("2d"),tip=document.getElementById("tip"),cells=[];
|
|
206
|
+
function squ(items,x,y,w,h,out){items=items.map(function(c){return {p:c,loc:CL(c)};}).filter(function(c){return c.loc>0});if(!items.length)return;var total=items.reduce(function(a,c){return a+c.loc},0),i=0;
|
|
207
|
+
while(i<items.length){var row=[],rs=0,horiz=w>=h,side=horiz?h:w,best=Infinity;
|
|
208
|
+
for(var j=i;j<items.length;j++){var ts=rs+items[j].loc,tr=row.concat([items[j]]),ra=ts*((w*h)/total),th=ra/side,worst=0;
|
|
209
|
+
tr.forEach(function(it){var len=(it.loc*((w*h)/total))/th,r=Math.max(th/len,len/th);if(r>worst)worst=r;});
|
|
210
|
+
if(worst<=best){best=worst;row=tr;rs=ts;}else break;}
|
|
211
|
+
i+=row.length;var ra2=rs*((w*h)/total),th=ra2/side,off=0;
|
|
212
|
+
row.forEach(function(it){var len=(it.loc*((w*h)/total))/th;out.push({p:it.p,x:horiz?x:x+off,y:horiz?y+off:y,w:horiz?th:len,h:horiz?len:th});off+=len;});
|
|
213
|
+
if(horiz){x+=th;w-=th;}else{y+=th;h-=th;}total-=rs;}}
|
|
214
|
+
function layout(){var r=cv.parentElement.getBoundingClientRect();cv.width=r.width*devicePixelRatio;cv.height=r.height*devicePixelRatio;ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0);cells=[];squ(D.projects.filter(function(p){return CL(p)>0;}),0,0,r.width,r.height,cells);draw();}
|
|
215
|
+
function draw(){var r=cv.getBoundingClientRect();ctx.clearRect(0,0,r.width,r.height);cells.forEach(function(c){ctx.fillStyle=col(c.p);ctx.fillRect(c.x+.5,c.y+.5,Math.max(0,c.w-1),Math.max(0,c.h-1));ctx.strokeStyle="#fff";ctx.strokeRect(c.x+.5,c.y+.5,Math.max(0,c.w-1),Math.max(0,c.h-1));
|
|
216
|
+
if(c.w>62&&c.h>20){ctx.fillStyle="rgba(0,0,0,.78)";ctx.font="600 11px system-ui";ctx.save();ctx.beginPath();ctx.rect(c.x+3,c.y+2,c.w-6,16);ctx.clip();ctx.fillText(c.p.name,c.x+5,c.y+13);ctx.restore();
|
|
217
|
+
if(c.h>34){ctx.fillStyle="rgba(0,0,0,.5)";ctx.font="10px system-ui";ctx.fillText(fmt(CL(c.p))+" LOC",c.x+5,c.y+26);}}});}
|
|
218
|
+
function at(ev){var r=cv.getBoundingClientRect(),x=ev.clientX-r.left,y=ev.clientY-r.top;for(var i=cells.length-1;i>=0;i--){var c=cells[i];if(x>=c.x&&x<=c.x+c.w&&y>=c.y&&y<=c.y+c.h)return c;}return null;}
|
|
219
|
+
cv.addEventListener("mousemove",function(ev){var c=at(ev);if(!c){tip.style.display="none";cv.style.cursor="default";return;}cv.style.cursor="pointer";tip.style.display="block";tip.style.left=Math.min(innerWidth-260,ev.clientX+14)+"px";tip.style.top=(ev.clientY+12)+"px";tip.innerHTML="<b>"+esc(c.p.name)+"</b><br>"+fmt(c.p.files)+" files · "+fmt(CL(c.p))+" code LOC<br>"+fmt(c.p.loc)+" total · "+esc(topLang(c.p));});
|
|
220
|
+
cv.addEventListener("mouseleave",function(){tip.style.display="none";});
|
|
221
|
+
cv.addEventListener("click",function(ev){var c=at(ev);if(c&&c.p.html)location.href=c.p.html;});
|
|
222
|
+
function renderList(){var q=(document.getElementById("search").value||"").toLowerCase();var el=document.getElementById("list");el.innerHTML="";
|
|
223
|
+
D.projects.filter(function(p){return!q||p.name.toLowerCase().indexOf(q)>=0;}).forEach(function(p){var d=document.createElement("div");d.className="row";
|
|
224
|
+
var tag=p.skipped?'<span class="tag">skipped</span>':p.failed?'<span class="tag">failed</span>':"";
|
|
225
|
+
d.innerHTML='<div class="nm"><span class="lang" style="background:'+col(p)+'"></span>'+esc(p.name)+tag+'</div><div class="meta">'+fmt(CL(p))+' code LOC · '+fmt(p.files)+'f</div>';
|
|
226
|
+
if(p.html)d.onclick=function(){location.href=p.html;};el.appendChild(d);});}
|
|
227
|
+
document.getElementById("search").addEventListener("input",renderList);
|
|
228
|
+
addEventListener("resize",layout);renderList();layout();
|
|
229
|
+
</script></body></html>`;
|
|
230
|
+
return TPL.replace("__DATA__", data).replace(/__ROOT__/g, String(d.root).replace(/[<>&]/g, ""));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────── Config ────────────────────────────
|
|
234
|
+
|
|
235
|
+
const DEFAULT_IGNORE_DIRS = new Set([
|
|
236
|
+
"node_modules", ".git", "dist", "build", "out", "coverage", "vendor",
|
|
237
|
+
"__pycache__", ".venv", "venv", "target", ".next", ".cache", ".idea",
|
|
238
|
+
".vscode", ".wrangler", ".wrangler-check", ".wrangler-typecheck",
|
|
239
|
+
"_archive", "_dump", ".devcontainer", ".turbo", ".parcel-cache",
|
|
240
|
+
// ambienti/dipendenze installate, non codice scritto
|
|
241
|
+
"site-packages", ".tox", ".mypy_cache", ".pytest_cache", ".gradle",
|
|
242
|
+
"Pods", ".terraform", "bower_components", ".pnp", "__snapshots__",
|
|
243
|
+
]);
|
|
244
|
+
// Linguaggi che NON sono codice sorgente scritto: per il portfolio servono i
|
|
245
|
+
// "code LOC" reali, non dati/markup/config. (Una repo da 6.9M LOC di JSON non è
|
|
246
|
+
// 6.9M di codice.) Restano nell'inventario, ma contano come dataLoc.
|
|
247
|
+
const NON_CODE_LANGS = new Set(["JSON", "YAML", "TOML", "Markdown", "CSV", "Other", "Binary"]);
|
|
248
|
+
const BINARY_EXTS = new Set([
|
|
249
|
+
".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".ico", ".bmp", ".tiff",
|
|
250
|
+
".mp4", ".mp3", ".wav", ".webm", ".mov", ".zip", ".gz", ".tar", ".7z",
|
|
251
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot", ".pdf", ".exe", ".dll", ".so",
|
|
252
|
+
".dylib", ".bin", ".db", ".sqlite", ".wasm", ".jar", ".class", ".pyc",
|
|
253
|
+
// ML model weights / tensors / serialized data — binary, "lines" are meaningless
|
|
254
|
+
// (caso reale: un model.onnx contava 1.5M "righe" → 38M LOC fantasma).
|
|
255
|
+
".onnx", ".safetensors", ".gguf", ".ggml", ".pt", ".pth", ".ckpt", ".h5",
|
|
256
|
+
".pb", ".tflite", ".npy", ".npz", ".pkl", ".pickle", ".joblib", ".model",
|
|
257
|
+
".weights", ".arrow", ".feather", ".parquet", ".msgpack", ".pyd", ".o", ".a", ".lib",
|
|
258
|
+
]);
|
|
259
|
+
const IGNORE_FILES = [
|
|
260
|
+
/\.min\.(js|css)$/i, /\.map$/i,
|
|
261
|
+
// lockfile — generati, enormi, non codice scritto
|
|
262
|
+
/(^|\/)package-lock\.json$/i, /(^|\/)yarn\.lock$/i, /(^|\/)pnpm-lock\.yaml$/i,
|
|
263
|
+
/(^|\/)cargo\.lock$/i, /(^|\/)poetry\.lock$/i, /(^|\/)composer\.lock$/i,
|
|
264
|
+
/(^|\/)gemfile\.lock$/i, /(^|\/)bun\.lockb$/i, /\.lock$/i,
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
const LANG_BY_EXT = {
|
|
268
|
+
".js": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript", ".jsx": "JavaScript",
|
|
269
|
+
".ts": "TypeScript", ".tsx": "TypeScript", ".mts": "TypeScript",
|
|
270
|
+
".py": "Python", ".rb": "Ruby", ".go": "Go", ".rs": "Rust", ".java": "Java",
|
|
271
|
+
".c": "C", ".h": "C", ".cpp": "C++", ".hpp": "C++", ".cs": "C#",
|
|
272
|
+
".php": "PHP", ".swift": "Swift", ".kt": "Kotlin",
|
|
273
|
+
".html": "HTML", ".css": "CSS", ".scss": "CSS", ".vue": "Vue", ".svelte": "Svelte",
|
|
274
|
+
".json": "JSON", ".jsonc": "JSON", ".yaml": "YAML", ".yml": "YAML", ".toml": "TOML",
|
|
275
|
+
".sql": "SQL", ".md": "Markdown", ".sh": "Shell", ".ps1": "PowerShell",
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
let userConfig = { ignore: [], extractors: [], name: null, lang: "en" };
|
|
279
|
+
if (CONFIG_PATH) {
|
|
280
|
+
try {
|
|
281
|
+
userConfig = { ...userConfig, ...JSON.parse(stripJsonc(fs.readFileSync(path.resolve(CONFIG_PATH), "utf8"))) };
|
|
282
|
+
} catch (e) {
|
|
283
|
+
console.error(`[repolens] config load failed (${CONFIG_PATH}): ${e.message}`);
|
|
284
|
+
process.exit(2);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function stripJsonc(s) {
|
|
289
|
+
// Strip // and /* */ comments + trailing commas — STRING-AWARE: i glob nei
|
|
290
|
+
// valori (es. "exports/**") contengono sequenze che sembrano commenti.
|
|
291
|
+
let out = "", inStr = false, esc = false;
|
|
292
|
+
for (let i = 0; i < s.length; i++) {
|
|
293
|
+
const c = s[i], n = s[i + 1];
|
|
294
|
+
if (inStr) {
|
|
295
|
+
out += c;
|
|
296
|
+
if (esc) esc = false;
|
|
297
|
+
else if (c === "\\") esc = true;
|
|
298
|
+
else if (c === '"') inStr = false;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (c === '"') { inStr = true; out += c; continue; }
|
|
302
|
+
if (c === "/" && n === "/") { while (i < s.length && s[i] !== "\n") i++; out += "\n"; continue; }
|
|
303
|
+
if (c === "/" && n === "*") { i += 2; while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) i++; i++; continue; }
|
|
304
|
+
out += c;
|
|
305
|
+
}
|
|
306
|
+
return out.replace(/,\s*([}\]])/g, "$1");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function globToRegex(glob) {
|
|
310
|
+
let re = "";
|
|
311
|
+
for (let i = 0; i < glob.length; i++) {
|
|
312
|
+
const c = glob[i];
|
|
313
|
+
if (c === "*") {
|
|
314
|
+
if (glob[i + 1] === "*") {
|
|
315
|
+
re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*";
|
|
316
|
+
i += glob[i + 2] === "/" ? 2 : 1;
|
|
317
|
+
} else re += "[^/]*";
|
|
318
|
+
} else if (c === "{") {
|
|
319
|
+
// {a,b,c} → (?:a|b|c)
|
|
320
|
+
const end = glob.indexOf("}", i);
|
|
321
|
+
if (end > i) { re += "(?:" + glob.slice(i + 1, end).split(",").join("|") + ")"; i = end; }
|
|
322
|
+
else re += "\\{";
|
|
323
|
+
} else if ("\\^$.|?+()[]}".includes(c)) re += "\\" + c;
|
|
324
|
+
else re += c;
|
|
325
|
+
}
|
|
326
|
+
return new RegExp("^" + re + "$");
|
|
327
|
+
}
|
|
328
|
+
const ignoreGlobs = (userConfig.ignore || []).map(globToRegex);
|
|
329
|
+
|
|
330
|
+
// ─────────────────────────── Walk ──────────────────────────────
|
|
331
|
+
|
|
332
|
+
/** @type {{rel:string, ext:string, lang:string, loc:number, bytes:number, binary:boolean}[]} */
|
|
333
|
+
const files = [];
|
|
334
|
+
|
|
335
|
+
function walk(dir) {
|
|
336
|
+
let entries;
|
|
337
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
338
|
+
for (const e of entries) {
|
|
339
|
+
if (e.name.startsWith(".") && e.isDirectory() && e.name !== ".github") continue;
|
|
340
|
+
const abs = path.join(dir, e.name);
|
|
341
|
+
const rel = path.relative(ROOT, abs).split(path.sep).join("/");
|
|
342
|
+
if (e.isDirectory()) {
|
|
343
|
+
if (DEFAULT_IGNORE_DIRS.has(e.name)) continue;
|
|
344
|
+
if (ignoreGlobs.some((g) => g.test(rel) || g.test(rel + "/"))) continue;
|
|
345
|
+
walk(abs);
|
|
346
|
+
} else if (e.isFile()) {
|
|
347
|
+
if (IGNORE_FILES.some((r) => r.test(e.name))) continue;
|
|
348
|
+
if (ignoreGlobs.some((g) => g.test(rel))) continue;
|
|
349
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
350
|
+
const binary = BINARY_EXTS.has(ext);
|
|
351
|
+
let bytes = 0;
|
|
352
|
+
try { bytes = fs.statSync(abs).size; } catch { /* ignore */ }
|
|
353
|
+
let loc = 0;
|
|
354
|
+
if (!binary && bytes <= MAX_READ_BYTES) {
|
|
355
|
+
try { loc = fs.readFileSync(abs, "utf8").split("\n").length; } catch { /* ignore */ }
|
|
356
|
+
} else if (!binary) {
|
|
357
|
+
loc = Math.round(bytes / 40); // estimate for oversized text files
|
|
358
|
+
}
|
|
359
|
+
files.push({ rel, ext, lang: LANG_BY_EXT[ext] || (binary ? "Binary" : "Other"), loc, bytes, binary });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
console.error(`[repolens] scanning ${ROOT} …`);
|
|
364
|
+
walk(ROOT);
|
|
365
|
+
|
|
366
|
+
const contentCache = new Map();
|
|
367
|
+
function readContent(rel) {
|
|
368
|
+
if (contentCache.has(rel)) return contentCache.get(rel);
|
|
369
|
+
const f = files.find((x) => x.rel === rel);
|
|
370
|
+
let s = null;
|
|
371
|
+
if (f && !f.binary && f.bytes <= MAX_READ_BYTES) {
|
|
372
|
+
try { s = fs.readFileSync(path.join(ROOT, rel), "utf8"); } catch { /* ignore */ }
|
|
373
|
+
}
|
|
374
|
+
contentCache.set(rel, s);
|
|
375
|
+
return s;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─────────────────────── Directory tree ────────────────────────
|
|
379
|
+
|
|
380
|
+
function buildTree() {
|
|
381
|
+
const root = { name: path.basename(ROOT), path: "", dirs: {}, files: [], loc: 0 };
|
|
382
|
+
for (const f of files) {
|
|
383
|
+
const parts = f.rel.split("/");
|
|
384
|
+
let node = root;
|
|
385
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
386
|
+
const p = parts[i];
|
|
387
|
+
node.dirs[p] = node.dirs[p] || { name: p, path: parts.slice(0, i + 1).join("/"), dirs: {}, files: [], loc: 0 };
|
|
388
|
+
node = node.dirs[p];
|
|
389
|
+
}
|
|
390
|
+
node.files.push({ name: parts[parts.length - 1], rel: f.rel, loc: f.loc, lang: f.lang });
|
|
391
|
+
}
|
|
392
|
+
(function sum(n) {
|
|
393
|
+
n.loc = n.files.reduce((a, f) => a + f.loc, 0);
|
|
394
|
+
for (const d of Object.values(n.dirs)) n.loc += sum(d);
|
|
395
|
+
return n.loc;
|
|
396
|
+
})(root);
|
|
397
|
+
return (function toArr(n) {
|
|
398
|
+
return {
|
|
399
|
+
name: n.name, path: n.path, loc: n.loc,
|
|
400
|
+
children: [
|
|
401
|
+
...Object.values(n.dirs).map(toArr).sort((a, b) => b.loc - a.loc),
|
|
402
|
+
...n.files.sort((a, b) => b.loc - a.loc).map((f) => ({ name: f.name, path: f.rel, loc: f.loc, lang: f.lang, file: true })),
|
|
403
|
+
],
|
|
404
|
+
};
|
|
405
|
+
})(root);
|
|
406
|
+
}
|
|
407
|
+
const tree = buildTree();
|
|
408
|
+
|
|
409
|
+
// ───────────────────── Import graph (JS/TS/Py) ─────────────────
|
|
410
|
+
|
|
411
|
+
const fileSet = new Set(files.map((f) => f.rel));
|
|
412
|
+
const edges = [];
|
|
413
|
+
const JS_EXTS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".jsx", ".tsx"];
|
|
414
|
+
|
|
415
|
+
// Path-alias bare-import (Vite/TS `@/...`, `~/...`). Config: `"aliases": {"@/":
|
|
416
|
+
// ["src/","frontend/src/"]}`. Default copre i casi comuni così l'import graph
|
|
417
|
+
// non considera orfani i file raggiunti solo via alias.
|
|
418
|
+
const ALIASES = userConfig.aliases || { "@/": ["src/", "frontend/src/", "app/"], "~/": ["src/", "frontend/src/"] };
|
|
419
|
+
function resolveCandidates(base) {
|
|
420
|
+
return [base, ...JS_EXTS.map((e) => base + e), ...JS_EXTS.map((e) => base + "/index" + e)].find((c) => fileSet.has(c)) || null;
|
|
421
|
+
}
|
|
422
|
+
function resolveJs(fromRel, spec) {
|
|
423
|
+
spec = spec.replace(/\?.*$/, "");
|
|
424
|
+
if (spec.startsWith(".")) {
|
|
425
|
+
return resolveCandidates(path.posix.join(path.posix.dirname(fromRel), spec));
|
|
426
|
+
}
|
|
427
|
+
// Alias bare-import: prova ogni base configurata.
|
|
428
|
+
for (const [prefix, bases] of Object.entries(ALIASES)) {
|
|
429
|
+
if (!spec.startsWith(prefix)) continue;
|
|
430
|
+
const rest = spec.slice(prefix.length);
|
|
431
|
+
for (const b of bases) {
|
|
432
|
+
const hit = resolveCandidates(path.posix.join(b, rest));
|
|
433
|
+
if (hit) return hit;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
function resolvePy(fromRel, mod) {
|
|
439
|
+
const p = mod.replace(/\./g, "/");
|
|
440
|
+
const cand = [
|
|
441
|
+
p + ".py", p + "/__init__.py",
|
|
442
|
+
path.posix.join(path.posix.dirname(fromRel), p + ".py"),
|
|
443
|
+
path.posix.join(path.posix.dirname(fromRel), p, "__init__.py"),
|
|
444
|
+
];
|
|
445
|
+
return cand.find((c) => fileSet.has(c)) || null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
for (const f of files) {
|
|
449
|
+
const isJs = JS_EXTS.includes(f.ext);
|
|
450
|
+
const isPy = f.ext === ".py";
|
|
451
|
+
if (!isJs && !isPy) continue;
|
|
452
|
+
const src = readContent(f.rel);
|
|
453
|
+
if (!src) continue;
|
|
454
|
+
if (isJs) {
|
|
455
|
+
const re = /(?:import\s+(?:[\w*{}\s,]+\s+from\s+)?|export\s+(?:[\w*{}\s,]+\s+from\s+)?|require\(\s*|import\(\s*)["']([^"']+)["']/g;
|
|
456
|
+
let m;
|
|
457
|
+
while ((m = re.exec(src))) {
|
|
458
|
+
const to = resolveJs(f.rel, m[1]);
|
|
459
|
+
if (to && to !== f.rel) edges.push([f.rel, to]);
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
const re = /^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/gm;
|
|
463
|
+
let m;
|
|
464
|
+
while ((m = re.exec(src))) {
|
|
465
|
+
const to = resolvePy(f.rel, m[1] || m[2]);
|
|
466
|
+
if (to && to !== f.rel) edges.push([f.rel, to]);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const edgeKey = new Set();
|
|
471
|
+
const uniqEdges = edges.filter(([a, b]) => {
|
|
472
|
+
const k = a + "→" + b;
|
|
473
|
+
if (edgeKey.has(k)) return false;
|
|
474
|
+
edgeKey.add(k);
|
|
475
|
+
return true;
|
|
476
|
+
});
|
|
477
|
+
const fanIn = {}, fanOut = {};
|
|
478
|
+
for (const [a, b] of uniqEdges) {
|
|
479
|
+
fanOut[a] = (fanOut[a] || 0) + 1;
|
|
480
|
+
fanIn[b] = (fanIn[b] || 0) + 1;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ─────────────────── File links (MD/JSON/HTML) ──────────────────
|
|
484
|
+
// Link RELATIVI dalla cartella di output alla root della repo: cliccabili su
|
|
485
|
+
// GitHub, in VS Code e nell'HTML aperto da disco. Con riga → #L<line> (GitHub).
|
|
486
|
+
|
|
487
|
+
const outPrefixAbs = path.resolve(OUT_PREFIX);
|
|
488
|
+
const linkPrefix = (path.relative(path.dirname(outPrefixAbs), ROOT).split(path.sep).join("/") || ".") + "/";
|
|
489
|
+
function fileLink(rel, line) {
|
|
490
|
+
return linkPrefix + rel + (line ? `#L${line}` : "");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ───────────────────── Built-in extractors ─────────────────────
|
|
494
|
+
|
|
495
|
+
const catalogs = {};
|
|
496
|
+
function addCatalog(name, title, columns) {
|
|
497
|
+
catalogs[name] = catalogs[name] || { title, columns, rows: [] };
|
|
498
|
+
return catalogs[name];
|
|
499
|
+
}
|
|
500
|
+
function lineOf(src, index) {
|
|
501
|
+
return src.slice(0, index).split("\n").length;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// HTTP routes — express/hono/koa style + raw path matching (CF Workers style)
|
|
505
|
+
{
|
|
506
|
+
const cat = addCatalog("routes", "HTTP routes", ["method", "route", "auth", "file", "line"]);
|
|
507
|
+
const seen = new Set();
|
|
508
|
+
const AUTH_HINTS = [
|
|
509
|
+
[/require\w*Admin|requirePlatformAdmin/i, "admin"],
|
|
510
|
+
[/requireClienteAccess|requireWorkspaceSession|requireAuth|requireSession|requireUser|isAuthenticated|verifyToken|authMiddleware/i, "auth"],
|
|
511
|
+
[/checkExecutorBearer|EXECUTOR_SECRET|bearer/i, "service-bearer"],
|
|
512
|
+
[/\bpublic\b|no.?auth/i, "public?"],
|
|
513
|
+
];
|
|
514
|
+
const GUARD_RE = /require\w*Admin|requireClienteAccess|requireWorkspaceSession|requireAuth|requireSession|requireUser|isAuthenticated|verifyToken|authMiddleware|checkExecutorBearer/;
|
|
515
|
+
function authHint(window) {
|
|
516
|
+
for (const [re, label] of AUTH_HINTS) if (re.test(window)) return label;
|
|
517
|
+
return "";
|
|
518
|
+
}
|
|
519
|
+
// Metodo HTTP nelle vicinanze: copre `request.method ===`, `req.method ===`
|
|
520
|
+
// E la variabile destrutturata `method ===` (stile dispatcher CF Workers).
|
|
521
|
+
function methodNear(src, idx) {
|
|
522
|
+
const win = src.slice(Math.max(0, idx - 160), idx + 300);
|
|
523
|
+
const m = win.match(/(?:\b(?:request|req)\.)?\bmethod\b\s*===?\s*["'`](\w+)/);
|
|
524
|
+
return m ? m[1].toUpperCase() : "?";
|
|
525
|
+
}
|
|
526
|
+
for (const f of files) {
|
|
527
|
+
if (![".js", ".mjs", ".ts", ".py", ".go", ".rb"].includes(f.ext)) continue;
|
|
528
|
+
const src = readContent(f.rel);
|
|
529
|
+
if (!src) continue;
|
|
530
|
+
// Inizi di funzione: per i dispatcher (es. routeAdmin) la guardia auth sta
|
|
531
|
+
// in cima alla funzione, lontano dalle singole route. Per ogni route
|
|
532
|
+
// troviamo la funzione che la contiene e, SE è corta (≤4500 char dalla decl
|
|
533
|
+
// alla route, per non assorbire la guardia di una route vicina dentro un
|
|
534
|
+
// mega-handler tipo `fetch()`), cerchiamo lì la guardia di scope.
|
|
535
|
+
const fnStarts = [];
|
|
536
|
+
const fnRe = /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+\w+\s*\(|(?:export\s+)?(?:const|let)\s+\w+\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=]+)?=>/g;
|
|
537
|
+
let fm;
|
|
538
|
+
while ((fm = fnRe.exec(src))) fnStarts.push(fm.index);
|
|
539
|
+
function scopeAuth(idx) {
|
|
540
|
+
let start = 0;
|
|
541
|
+
for (const s of fnStarts) { if (s < idx) start = s; else break; }
|
|
542
|
+
if (idx - start > 4500) return ""; // funzione troppo grande → niente scope-guard
|
|
543
|
+
return GUARD_RE.test(src.slice(start, idx)) ? authHint(src.slice(start, idx)) : "";
|
|
544
|
+
}
|
|
545
|
+
const push = (method, route, idx) => {
|
|
546
|
+
const k = f.rel + "|" + method + "|" + route;
|
|
547
|
+
if (seen.has(k) || !route.startsWith("/")) return;
|
|
548
|
+
seen.add(k);
|
|
549
|
+
const line = lineOf(src, idx);
|
|
550
|
+
// Auth: il body del handler + 2 righe sopra (commento/guardia inline, es.
|
|
551
|
+
// route che delega a un handler "// bearer EXECUTOR_SECRET"); poi, per i
|
|
552
|
+
// dispatcher, la guardia in cima alla funzione (scope, backward limitato).
|
|
553
|
+
const auth = authHint(src.slice(Math.max(0, idx - 120), idx + 400)) || scopeAuth(idx);
|
|
554
|
+
cat.rows.push({ method, route, auth, file: f.rel, line, link: fileLink(f.rel, line) });
|
|
555
|
+
};
|
|
556
|
+
let m;
|
|
557
|
+
const re1 = /\b\w+\.(get|post|put|delete|patch|options|all|use|GET|POST|PUT|DELETE)\(\s*["'`](\/[^"'`\s]*)/g;
|
|
558
|
+
while ((m = re1.exec(src))) push(m[1].toUpperCase() === "USE" ? "USE" : m[1].toUpperCase(), m[2], m.index);
|
|
559
|
+
// Eguaglianza E disuguaglianza: `path === "/x"` (handler) e la guardia negata
|
|
560
|
+
// `if (url.pathname !== "/x") return` (stile route-module CF Workers).
|
|
561
|
+
const re2 = /path(?:name)?\s*[!=]==?\s*["'`](\/[^"'`]+)["'`]/g;
|
|
562
|
+
while ((m = re2.exec(src))) push(methodNear(src, m.index), m[1], m.index);
|
|
563
|
+
const re3 = /path(?:name)?\.startsWith\(\s*["'`](\/[^"'`]{3,})/g;
|
|
564
|
+
while ((m = re3.exec(src))) push(methodNear(src, m.index), m[1] + "*", m.index);
|
|
565
|
+
const re4 = /@\w+\.(get|post|put|delete|patch|route)\(\s*["'](\/[^"']*)/g;
|
|
566
|
+
while ((m = re4.exec(src))) push(m[1].toUpperCase(), m[2], m.index);
|
|
567
|
+
}
|
|
568
|
+
cat.rows.sort((a, b) => a.route.localeCompare(b.route));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// SQL tables — CREATE TABLE (+ columns) and ALTER TABLE ADD COLUMN
|
|
572
|
+
{
|
|
573
|
+
const cat = addCatalog("tables", "Database tables", ["table", "columns", "file"]);
|
|
574
|
+
const byTable = new Map();
|
|
575
|
+
for (const f of files) {
|
|
576
|
+
if (![".sql", ".ts", ".js", ".mjs", ".py"].includes(f.ext)) continue;
|
|
577
|
+
const src = readContent(f.rel);
|
|
578
|
+
if (!src || !/CREATE TABLE|ALTER TABLE/i.test(src)) continue;
|
|
579
|
+
let m;
|
|
580
|
+
const reC = /CREATE TABLE (?:IF NOT EXISTS )?[`"]?(\w+)[`"]?\s*\(([\s\S]*?)\)\s*;/gi;
|
|
581
|
+
while ((m = reC.exec(src))) {
|
|
582
|
+
const cols = [];
|
|
583
|
+
let depth = 0, cur = "";
|
|
584
|
+
for (const ch of m[2]) {
|
|
585
|
+
if (ch === "(") depth++;
|
|
586
|
+
if (ch === ")") depth--;
|
|
587
|
+
if (ch === "," && depth === 0) { cols.push(cur); cur = ""; } else cur += ch;
|
|
588
|
+
}
|
|
589
|
+
cols.push(cur);
|
|
590
|
+
const names = cols
|
|
591
|
+
.map((c) => c.replace(/--.*$/gm, "").trim().split(/\s+/)[0])
|
|
592
|
+
.filter((n) => n && !/^(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT)$/i.test(n))
|
|
593
|
+
.map((n) => n.replace(/[`"]/g, ""));
|
|
594
|
+
const prev = byTable.get(m[1]) || { table: m[1], cols: [], file: f.rel, line: lineOf(src, m.index) };
|
|
595
|
+
prev.cols = [...new Set([...prev.cols, ...names])];
|
|
596
|
+
byTable.set(m[1], prev);
|
|
597
|
+
}
|
|
598
|
+
const reA = /ALTER TABLE [`"]?(\w+)[`"]?\s+ADD (?:COLUMN )?[`"]?(\w+)/gi;
|
|
599
|
+
while ((m = reA.exec(src))) {
|
|
600
|
+
const prev = byTable.get(m[1]) || { table: m[1], cols: [], file: f.rel, line: lineOf(src, m.index) };
|
|
601
|
+
if (!prev.cols.includes(m[2])) prev.cols.push(m[2]);
|
|
602
|
+
byTable.set(m[1], prev);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
for (const t of [...byTable.values()].sort((a, b) => a.table.localeCompare(b.table))) {
|
|
606
|
+
cat.rows.push({ table: t.table, columns: t.cols.join(", "), file: t.file, line: t.line, link: fileLink(t.file, t.line) });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Env vars
|
|
611
|
+
{
|
|
612
|
+
const cat = addCatalog("env", "Environment variables", ["name", "refs", "files"]);
|
|
613
|
+
const acc = new Map();
|
|
614
|
+
for (const f of files) {
|
|
615
|
+
if (![".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".sh"].includes(f.ext)) continue;
|
|
616
|
+
const src = readContent(f.rel);
|
|
617
|
+
if (!src) continue;
|
|
618
|
+
const res = [
|
|
619
|
+
/process\.env\.([A-Z][A-Z0-9_]+)/g,
|
|
620
|
+
/import\.meta\.env\.([A-Z][A-Z0-9_]+)/g,
|
|
621
|
+
/\benv\.([A-Z][A-Z0-9_]{2,})\b/g,
|
|
622
|
+
/os\.environ(?:\.get)?\(["']([A-Z][A-Z0-9_]+)["']/g,
|
|
623
|
+
];
|
|
624
|
+
for (const re of res) {
|
|
625
|
+
let m;
|
|
626
|
+
while ((m = re.exec(src))) {
|
|
627
|
+
const e = acc.get(m[1]) || { name: m[1], refs: 0, files: new Set() };
|
|
628
|
+
e.refs++;
|
|
629
|
+
e.files.add(f.rel);
|
|
630
|
+
acc.set(m[1], e);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
for (const e of [...acc.values()].sort((a, b) => b.refs - a.refs)) {
|
|
635
|
+
const first = [...e.files][0];
|
|
636
|
+
cat.rows.push({
|
|
637
|
+
name: e.name, refs: e.refs,
|
|
638
|
+
files: [...e.files].slice(0, 3).join(", ") + (e.files.size > 3 ? ` (+${e.files.size - 3})` : ""),
|
|
639
|
+
file: first, link: fileLink(first),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// HTML pages + the API endpoints each page's JS references
|
|
645
|
+
{
|
|
646
|
+
const cat = addCatalog("pages", "Pages (HTML + API calls)", ["page", "title", "js", "api_calls"]);
|
|
647
|
+
const htmls = files.filter((f) => f.ext === ".html" && !f.rel.includes("templates/"));
|
|
648
|
+
for (const h of htmls) {
|
|
649
|
+
const src = readContent(h.rel);
|
|
650
|
+
if (!src) continue;
|
|
651
|
+
const title = (src.match(/<title>([^<]*)<\/title>/i) || [])[1] || "";
|
|
652
|
+
const scripts = [...src.matchAll(/<script[^>]+src=["']([^"']+)["']/g)].map((m) => m[1]).filter((s) => !s.startsWith("http"));
|
|
653
|
+
const apis = new Set();
|
|
654
|
+
for (const s of scripts) {
|
|
655
|
+
const guess = s.replace(/^\//, "");
|
|
656
|
+
const jf = files.find((f) => f.rel.endsWith(guess));
|
|
657
|
+
const js = jf ? readContent(jf.rel) : null;
|
|
658
|
+
if (!js) continue;
|
|
659
|
+
let m;
|
|
660
|
+
const re = /["'`](\/api\/[a-zA-Z0-9_\-/:.]+)/g;
|
|
661
|
+
while ((m = re.exec(js))) apis.add(m[1]);
|
|
662
|
+
}
|
|
663
|
+
cat.rows.push({
|
|
664
|
+
page: h.rel, title: title.trim(),
|
|
665
|
+
js: scripts.slice(0, 4).join(", "),
|
|
666
|
+
api_calls: [...apis].slice(0, 12).join(", ") + (apis.size > 12 ? ` (+${apis.size - 12})` : ""),
|
|
667
|
+
file: h.rel, link: fileLink(h.rel),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// npm scripts
|
|
673
|
+
{
|
|
674
|
+
const cat = addCatalog("scripts", "npm scripts", ["package", "script", "command"]);
|
|
675
|
+
for (const f of files.filter((x) => x.rel.endsWith("package.json"))) {
|
|
676
|
+
try {
|
|
677
|
+
const pkg = JSON.parse(readContent(f.rel) || "{}");
|
|
678
|
+
for (const [k, v] of Object.entries(pkg.scripts || {})) {
|
|
679
|
+
cat.rows.push({ package: f.rel, script: k, command: String(v).slice(0, 120), file: f.rel, link: fileLink(f.rel) });
|
|
680
|
+
}
|
|
681
|
+
} catch { /* ignore */ }
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Cloudflare wrangler config (bindings, crons) — no-op on other repos
|
|
686
|
+
{
|
|
687
|
+
const wranglers = files.filter((f) => /wrangler.*\.(toml|jsonc?|json)$/.test(f.rel) && !f.rel.includes("node_modules"));
|
|
688
|
+
if (wranglers.length) {
|
|
689
|
+
const cat = addCatalog("bindings", "Platform bindings (wrangler)", ["kind", "name", "detail", "file"]);
|
|
690
|
+
for (const w of wranglers) {
|
|
691
|
+
const src = readContent(w.rel);
|
|
692
|
+
if (!src) continue;
|
|
693
|
+
const push = (kind, name, detail) => cat.rows.push({ kind, name, detail, file: w.rel, link: fileLink(w.rel) });
|
|
694
|
+
if (w.ext === ".toml") {
|
|
695
|
+
for (const m of src.matchAll(/\[\[(d1_databases|r2_buckets|kv_namespaces|queues|services)\]\][\s\S]*?binding\s*=\s*"(\w+)"/g)) push(m[1], m[2], "");
|
|
696
|
+
for (const m of src.matchAll(/crons\s*=\s*\[([^\]]*)\]/g)) push("cron", m[1].replace(/["\s]/g, ""), "");
|
|
697
|
+
} else {
|
|
698
|
+
try {
|
|
699
|
+
const cfg = JSON.parse(stripJsonc(src));
|
|
700
|
+
for (const k of ["d1_databases", "r2_buckets", "kv_namespaces", "queues", "services", "workflows"]) {
|
|
701
|
+
for (const b of cfg[k] || []) push(k, b.binding || b.name || "", b.database_name || b.bucket_name || b.service || b.class_name || "");
|
|
702
|
+
}
|
|
703
|
+
for (const c of cfg.triggers?.crons || []) push("cron", c, "");
|
|
704
|
+
if (cfg.vars) push("vars", Object.keys(cfg.vars).join(", ").slice(0, 200), `${Object.keys(cfg.vars).length} vars`);
|
|
705
|
+
} catch { /* ignore */ }
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ───────────────── Custom extractors (from config) ─────────────
|
|
712
|
+
|
|
713
|
+
for (const ex of userConfig.extractors || []) {
|
|
714
|
+
try {
|
|
715
|
+
const cat = addCatalog(ex.name, ex.title || ex.name, [...ex.fields, "file", "line"]);
|
|
716
|
+
const glob = globToRegex(ex.glob);
|
|
717
|
+
const re = new RegExp(ex.pattern, ex.flags ?? "g");
|
|
718
|
+
const seen = new Set();
|
|
719
|
+
const emit = (m, f, src) => {
|
|
720
|
+
const line = lineOf(src, m.index);
|
|
721
|
+
const row = { file: f.rel, line, link: fileLink(f.rel, line) };
|
|
722
|
+
ex.fields.forEach((field, i) => {
|
|
723
|
+
let v = (m[i + 1] || "").trim().replace(/\s+/g, " ");
|
|
724
|
+
if (ex.maxLen) v = v.slice(0, ex.maxLen);
|
|
725
|
+
row[field] = v;
|
|
726
|
+
});
|
|
727
|
+
if (ex.postSplit && row[ex.postSplit.field] != null) {
|
|
728
|
+
const sub = new RegExp(ex.postSplit.pattern, "g");
|
|
729
|
+
const items = [];
|
|
730
|
+
let sm;
|
|
731
|
+
while ((sm = sub.exec(row[ex.postSplit.field]))) items.push(sm[1]);
|
|
732
|
+
row[ex.postSplit.field] = items.join(", ");
|
|
733
|
+
row[ex.postSplit.field + "_count"] = items.length;
|
|
734
|
+
if (!cat.columns.includes(ex.postSplit.field + "_count")) cat.columns.splice(cat.columns.length - 2, 0, ex.postSplit.field + "_count");
|
|
735
|
+
}
|
|
736
|
+
const key = ex.fields.map((x) => row[x]).join("|");
|
|
737
|
+
if (ex.unique && seen.has(key)) return;
|
|
738
|
+
seen.add(key);
|
|
739
|
+
cat.rows.push(row);
|
|
740
|
+
};
|
|
741
|
+
for (const f of files) {
|
|
742
|
+
if (!glob.test(f.rel)) continue;
|
|
743
|
+
const src = readContent(f.rel);
|
|
744
|
+
if (!src) continue;
|
|
745
|
+
let m;
|
|
746
|
+
if (!re.flags.includes("g")) {
|
|
747
|
+
m = re.exec(src);
|
|
748
|
+
if (m) emit(m, f, src);
|
|
749
|
+
} else {
|
|
750
|
+
re.lastIndex = 0;
|
|
751
|
+
while ((m = re.exec(src))) emit(m, f, src);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
} catch (e) {
|
|
755
|
+
console.error(`[repolens] custom extractor "${ex.name}" failed: ${e.message}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
for (const k of Object.keys(catalogs)) if (!catalogs[k].rows.length) delete catalogs[k];
|
|
760
|
+
|
|
761
|
+
// ─────────────────────────── Stats ─────────────────────────────
|
|
762
|
+
|
|
763
|
+
const totalLoc = files.reduce((a, f) => a + f.loc, 0);
|
|
764
|
+
// codeLoc = righe dei soli linguaggi di codice (esclude JSON/YAML/MD/data/binari):
|
|
765
|
+
// è la metrica che conta davvero per "quanto codice è stato scritto".
|
|
766
|
+
const codeLoc = files.reduce((a, f) => a + (NON_CODE_LANGS.has(f.lang) ? 0 : f.loc), 0);
|
|
767
|
+
const byLang = {};
|
|
768
|
+
for (const f of files) byLang[f.lang] = (byLang[f.lang] || 0) + f.loc;
|
|
769
|
+
const areas = tree.children.filter((c) => !c.file).map((c) => ({ area: c.name, loc: c.loc, files: countFiles(c) }));
|
|
770
|
+
function countFiles(n) { return n.file ? 1 : (n.children || []).reduce((a, c) => a + countFiles(c), 0); }
|
|
771
|
+
const godFiles = files.filter((f) => f.loc >= 1500 && f.lang !== "JSON" && f.lang !== "Markdown").sort((a, b) => b.loc - a.loc);
|
|
772
|
+
const topFanIn = Object.entries(fanIn).sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
773
|
+
const topFanOut = Object.entries(fanOut).sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
774
|
+
|
|
775
|
+
const repoName = userConfig.name || path.basename(ROOT);
|
|
776
|
+
const generatedAt = new Date().toISOString();
|
|
777
|
+
|
|
778
|
+
// ─────────────────────────── JSON out ──────────────────────────
|
|
779
|
+
|
|
780
|
+
fs.mkdirSync(path.dirname(outPrefixAbs), { recursive: true });
|
|
781
|
+
|
|
782
|
+
const jsonOut = {
|
|
783
|
+
meta: { tool: "repolens", repo: repoName, root: ROOT, generatedAt, linkPrefix },
|
|
784
|
+
stats: { files: files.length, loc: totalLoc, codeLoc, byLang, areas },
|
|
785
|
+
godFiles: godFiles.map((f) => ({ file: f.rel, loc: f.loc, link: fileLink(f.rel) })),
|
|
786
|
+
fan: { topFanIn, topFanOut },
|
|
787
|
+
catalogs,
|
|
788
|
+
tree,
|
|
789
|
+
files: files.map((f) => ({ p: f.rel, loc: f.loc, l: f.lang })),
|
|
790
|
+
edges: uniqEdges,
|
|
791
|
+
};
|
|
792
|
+
fs.writeFileSync(outPrefixAbs + ".json", JSON.stringify(jsonOut, null, 1));
|
|
793
|
+
console.error(`[repolens] wrote ${outPrefixAbs}.json`);
|
|
794
|
+
|
|
795
|
+
// ─────────────────────────── MD out ────────────────────────────
|
|
796
|
+
|
|
797
|
+
function mdCell(row, col) {
|
|
798
|
+
const v = String(row[col] ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
799
|
+
if (col === "file" && row.link) {
|
|
800
|
+
const label = row.line ? `${row.file}:${row.line}` : row.file;
|
|
801
|
+
return `[${label}](${row.link})`;
|
|
802
|
+
}
|
|
803
|
+
if (col === "page" && row.link) return `[${v}](${row.link})`;
|
|
804
|
+
return v;
|
|
805
|
+
}
|
|
806
|
+
function mdTable(columns, rows, max = 400) {
|
|
807
|
+
const cols = columns.filter((c) => c !== "line"); // la riga è già nel link del file
|
|
808
|
+
const head = `| ${cols.join(" | ")} |\n| ${cols.map(() => "---").join(" | ")} |`;
|
|
809
|
+
const body = rows.slice(0, max).map((r) => `| ${cols.map((c) => mdCell(r, c)).join(" | ")} |`).join("\n");
|
|
810
|
+
const more = rows.length > max ? `\n\n*(+${rows.length - max} more rows — see JSON)*` : "";
|
|
811
|
+
return head + "\n" + body + more;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
let md = `# Repo Map — ${repoName}
|
|
815
|
+
|
|
816
|
+
> Generated by repolens on ${generatedAt}. Do NOT edit by hand — regenerate with \`npm run map\`.
|
|
817
|
+
> Machine-readable: \`${path.basename(outPrefixAbs)}.json\` · Visual: \`${path.basename(outPrefixAbs)}.html\`
|
|
818
|
+
|
|
819
|
+
## Stats
|
|
820
|
+
|
|
821
|
+
- **Files:** ${files.length} · **LOC:** ${totalLoc.toLocaleString()}
|
|
822
|
+
- **Languages:** ${Object.entries(byLang).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([l, n]) => `${l} ${Math.round((n / totalLoc) * 100)}%`).join(" · ")}
|
|
823
|
+
|
|
824
|
+
### Top-level areas
|
|
825
|
+
|
|
826
|
+
${mdTable(["area", "loc", "files"], areas)}
|
|
827
|
+
|
|
828
|
+
### Giant files (≥1500 LOC — refactor candidates)
|
|
829
|
+
|
|
830
|
+
${godFiles.length ? mdTable(["file", "loc"], godFiles.map((f) => ({ file: f.rel, loc: f.loc, link: fileLink(f.rel) }))) : "_none_"}
|
|
831
|
+
|
|
832
|
+
### Dependency hubs
|
|
833
|
+
|
|
834
|
+
**Most imported (fan-in):** ${topFanIn.slice(0, 8).map(([f, n]) => `[\`${f}\`](${fileLink(f)}) (${n})`).join(", ")}
|
|
835
|
+
|
|
836
|
+
**Most dependent (fan-out):** ${topFanOut.slice(0, 8).map(([f, n]) => `[\`${f}\`](${fileLink(f)}) (${n})`).join(", ")}
|
|
837
|
+
`;
|
|
838
|
+
|
|
839
|
+
for (const [, cat] of Object.entries(catalogs)) {
|
|
840
|
+
md += `\n## ${cat.title} (${cat.rows.length})\n\n${mdTable(cat.columns, cat.rows)}\n`;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
md += `\n---\n*Generated by ◳ repolens — zero LLM tokens. Built by Maurizio Tarricone · [X Quantum Tech](https://xquantumtech.com)*\n`;
|
|
844
|
+
|
|
845
|
+
fs.writeFileSync(outPrefixAbs + ".md", md);
|
|
846
|
+
console.error(`[repolens] wrote ${outPrefixAbs}.md`);
|
|
847
|
+
|
|
848
|
+
// ─────────────────────────── HTML out ──────────────────────────
|
|
849
|
+
|
|
850
|
+
const html = buildHtml(JSON.stringify(jsonOut));
|
|
851
|
+
fs.writeFileSync(outPrefixAbs + ".html", html);
|
|
852
|
+
console.error(`[repolens] wrote ${outPrefixAbs}.html`);
|
|
853
|
+
console.error(`[repolens] done: ${files.length} files, ${totalLoc.toLocaleString()} LOC, ${Object.keys(catalogs).length} catalogs, ${uniqEdges.length} import edges`);
|
|
854
|
+
|
|
855
|
+
function buildHtml(dataJson) {
|
|
856
|
+
// Dashboard self-contained, leggibile da umani: panoramica con barre,
|
|
857
|
+
// treemap zoomabile con legenda, cataloghi come tabelle ordinabili.
|
|
858
|
+
// Client JS senza template literal (vive dentro una stringa server-side).
|
|
859
|
+
const LANG = (userConfig.lang || "en").toLowerCase().startsWith("it") ? "it" : "en";
|
|
860
|
+
const I18N = {
|
|
861
|
+
it: {
|
|
862
|
+
overview: "Panoramica", map: "Mappa del codice", files: "File", loc: "Righe di codice",
|
|
863
|
+
edges: "Dipendenze (import)", searchPh: "Cerca… (route, tool, tabella, file)",
|
|
864
|
+
areas: "Aree del progetto", langs: "Linguaggi",
|
|
865
|
+
god: "File giganti (≥1500 righe — candidati a refactor)",
|
|
866
|
+
hubsIn: "I file più usati dagli altri (fan-in)", hubsOut: "I file che dipendono da più cose (fan-out)",
|
|
867
|
+
mapHint: "Ogni rettangolo è una cartella o un file: più è grande, più righe di codice contiene. <b>Click su una cartella</b> per entrarci, <b>click su un file</b> per vedere le sue dipendenze (blu = file che importa, rosa = file che lo importano). Usa il percorso in alto per tornare indietro.",
|
|
868
|
+
legend: "Colori per area:", close: "✕ chiudi", imports: "importa", importedBy: "importato da",
|
|
869
|
+
linksToggle: "⛓ Collegamenti import", linksHint: "linee = chi importa chi (spessore = quanti import)",
|
|
870
|
+
generated: "generato il", openFile: "apri", rows: "righe", noResults: "Nessun risultato per",
|
|
871
|
+
refine: "raffina la ricerca per vedere le altre", filesTab: "Tutti i file",
|
|
872
|
+
},
|
|
873
|
+
en: {
|
|
874
|
+
overview: "Overview", map: "Code map", files: "Files", loc: "Lines of code",
|
|
875
|
+
edges: "Import edges", searchPh: "Search… (route, tool, table, file)",
|
|
876
|
+
areas: "Project areas", langs: "Languages",
|
|
877
|
+
god: "Giant files (≥1500 lines — refactor candidates)",
|
|
878
|
+
hubsIn: "Most imported files (fan-in)", hubsOut: "Files depending on most things (fan-out)",
|
|
879
|
+
mapHint: "Each rectangle is a folder or a file: the bigger it is, the more lines of code it contains. <b>Click a folder</b> to zoom in, <b>click a file</b> to see its dependencies (blue = files it imports, pink = files importing it). Use the breadcrumb to go back.",
|
|
880
|
+
legend: "Area colors:", close: "✕ close", imports: "imports", importedBy: "imported by",
|
|
881
|
+
linksToggle: "⛓ Import links", linksHint: "lines = who imports whom (thickness = how many imports)",
|
|
882
|
+
generated: "generated", openFile: "open", rows: "rows", noResults: "No results for",
|
|
883
|
+
refine: "refine your search to see the rest", filesTab: "All files",
|
|
884
|
+
},
|
|
885
|
+
}[LANG];
|
|
886
|
+
|
|
887
|
+
const TEMPLATE = `<!DOCTYPE html>
|
|
888
|
+
<html lang="${LANG}">
|
|
889
|
+
<head>
|
|
890
|
+
<meta charset="utf-8"/>
|
|
891
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
892
|
+
<title>__REPO__ — repo map</title>
|
|
893
|
+
<style>
|
|
894
|
+
:root { --bg:#fafafa; --card:#ffffff; --line:#e4e4e7; --txt:#18181b; --dim:#71717a; --soft:#a1a1aa;
|
|
895
|
+
--acc:#65a30d; --accbg:#ecfccb; --dark:#18181b; }
|
|
896
|
+
* { box-sizing:border-box; margin:0; }
|
|
897
|
+
body { background:var(--bg); color:var(--txt); font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; min-height:100vh; }
|
|
898
|
+
a { color:var(--acc); text-decoration:none; }
|
|
899
|
+
a:hover { text-decoration:underline; }
|
|
900
|
+
header { background:var(--card); border-bottom:1px solid var(--line); padding:18px 28px;
|
|
901
|
+
border-top:4px solid; border-image:linear-gradient(90deg,#a3e635,#65a30d,#18181b) 1; }
|
|
902
|
+
header .ttl { font-size:21px; font-weight:800; letter-spacing:-0.02em; }
|
|
903
|
+
header .ttl em { font-style:normal; color:var(--acc); }
|
|
904
|
+
header .sub { color:var(--dim); font-size:12.5px; margin-top:3px; }
|
|
905
|
+
.wrap { max-width:1480px; margin:0 auto; padding:20px 28px 60px; }
|
|
906
|
+
.cards { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:18px; }
|
|
907
|
+
.card-stat { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 20px; min-width:130px;
|
|
908
|
+
box-shadow:0 1px 3px rgba(0,0,0,0.04); transition:transform .12s, box-shadow .12s; }
|
|
909
|
+
.card-stat:hover { transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,0.07); }
|
|
910
|
+
.card-stat .n { font-size:24px; font-weight:800; letter-spacing:-0.02em; }
|
|
911
|
+
.card-stat .l { color:var(--dim); font-size:12px; margin-top:2px; }
|
|
912
|
+
#tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:18px; }
|
|
913
|
+
#tabs button { background:var(--card); color:var(--dim); border:1px solid var(--line); border-radius:999px;
|
|
914
|
+
padding:7px 16px; font:inherit; font-size:13px; cursor:pointer; font-weight:600; }
|
|
915
|
+
#tabs button:hover { border-color:var(--soft); color:var(--txt); }
|
|
916
|
+
#tabs button.on { background:var(--dark); color:#fff; border-color:var(--dark); }
|
|
917
|
+
.panel { background:var(--card); border:1px solid var(--line); border-radius:16px; padding:20px 22px; margin-bottom:16px;
|
|
918
|
+
box-shadow:0 1px 3px rgba(0,0,0,0.04); }
|
|
919
|
+
.panel h2 { font-size:15px; font-weight:700; margin-bottom:14px; }
|
|
920
|
+
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
|
921
|
+
@media (max-width:980px){ .grid2 { grid-template-columns:1fr; } }
|
|
922
|
+
.bar-row { display:grid; grid-template-columns:170px 1fr 110px; align-items:center; gap:10px; padding:4px 0; font-size:13px; }
|
|
923
|
+
.bar-row .nm { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
924
|
+
.bar-track { background:#f4f4f5; border-radius:6px; height:18px; overflow:hidden; }
|
|
925
|
+
.bar-fill { height:100%; border-radius:6px; background:linear-gradient(90deg,#a3e635,#65a30d); }
|
|
926
|
+
.bar-row .val { color:var(--dim); font-size:12px; text-align:right; font-variant-numeric:tabular-nums; }
|
|
927
|
+
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
928
|
+
th { text-align:left; color:var(--dim); font-size:11.5px; text-transform:uppercase; letter-spacing:0.04em;
|
|
929
|
+
padding:8px 10px; border-bottom:2px solid var(--line); cursor:pointer; user-select:none; white-space:nowrap; }
|
|
930
|
+
th:hover { color:var(--txt); }
|
|
931
|
+
th .arrow { color:var(--acc); }
|
|
932
|
+
td { padding:8px 10px; border-bottom:1px solid #f1f1f3; vertical-align:top; word-break:break-word; }
|
|
933
|
+
tr:hover td { background:#fafaf9; }
|
|
934
|
+
td.first { font-weight:600; }
|
|
935
|
+
td .filelnk { color:var(--soft); font-size:12px; }
|
|
936
|
+
td .filelnk:hover { color:var(--acc); }
|
|
937
|
+
.badge { display:inline-block; background:var(--accbg); color:#3f6212; font-weight:700; font-size:11px;
|
|
938
|
+
border-radius:6px; padding:2px 7px; }
|
|
939
|
+
.badge.warn { background:#fef3c7; color:#92400e; }
|
|
940
|
+
.badge.q { background:#f4f4f5; color:var(--dim); }
|
|
941
|
+
#search { width:100%; max-width:460px; padding:9px 14px; border:1px solid var(--line); border-radius:10px;
|
|
942
|
+
font:inherit; margin-bottom:14px; background:#fff; }
|
|
943
|
+
#search:focus { outline:2px solid #d9f99d; border-color:var(--acc); }
|
|
944
|
+
.hint { color:var(--dim); font-size:13px; margin-bottom:12px; line-height:1.55; }
|
|
945
|
+
.legend { display:flex; flex-wrap:wrap; gap:8px 14px; margin-bottom:12px; font-size:12.5px; color:var(--dim); align-items:center; }
|
|
946
|
+
.chip { display:inline-flex; align-items:center; gap:6px; }
|
|
947
|
+
.chip i { width:12px; height:12px; border-radius:3px; display:inline-block; }
|
|
948
|
+
#crumb { font-size:13px; color:var(--dim); margin-bottom:8px; }
|
|
949
|
+
#crumb span { color:var(--acc); cursor:pointer; font-weight:600; }
|
|
950
|
+
#crumb span:hover { text-decoration:underline; }
|
|
951
|
+
#cv { width:100%; height:62vh; border:1px solid var(--line); border-radius:12px; background:#fff; display:block; }
|
|
952
|
+
#tip { position:fixed; pointer-events:none; background:#18181b; color:#fafafa; padding:7px 11px; border-radius:8px;
|
|
953
|
+
font-size:12.5px; display:none; z-index:10; max-width:440px; box-shadow:0 8px 24px rgba(0,0,0,0.18); }
|
|
954
|
+
#info { margin-top:10px; font-size:13px; display:none; background:#fafaf9; border:1px solid var(--line);
|
|
955
|
+
border-radius:10px; padding:12px 14px; }
|
|
956
|
+
#info b { font-weight:700; }
|
|
957
|
+
#info .lnk { color:#0369a1; cursor:pointer; }
|
|
958
|
+
#info .lnk:hover { text-decoration:underline; }
|
|
959
|
+
.muted { color:var(--dim); }
|
|
960
|
+
ul.plain { list-style:none; }
|
|
961
|
+
ul.plain li { padding:4px 0; border-bottom:1px solid #f1f1f3; font-size:13px; display:flex; justify-content:space-between; gap:10px; }
|
|
962
|
+
ul.plain li .c { color:var(--dim); font-variant-numeric:tabular-nums; }
|
|
963
|
+
</style>
|
|
964
|
+
</head>
|
|
965
|
+
<body>
|
|
966
|
+
<header>
|
|
967
|
+
<div class="ttl"><em>◳ repolens</em> · __REPO__</div>
|
|
968
|
+
<div class="sub" id="hsub"></div>
|
|
969
|
+
</header>
|
|
970
|
+
<div class="wrap">
|
|
971
|
+
<div class="cards" id="cards"></div>
|
|
972
|
+
<div id="tabs"></div>
|
|
973
|
+
<div id="content"></div>
|
|
974
|
+
<footer style="margin-top:28px; padding-top:16px; border-top:1px solid var(--line); color:var(--soft); font-size:12px; display:flex; justify-content:space-between; flex-wrap:wrap; gap:8px;">
|
|
975
|
+
<span>◳ <b>repolens</b> — messy or gigantic repo? Don't waste tokens to map it.</span>
|
|
976
|
+
<span>Built by Maurizio Tarricone · <a href="https://xquantumtech.com" target="_blank" rel="noopener">X Quantum Tech</a></span>
|
|
977
|
+
</footer>
|
|
978
|
+
</div>
|
|
979
|
+
<div id="tip"></div>
|
|
980
|
+
<script>
|
|
981
|
+
var DATA = __DATA__;
|
|
982
|
+
var T = __I18N__;
|
|
983
|
+
var PALETTE = ["#84cc16","#3b82f6","#ec4899","#f59e0b","#10b981","#8b5cf6","#ef4444","#06b6d4","#f97316","#6366f1","#22c55e","#d946ef"];
|
|
984
|
+
var areaColor = {};
|
|
985
|
+
(DATA.tree.children||[]).filter(function(c){return !c.file;}).forEach(function(c,i){ areaColor[c.name] = PALETTE[i % PALETTE.length]; });
|
|
986
|
+
function colorFor(p){ var a = (p||"").split("/")[0]; return areaColor[a] || "#94a3b8"; }
|
|
987
|
+
function tint(hex, p){ var n=parseInt(hex.slice(1),16),r=(n>>16)&255,g=(n>>8)&255,b=n&255;
|
|
988
|
+
r=Math.round(r+(255-r)*p); g=Math.round(g+(255-g)*p); b=Math.round(b+(255-b)*p);
|
|
989
|
+
return "rgb("+r+","+g+","+b+")"; }
|
|
990
|
+
function esc(s){ return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
|
991
|
+
function fmt(n){ return Number(n||0).toLocaleString(); }
|
|
992
|
+
|
|
993
|
+
document.getElementById("hsub").textContent = T.generated + " " + DATA.meta.generatedAt.slice(0,16).replace("T"," ") + " · " + DATA.meta.root;
|
|
994
|
+
|
|
995
|
+
// ── stat cards ──
|
|
996
|
+
(function(){
|
|
997
|
+
var cards = [[fmt(DATA.stats.files), T.files],[fmt(DATA.stats.loc), T.loc],[fmt(DATA.edges.length), T.edges]];
|
|
998
|
+
Object.keys(DATA.catalogs).slice(0,4).forEach(function(k){
|
|
999
|
+
cards.push([fmt(DATA.catalogs[k].rows.length), DATA.catalogs[k].title]);
|
|
1000
|
+
});
|
|
1001
|
+
document.getElementById("cards").innerHTML = cards.map(function(c){
|
|
1002
|
+
return '<div class="card-stat"><div class="n">'+c[0]+'</div><div class="l">'+esc(c[1])+'</div></div>';
|
|
1003
|
+
}).join("");
|
|
1004
|
+
})();
|
|
1005
|
+
|
|
1006
|
+
// ── indexes ──
|
|
1007
|
+
var nodeByPath = {};
|
|
1008
|
+
(function idx(n){ nodeByPath[n.path]=n; (n.children||[]).forEach(idx); })(DATA.tree);
|
|
1009
|
+
var importsOf = {}, importersOf = {};
|
|
1010
|
+
DATA.edges.forEach(function(e){ (importsOf[e[0]]=importsOf[e[0]]||[]).push(e[1]); (importersOf[e[1]]=importersOf[e[1]]||[]).push(e[0]); });
|
|
1011
|
+
function flink(p, line){ return DATA.meta.linkPrefix + p + (line ? "#L"+line : ""); }
|
|
1012
|
+
|
|
1013
|
+
// ── tabs ──
|
|
1014
|
+
var TABS = [{k:"overview", t:T.overview},{k:"map", t:T.map}];
|
|
1015
|
+
Object.keys(DATA.catalogs).forEach(function(k){ TABS.push({k:"cat:"+k, t:DATA.catalogs[k].title+" ("+DATA.catalogs[k].rows.length+")"}); });
|
|
1016
|
+
TABS.push({k:"cat:__files", t:T.filesTab+" ("+DATA.files.length+")"});
|
|
1017
|
+
var current = "overview";
|
|
1018
|
+
function renderTabs(){
|
|
1019
|
+
document.getElementById("tabs").innerHTML = TABS.map(function(tb){
|
|
1020
|
+
return '<button data-k="'+tb.k+'" class="'+(tb.k===current?"on":"")+'">'+esc(tb.t)+"</button>";
|
|
1021
|
+
}).join("");
|
|
1022
|
+
document.querySelectorAll("#tabs button").forEach(function(b){
|
|
1023
|
+
b.onclick = function(){ current = b.getAttribute("data-k"); renderTabs(); renderContent(); };
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ── content render ──
|
|
1028
|
+
function renderContent(){
|
|
1029
|
+
var el = document.getElementById("content");
|
|
1030
|
+
if (current === "overview") return renderOverview(el);
|
|
1031
|
+
if (current === "map") return renderMap(el);
|
|
1032
|
+
renderCatalog(el, current.slice(4));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function barRows(items, nameKey, valKey, extra){
|
|
1036
|
+
var max = Math.max.apply(null, items.map(function(x){ return x[valKey]; }).concat([1]));
|
|
1037
|
+
return items.map(function(x){
|
|
1038
|
+
var pct = Math.max(1, Math.round(100*x[valKey]/max));
|
|
1039
|
+
return '<div class="bar-row"><div class="nm">'+esc(x[nameKey])+'</div>'
|
|
1040
|
+
+'<div class="bar-track"><div class="bar-fill" style="width:'+pct+'%"></div></div>'
|
|
1041
|
+
+'<div class="val">'+fmt(x[valKey])+(extra?(' · '+fmt(x[extra])+' file'):'')+"</div></div>";
|
|
1042
|
+
}).join("");
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function renderOverview(el){
|
|
1046
|
+
var langs = Object.keys(DATA.stats.byLang).map(function(l){ return {l:l, n:DATA.stats.byLang[l]}; })
|
|
1047
|
+
.sort(function(a,b){ return b.n-a.n; }).slice(0,9);
|
|
1048
|
+
var god = DATA.godFiles.map(function(g){
|
|
1049
|
+
return "<tr><td class='first'><a href='"+esc(g.link||flink(g.file))+"'>"+esc(g.file)+"</a></td><td><span class='badge warn'>"+fmt(g.loc)+" LOC</span></td></tr>";
|
|
1050
|
+
}).join("");
|
|
1051
|
+
var hubsIn = DATA.fan.topFanIn.slice(0,10).map(function(x){
|
|
1052
|
+
return "<li><a href='"+esc(flink(x[0]))+"'>"+esc(x[0])+"</a><span class='c'>"+x[1]+"</span></li>";
|
|
1053
|
+
}).join("");
|
|
1054
|
+
var hubsOut = DATA.fan.topFanOut.slice(0,10).map(function(x){
|
|
1055
|
+
return "<li><a href='"+esc(flink(x[0]))+"'>"+esc(x[0])+"</a><span class='c'>"+x[1]+"</span></li>";
|
|
1056
|
+
}).join("");
|
|
1057
|
+
el.innerHTML =
|
|
1058
|
+
'<div class="grid2">'
|
|
1059
|
+
+'<div class="panel"><h2>'+esc(T.areas)+'</h2>'+barRows(DATA.stats.areas,"area","loc","files")+"</div>"
|
|
1060
|
+
+'<div class="panel"><h2>'+esc(T.langs)+'</h2>'+barRows(langs,"l","n")+"</div>"
|
|
1061
|
+
+"</div>"
|
|
1062
|
+
+'<div class="panel"><h2>'+esc(T.god)+"</h2><table><tbody>"+(god||"<tr><td class='muted'>—</td></tr>")+"</tbody></table></div>"
|
|
1063
|
+
+'<div class="grid2">'
|
|
1064
|
+
+'<div class="panel"><h2>'+esc(T.hubsIn)+'</h2><ul class="plain">'+hubsIn+"</ul></div>"
|
|
1065
|
+
+'<div class="panel"><h2>'+esc(T.hubsOut)+'</h2><ul class="plain">'+hubsOut+"</ul></div>"
|
|
1066
|
+
+"</div>";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ── treemap ──
|
|
1070
|
+
var rootNode = DATA.tree, cells = [], highlight = null;
|
|
1071
|
+
var linksOn = true; // overlay "circuiti": fasci di import tra i blocchi visibili
|
|
1072
|
+
var cellByPath = {}; // node.path → cella visibile (le sub-celle sovrascrivono i parent)
|
|
1073
|
+
|
|
1074
|
+
function renderMap(el){
|
|
1075
|
+
var chips = Object.keys(areaColor).map(function(a){
|
|
1076
|
+
return '<span class="chip"><i style="background:'+areaColor[a]+'"></i>'+esc(a)+"</span>";
|
|
1077
|
+
}).join("");
|
|
1078
|
+
el.innerHTML =
|
|
1079
|
+
'<div class="panel">'
|
|
1080
|
+
+'<div class="hint">'+T.mapHint+"</div>"
|
|
1081
|
+
+'<div class="legend">'+esc(T.legend)+" "+chips
|
|
1082
|
+
+'<label style="margin-left:auto; display:inline-flex; align-items:center; gap:6px; cursor:pointer; color:#3f3f46; font-weight:600;">'
|
|
1083
|
+
+'<input type="checkbox" id="linkstoggle"'+(linksOn?" checked":"")+'/> '+esc(T.linksToggle)
|
|
1084
|
+
+'</label></div>'
|
|
1085
|
+
+'<div id="crumb"></div>'
|
|
1086
|
+
+'<canvas id="cv"></canvas>'
|
|
1087
|
+
+'<div id="info"></div>'
|
|
1088
|
+
+"</div>";
|
|
1089
|
+
wireCanvas();
|
|
1090
|
+
document.getElementById("linkstoggle").onchange = function(){ linksOn = this.checked; draw(); };
|
|
1091
|
+
renderCrumb();
|
|
1092
|
+
layout();
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function squarify(items, x, y, w, h, out){
|
|
1096
|
+
items = items.filter(function(c){ return c.loc > 0; });
|
|
1097
|
+
if (!items.length) return;
|
|
1098
|
+
var total = items.reduce(function(a,c){ return a+c.loc; },0);
|
|
1099
|
+
var i = 0;
|
|
1100
|
+
while (i < items.length){
|
|
1101
|
+
var row = [], rowSum = 0;
|
|
1102
|
+
var horiz = w >= h;
|
|
1103
|
+
var side = horiz ? h : w;
|
|
1104
|
+
var best = Infinity;
|
|
1105
|
+
for (var j=i; j<items.length; j++){
|
|
1106
|
+
var trySum = rowSum + items[j].loc;
|
|
1107
|
+
var tryRow = row.concat([items[j]]);
|
|
1108
|
+
var rowArea = trySum * ((w*h)/total);
|
|
1109
|
+
var thickness = rowArea / side;
|
|
1110
|
+
var worst = 0;
|
|
1111
|
+
tryRow.forEach(function(it){
|
|
1112
|
+
var len = (it.loc * ((w*h)/total)) / thickness;
|
|
1113
|
+
var ratio = Math.max(thickness/len, len/thickness);
|
|
1114
|
+
if (ratio > worst) worst = ratio;
|
|
1115
|
+
});
|
|
1116
|
+
if (worst <= best){ best = worst; row = tryRow; rowSum = trySum; }
|
|
1117
|
+
else break;
|
|
1118
|
+
}
|
|
1119
|
+
i += row.length;
|
|
1120
|
+
var rowArea2 = rowSum * ((w*h)/total);
|
|
1121
|
+
var thick = rowArea2 / side;
|
|
1122
|
+
var off = 0;
|
|
1123
|
+
row.forEach(function(it){
|
|
1124
|
+
var len = (it.loc * ((w*h)/total)) / thick;
|
|
1125
|
+
out.push({ node: it, x: horiz?x:x+off, y: horiz?y+off:y, w: horiz?thick:len, h: horiz?len:thick });
|
|
1126
|
+
off += len;
|
|
1127
|
+
});
|
|
1128
|
+
if (horiz){ x += thick; w -= thick; } else { y += thick; h -= thick; }
|
|
1129
|
+
total -= rowSum;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
var cv, ctx, tip;
|
|
1134
|
+
function wireCanvas(){
|
|
1135
|
+
cv = document.getElementById("cv");
|
|
1136
|
+
ctx = cv.getContext("2d");
|
|
1137
|
+
tip = document.getElementById("tip");
|
|
1138
|
+
cv.addEventListener("mousemove", function(ev){
|
|
1139
|
+
var c = cellAt(ev);
|
|
1140
|
+
if (!c){ tip.style.display = "none"; cv.style.cursor = "default"; return; }
|
|
1141
|
+
cv.style.cursor = "pointer";
|
|
1142
|
+
tip.style.display = "block";
|
|
1143
|
+
tip.style.left = Math.min(window.innerWidth-460, ev.clientX+14)+"px";
|
|
1144
|
+
tip.style.top = (ev.clientY+12)+"px";
|
|
1145
|
+
var fin = (importersOf[c.node.path]||[]).length, fout = (importsOf[c.node.path]||[]).length;
|
|
1146
|
+
tip.innerHTML = "<b>"+esc(c.node.path||c.node.name)+"</b><br>"+fmt(c.node.loc)+" LOC"
|
|
1147
|
+
+ (c.node.lang ? " · "+esc(c.node.lang) : "")
|
|
1148
|
+
+ (c.node.file ? "<br>fan-in "+fin+" · fan-out "+fout : "");
|
|
1149
|
+
});
|
|
1150
|
+
cv.addEventListener("mouseleave", function(){ tip.style.display = "none"; });
|
|
1151
|
+
cv.addEventListener("click", function(ev){
|
|
1152
|
+
var c = cellAt(ev);
|
|
1153
|
+
if (!c) return;
|
|
1154
|
+
if (c.node.file){ showFile(c.node.path); }
|
|
1155
|
+
else { rootNode = c.node; highlight = null; hideInfo(); renderCrumb(); layout(); }
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function layout(){
|
|
1160
|
+
if (!cv) return;
|
|
1161
|
+
var r = cv.getBoundingClientRect();
|
|
1162
|
+
cv.width = r.width * devicePixelRatio;
|
|
1163
|
+
cv.height = r.height * devicePixelRatio;
|
|
1164
|
+
ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0);
|
|
1165
|
+
cells = [];
|
|
1166
|
+
squarify(rootNode.children||[], 0, 0, r.width, r.height, cells);
|
|
1167
|
+
var sub = [];
|
|
1168
|
+
cells.forEach(function(c){
|
|
1169
|
+
if (!c.node.file && c.w > 80 && c.h > 52){
|
|
1170
|
+
var inner = [];
|
|
1171
|
+
squarify(c.node.children||[], c.x+4, c.y+20, c.w-8, c.h-24, inner);
|
|
1172
|
+
inner.forEach(function(s){ s.parent = c; });
|
|
1173
|
+
sub = sub.concat(inner);
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
cells = cells.concat(sub);
|
|
1177
|
+
// indice path → cella visibile: i parent prima, le sub-celle dopo (sovrascrivono)
|
|
1178
|
+
cellByPath = {};
|
|
1179
|
+
cells.forEach(function(c){ cellByPath[c.node.path] = c; });
|
|
1180
|
+
draw();
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Cella visibile che contiene il path (risale finché trova un antenato renderizzato).
|
|
1184
|
+
function visibleCellFor(p){
|
|
1185
|
+
var parts = p.split("/");
|
|
1186
|
+
for (var i = parts.length; i >= 1; i--){
|
|
1187
|
+
var c = cellByPath[parts.slice(0, i).join("/")];
|
|
1188
|
+
if (c) return c;
|
|
1189
|
+
}
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
function centerOf(c){ return [c.x + c.w/2, c.y + c.h/2]; }
|
|
1193
|
+
function darken(hex, f){ var n=parseInt(hex.slice(1),16),r=(n>>16)&255,g=(n>>8)&255,b=n&255;
|
|
1194
|
+
return "rgb("+Math.round(r*(1-f))+","+Math.round(g*(1-f))+","+Math.round(b*(1-f))+")"; }
|
|
1195
|
+
|
|
1196
|
+
// Fascio curvo con freccia: il "circuito" tra due blocchi.
|
|
1197
|
+
function drawBeam(a, b, width, color, alpha){
|
|
1198
|
+
var ax=a[0], ay=a[1], bx=b[0], by=b[1];
|
|
1199
|
+
var mx=(ax+bx)/2, my=(ay+by)/2;
|
|
1200
|
+
var dx=bx-ax, dy=by-ay;
|
|
1201
|
+
var len=Math.sqrt(dx*dx+dy*dy)||1;
|
|
1202
|
+
var bend=Math.min(90, len*0.22);
|
|
1203
|
+
var cxp=mx - dy/len*bend, cyp=my + dx/len*bend;
|
|
1204
|
+
ctx.globalAlpha = alpha;
|
|
1205
|
+
ctx.strokeStyle = color;
|
|
1206
|
+
ctx.lineWidth = width;
|
|
1207
|
+
ctx.lineCap = "round";
|
|
1208
|
+
ctx.beginPath();
|
|
1209
|
+
ctx.moveTo(ax, ay);
|
|
1210
|
+
ctx.quadraticCurveTo(cxp, cyp, bx, by);
|
|
1211
|
+
ctx.stroke();
|
|
1212
|
+
var adx=bx-cxp, ady=by-cyp, al=Math.sqrt(adx*adx+ady*ady)||1;
|
|
1213
|
+
adx/=al; ady/=al;
|
|
1214
|
+
ctx.beginPath();
|
|
1215
|
+
ctx.moveTo(bx, by);
|
|
1216
|
+
ctx.lineTo(bx - adx*8 - ady*4, by - ady*8 + adx*4);
|
|
1217
|
+
ctx.lineTo(bx - adx*8 + ady*4, by - ady*8 - adx*4);
|
|
1218
|
+
ctx.closePath();
|
|
1219
|
+
ctx.fillStyle = color;
|
|
1220
|
+
ctx.fill();
|
|
1221
|
+
ctx.globalAlpha = 1;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Overlay aggregato: gli edge file→file vengono proiettati sulle celle VISIBILI
|
|
1225
|
+
// al livello di zoom corrente (cartella↔cartella da lontano, file↔file da vicino).
|
|
1226
|
+
function drawAggregatedLinks(){
|
|
1227
|
+
var agg = {};
|
|
1228
|
+
DATA.edges.forEach(function(e){
|
|
1229
|
+
var ca = visibleCellFor(e[0]), cb = visibleCellFor(e[1]);
|
|
1230
|
+
if (!ca || !cb || ca === cb) return;
|
|
1231
|
+
var k = (ca.node.path||"·") + "|" + (cb.node.path||"·");
|
|
1232
|
+
if (!agg[k]) agg[k] = { a: ca, b: cb, n: 0 };
|
|
1233
|
+
agg[k].n++;
|
|
1234
|
+
});
|
|
1235
|
+
var list = Object.keys(agg).map(function(k){ return agg[k]; })
|
|
1236
|
+
.sort(function(x,y){ return y.n-x.n; }).slice(0, 140);
|
|
1237
|
+
list.forEach(function(l){
|
|
1238
|
+
var col = darken(colorFor(l.a.node.path || l.a.node.name), 0.25);
|
|
1239
|
+
drawBeam(centerOf(l.a), centerOf(l.b),
|
|
1240
|
+
Math.min(4, 0.6 + Math.log2(l.n+1)*0.7), col,
|
|
1241
|
+
Math.min(0.45, 0.10 + 0.05*l.n));
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Link del file selezionato: blu = ciò che importa, rosa = chi lo importa.
|
|
1246
|
+
function drawHighlightLinks(){
|
|
1247
|
+
var selfCell = visibleCellFor(highlight.self);
|
|
1248
|
+
if (!selfCell) return;
|
|
1249
|
+
highlight.imports.forEach(function(p){
|
|
1250
|
+
var c = visibleCellFor(p);
|
|
1251
|
+
if (c && c !== selfCell) drawBeam(centerOf(selfCell), centerOf(c), 2, "#2563eb", 0.75);
|
|
1252
|
+
});
|
|
1253
|
+
highlight.importers.forEach(function(p){
|
|
1254
|
+
var c = visibleCellFor(p);
|
|
1255
|
+
if (c && c !== selfCell) drawBeam(centerOf(c), centerOf(selfCell), 2, "#db2777", 0.75);
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function draw(){
|
|
1260
|
+
var r = cv.getBoundingClientRect();
|
|
1261
|
+
ctx.clearRect(0,0,r.width,r.height);
|
|
1262
|
+
cells.forEach(function(c){
|
|
1263
|
+
var base = colorFor(c.node.path || c.node.name);
|
|
1264
|
+
var isFile = !!c.node.file;
|
|
1265
|
+
var fill = c.parent ? tint(base, isFile ? 0.55 : 0.72) : tint(base, isFile ? 0.45 : 0.82);
|
|
1266
|
+
if (highlight){
|
|
1267
|
+
if (highlight.self === c.node.path) fill = "#fde047";
|
|
1268
|
+
else if (highlight.imports.indexOf(c.node.path) >= 0) fill = "#93c5fd";
|
|
1269
|
+
else if (highlight.importers.indexOf(c.node.path) >= 0) fill = "#f9a8d4";
|
|
1270
|
+
}
|
|
1271
|
+
ctx.fillStyle = fill;
|
|
1272
|
+
ctx.fillRect(c.x+0.5, c.y+0.5, Math.max(0,c.w-1), Math.max(0,c.h-1));
|
|
1273
|
+
ctx.strokeStyle = "#ffffff";
|
|
1274
|
+
ctx.lineWidth = 1;
|
|
1275
|
+
ctx.strokeRect(c.x+0.5, c.y+0.5, Math.max(0,c.w-1), Math.max(0,c.h-1));
|
|
1276
|
+
if (c.w > 58 && c.h > 16){
|
|
1277
|
+
ctx.fillStyle = "#3f3f46";
|
|
1278
|
+
ctx.font = (c.parent ? "11px" : "600 12px") + " system-ui,sans-serif";
|
|
1279
|
+
var label = c.node.name + (c.node.file ? "" : "/");
|
|
1280
|
+
ctx.save();
|
|
1281
|
+
ctx.beginPath(); ctx.rect(c.x+3, c.y+2, c.w-8, 15); ctx.clip();
|
|
1282
|
+
ctx.fillText(label, c.x+6, c.y+13);
|
|
1283
|
+
ctx.restore();
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
// Overlay "circuiti": file selezionato → solo i suoi link; altrimenti aggregato.
|
|
1287
|
+
if (highlight) drawHighlightLinks();
|
|
1288
|
+
else if (linksOn) drawAggregatedLinks();
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function cellAt(ev){
|
|
1292
|
+
var r = cv.getBoundingClientRect();
|
|
1293
|
+
var x = ev.clientX - r.left, y = ev.clientY - r.top;
|
|
1294
|
+
for (var i = cells.length-1; i >= 0; i--){
|
|
1295
|
+
var c = cells[i];
|
|
1296
|
+
if (x >= c.x && x <= c.x+c.w && y >= c.y && y <= c.y+c.h) return c;
|
|
1297
|
+
}
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function renderCrumb(){
|
|
1302
|
+
var el = document.getElementById("crumb");
|
|
1303
|
+
if (!el) return;
|
|
1304
|
+
var parts = (rootNode.path||"").split("/").filter(Boolean);
|
|
1305
|
+
var h = '<span data-p="">'+esc(DATA.meta.repo)+"</span>";
|
|
1306
|
+
var acc = "";
|
|
1307
|
+
parts.forEach(function(p){ acc += (acc?"/":"")+p; h += " / " + '<span data-p="'+esc(acc)+'">'+esc(p)+"</span>"; });
|
|
1308
|
+
el.innerHTML = h;
|
|
1309
|
+
el.querySelectorAll("span").forEach(function(s){
|
|
1310
|
+
s.onclick = function(){ rootNode = nodeByPath[s.getAttribute("data-p")] || DATA.tree; highlight = null; hideInfo(); renderCrumb(); layout(); };
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function hideInfo(){ var i = document.getElementById("info"); if (i) i.style.display = "none"; }
|
|
1315
|
+
|
|
1316
|
+
function showFile(p){
|
|
1317
|
+
var info = document.getElementById("info");
|
|
1318
|
+
if (!info) return;
|
|
1319
|
+
var fin = importersOf[p] || [], fout = importsOf[p] || [];
|
|
1320
|
+
highlight = { self: p, imports: fout, importers: fin };
|
|
1321
|
+
var h = "<b>"+esc(p)+"</b> — "+ fmt(nodeByPath[p] ? nodeByPath[p].loc : 0) +" LOC"
|
|
1322
|
+
+ ' · <a href="'+esc(flink(p))+'">'+esc(T.openFile)+"</a>";
|
|
1323
|
+
if (fout.length) h += "<br><span class='muted'>"+esc(T.imports)+" ("+fout.length+"):</span> " + fout.slice(0,12).map(function(x){ return '<span class="lnk" data-p="'+esc(x)+'">'+esc(x)+"</span>"; }).join(", ");
|
|
1324
|
+
if (fin.length) h += "<br><span class='muted'>"+esc(T.importedBy)+" ("+fin.length+"):</span> " + fin.slice(0,12).map(function(x){ return '<span class="lnk" data-p="'+esc(x)+'">'+esc(x)+"</span>"; }).join(", ");
|
|
1325
|
+
h += ' <span class="lnk" id="clearhl">'+esc(T.close)+"</span>";
|
|
1326
|
+
info.innerHTML = h;
|
|
1327
|
+
info.style.display = "block";
|
|
1328
|
+
info.querySelectorAll(".lnk[data-p]").forEach(function(s){ s.onclick = function(){ locate(s.getAttribute("data-p")); }; });
|
|
1329
|
+
document.getElementById("clearhl").onclick = function(){ highlight = null; hideInfo(); layout(); };
|
|
1330
|
+
renderCrumb();
|
|
1331
|
+
layout();
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function locate(p){
|
|
1335
|
+
if (current !== "map"){ current = "map"; renderTabs(); renderContent(); }
|
|
1336
|
+
var parent = p.split("/").slice(0,-1).join("/");
|
|
1337
|
+
var n = nodeByPath[parent];
|
|
1338
|
+
if (n) rootNode = n;
|
|
1339
|
+
showFile(p);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ── catalogs ──
|
|
1343
|
+
var sortState = {};
|
|
1344
|
+
function renderCatalog(el, key){
|
|
1345
|
+
var isFiles = key === "__files";
|
|
1346
|
+
var cat = isFiles
|
|
1347
|
+
? { title: T.filesTab, columns: ["file","loc","lang"], rows: DATA.files.map(function(f){ return { file: f.p, loc: f.loc, lang: f.l, link: flink(f.p) }; }) }
|
|
1348
|
+
: DATA.catalogs[key];
|
|
1349
|
+
if (!cat) return;
|
|
1350
|
+
var cols = cat.columns.filter(function(c){ return c !== "line"; });
|
|
1351
|
+
el.innerHTML = '<div class="panel">'
|
|
1352
|
+
+'<input id="search" placeholder="'+esc(T.searchPh)+'"/>'
|
|
1353
|
+
+'<div id="tablebox"></div>'
|
|
1354
|
+
+"</div>";
|
|
1355
|
+
var st = sortState[key] = sortState[key] || { col: null, asc: true };
|
|
1356
|
+
var searchEl = document.getElementById("search");
|
|
1357
|
+
|
|
1358
|
+
function rowsFiltered(){
|
|
1359
|
+
var q = (searchEl.value||"").toLowerCase();
|
|
1360
|
+
var rows = cat.rows.filter(function(r){
|
|
1361
|
+
if (!q) return true;
|
|
1362
|
+
return cols.some(function(c){ return String(r[c]||"").toLowerCase().indexOf(q) >= 0; });
|
|
1363
|
+
});
|
|
1364
|
+
if (st.col){
|
|
1365
|
+
rows = rows.slice().sort(function(a,b){
|
|
1366
|
+
var x = a[st.col], y = b[st.col];
|
|
1367
|
+
if (typeof x === "number" && typeof y === "number") return st.asc ? x-y : y-x;
|
|
1368
|
+
return st.asc ? String(x||"").localeCompare(String(y||"")) : String(y||"").localeCompare(String(x||""));
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
return rows;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function renderTable(){
|
|
1375
|
+
var rows = rowsFiltered();
|
|
1376
|
+
var thead = "<tr>" + cols.map(function(c){
|
|
1377
|
+
var arrow = st.col === c ? ' <span class="arrow">'+(st.asc?"▲":"▼")+"</span>" : "";
|
|
1378
|
+
return '<th data-c="'+esc(c)+'">'+esc(c.replace(/_/g," "))+arrow+"</th>";
|
|
1379
|
+
}).join("") + "</tr>";
|
|
1380
|
+
var body = rows.slice(0, 800).map(function(r){
|
|
1381
|
+
return "<tr>" + cols.map(function(c, ci){
|
|
1382
|
+
var v = r[c];
|
|
1383
|
+
if (c === "file" && r.link){
|
|
1384
|
+
var label = r.line ? r.file + ":" + r.line : r.file;
|
|
1385
|
+
return '<td><a class="filelnk" href="'+esc(r.link)+'">'+esc(label)+"</a></td>";
|
|
1386
|
+
}
|
|
1387
|
+
if (c === "page" && r.link) return '<td class="first"><a href="'+esc(r.link)+'">'+esc(v)+"</a></td>";
|
|
1388
|
+
if (c === "method"){
|
|
1389
|
+
var cls = v === "?" ? "badge q" : "badge";
|
|
1390
|
+
return '<td><span class="'+cls+'">'+esc(v)+"</span></td>";
|
|
1391
|
+
}
|
|
1392
|
+
return "<td"+(ci===0?' class="first"':"")+">"+esc(v)+"</td>";
|
|
1393
|
+
}).join("") + "</tr>";
|
|
1394
|
+
}).join("");
|
|
1395
|
+
var foot = "";
|
|
1396
|
+
if (!rows.length) foot = '<tr><td colspan="'+cols.length+'" class="muted">'+esc(T.noResults)+' "'+esc(searchEl.value)+'"</td></tr>';
|
|
1397
|
+
else if (rows.length > 800) foot = '<tr><td colspan="'+cols.length+'" class="muted">+'+fmt(rows.length-800)+" "+esc(T.rows)+" — "+esc(T.refine)+"</td></tr>";
|
|
1398
|
+
document.getElementById("tablebox").innerHTML = "<table><thead>"+thead+"</thead><tbody>"+body+foot+"</tbody></table>";
|
|
1399
|
+
document.querySelectorAll("th[data-c]").forEach(function(th){
|
|
1400
|
+
th.onclick = function(){
|
|
1401
|
+
var c = th.getAttribute("data-c");
|
|
1402
|
+
if (st.col === c) st.asc = !st.asc; else { st.col = c; st.asc = true; }
|
|
1403
|
+
renderTable();
|
|
1404
|
+
};
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
searchEl.addEventListener("input", renderTable);
|
|
1408
|
+
renderTable();
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
window.addEventListener("resize", function(){ if (current === "map") layout(); });
|
|
1412
|
+
renderTabs();
|
|
1413
|
+
renderContent();
|
|
1414
|
+
</script>
|
|
1415
|
+
</body>
|
|
1416
|
+
</html>`;
|
|
1417
|
+
return TEMPLATE
|
|
1418
|
+
.replace("__DATA__", dataJson)
|
|
1419
|
+
.replace("__I18N__", JSON.stringify(I18N))
|
|
1420
|
+
.replace(/__REPO__/g, repoName.replace(/[<>&]/g, ""));
|
|
1421
|
+
}
|