universal-ast-mapper 1.28.0 → 2.0.1

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/dist/html.js CHANGED
@@ -11,33 +11,51 @@ const KIND_COLORS = {
11
11
  field: "#64748b",
12
12
  namespace: "#9333ea",
13
13
  };
14
+ const LANG_COLOR = {
15
+ typescript: "#3178c6", javascript: "#f7df1e", python: "#3572a5",
16
+ go: "#00acd7", rust: "#dea584", java: "#b07219", "c++": "#f34b7d",
17
+ c: "#555555", csharp: "#239120", kotlin: "#a97bff", swift: "#f05138",
18
+ tsx: "#3178c6", jsx: "#f7df1e",
19
+ };
14
20
  function esc(s) {
15
- return s
16
- .replace(/&/g, "&")
17
- .replace(/</g, "&lt;")
18
- .replace(/>/g, "&gt;")
19
- .replace(/"/g, "&quot;");
21
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20
22
  }
21
23
  function badge(kind) {
22
24
  const color = KIND_COLORS[kind] ?? "#64748b";
23
- return `<span class="badge" style="background:${color}1a;color:${color};border:1px solid ${color}55;">${kind}</span>`;
25
+ return `<span class="badge" style="background:${color}1a;color:${color};border:1px solid ${color}44;" data-kind="${kind}">${kind}</span>`;
26
+ }
27
+ function langDot(lang) {
28
+ const color = LANG_COLOR[lang] ?? "#94a3b8";
29
+ return `<span class="lang-dot" style="background:${color}" title="${esc(lang)}"></span>`;
24
30
  }
25
- function renderSymbol(sym) {
31
+ function renderSymbol(sym, depth = 0) {
26
32
  const vis = sym.visibility === "private"
27
- ? `<span class="vis priv" title="private">private</span>`
28
- : "";
29
- const exported = sym.exported ? `<span class="vis exp" title="exported">export</span>` : "";
33
+ ? `<span class="vis priv" title="private">pvt</span>` : "";
34
+ const exported = sym.exported
35
+ ? `<span class="vis exp" title="exported">exp</span>` : "";
30
36
  const sig = sym.signature
31
- ? `<code class="sig">${esc(sym.signature)}</code>`
32
- : "";
37
+ ? `<code class="sig" title="${esc(sym.signature)}">${esc(sym.signature)}</code>` : "";
33
38
  const lines = `<span class="lines">L${sym.range.startLine}–${sym.range.endLine}</span>`;
34
39
  const doc = sym.doc ? `<div class="doc">${esc(sym.doc)}</div>` : "";
35
- const head = `${badge(sym.kind)}<span class="name">${esc(sym.name)}</span>${exported}${vis}${sig}${lines}`;
40
+ const copyBtn = `<button class="copy-btn" title="Copy name" onclick="event.stopPropagation();copyText('${esc(sym.name)}',this)">⎘</button>`;
41
+ const head = `${badge(sym.kind)}<span class="name">${esc(sym.name)}</span>${exported}${vis}${sig}${lines}${copyBtn}`;
36
42
  if (sym.children.length > 0) {
37
- const kids = sym.children.map(renderSymbol).join("");
38
- return `<details open class="node"><summary>${head}</summary>${doc}<div class="children">${kids}</div></details>`;
43
+ const kids = sym.children.map((c) => renderSymbol(c, depth + 1)).join("");
44
+ return `<details open class="node" data-kind="${sym.kind}"><summary>${head}</summary>${doc}<div class="children">${kids}</div></details>`;
39
45
  }
40
- return `<div class="node leaf">${head}${doc}</div>`;
46
+ return `<div class="node leaf" data-kind="${sym.kind}">${head}${doc}</div>`;
47
+ }
48
+ function collectAllKinds(symbols) {
49
+ const counts = new Map();
50
+ const walk = (syms) => {
51
+ for (const s of syms) {
52
+ counts.set(s.kind, (counts.get(s.kind) ?? 0) + 1);
53
+ if (s.children.length)
54
+ walk(s.children);
55
+ }
56
+ };
57
+ walk(symbols);
58
+ return counts;
41
59
  }
42
60
  function collectSymbolNames(symbols) {
43
61
  const names = [];
@@ -50,21 +68,40 @@ function collectSymbolNames(symbols) {
50
68
  }
51
69
  function renderFileSection(skel, index) {
52
70
  const body = skel.symbols.length > 0
53
- ? skel.symbols.map(renderSymbol).join("")
71
+ ? skel.symbols.map((s) => renderSymbol(s)).join("")
54
72
  : `<p class="empty">No top-level symbols found.</p>`;
55
- return `<details id="file-${index}" class="file-section" open>
56
- <summary class="file-summary">
57
- <span class="fs-path">${esc(skel.file)}</span>
58
- <span class="fs-meta">${esc(skel.language)} &middot; ${skel.symbolCount} symbols &middot; <time>${esc(skel.generatedAt)}</time></span>
59
- </summary>
60
- <div class="fs-body"><div class="tree">${body}</div></div>
61
- </details>`;
73
+ const lc = LANG_COLOR[skel.language] ?? "#94a3b8";
74
+ return `<section id="file-${index}" class="file-section">
75
+ <div class="file-header" onclick="toggleSection(${index})">
76
+ <span class="toggle-icon" id="tog-${index}">▾</span>
77
+ <span class="fs-path">${esc(skel.file)}</span>
78
+ <span class="fs-lang" style="background:${lc}22;color:${lc};border:1px solid ${lc}44">${esc(skel.language)}</span>
79
+ <span class="fs-count">${skel.symbolCount} symbols</span>
80
+ </div>
81
+ <div class="fs-body" id="fsbody-${index}"><div class="tree">${body}</div></div>
82
+ </section>`;
62
83
  }
63
84
  export function renderCombinedHtml(skeletons) {
64
85
  const sections = skeletons.map((s, i) => renderFileSection(s, i)).join("\n");
65
86
  const totalSymbols = skeletons.reduce((n, s) => n + s.symbolCount, 0);
66
87
  const generatedAt = new Date().toISOString();
67
- // Compact per-file data for client-side search and tree rendering.
88
+ // Count all kinds globally
89
+ const allKindCounts = new Map();
90
+ for (const skel of skeletons) {
91
+ const kc = collectAllKinds(skel.symbols);
92
+ for (const [k, v] of kc)
93
+ allKindCounts.set(k, (allKindCounts.get(k) ?? 0) + v);
94
+ }
95
+ const sortedKinds = [...allKindCounts.entries()].sort((a, b) => b[1] - a[1]);
96
+ // Language distribution
97
+ const langCounts = new Map();
98
+ for (const skel of skeletons)
99
+ langCounts.set(skel.language, (langCounts.get(skel.language) ?? 0) + 1);
100
+ const sortedLangs = [...langCounts.entries()].sort((a, b) => b[1] - a[1]);
101
+ const kindPills = sortedKinds.map(([k]) => {
102
+ const color = KIND_COLORS[k] ?? "#64748b";
103
+ return `<button class="kind-pill" data-kind="${k}" onclick="toggleKind('${k}',this)" style="--kc:${color}">${k}</button>`;
104
+ }).join("");
68
105
  const fileData = JSON.stringify(skeletons.map((s, i) => ({
69
106
  id: i,
70
107
  file: s.file,
@@ -72,255 +109,550 @@ export function renderCombinedHtml(skeletons) {
72
109
  n: s.symbolCount,
73
110
  syms: collectSymbolNames(s.symbols).join(" "),
74
111
  })));
75
- return `<!doctype html>
76
- <html lang="en">
77
- <head>
78
- <meta charset="utf-8">
79
- <meta name="viewport" content="width=device-width,initial-scale=1">
80
- <title>Codebase Skeleton (${skeletons.length} files)</title>
81
- <style>
82
- :root{color-scheme:light dark;--bg:#f8fafc;--bg2:#fff;--fg:#0f172a;--fg2:#475569;--bdr:#e2e8f0;--hover:#f1f5f9;--sb-bg:#fff;--sb-w:260px;--accent:#6366f1;}
83
- @media(prefers-color-scheme:dark){:root{--bg:#0b1120;--bg2:#111827;--fg:#e2e8f0;--fg2:#94a3b8;--bdr:#1f2937;--hover:#111827;--sb-bg:#0f172a;}}
84
- *{box-sizing:border-box;margin:0;padding:0;}
85
- body{font:13px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--fg);display:flex;flex-direction:column;height:100vh;overflow:hidden;}
86
- /* topbar */
87
- .topbar{display:flex;align-items:center;gap:12px;padding:8px 16px;background:var(--bg2);border-bottom:1px solid var(--bdr);flex-shrink:0;flex-wrap:wrap;}
88
- .topbar-title{font-weight:700;font-size:14px;}
89
- .topbar-meta{font-size:12px;color:var(--fg2);flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
90
- .topbar-actions{display:flex;gap:6px;flex-shrink:0;}
91
- button{font:inherit;cursor:pointer;border:1px solid var(--bdr);background:transparent;color:inherit;border-radius:8px;padding:3px 10px;font-size:12px;}
92
- button:hover{background:var(--hover);}
93
- /* layout */
94
- .layout{display:flex;flex:1;min-height:0;}
95
- /* sidebar */
96
- .sidebar{width:var(--sb-w);flex-shrink:0;background:var(--sb-bg);border-right:1px solid var(--bdr);display:flex;flex-direction:column;overflow:hidden;}
97
- .search-wrap{padding:8px;border-bottom:1px solid var(--bdr);}
98
- #search{width:100%;font:inherit;font-size:12px;padding:5px 8px;border:1px solid var(--bdr);border-radius:8px;background:var(--bg);color:var(--fg);outline:none;}
99
- #search:focus{border-color:var(--accent);}
100
- .nav-tree{flex:1;overflow-y:auto;padding:6px 4px;}
101
- /* nav tree nodes */
102
- .dir-node{margin:1px 0;}
103
- .dir-node>summary{list-style:none;cursor:pointer;padding:3px 6px;border-radius:6px;font-size:12px;font-weight:600;color:var(--fg2);display:flex;align-items:center;gap:4px;user-select:none;}
104
- .dir-node>summary::-webkit-details-marker{display:none;}
105
- .dir-node>summary::before{content:"\\25B8";font-size:10px;opacity:.5;transition:transform .12s;flex-shrink:0;}
106
- .dir-node[open]>summary::before{transform:rotate(90deg);}
107
- .dir-node>summary:hover{background:var(--hover);}
108
- .dir-children{padding-left:12px;}
109
- a.file-link{display:flex;align-items:center;justify-content:space-between;padding:3px 8px;border-radius:6px;text-decoration:none;color:var(--fg);font-size:12px;cursor:pointer;gap:4px;}
110
- a.file-link:hover{background:var(--hover);}
111
- a.file-link.active{background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent);}
112
- .fname{font-family:ui-monospace,monospace;font-size:11px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
113
- .fmeta{font-size:10px;color:var(--fg2);flex-shrink:0;}
114
- /* main panel */
115
- .main-panel{flex:1;overflow-y:auto;padding:16px;}
116
- /* file sections */
117
- details.file-section{border:1px solid var(--bdr);border-radius:12px;margin-bottom:14px;background:var(--bg2);}
118
- summary.file-summary{list-style:none;cursor:pointer;padding:10px 14px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;border-radius:12px;}
119
- summary.file-summary::-webkit-details-marker{display:none;}
120
- summary.file-summary::before{content:"\\25B8";opacity:.4;transition:transform .15s;flex-shrink:0;}
121
- details.file-section[open]>summary.file-summary::before{transform:rotate(90deg);}
122
- summary.file-summary:hover{background:var(--hover);}
123
- .fs-path{font-family:ui-monospace,monospace;font-weight:700;font-size:13px;}
124
- .fs-meta{font-size:11px;color:var(--fg2);margin-left:auto;}
125
- .fs-body{padding:8px 14px 14px;}
126
- /* symbol tree (reused styles) */
127
- .tree{display:flex;flex-direction:column;gap:4px;}
128
- details.node{border:1px solid var(--bdr);border-radius:10px;padding:2px 4px;}
129
- summary{list-style:none;cursor:pointer;padding:6px 8px;border-radius:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
130
- summary::-webkit-details-marker{display:none;}
131
- summary::before{content:"\\25B8";opacity:.5;transition:transform .15s;}
132
- details[open]>summary::before{transform:rotate(90deg);}
133
- .leaf{padding:6px 8px 6px 24px;border-radius:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
134
- summary:hover,.leaf:hover{background:var(--hover);}
135
- .children{margin:2px 0 6px 18px;padding-left:10px;border-left:2px solid #e2e8f033;display:flex;flex-direction:column;gap:4px;}
136
- .badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;text-transform:uppercase;letter-spacing:.03em;}
137
- .name{font-family:ui-monospace,monospace;font-weight:600;}
138
- .vis{font-size:10px;padding:1px 6px;border-radius:6px;}
139
- .vis.priv{background:#ef44441a;color:#ef4444;}
140
- .vis.exp{background:#22c55e1a;color:#16a34a;}
141
- .sig{font-family:ui-monospace,monospace;font-size:12px;background:var(--hover);padding:1px 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
142
- .lines{font-size:11px;opacity:.55;margin-left:auto;font-family:ui-monospace,monospace;}
143
- .doc{font-size:12px;opacity:.8;margin:2px 0 6px 28px;white-space:pre-wrap;font-style:italic;}
144
- .empty{opacity:.6;}
145
- .no-match{padding:40px;text-align:center;color:var(--fg2);font-size:13px;}
146
- </style>
147
- </head>
148
- <body>
149
- <header class="topbar">
150
- <span class="topbar-title">Codebase Skeleton</span>
151
- <span class="topbar-meta">${skeletons.length} files &middot; ${totalSymbols} symbols &middot; ${esc(generatedAt)}</span>
152
- <div class="topbar-actions">
153
- <button id="btn-expand">Expand all</button>
154
- <button id="btn-collapse">Collapse all</button>
155
- </div>
156
- </header>
157
- <div class="layout">
158
- <nav class="sidebar">
159
- <div class="search-wrap">
160
- <input id="search" type="search" placeholder="Search files or symbols…" autocomplete="off" spellcheck="false">
161
- </div>
162
- <div id="nav-tree" class="nav-tree"></div>
163
- </nav>
164
- <main id="main" class="main-panel">
165
- ${sections}
166
- <div id="no-match" class="no-match" style="display:none">No matching files or symbols.</div>
167
- </main>
168
- </div>
169
- <script>
170
- (function(){
171
- const FILES=${fileData};
172
-
173
- // ── folder tree ──────────────────────────────────────────────────────────────
174
- function buildTreeData(files){
175
- const root={dirs:{},files:[]};
176
- for(const f of files){
177
- const parts=f.file.split('/');
178
- let node=root;
179
- for(let i=0;i<parts.length-1;i++){
180
- if(!node.dirs[parts[i]])node.dirs[parts[i]]={dirs:{},files:[]};
181
- node=node.dirs[parts[i]];
182
- }
183
- node.files.push(f);
184
- }
185
- return root;
186
- }
187
-
188
- function e(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
189
-
190
- const linkMap=new Map(); // id -> <a>
191
- function renderTreeNode(node,container){
192
- const dirs=Object.entries(node.dirs).sort(([a],[b])=>a.localeCompare(b));
193
- for(const[name,child]of dirs){
194
- const det=document.createElement('details');
195
- det.className='dir-node';
196
- det.open=true;
197
- const sum=document.createElement('summary');
198
- sum.textContent=name;
199
- det.appendChild(sum);
200
- const inner=document.createElement('div');
201
- inner.className='dir-children';
202
- renderTreeNode(child,inner);
203
- det.appendChild(inner);
204
- container.appendChild(det);
205
- }
206
- for(const f of node.files){
207
- const a=document.createElement('a');
208
- a.href='#file-'+f.id;
209
- a.className='file-link';
210
- a.dataset.id=String(f.id);
211
- const fname=f.file.split('/').pop()||f.file;
212
- a.innerHTML='<span class="fname">'+e(fname)+'</span><span class="fmeta">'+f.n+'</span>';
213
- container.appendChild(a);
214
- linkMap.set(f.id,a);
215
- }
216
- }
217
-
218
- const navTree=document.getElementById('nav-tree');
219
- renderTreeNode(buildTreeData(FILES),navTree);
220
-
221
- // ── search ───────────────────────────────────────────────────────────────────
222
- const searchEl=document.getElementById('search');
223
- const mainEl=document.getElementById('main');
224
- const noMatch=document.getElementById('no-match');
225
- const sections=Array.from(document.querySelectorAll('details.file-section'));
226
-
227
- searchEl.addEventListener('input',applyFilter);
228
- function applyFilter(){
229
- const q=searchEl.value.trim().toLowerCase();
230
- let visCount=0;
231
- FILES.forEach(f=>{
232
- const sec=document.getElementById('file-'+f.id);
233
- if(!sec)return;
234
- const match=!q||f.file.toLowerCase().includes(q)||f.syms.toLowerCase().includes(q);
235
- sec.style.display=match?'':'none';
236
- const link=linkMap.get(f.id);
237
- if(link)link.style.display=match?'':'none';
238
- if(match)visCount++;
239
- });
240
- noMatch.style.display=visCount===0&&q?'':'none';
241
- }
242
-
243
- // ── expand / collapse ────────────────────────────────────────────────────────
244
- document.getElementById('btn-expand').addEventListener('click',()=>{
245
- document.querySelectorAll('details').forEach(d=>d.open=true);
246
- });
247
- document.getElementById('btn-collapse').addEventListener('click',()=>{
248
- document.querySelectorAll('details').forEach(d=>d.open=false);
249
- });
250
-
251
- // ── active sidebar link on scroll ────────────────────────────────────────────
252
- const io=new IntersectionObserver(entries=>{
253
- for(const en of entries){
254
- if(en.isIntersecting){
255
- const idx=parseInt(en.target.id.replace('file-',''),10);
256
- linkMap.forEach((a,id)=>a.classList.toggle('active',id===idx));
257
- }
258
- }
259
- },{root:mainEl,threshold:0.15});
260
- sections.forEach(s=>io.observe(s));
261
- })();
262
- </script>
263
- </body>
112
+ const kindData = JSON.stringify(Object.fromEntries(allKindCounts));
113
+ const langData = JSON.stringify(sortedLangs);
114
+ return `<!doctype html>
115
+ <html lang="en">
116
+ <head>
117
+ <meta charset="utf-8">
118
+ <meta name="viewport" content="width=device-width,initial-scale=1">
119
+ <title>AST Map — ${skeletons.length} files · ${totalSymbols} symbols</title>
120
+ <style>
121
+ :root{
122
+ color-scheme:light dark;
123
+ --bg:#f6f8fa;--bg2:#fff;--fg:#0f172a;--fg2:#64748b;--bdr:#e2e8f0;
124
+ --hover:#f1f5f9;--sb-bg:#fff;--sb-w:272px;--accent:#6366f1;
125
+ --accent2:#8b5cf6;--topbar-h:52px;--filter-h:40px;
126
+ --shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
127
+ }
128
+ @media(prefers-color-scheme:dark){
129
+ :root{
130
+ --bg:#0d1117;--bg2:#161b22;--fg:#e6edf3;--fg2:#7d8590;--bdr:#21262d;
131
+ --hover:#1c2128;--sb-bg:#13181f;
132
+ --shadow:0 1px 3px rgba(0,0,0,.3),0 1px 2px rgba(0,0,0,.2);
133
+ }
134
+ }
135
+ *{box-sizing:border-box;margin:0;padding:0;}
136
+ body{font:13px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--fg);display:flex;flex-direction:column;height:100vh;overflow:hidden;}
137
+
138
+ /* ── Topbar ──────────────────────────────────────────────── */
139
+ .topbar{
140
+ display:flex;align-items:center;gap:10px;padding:0 16px;
141
+ height:var(--topbar-h);background:var(--bg2);
142
+ border-bottom:1px solid var(--bdr);flex-shrink:0;z-index:10;
143
+ box-shadow:var(--shadow);
144
+ }
145
+ .topbar-logo{display:flex;align-items:center;gap:7px;font-weight:700;font-size:14px;color:var(--accent);flex-shrink:0;}
146
+ .topbar-logo svg{opacity:.85;}
147
+ .topbar-sep{width:1px;height:20px;background:var(--bdr);flex-shrink:0;}
148
+ .topbar-meta{font-size:12px;color:var(--fg2);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
149
+ .topbar-actions{display:flex;gap:6px;flex-shrink:0;}
150
+ .btn{font:inherit;cursor:pointer;border:1px solid var(--bdr);background:transparent;color:inherit;border-radius:7px;padding:4px 11px;font-size:12px;transition:background .12s,border-color .12s;}
151
+ .btn:hover{background:var(--hover);border-color:color-mix(in srgb,var(--accent) 30%,var(--bdr));}
152
+ .btn-accent{background:var(--accent);color:#fff;border-color:var(--accent);}
153
+ .btn-accent:hover{background:var(--accent2);border-color:var(--accent2);}
154
+
155
+ /* ── Filter bar ──────────────────────────────────────────── */
156
+ .filter-bar{
157
+ display:flex;align-items:center;gap:6px;padding:0 12px;
158
+ height:var(--filter-h);background:var(--bg2);
159
+ border-bottom:1px solid var(--bdr);flex-shrink:0;overflow-x:auto;
160
+ scrollbar-width:none;
161
+ }
162
+ .filter-bar::-webkit-scrollbar{display:none;}
163
+ .filter-label{font-size:11px;color:var(--fg2);flex-shrink:0;font-weight:500;text-transform:uppercase;letter-spacing:.04em;}
164
+ .kind-pill{
165
+ font:11px/1 ui-sans-serif,system-ui,sans-serif;cursor:pointer;
166
+ border:1px solid var(--kc,var(--bdr));
167
+ background:color-mix(in srgb,var(--kc,var(--bdr)) 8%,transparent);
168
+ color:var(--kc,var(--fg2));border-radius:999px;
169
+ padding:3px 10px;white-space:nowrap;transition:all .12s;flex-shrink:0;
170
+ }
171
+ .kind-pill:hover{background:color-mix(in srgb,var(--kc,var(--bdr)) 18%,transparent);}
172
+ .kind-pill.active{background:var(--kc,var(--fg2));color:#fff;border-color:var(--kc,var(--fg2));}
173
+ .filter-div{width:1px;height:16px;background:var(--bdr);flex-shrink:0;margin:0 2px;}
174
+
175
+ /* ── Layout ──────────────────────────────────────────────── */
176
+ .layout{display:flex;flex:1;min-height:0;}
177
+
178
+ /* ── Sidebar ─────────────────────────────────────────────── */
179
+ .sidebar{
180
+ width:var(--sb-w);flex-shrink:0;background:var(--sb-bg);
181
+ border-right:1px solid var(--bdr);
182
+ display:flex;flex-direction:column;overflow:hidden;
183
+ }
184
+ .search-wrap{padding:8px 10px;border-bottom:1px solid var(--bdr);position:relative;}
185
+ .search-icon{position:absolute;left:18px;top:50%;transform:translateY(-50%);opacity:.4;pointer-events:none;font-size:12px;}
186
+ #search{
187
+ width:100%;font:inherit;font-size:12px;padding:5px 8px 5px 26px;
188
+ border:1px solid var(--bdr);border-radius:8px;
189
+ background:var(--bg);color:var(--fg);outline:none;
190
+ transition:border-color .15s;
191
+ }
192
+ #search:focus{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 20%,transparent);}
193
+ .search-hint{font-size:10px;color:var(--fg2);text-align:right;padding:2px 2px 0;opacity:.7;}
194
+
195
+ /* Sidebar stats */
196
+ .sb-section{border-bottom:1px solid var(--bdr);padding:8px 10px;}
197
+ .sb-title{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--fg2);margin-bottom:6px;}
198
+ .sb-stat-row{display:flex;align-items:center;gap:6px;margin:3px 0;}
199
+ .sb-stat-bar{flex:1;height:4px;background:var(--bdr);border-radius:2px;overflow:hidden;}
200
+ .sb-stat-fill{height:100%;border-radius:2px;}
201
+ .sb-stat-label{font-size:11px;color:var(--fg2);min-width:60px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
202
+ .sb-stat-num{font-size:11px;color:var(--fg2);min-width:24px;text-align:right;flex-shrink:0;}
203
+ .lang-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;display:inline-block;}
204
+
205
+ /* Nav tree */
206
+ .nav-tree{flex:1;overflow-y:auto;padding:6px 4px;scrollbar-width:thin;scrollbar-color:var(--bdr) transparent;}
207
+ .dir-node{margin:1px 0;}
208
+ .dir-node>summary{
209
+ list-style:none;cursor:pointer;padding:3px 7px;border-radius:6px;
210
+ font-size:12px;font-weight:600;color:var(--fg2);
211
+ display:flex;align-items:center;gap:5px;user-select:none;
212
+ }
213
+ .dir-node>summary::-webkit-details-marker{display:none;}
214
+ .dir-node>summary::before{content:"\\25B8";font-size:9px;opacity:.5;transition:transform .12s;flex-shrink:0;}
215
+ .dir-node[open]>summary::before{transform:rotate(90deg);}
216
+ .dir-node>summary:hover{background:var(--hover);}
217
+ .dir-children{padding-left:12px;}
218
+ a.file-link{
219
+ display:flex;align-items:center;padding:3px 7px;border-radius:6px;
220
+ text-decoration:none;color:var(--fg);font-size:12px;cursor:pointer;gap:5px;
221
+ transition:background .1s;
222
+ }
223
+ a.file-link:hover{background:var(--hover);}
224
+ a.file-link.active{background:color-mix(in srgb,var(--accent) 10%,transparent);color:var(--accent);}
225
+ .fname{font-family:ui-monospace,monospace;font-size:11px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;}
226
+ .fmeta{font-size:10px;color:var(--fg2);flex-shrink:0;}
227
+
228
+ /* ── Main panel ──────────────────────────────────────────── */
229
+ .main-panel{flex:1;overflow-y:auto;padding:14px 16px;scrollbar-width:thin;scrollbar-color:var(--bdr) transparent;}
230
+
231
+ /* File sections */
232
+ .file-section{border:1px solid var(--bdr);border-radius:12px;margin-bottom:12px;background:var(--bg2);overflow:hidden;box-shadow:var(--shadow);}
233
+ .file-header{
234
+ display:flex;align-items:center;gap:8px;padding:10px 14px;
235
+ cursor:pointer;user-select:none;
236
+ border-bottom:1px solid transparent;transition:background .1s;
237
+ }
238
+ .file-header:hover{background:var(--hover);}
239
+ .file-section.open .file-header{border-bottom-color:var(--bdr);}
240
+ .toggle-icon{font-size:11px;opacity:.5;transition:transform .15s;flex-shrink:0;color:var(--fg2);}
241
+ .file-section:not(.open) .toggle-icon{transform:rotate(-90deg);}
242
+ .fs-path{font-family:ui-monospace,monospace;font-weight:700;font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
243
+ .fs-lang{font-size:10px;font-weight:600;padding:2px 7px;border-radius:999px;flex-shrink:0;}
244
+ .fs-count{font-size:11px;color:var(--fg2);flex-shrink:0;}
245
+ .fs-body{padding:8px 12px 12px;}
246
+ .file-section:not(.open) .fs-body{display:none;}
247
+
248
+ /* Symbol tree */
249
+ .tree{display:flex;flex-direction:column;gap:3px;}
250
+ details.node{border:1px solid var(--bdr);border-radius:9px;padding:1px 3px;transition:border-color .12s;}
251
+ details.node[open]{border-color:color-mix(in srgb,var(--accent) 25%,var(--bdr));}
252
+ summary{list-style:none;cursor:pointer;padding:5px 7px;border-radius:7px;display:flex;align-items:center;gap:7px;flex-wrap:wrap;}
253
+ summary::-webkit-details-marker{display:none;}
254
+ summary::before{content:"\\25B8";opacity:.4;transition:transform .15s;font-size:10px;flex-shrink:0;}
255
+ details[open]>summary::before{transform:rotate(90deg);}
256
+ .leaf{padding:5px 7px 5px 22px;border-radius:7px;display:flex;align-items:center;gap:7px;flex-wrap:wrap;}
257
+ summary:hover,.leaf:hover{background:var(--hover);}
258
+ .children{margin:2px 0 5px 16px;padding-left:10px;border-left:2px solid color-mix(in srgb,var(--accent) 15%,var(--bdr));display:flex;flex-direction:column;gap:3px;}
259
+ .badge{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;letter-spacing:.04em;flex-shrink:0;}
260
+ .name{font-family:ui-monospace,monospace;font-weight:600;font-size:12px;}
261
+ .vis{font-size:10px;padding:1px 5px;border-radius:5px;flex-shrink:0;}
262
+ .vis.priv{background:#ef44441a;color:#ef4444;}
263
+ .vis.exp{background:#22c55e1a;color:#16a34a;}
264
+ .sig{font-family:ui-monospace,monospace;font-size:11px;background:var(--hover);padding:1px 6px;border-radius:5px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0;}
265
+ .lines{font-size:10px;opacity:.5;margin-left:auto;font-family:ui-monospace,monospace;flex-shrink:0;}
266
+ .doc{font-size:11px;opacity:.75;margin:2px 0 5px 26px;white-space:pre-wrap;font-style:italic;color:var(--fg2);}
267
+ .copy-btn{
268
+ opacity:0;font-size:11px;padding:1px 5px;border-radius:4px;border:1px solid var(--bdr);
269
+ background:transparent;color:var(--fg2);cursor:pointer;transition:opacity .12s,background .12s;
270
+ flex-shrink:0;margin-left:2px;
271
+ }
272
+ .copy-btn:hover{background:var(--hover);color:var(--fg);}
273
+ summary:hover .copy-btn,.leaf:hover .copy-btn{opacity:1;}
274
+ .copy-btn.copied{opacity:1;color:#16a34a;border-color:#16a34a;}
275
+
276
+ .empty{opacity:.6;font-size:12px;padding:4px 0;}
277
+ .no-match{padding:60px 20px;text-align:center;color:var(--fg2);font-size:13px;}
278
+ .no-match-icon{font-size:32px;margin-bottom:8px;opacity:.4;}
279
+ .hidden-kind{display:none !important;}
280
+
281
+ /* ── Keyboard hint tooltip ───────────────────────────────── */
282
+ .kbd{font-size:10px;background:var(--bdr);color:var(--fg2);border-radius:4px;padding:1px 5px;font-family:ui-monospace,monospace;border:1px solid color-mix(in srgb,var(--fg) 20%,var(--bdr));}
283
+
284
+ /* ── Toast ───────────────────────────────────────────────── */
285
+ #toast{
286
+ position:fixed;bottom:16px;left:50%;transform:translateX(-50%);
287
+ background:#0f172a;color:#fff;font-size:12px;padding:6px 14px;
288
+ border-radius:8px;opacity:0;pointer-events:none;z-index:99;
289
+ transition:opacity .2s;box-shadow:0 4px 12px rgba(0,0,0,.3);
290
+ }
291
+ #toast.show{opacity:1;}
292
+ </style>
293
+ </head>
294
+ <body>
295
+
296
+ <header class="topbar">
297
+ <div class="topbar-logo">
298
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
299
+ <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
300
+ </svg>
301
+ AST Map
302
+ </div>
303
+ <div class="topbar-sep"></div>
304
+ <span class="topbar-meta">${skeletons.length} files &middot; ${totalSymbols} symbols &middot; <time title="${esc(generatedAt)}">${esc(generatedAt.slice(0, 10))}</time></span>
305
+ <div class="topbar-actions">
306
+ <button class="btn" id="btn-expand" title="Expand all sections">Expand all</button>
307
+ <button class="btn" id="btn-collapse" title="Collapse all sections">Collapse all</button>
308
+ <button class="btn btn-accent" id="btn-export" title="Export skeleton as JSON">Export JSON</button>
309
+ </div>
310
+ </header>
311
+
312
+ <div class="filter-bar" id="filter-bar">
313
+ <span class="filter-label">Filter:</span>
314
+ ${kindPills}
315
+ <div class="filter-div"></div>
316
+ <button class="kind-pill" id="clear-filter" style="--kc:#64748b" onclick="clearKindFilter()">× clear</button>
317
+ </div>
318
+
319
+ <div class="layout">
320
+ <nav class="sidebar">
321
+ <div class="search-wrap">
322
+ <span class="search-icon">⌕</span>
323
+ <input id="search" type="search" placeholder="Search files or symbols…" autocomplete="off" spellcheck="false" aria-label="Search">
324
+ <div class="search-hint">Press <kbd class="kbd">/</kbd> to focus</div>
325
+ </div>
326
+
327
+ <div class="sb-section" id="lang-stats"></div>
328
+ <div class="sb-section" id="kind-stats"></div>
329
+
330
+ <div id="nav-tree" class="nav-tree"></div>
331
+ </nav>
332
+
333
+ <main id="main" class="main-panel">
334
+ ${sections}
335
+ <div id="no-match" class="no-match" style="display:none">
336
+ <div class="no-match-icon">⊘</div>
337
+ No matching files or symbols found.
338
+ </div>
339
+ </main>
340
+ </div>
341
+
342
+ <div id="toast"></div>
343
+
344
+ <script>
345
+ (function(){
346
+ 'use strict';
347
+ const FILES=${fileData};
348
+ const KIND_COUNTS=${kindData};
349
+ const LANG_DATA=${langData};
350
+ const KIND_COLORS={class:"#7c3aed",interface:"#0ea5e9",struct:"#0d9488",function:"#2563eb",method:"#4f46e5",type:"#db2777",enum:"#ea580c","const":"#65a30d","var":"#ca8a04",field:"#64748b",namespace:"#9333ea"};
351
+ const LANG_COLORS={typescript:"#3178c6",javascript:"#f7df1e",python:"#3572a5",go:"#00acd7",rust:"#dea584",java:"#b07219","c++":"#f34b7d",c:"#555555",csharp:"#239120",kotlin:"#a97bff",swift:"#f05138",tsx:"#3178c6",jsx:"#f7df1e"};
352
+
353
+ // ── Open state ─────────────────────────────────────────────
354
+ const openState=new Set();
355
+ FILES.forEach(f=>openState.add(f.id));
356
+
357
+ function toggleSection(id){
358
+ const sec=document.getElementById('file-'+id);
359
+ if(!sec)return;
360
+ if(openState.has(id)){openState.delete(id);sec.classList.remove('open');}
361
+ else{openState.add(id);sec.classList.add('open');}
362
+ document.getElementById('tog-'+id).textContent=openState.has(id)?'▾':'▸';
363
+ }
364
+
365
+ // Initialise open state
366
+ FILES.forEach(f=>{
367
+ const sec=document.getElementById('file-'+f.id);
368
+ if(sec){sec.classList.add('open');}
369
+ });
370
+
371
+ // ── Sidebar stats ──────────────────────────────────────────
372
+ function e(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
373
+
374
+ function buildLangStats(){
375
+ const el=document.getElementById('lang-stats');
376
+ if(!LANG_DATA.length){el.style.display='none';return;}
377
+ const max=LANG_DATA[0][1];
378
+ let html='<div class="sb-title">Languages</div>';
379
+ for(const[lang,cnt]of LANG_DATA){
380
+ const c=LANG_COLORS[lang]||'#94a3b8';
381
+ const pct=Math.round(cnt/max*100);
382
+ html+=\`<div class="sb-stat-row">
383
+ <span class="lang-dot" style="background:\${c}"></span>
384
+ <span class="sb-stat-label">\${e(lang)}</span>
385
+ <div class="sb-stat-bar"><div class="sb-stat-fill" style="width:\${pct}%;background:\${c}"></div></div>
386
+ <span class="sb-stat-num">\${cnt}</span>
387
+ </div>\`;
388
+ }
389
+ el.innerHTML=html;
390
+ }
391
+
392
+ function buildKindStats(){
393
+ const el=document.getElementById('kind-stats');
394
+ const entries=Object.entries(KIND_COUNTS).sort((a,b)=>b[1]-a[1]);
395
+ if(!entries.length){el.style.display='none';return;}
396
+ const max=entries[0][1];
397
+ let html='<div class="sb-title">Symbol kinds</div>';
398
+ for(const[kind,cnt]of entries.slice(0,8)){
399
+ const c=KIND_COLORS[kind]||'#64748b';
400
+ const pct=Math.round(cnt/max*100);
401
+ html+=\`<div class="sb-stat-row">
402
+ <span style="width:8px;height:8px;border-radius:2px;background:\${c};flex-shrink:0;display:inline-block"></span>
403
+ <span class="sb-stat-label">\${e(kind)}</span>
404
+ <div class="sb-stat-bar"><div class="sb-stat-fill" style="width:\${pct}%;background:\${c}44"></div></div>
405
+ <span class="sb-stat-num">\${cnt}</span>
406
+ </div>\`;
407
+ }
408
+ el.innerHTML=html;
409
+ }
410
+
411
+ buildLangStats();
412
+ buildKindStats();
413
+
414
+ // ── Nav tree ───────────────────────────────────────────────
415
+ function buildTreeData(files){
416
+ const root={dirs:{},files:[]};
417
+ for(const f of files){
418
+ const parts=f.file.split('/');
419
+ let node=root;
420
+ for(let i=0;i<parts.length-1;i++){
421
+ if(!node.dirs[parts[i]])node.dirs[parts[i]]={dirs:{},files:[]};
422
+ node=node.dirs[parts[i]];
423
+ }
424
+ node.files.push(f);
425
+ }
426
+ return root;
427
+ }
428
+
429
+ const linkMap=new Map();
430
+ function renderTreeNode(node,container){
431
+ const dirs=Object.entries(node.dirs).sort(([a],[b])=>a.localeCompare(b));
432
+ for(const[name,child]of dirs){
433
+ const det=document.createElement('details');
434
+ det.className='dir-node';det.open=true;
435
+ const sum=document.createElement('summary');
436
+ sum.textContent=name;
437
+ det.appendChild(sum);
438
+ const inner=document.createElement('div');
439
+ inner.className='dir-children';
440
+ renderTreeNode(child,inner);
441
+ det.appendChild(inner);
442
+ container.appendChild(det);
443
+ }
444
+ for(const f of node.files){
445
+ const a=document.createElement('a');
446
+ a.href='#file-'+f.id;
447
+ a.className='file-link';
448
+ a.dataset.id=String(f.id);
449
+ const fname=f.file.split('/').pop()||f.file;
450
+ const lc=LANG_COLORS[f.lang]||'#94a3b8';
451
+ a.innerHTML=\`<span class="lang-dot" style="background:\${lc}"></span><span class="fname">\${e(fname)}</span><span class="fmeta">\${f.n}</span>\`;
452
+ a.addEventListener('click',ev=>{
453
+ ev.preventDefault();
454
+ const sec=document.getElementById('file-'+f.id);
455
+ if(sec){
456
+ if(!openState.has(f.id)){toggleSection(f.id);}
457
+ sec.scrollIntoView({behavior:'smooth',block:'start'});
458
+ }
459
+ });
460
+ container.appendChild(a);
461
+ linkMap.set(f.id,a);
462
+ }
463
+ }
464
+
465
+ const navTree=document.getElementById('nav-tree');
466
+ renderTreeNode(buildTreeData(FILES),navTree);
467
+
468
+ // ── Search ─────────────────────────────────────────────────
469
+ const searchEl=document.getElementById('search');
470
+ const mainEl=document.getElementById('main');
471
+ const noMatch=document.getElementById('no-match');
472
+
473
+ searchEl.addEventListener('input',applyFilter);
474
+ function applyFilter(){
475
+ const q=searchEl.value.trim().toLowerCase();
476
+ let vis=0;
477
+ FILES.forEach(f=>{
478
+ const sec=document.getElementById('file-'+f.id);
479
+ if(!sec)return;
480
+ const match=!q||f.file.toLowerCase().includes(q)||f.syms.toLowerCase().includes(q);
481
+ sec.style.display=match?'':'none';
482
+ const link=linkMap.get(f.id);
483
+ if(link)link.style.display=match?'':'none';
484
+ if(match)vis++;
485
+ });
486
+ noMatch.style.display=vis===0&&q?'':'none';
487
+ }
488
+
489
+ // ── Kind filter ────────────────────────────────────────────
490
+ let activeKinds=new Set();
491
+ function toggleKind(kind,btn){
492
+ if(activeKinds.has(kind)){activeKinds.delete(kind);btn.classList.remove('active');}
493
+ else{activeKinds.add(kind);btn.classList.add('active');}
494
+ applyKindFilter();
495
+ }
496
+ function clearKindFilter(){
497
+ activeKinds.clear();
498
+ document.querySelectorAll('.kind-pill').forEach(p=>p.classList.remove('active'));
499
+ applyKindFilter();
500
+ }
501
+ function applyKindFilter(){
502
+ if(!activeKinds.size){
503
+ document.querySelectorAll('.node[data-kind]').forEach(n=>n.classList.remove('hidden-kind'));
504
+ return;
505
+ }
506
+ document.querySelectorAll('.node[data-kind]').forEach(n=>{
507
+ const k=n.getAttribute('data-kind');
508
+ n.classList.toggle('hidden-kind',!activeKinds.has(k));
509
+ });
510
+ }
511
+ window.toggleKind=toggleKind;
512
+ window.clearKindFilter=clearKindFilter;
513
+
514
+ // ── Expand / Collapse ──────────────────────────────────────
515
+ document.getElementById('btn-expand').addEventListener('click',()=>{
516
+ FILES.forEach(f=>{
517
+ const sec=document.getElementById('file-'+f.id);
518
+ if(sec&&!openState.has(f.id)){toggleSection(f.id);}
519
+ });
520
+ document.querySelectorAll('details.node').forEach(d=>d.open=true);
521
+ });
522
+ document.getElementById('btn-collapse').addEventListener('click',()=>{
523
+ FILES.forEach(f=>{
524
+ const sec=document.getElementById('file-'+f.id);
525
+ if(sec&&openState.has(f.id)){toggleSection(f.id);}
526
+ });
527
+ document.querySelectorAll('details.node').forEach(d=>d.open=false);
528
+ });
529
+
530
+ // ── Export JSON ────────────────────────────────────────────
531
+ document.getElementById('btn-export').addEventListener('click',()=>{
532
+ const data=JSON.stringify(FILES,null,2);
533
+ const blob=new Blob([data],{type:'application/json'});
534
+ const url=URL.createObjectURL(blob);
535
+ const a=document.createElement('a');
536
+ a.href=url;a.download='ast-map.json';a.click();
537
+ URL.revokeObjectURL(url);
538
+ showToast('Exported ast-map.json');
539
+ });
540
+
541
+ // ── Active sidebar link on scroll ─────────────────────────
542
+ const io=new IntersectionObserver(entries=>{
543
+ for(const en of entries){
544
+ if(en.isIntersecting){
545
+ const idx=parseInt(en.target.id.replace('file-',''),10);
546
+ linkMap.forEach((a,id)=>a.classList.toggle('active',id===idx));
547
+ }
548
+ }
549
+ },{root:mainEl,threshold:0.1});
550
+ document.querySelectorAll('.file-section').forEach(s=>io.observe(s));
551
+
552
+ // ── Keyboard shortcuts ─────────────────────────────────────
553
+ document.addEventListener('keydown',ev=>{
554
+ if(ev.key==='/'&&document.activeElement!==searchEl){
555
+ ev.preventDefault();
556
+ searchEl.focus();searchEl.select();
557
+ }
558
+ if(ev.key==='Escape'&&document.activeElement===searchEl){
559
+ searchEl.value='';applyFilter();searchEl.blur();
560
+ }
561
+ });
562
+
563
+ // ── Copy helper ────────────────────────────────────────────
564
+ function copyText(text,btn){
565
+ navigator.clipboard.writeText(text).then(()=>{
566
+ btn.classList.add('copied');
567
+ btn.textContent='✓';
568
+ showToast('Copied: '+text);
569
+ setTimeout(()=>{btn.classList.remove('copied');btn.textContent='⎘';},1500);
570
+ }).catch(()=>{showToast('Copy failed');});
571
+ }
572
+ window.copyText=copyText;
573
+
574
+ // ── Toast ──────────────────────────────────────────────────
575
+ let toastTimer;
576
+ function showToast(msg){
577
+ const t=document.getElementById('toast');
578
+ t.textContent=msg;t.classList.add('show');
579
+ clearTimeout(toastTimer);
580
+ toastTimer=setTimeout(()=>t.classList.remove('show'),2200);
581
+ }
582
+ window.showToast=showToast;
583
+
584
+ // ── window.toggleSection ───────────────────────────────────
585
+ window.toggleSection=toggleSection;
586
+
587
+ })();
588
+ </script>
589
+ </body>
264
590
  </html>`;
265
591
  }
266
592
  export function renderHtml(skel) {
267
593
  const body = skel.symbols.length > 0
268
- ? skel.symbols.map(renderSymbol).join("")
594
+ ? skel.symbols.map((s) => renderSymbol(s)).join("")
269
595
  : `<p class="empty">No top-level symbols found.</p>`;
270
- return `<!doctype html>
271
- <html lang="en">
272
- <head>
273
- <meta charset="utf-8">
274
- <meta name="viewport" content="width=device-width, initial-scale=1">
275
- <title>Skeleton ${esc(skel.file)}</title>
276
- <style>
277
- :root { color-scheme: light dark; }
278
- * { box-sizing: border-box; }
279
- body { font: 14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
280
- margin: 0; padding: 24px; background: #f8fafc; color: #0f172a; }
281
- @media (prefers-color-scheme: dark) { body { background:#0b1120; color:#e2e8f0; } header.meta{background:#111827;border-color:#1f2937;} .node{border-color:#1f2937;} summary:hover,.leaf:hover{background:#111827;} .sig{background:#111827;color:#93c5fd;} .doc{color:#94a3b8;} }
282
- header.meta { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:16px 20px; margin-bottom:20px; }
283
- header.meta h1 { font-size:16px; margin:0 0 6px; font-family:ui-monospace,monospace; }
284
- header.meta .sub { font-size:12px; opacity:.7; }
285
- .toolbar { margin:14px 0; display:flex; gap:8px; }
286
- button { font:inherit; cursor:pointer; border:1px solid #cbd5e1; background:transparent; color:inherit;
287
- border-radius:8px; padding:4px 10px; }
288
- .tree { display:flex; flex-direction:column; gap:4px; }
289
- details.node { border:1px solid #e2e8f0; border-radius:10px; padding:2px 4px; }
290
- summary { list-style:none; cursor:pointer; padding:6px 8px; border-radius:8px; display:flex; align-items:center;
291
- gap:8px; flex-wrap:wrap; }
292
- summary::-webkit-details-marker { display:none; }
293
- summary::before { content:"\\25B8"; opacity:.5; transition:transform .15s; }
294
- details[open] > summary::before { transform:rotate(90deg); }
295
- .leaf { padding:6px 8px 6px 24px; border-radius:8px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
296
- summary:hover,.leaf:hover { background:#f1f5f9; }
297
- .children { margin:2px 0 6px 18px; padding-left:10px; border-left:2px solid #e2e8f033; display:flex;
298
- flex-direction:column; gap:4px; }
299
- .badge { font-size:11px; font-weight:600; padding:1px 7px; border-radius:999px; text-transform:uppercase;
300
- letter-spacing:.03em; }
301
- .name { font-family:ui-monospace,monospace; font-weight:600; }
302
- .vis { font-size:10px; padding:1px 6px; border-radius:6px; }
303
- .vis.priv { background:#ef44441a; color:#ef4444; }
304
- .vis.exp { background:#22c55e1a; color:#16a34a; }
305
- .sig { font-family:ui-monospace,monospace; font-size:12px; background:#f1f5f9; padding:1px 6px; border-radius:6px;
306
- max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
307
- .lines { font-size:11px; opacity:.55; margin-left:auto; font-family:ui-monospace,monospace; }
308
- .doc { font-size:12px; opacity:.8; margin:2px 0 6px 28px; white-space:pre-wrap; font-style:italic; }
309
- .empty { opacity:.6; }
310
- </style>
311
- </head>
312
- <body>
313
- <header class="meta">
314
- <h1>${esc(skel.file)}</h1>
315
- <div class="sub">${esc(skel.language)} &middot; ${skel.symbolCount} symbols &middot; parser: ${esc(skel.parser.grammar)} &middot; ${esc(skel.generatedAt)}</div>
316
- </header>
317
- <div class="toolbar">
318
- <button onclick="document.querySelectorAll('details').forEach(d=>d.open=true)">Expand all</button>
319
- <button onclick="document.querySelectorAll('details').forEach(d=>d.open=false)">Collapse all</button>
320
- </div>
321
- <div class="tree">
322
- ${body}
323
- </div>
324
- </body>
596
+ const lc = LANG_COLOR[skel.language] ?? "#94a3b8";
597
+ return `<!doctype html>
598
+ <html lang="en">
599
+ <head>
600
+ <meta charset="utf-8">
601
+ <meta name="viewport" content="width=device-width, initial-scale=1">
602
+ <title>AST Map — ${esc(skel.file)}</title>
603
+ <style>
604
+ :root{color-scheme:light dark;--bg:#f6f8fa;--bg2:#fff;--fg:#0f172a;--fg2:#64748b;--bdr:#e2e8f0;--hover:#f1f5f9;--accent:#6366f1;}
605
+ @media(prefers-color-scheme:dark){:root{--bg:#0d1117;--bg2:#161b22;--fg:#e6edf3;--fg2:#7d8590;--bdr:#21262d;--hover:#1c2128;}}
606
+ *{box-sizing:border-box;margin:0;padding:0;}
607
+ body{font:13px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--fg);padding:20px;}
608
+ header.meta{background:var(--bg2);border:1px solid var(--bdr);border-radius:12px;padding:14px 18px;margin-bottom:14px;}
609
+ header.meta h1{font-size:14px;font-family:ui-monospace,monospace;font-weight:700;margin-bottom:4px;}
610
+ header.meta .sub{font-size:11px;color:var(--fg2);}
611
+ .lang-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 8px;border-radius:999px;margin-right:8px;}
612
+ .toolbar{margin:10px 0;display:flex;gap:6px;flex-wrap:wrap;}
613
+ .btn{font:12px ui-sans-serif,sans-serif;cursor:pointer;border:1px solid var(--bdr);background:transparent;color:inherit;border-radius:7px;padding:4px 11px;}
614
+ .btn:hover{background:var(--hover);}
615
+ .tree{display:flex;flex-direction:column;gap:3px;}
616
+ details.node{border:1px solid var(--bdr);border-radius:9px;padding:1px 3px;}
617
+ details.node[open]{border-color:color-mix(in srgb,var(--accent) 25%,var(--bdr));}
618
+ summary{list-style:none;cursor:pointer;padding:5px 7px;border-radius:7px;display:flex;align-items:center;gap:7px;flex-wrap:wrap;}
619
+ summary::-webkit-details-marker{display:none;}
620
+ summary::before{content:"\\25B8";opacity:.4;transition:transform .15s;font-size:10px;}
621
+ details[open]>summary::before{transform:rotate(90deg);}
622
+ .leaf{padding:5px 7px 5px 22px;border-radius:7px;display:flex;align-items:center;gap:7px;flex-wrap:wrap;}
623
+ summary:hover,.leaf:hover{background:var(--hover);}
624
+ .children{margin:2px 0 5px 16px;padding-left:10px;border-left:2px solid color-mix(in srgb,var(--accent) 15%,var(--bdr));display:flex;flex-direction:column;gap:3px;}
625
+ .badge{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;letter-spacing:.04em;}
626
+ .name{font-family:ui-monospace,monospace;font-weight:600;font-size:12px;}
627
+ .vis{font-size:10px;padding:1px 5px;border-radius:5px;}
628
+ .vis.priv{background:#ef44441a;color:#ef4444;}.vis.exp{background:#22c55e1a;color:#16a34a;}
629
+ .sig{font-family:ui-monospace,monospace;font-size:11px;background:var(--hover);padding:1px 6px;border-radius:5px;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
630
+ .lines{font-size:10px;opacity:.5;margin-left:auto;font-family:ui-monospace,monospace;}
631
+ .doc{font-size:11px;opacity:.75;margin:2px 0 5px 26px;white-space:pre-wrap;font-style:italic;color:var(--fg2);}
632
+ .copy-btn{opacity:0;font-size:11px;padding:1px 5px;border-radius:4px;border:1px solid var(--bdr);background:transparent;color:var(--fg2);cursor:pointer;transition:opacity .12s;}
633
+ summary:hover .copy-btn,.leaf:hover .copy-btn{opacity:1;}
634
+ .copy-btn.copied{opacity:1;color:#16a34a;border-color:#16a34a;}
635
+ .empty{opacity:.6;}
636
+ </style>
637
+ </head>
638
+ <body>
639
+ <header class="meta">
640
+ <h1>${esc(skel.file)}</h1>
641
+ <div class="sub">
642
+ <span class="lang-badge" style="background:${lc}22;color:${lc};border:1px solid ${lc}44">${esc(skel.language)}</span>
643
+ ${skel.symbolCount} symbols &middot; ${esc(skel.parser.grammar)} &middot; <time>${esc(skel.generatedAt.slice(0, 10))}</time>
644
+ </div>
645
+ </header>
646
+ <div class="toolbar">
647
+ <button class="btn" onclick="document.querySelectorAll('details').forEach(d=>d.open=true)">Expand all</button>
648
+ <button class="btn" onclick="document.querySelectorAll('details').forEach(d=>d.open=false)">Collapse all</button>
649
+ </div>
650
+ <div class="tree">
651
+ ${body}
652
+ </div>
653
+ <script>
654
+ function copyText(text,btn){navigator.clipboard.writeText(text).then(()=>{btn.classList.add('copied');btn.textContent='✓';setTimeout(()=>{btn.classList.remove('copied');btn.textContent='⎘';},1500);});}
655
+ </script>
656
+ </body>
325
657
  </html>`;
326
658
  }