OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 0889f3c

The file level: symbol nodes with expandable code
`codetree serve` now has /floor — a level of the ladder, drawn from the derivation. One node per SYMBOL (class, top-level function, const, module body), laid out by the file's own call graph, each node collapsible to a signature or expandable to its code. Verified by screenshot at three states: collapsed, two nodes expanded, and expand-all on lib/floor/cells.js. ## Decisions worth recording **SVG, not WebGL.** The file level is 5-35 nodes. lib/floor/cells.js exists for the case where hundreds of nodes are expanded at the expression floor; building a GPU pipeline for thirty boxes would repeat the mistake that produced the abandoned substrate design. It is still the right tool — later. **Served as real files, not inlined.** lib/floor/level.html and level.js are served statically. render.js is a 4,000-line template literal with a no-backtick rule, and inlining has already cost a duplicate escapeHtml, a </script> breakout and an implicit global. test/floor-view.test.js is the parse gate for the new files, with the same rationale as render.test.js. **The browser imports the same lib/layout.js the tests run against**, served at /floor/layout.js, rather than a copy that can drift from it. Expanding a node is therefore honest end to end: height changes, layout runs again, columns reflow (spec L4). ## What the view shows - links are the file's own call graph: an operation calling `foo`, where `foo` is a symbol in this file, is an edge from foo to whatever symbol holds the call - a zone-depth rail behind each expanded code line — the same semantic channel cells.js packs into a byte, at line granularity, so control-flow structure is visible as tinted bands without reading the code - an expanded node widens to 520px, which the layout handles because column width is the max of its members ## Two things fixed while looking at it The module body's RANGE is the whole file — it IS the program — so expanding it dumped every line including every other symbol's body. It now shows only the lines its own operations and wires sit on, with a marker where the slice skips. Expansion is deep-linkable (`?expand=all` or a comma-separated name list), because invariant I5 says every view in codetree serve is. Tests: 1063 asserts green, including 4 new view gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0889f3c764a0e61dbf4681a15b3120cdce5957f6
by plankatron <fleeky@gmail.com> · 2026-09-20T22:44:00.000Z

Changed files (4) +611 -1

lib/floor/level.html +56 -0
……@@ -0,0 +1,56 @@
1<!DOCTYPE html>
2<html>
3<head>
4<meta charset="utf-8">
5<title>codetree — file level</title>
6<style>
7 html, body { margin: 0; height: 100%; background: #131417; color: #c9d1d9; overflow: hidden;
8 font: 12px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
9 svg { display: block; width: 100vw; height: 100vh; cursor: grab; }
10 svg.dragging { cursor: grabbing; }
11 .hud { position: absolute; background: rgba(28,29,33,0.94); border: 1px solid #2f3136;
12 border-radius: 6px; padding: 8px 12px; line-height: 1.8; }
13 #head { top: 12px; left: 12px; }
14 #head input { background: #131417; border: 1px solid #2f3136; color: #c9d1d9; font: inherit;
15 padding: 3px 6px; border-radius: 3px; width: 340px; }
16 #head b { color: #e8e8ea; }
17 #stats { color: #8b949e; }
18 #hint { bottom: 12px; right: 12px; color: #8b949e; }
19 #legend { bottom: 12px; left: 12px; }
20 .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px;
21 vertical-align: -1px; }
22 #banner { top: 12px; right: 12px; color: #f0883e; max-width: 360px; display: none; }
23 .n-body { fill: #26272b; stroke: #17181a; }
24 .n-head { stroke: none; }
25 .n-title { fill: #e8e8ea; font-size: 11px; font-weight: 600; }
26 .n-sub { fill: #a0a0a8; font-size: 9px; }
27 .n-sig { fill: #8b949e; font-size: 9px; font-family: ui-monospace, 'SF Mono', monospace; }
28 .n-code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 8.5px; fill: #9aa0a6;
29 white-space: pre; }
30 .n-lineno { font-family: ui-monospace, monospace; font-size: 7px; fill: #484f58; }
31 .n-chev { fill: #8b949e; font-size: 10px; cursor: pointer; }
32 .n-hit { fill: transparent; cursor: pointer; }
33 .link { fill: none; stroke: #4d5866; stroke-width: 1.4; }
34 .depth1 { fill: #2b2f3a } .depth2 { fill: #33405a } .depth3 { fill: #3b5378 } .depth4 { fill: #436796 }
35</style>
36</head>
37<body>
38<svg id="svg"><g id="world"></g></svg>
39
40<div class="hud" id="head">
41 <div><b>file level</b> — one node per symbol &middot; click a node to expand its code</div>
42 <div style="margin-top:6px"><input id="path" spellcheck="false"> <span id="stats"></span></div>
43</div>
44<div class="hud" id="banner"></div>
45<div class="hud" id="legend">
46 <span class="sw" style="background:#b8642a"></span>function &nbsp;
47 <span class="sw" style="background:#6b4a86"></span>class &nbsp;
48 <span class="sw" style="background:#8a6a2a"></span>const &nbsp;
49 <span class="sw" style="background:#1f4a6b"></span>module body &nbsp;&nbsp;
50 <span class="sw" style="background:#33405a"></span>zone depth rail
51</div>
52<div class="hud" id="hint">drag to pan &middot; scroll to zoom &middot; E expand all &middot; C collapse all</div>
53
54<script type="module" src="/floor/level.js"></script>
55</body>
56</html>
lib/floor/level.js +424 -0
……@@ -0,0 +1,424 @@
1/**
2 * The file level: one node per symbol, each expandable to its own code.
3 *
4 * Specified in docs/EXPRESSION-FLOOR.md section 3. A node here is a SYMBOL — a
5 * class, a top-level function, a const, or the module body — never a derived
6 * function, because 12 of lib/sections.js's 16 functions are nested inside
7 * another and belong to an interior instead (L1).
8 *
9 * It imports the SAME lib/layout.js the node tests run against, served
10 * statically, rather than a copy that can drift from it. Expanding a node is
11 * therefore honest end to end: the height changes, layout runs again, containers
12 * and columns reflow (L4).
13 */
14
15import { layout } from '/floor/layout.js'
16
17const SVG_NS = 'http://www.w3.org/2000/svg'
18
19const KIND_COLOR = {
20 function: '#b8642a',
21 class: '#6b4a86',
22 const: '#8a6a2a',
23 'module-body': '#1f4a6b'
24}
25
26const NODE_W = 260
27const NODE_W_OPEN = 520 // an expanded node earns room for its code
28const HEAD_H = 20
29const LINE_H = 11
30const CODE_LINE_H = 10
31const MAX_CODE_LINES = 120 // an expanded 400-line function is not readable anyway
32
33const view = {
34 path: null,
35 model: null,
36 source: null,
37 lines: [],
38 lineDepth: [],
39 expanded: new Set(),
40 laid: null,
41 panX: 60,
42 panY: 90,
43 zoom: 0.9,
44 panning: false,
45 sx: 0,
46 sy: 0
47}
48
49// --- data ------------------------------------------------------------------
50
51async function load (path) {
52 const res = await fetch('/api/derive?path=' + encodeURIComponent(path))
53 const data = await res.json()
54 if (data.error) {
55 banner('cannot derive ' + path + ': ' + data.error)
56 return
57 }
58 view.path = path
59 view.model = data.model
60 view.source = data.source
61 view.lines = data.source.split('\n')
62 view.lineDepth = lineDepths(data.model, view.lines.length)
63 view.expanded = new Set()
64 applyExpandParam()
65 document.getElementById('path').value = path
66
67 const m = data.model
68 document.getElementById('stats').textContent =
69 m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' +
70 m.zones.length + ' zones · ' + m.exits.length + ' exits' +
71 (m.language ? ' · ' + m.language : '')
72 banner(m.partial ? 'PARTIAL — ' + m.partialReason : null)
73
74 relayout()
75}
76
77// The deepest zone covering each line, so an expanded node can show a depth rail
78// beside its code. This is the same semantic channel cells.js packs into a byte;
79// at this scale a per-line value is enough.
80function lineDepths (model, lineCount) {
81 const out = new Array(lineCount + 1).fill(0)
82 for (const z of model.zones || []) {
83 for (let l = z.range.startLine; l <= z.range.endLine; l++) {
84 if (l <= lineCount && z.depth > out[l]) out[l] = z.depth
85 }
86 }
87 return out
88}
89
90function codeOf (sym) {
91 // The module body's RANGE is the whole file - it IS the program - so slicing it
92 // would dump every line including every other symbol's body. Its own code is
93 // the lines its direct operations and wires actually sit on.
94 if (sym.kind === 'module-body') {
95 const own = new Set()
96 for (const n of view.model.nodes) {
97 if (n.container !== sym.fn) continue
98 for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l)
99 }
100 for (const n of view.model.names) {
101 if (n.container !== sym.fn) continue
102 for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l)
103 }
104 const sorted = Array.from(own).sort((a, b) => a - b).slice(0, MAX_CODE_LINES)
105 return {
106 numbers: sorted,
107 lines: sorted.map(l => view.lines[l - 1] || ''),
108 clipped: own.size > sorted.length
109 }
110 }
111 const from = sym.range.startLine
112 const to = Math.min(sym.range.endLine, from + MAX_CODE_LINES - 1)
113 const lines = view.lines.slice(from - 1, to)
114 return { numbers: lines.map((_, i) => from + i), lines, clipped: to < sym.range.endLine }
115}
116
117function interiorCounts (sym) {
118 const m = view.model
119 if (!sym.fn) {
120 return sym.members.length > 0 ? sym.members.length + ' members' : ''
121 }
122 const ops = m.nodes.filter(n => n.container === sym.fn).length
123 const zones = m.zones.filter(z => z.container === sym.fn).length
124 const wires = m.names.filter(n => n.container === sym.fn).length
125 return ops + ' ops · ' + zones + ' zones · ' + wires + ' wires'
126}
127
128function signatureOf (sym) {
129 const fn = (view.model.functions || []).find(f => f.id === sym.fn)
130 if (fn) return fn.name + ' (' + fn.params.join(', ') + ')'
131 if (sym.kind === 'class') return 'class ' + sym.name
132 return sym.name
133}
134
135function widthOf (sym) {
136 return view.expanded.has(sym.id) ? NODE_W_OPEN : NODE_W
137}
138
139function heightOf (sym) {
140 let h = HEAD_H + LINE_H + LINE_H + 6 // signature + counts
141 if (view.expanded.has(sym.id)) {
142 const code = codeOf(sym)
143 let gaps = 0
144 for (let i = 1; i < code.numbers.length; i++) {
145 if (code.numbers[i] > code.numbers[i - 1] + 1) gaps++
146 }
147 h += (code.lines.length + gaps) * CODE_LINE_H + 8
148 if (code.clipped) h += CODE_LINE_H
149 }
150 return h
151}
152
153// A call between two file-level symbols. An operation calling `foo` where `foo`
154// is a symbol in this file becomes an edge from foo to whatever symbol contains
155// the call — the file's own call graph, derived, not authored.
156function buildLinks () {
157 const m = view.model
158 const byName = new Map()
159 for (const s of m.symbols) if (s.name) byName.set(s.name, s)
160 const byFn = new Map()
161 for (const s of m.symbols) if (s.fn) byFn.set(s.fn, s)
162
163 const seen = new Set()
164 const links = []
165 for (const op of m.nodes) {
166 if (op.kind !== 'CallExpression') continue
167 const callee = op.inputs.find(i => i.slot === 'callee')
168 if (!callee || !callee.name) continue
169 const root = callee.name.split('.')[0].split('[')[0]
170 const target = byName.get(root)
171 const holder = byFn.get(op.container)
172 if (!target || !holder || target.id === holder.id) continue
173 const key = target.id + '->' + holder.id
174 if (seen.has(key)) continue
175 seen.add(key)
176 links.push({ from: target.id, to: holder.id })
177 }
178 return links
179}
180
181// Every view in codetree serve is deep-linkable (invariant I5), so which nodes
182// are expanded lives in the URL: ?expand=all, or a comma-separated name list.
183function applyExpandParam () {
184 const want = new URLSearchParams(location.search).get('expand')
185 if (!want) return
186 if (want === 'all') {
187 for (const s of view.model.symbols) view.expanded.add(s.id)
188 return
189 }
190 const names = new Set(want.split(',').map(x => x.trim()).filter(Boolean))
191 for (const s of view.model.symbols) if (names.has(s.name)) view.expanded.add(s.id)
192}
193
194function syncExpandParam () {
195 const names = view.model.symbols.filter(s => view.expanded.has(s.id)).map(s => s.name)
196 history.replaceState(null, '', '/floor?path=' + encodeURIComponent(view.path) +
197 (names.length === 0 ? '' : '&expand=' + encodeURIComponent(names.join(','))))
198}
199
200function relayout () {
201 const m = view.model
202 const nodes = m.symbols.map(s => ({ id: s.id, container: null, w: widthOf(s), h: heightOf(s) }))
203 view.laid = layout({ nodes, links: buildLinks(), containers: [] })
204 render()
205}
206
207// --- rendering -------------------------------------------------------------
208
209function el (name, attrs) {
210 const node = document.createElementNS(SVG_NS, name)
211 for (const k in attrs) node.setAttribute(k, attrs[k])
212 return node
213}
214
215function banner (text) {
216 const b = document.getElementById('banner')
217 b.style.display = text ? 'block' : 'none'
218 b.textContent = text || ''
219}
220
221function render () {
222 const world = document.getElementById('world')
223 world.textContent = ''
224 const m = view.model
225 const placed = new Map(view.laid.nodes.map(n => [n.id, n]))
226
227 // links first, so nodes sit on top of them
228 for (const l of view.laid ? buildLinks() : []) {
229 const a = placed.get(l.from)
230 const b = placed.get(l.to)
231 if (!a || !b) continue
232 const x1 = a.x + a.w
233 const y1 = a.y + HEAD_H / 2
234 const x2 = b.x
235 const y2 = b.y + HEAD_H / 2
236 const dx = Math.max(40, Math.abs(x2 - x1) * 0.5)
237 world.appendChild(el('path', {
238 class: 'link',
239 d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 +
240 ', ' + x2 + ' ' + y2
241 }))
242 }
243
244 for (const sym of m.symbols) {
245 const pos = placed.get(sym.id)
246 if (pos) drawNode(world, sym, pos)
247 }
248 applyTransform()
249}
250
251function drawNode (world, sym, pos) {
252 const g = el('g', { transform: 'translate(' + pos.x + ',' + pos.y + ')' })
253 const open = view.expanded.has(sym.id)
254
255 g.appendChild(el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 }))
256 g.appendChild(el('path', {
257 class: 'n-head',
258 fill: KIND_COLOR[sym.kind] || '#3a3d44',
259 d: 'M 0 5 a 5 5 0 0 1 5 -5 h ' + (pos.w - 10) + ' a 5 5 0 0 1 5 5 v ' + (HEAD_H - 5) +
260 ' h ' + -pos.w + ' z'
261 }))
262
263 const title = el('text', { class: 'n-title', x: 20, y: 14 })
264 title.textContent = sym.name
265 g.appendChild(title)
266
267 const kind = el('text', { class: 'n-sub', x: pos.w - 7, y: 14, 'text-anchor': 'end' })
268 kind.textContent = sym.kind
269 g.appendChild(kind)
270
271 const chev = el('text', { class: 'n-chev', x: 7, y: 14 })
272 chev.textContent = open ? '▾' : '▸'
273 g.appendChild(chev)
274
275 const sig = el('text', { class: 'n-sig', x: 8, y: HEAD_H + 11 })
276 sig.textContent = clip(signatureOf(sym), Math.floor((pos.w - 16) / 5.4))
277 g.appendChild(sig)
278
279 const counts = el('text', { class: 'n-sub', x: 8, y: HEAD_H + 11 + LINE_H })
280 counts.textContent = interiorCounts(sym) + ' L' + sym.range.startLine + '-' + sym.range.endLine
281 g.appendChild(counts)
282
283 if (open) {
284 const code = codeOf(sym)
285 let y = HEAD_H + LINE_H + LINE_H + 12
286 let prevLine = null
287 for (let i = 0; i < code.lines.length; i++) {
288 const lineNo = code.numbers[i]
289 // A sparse slice (the module body) skips other symbols' bodies; mark the
290 // break so the line numbers are not read as contiguous.
291 if (prevLine !== null && lineNo > prevLine + 1) {
292 const gap = el('text', { class: 'n-lineno', x: 24, y })
293 gap.textContent = '...'
294 g.appendChild(gap)
295 y += CODE_LINE_H
296 }
297 prevLine = lineNo
298 const depth = Math.min(view.lineDepth[lineNo] || 0, 4)
299 if (depth > 0) {
300 // The zone-depth rail: the semantic channel, at line granularity.
301 g.appendChild(el('rect', {
302 class: 'depth' + depth, x: 22, y: y - 7, width: pos.w - 26, height: CODE_LINE_H,
303 opacity: 0.35
304 }))
305 }
306 const num = el('text', { class: 'n-lineno', x: 18, y, 'text-anchor': 'end' })
307 num.textContent = lineNo
308 g.appendChild(num)
309
310 const t = el('text', { class: 'n-code', x: 24, y })
311 t.textContent = clip(code.lines[i].replace(/\t/g, ' '), Math.floor((pos.w - 30) / 4.9))
312 g.appendChild(t)
313 y += CODE_LINE_H
314 }
315 if (code.clipped) {
316 const more = el('text', { class: 'n-sub', x: 24, y })
317 more.textContent = 'more lines (clipped at ' + MAX_CODE_LINES + ')'
318 g.appendChild(more)
319 }
320 }
321
322 // One hit target over the header, so clicking anywhere on it toggles.
323 const hit = el('rect', { class: 'n-hit', width: pos.w, height: HEAD_H })
324 hit.addEventListener('click', (e) => {
325 e.stopPropagation()
326 toggle(sym.id)
327 })
328 g.appendChild(hit)
329
330 world.appendChild(g)
331}
332
333function clip (text, n) {
334 return text.length > n ? text.slice(0, n - 1) + '…' : text
335}
336
337function toggle (id) {
338 if (view.expanded.has(id)) view.expanded.delete(id)
339 else view.expanded.add(id)
340 syncExpandParam()
341 relayout()
342}
343
344function applyTransform () {
345 document.getElementById('world').setAttribute('transform',
346 'translate(' + view.panX.toFixed(1) + ',' + view.panY.toFixed(1) + ') scale(' + view.zoom.toFixed(3) + ')')
347}
348
349// --- input -----------------------------------------------------------------
350
351function wire () {
352 const svg = document.getElementById('svg')
353
354 svg.addEventListener('wheel', (e) => {
355 e.preventDefault()
356 const f = e.deltaY > 0 ? 0.9 : 1.1
357 const next = Math.max(0.1, Math.min(4, view.zoom * f))
358 const r = svg.getBoundingClientRect()
359 const mx = e.clientX - r.left
360 const my = e.clientY - r.top
361 view.panX = mx - (mx - view.panX) * (next / view.zoom)
362 view.panY = my - (my - view.panY) * (next / view.zoom)
363 view.zoom = next
364 applyTransform()
365 }, { passive: false })
366
367 svg.addEventListener('mousedown', (e) => {
368 view.panning = true
369 view.sx = e.clientX - view.panX
370 view.sy = e.clientY - view.panY
371 svg.classList.add('dragging')
372 })
373 document.addEventListener('mousemove', (e) => {
374 if (!view.panning) return
375 view.panX = e.clientX - view.sx
376 view.panY = e.clientY - view.sy
377 applyTransform()
378 })
379 document.addEventListener('mouseup', () => {
380 view.panning = false
381 svg.classList.remove('dragging')
382 })
383
384 document.addEventListener('keydown', (e) => {
385 if (!view.model) return
386 if (e.key === 'e' || e.key === 'E') {
387 for (const s of view.model.symbols) view.expanded.add(s.id)
388 syncExpandParam()
389 relayout()
390 } else if (e.key === 'c' || e.key === 'C') {
391 view.expanded.clear()
392 syncExpandParam()
393 relayout()
394 }
395 })
396
397 const input = document.getElementById('path')
398 input.addEventListener('change', () => {
399 const p = input.value.trim()
400 if (p) {
401 history.replaceState(null, '', '/floor?path=' + encodeURIComponent(p))
402 load(p)
403 }
404 })
405}
406
407async function start () {
408 wire()
409 const params = new URLSearchParams(location.search)
410 let path = params.get('path')
411 if (!path) {
412 const res = await fetch('/api/files')
413 const data = await res.json()
414 const first = (data.files || []).find(f => f.path.endsWith('.js')) || (data.files || [])[0]
415 path = first ? first.path : null
416 }
417 if (!path) {
418 banner('no files in the scan to open')
419 return
420 }
421 await load(path)
422}
423
424start()
lib/server.js +79 -1
……@@ -1,7 +1,8 @@
11import http from 'http'
22import { gzipSync } from 'zlib'
33import { readFileSync } from 'fs'
4import { resolve } from 'path'
4import { resolve, dirname } from 'path'
5import { fileURLToPath } from 'url'
56import { renderPage } from './render.js'
67import { buildIgnoreMatcher } from './ignore.js'
78import { LiveStore } from './live-store.js'
……@@ -33,6 +34,21 @@ export function startServer (tree, opts = {}) {
3334 live.start()
3435 }
3536
37 // The floor view ships as REAL FILES served statically, not as a string inlined
38 // into render.js. That file is a 4,000-line template literal with a no-backtick
39 // rule, and inlining has already cost a duplicate escapeHtml, a </script>
40 // breakout and an implicit global. A view with its own logic does not belong in
41 // a string.
42 const LIB = dirname(fileURLToPath(import.meta.url))
43
44 function sendFile (req, res, relPath, contentType) {
45 try {
46 send(req, res, 200, contentType, readFileSync(resolve(LIB, relPath), 'utf-8'))
47 } catch (err) {
48 send(req, res, 500, 'text/plain', 'cannot read ' + relPath + ': ' + (err.message || err))
49 }
50 }
51
3652 // --- Response helpers ---
3753
3854 // Compress the body with gzip if the client sent Accept-Encoding: gzip.
……@@ -259,6 +275,67 @@ export function startServer (tree, opts = {}) {
259275 return
260276 }
261277
278 // --- the floor: a level of the node ladder, drawn from the derivation.
279 if (url === '/floor' || url === '/floor/') {
280 sendFile(req, res, 'floor/level.html', 'text/html; charset=utf-8')
281 return
282 }
283 if (url === '/floor/level.js') {
284 sendFile(req, res, 'floor/level.js', 'application/javascript; charset=utf-8')
285 return
286 }
287 // The view imports the SAME layout module the tests run against, rather than
288 // a copy of it that can drift.
289 if (url === '/floor/layout.js') {
290 sendFile(req, res, 'layout.js', 'application/javascript; charset=utf-8')
291 return
292 }
293
294 // The expression-floor model for one file: /api/derive?path=relative/path.js
295 // Same traversal and ignore checks as /api/file — a model is a read of the
296 // file's contents by another name.
297 if (url === '/api/derive' && query) {
298 const params = new URLSearchParams(query)
299 const filePath = params.get('path')
300 if (!filePath) {
301 sendJSON(req, res, 400, { error: 'path parameter required' })
302 return
303 }
304 const absPath = resolve(rootDir, filePath)
305 if (!absPath.startsWith(rootDir)) {
306 sendJSON(req, res, 403, { error: 'Access denied' })
307 return
308 }
309 if (ignore(absPath)) {
310 sendJSON(req, res, 404, { error: 'File not found: ' + filePath })
311 return
312 }
313 ;(async () => {
314 try {
315 const { deriveFile } = await import('./derive.js')
316 const source = readFileSync(absPath, 'utf-8')
317 const model = deriveFile(source, filePath)
318 sendJSON(req, res, 200, { model, source })
319 } catch (err) {
320 sendJSON(req, res, 500, { error: err.message || String(err) })
321 }
322 })()
323 return
324 }
325
326 // A list of the files the scan knows about, so the view has something to open.
327 if (url === '/api/files') {
328 const out = []
329 for (const [modName, mod] of Object.entries(tree.modules)) {
330 for (const rel of Object.keys(mod.files)) {
331 out.push({ module: modName, path: mod.path === '.' ? rel : mod.path + '/' + rel })
332 }
333 }
334 out.sort((a, b) => (a.path < b.path ? -1 : 1))
335 sendJSON(req, res, 200, { files: out })
336 return
337 }
338
262339 // File contents API: /api/file?path=relative/path.js
263340 if (url === '/api/file' && query) {
264341 const params = new URLSearchParams(query)
……@@ -296,6 +373,7 @@ export function startServer (tree, opts = {}) {
296373
297374 server.listen(port, () => {
298375 console.log(`codetree visualization: http://localhost:${port}`)
376 console.log(`floor (file level): http://localhost:${port}/floor`)
299377 console.log(`API: http://localhost:${port}/api/tree`)
300378 console.log(`${tree.system.modules.length} modules loaded`)
301379 })
test/floor-view.test.js +52 -0
……@@ -0,0 +1,52 @@
1import { test } from 'brittle'
2import { readFileSync } from 'fs'
3import { resolve, dirname } from 'path'
4import { fileURLToPath } from 'url'
5import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs'
6
7const HERE = dirname(fileURLToPath(import.meta.url))
8const LIB = resolve(HERE, '../lib')
9
10// The floor view is served as real files, so nothing lints or type-checks it and
11// a stray brace ships a white page. Same gate as render.test.js, same reason.
12
13test('floor view: level.js parses as a module', (t) => {
14 const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8')
15 try {
16 acornParse(src, { ecmaVersion: 'latest', sourceType: 'module' })
17 t.pass('parses')
18 } catch (err) {
19 t.fail('level.js is not valid JS: ' + err.message)
20 }
21})
22
23test('floor view: no duplicate top-level function declarations', (t) => {
24 const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8')
25 const ast = acornParse(src, { ecmaVersion: 'latest', sourceType: 'module' })
26 const seen = new Set()
27 const dupes = []
28 for (const node of ast.body) {
29 if (node.type !== 'FunctionDeclaration' || !node.id) continue
30 if (seen.has(node.id.name)) dupes.push(node.id.name)
31 seen.add(node.id.name)
32 }
33 // A second declaration silently replaces the first for the whole module —
34 // exactly the escapeHtml bug that shipped in render.js.
35 t.alike(dupes, [], 'no name declared twice: ' + dupes.join(', '))
36})
37
38test('floor view: it imports the real layout module, not a copy', (t) => {
39 const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8')
40 t.ok(src.includes("from '/floor/layout.js'"), 'imports the served layout')
41
42 const server = readFileSync(resolve(LIB, 'server.js'), 'utf-8')
43 t.ok(server.includes("'/floor/layout.js'"), 'and the server routes that path')
44 t.ok(server.includes("sendFile(req, res, 'layout.js'"), 'to lib/layout.js itself')
45})
46
47test('floor view: the page loads the script it is served with', (t) => {
48 const html = readFileSync(resolve(LIB, 'floor/level.html'), 'utf-8')
49 t.ok(html.includes('src="/floor/level.js"'), 'script tag points at the served path')
50 t.ok(html.includes('type="module"'), 'as a module, since it imports layout')
51 t.ok(html.includes('id="world"'), 'and the SVG group the view draws into exists')
52})