| … | … | @@ -536,3 +536,138 @@ function innermostZoneOf (zones, range) { |
| 536 | 536 | } |
| 537 | 537 | return best ? best.id : null |
| 538 | 538 | } |
| 539 | ||
| 540 | // --- the tree, walked once ------------------------------------------------- | |
| 541 | // | |
| 542 | // These were implemented twice: here for the floor, and again inside render.js | |
| 543 | // for the SPA, from a DIFFERENT field of the same scan. getNodesAtPath walked | |
| 544 | // `tree.dirTree`, buildModuleLevel walked `tree.modules`, and nothing made them | |
| 545 | // agree about what exists at a path. One implementation, both views. | |
| 546 | ||
| 547 | export function findDirTreeNode (tree, path) { | |
| 548 | if (!path) return tree.dirTree | |
| 549 | const parts = path.split('/') | |
| 550 | let node = tree.dirTree | |
| 551 | for (const part of parts) { | |
| 552 | if (!node || !node.children) return null | |
| 553 | const next = node.children.find(c => c.name === part) | |
| 554 | if (!next) return null | |
| 555 | node = next | |
| 556 | } | |
| 557 | return node | |
| 558 | } | |
| 559 | ||
| 560 | export function countPackagesUnder (dirEntry) { | |
| 561 | let count = 0 | |
| 562 | const visit = (n) => { | |
| 563 | if (!n) return | |
| 564 | if (n.module) count++ | |
| 565 | if (n.children) for (const c of n.children) visit(c) | |
| 566 | } | |
| 567 | visit(dirEntry) | |
| 568 | return count | |
| 569 | } | |
| 570 | ||
| 571 | /** One level of the directory tree: [{ name, type: 'dir'|'module', mod, dirEntry, packageCount }] */ | |
| 572 | export function nodesAtPath (tree, path) { | |
| 573 | const node = findDirTreeNode(tree, path) | |
| 574 | if (!node || !node.children) return [] | |
| 575 | const out = [] | |
| 576 | for (const child of node.children) { | |
| 577 | if (child.type === 'file') continue | |
| 578 | if (child.module && tree.modules[child.module]) { | |
| 579 | out.push({ | |
| 580 | name: child.module, | |
| 581 | type: 'module', | |
| 582 | dirEntry: child, | |
| 583 | mod: tree.modules[child.module], | |
| 584 | packageCount: 1 | |
| 585 | }) | |
| 586 | } else if (child.children && child.children.length > 0) { | |
| 587 | out.push({ | |
| 588 | name: child.name, | |
| 589 | type: 'dir', | |
| 590 | dirEntry: child, | |
| 591 | mod: null, | |
| 592 | packageCount: countPackagesUnder(child) | |
| 593 | }) | |
| 594 | } | |
| 595 | } | |
| 596 | return out | |
| 597 | } | |
| 598 | ||
| 599 | /** | |
| 600 | * The category a module belongs to. | |
| 601 | * | |
| 602 | * The scanner resolves this ONCE into tree.moduleCategories — curated groupings | |
| 603 | * from spec/SYSTEM.md first, then the most-shared-dependency fallback — exactly | |
| 604 | * so the CLI and the views cannot drift. render.js reimplemented that algorithm | |
| 605 | * against tree.categories instead, which is a second answer to the same | |
| 606 | * question that nothing keeps in step. | |
| 607 | */ | |
| 608 | export function categoryOf (tree, moduleName) { | |
| 609 | const resolved = tree.moduleCategories || {} | |
| 610 | if (resolved[moduleName]) return resolved[moduleName] | |
| 611 | ||
| 612 | // A tree scanned before the scanner resolved categories: fall back to the | |
| 613 | // curated map alone rather than reimplementing the derivation. | |
| 614 | for (const [cat, mods] of Object.entries(tree.categories || {})) { | |
| 615 | if (mods.includes(moduleName)) return cat | |
| 616 | } | |
| 617 | return 'Other' | |
| 618 | } | |
| 619 | ||
| 620 | /** Every category in use, in stable order. */ | |
| 621 | export function categoriesOf (tree) { | |
| 622 | const seen = new Set() | |
| 623 | for (const name of (tree.system && tree.system.modules) || []) seen.add(categoryOf(tree, name)) | |
| 624 | return Array.from(seen).sort() | |
| 625 | } | |
| 626 | ||
| 627 | const CATEGORY_COLORS = [ | |
| 628 | '#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72', | |
| 629 | '#ffa657', '#79c0ff', '#f778ba', '#56d364' | |
| 630 | ] | |
| 631 | ||
| 632 | /** | |
| 633 | * A stable colour for a category. | |
| 634 | * | |
| 635 | * render.js coloured only the CURATED categories, so every derived one ("b4a | |
| 636 | * users") fell through to a per-module colour and the grouping lost its cue. | |
| 637 | */ | |
| 638 | export function categoryColor (tree, category) { | |
| 639 | const all = categoriesOf(tree) | |
| 640 | const idx = all.indexOf(category) | |
| 641 | return CATEGORY_COLORS[(idx < 0 ? 0 : idx) % CATEGORY_COLORS.length] | |
| 642 | } | |
| 643 | ||
| 644 | /** | |
| 645 | * The counts every view shows for a module, computed once. | |
| 646 | * | |
| 647 | * render.js carried TWO definitions of memberCount: at module level it counted | |
| 648 | * only class methods and getters, at file level it also counted free functions. | |
| 649 | * So a module of plain functions reported zero members in the graph and a real | |
| 650 | * number in the file view. This is the fuller definition, used by both. | |
| 651 | */ | |
| 652 | export function moduleStats (mod) { | |
| 653 | if (!mod) return { fileCount: 0, memberCount: 0, exportCount: 0 } | |
| 654 | const files = mod.files || {} | |
| 655 | let memberCount = 0 | |
| 656 | for (const file of Object.values(files)) memberCount += fileMembers(file) | |
| 657 | return { | |
| 658 | fileCount: Object.keys(files).length, | |
| 659 | memberCount, | |
| 660 | exportCount: (mod.publicAPI || []).length | |
| 661 | } | |
| 662 | } | |
| 663 | ||
| 664 | /** Members of one file: its functions, plus every class method and getter. */ | |
| 665 | export function fileMembers (file) { | |
| 666 | if (!file) return 0 | |
| 667 | let count = (file.functions || []).length | |
| 668 | for (const cls of file.classes || []) { | |
| 669 | count += (cls.methods || []).length | |
| 670 | count += (cls.getters || []).length | |
| 671 | } | |
| 672 | return count | |
| 673 | } |
| … | … | @@ -442,21 +442,9 @@ window.nvToggleFrames = function (on) { |
| 442 | 442 | |
| 443 | 443 | function nvLayout (nodes, links) { |
| 444 | 444 | NV.frames = [] |
| 445 | // The <script type="module"> that supplies the shared layout is DEFERRED, so | |
| 446 | // it has not necessarily run when this does — a human clicking the tab gets | |
| 447 | // it, an immediate deep-link does not, which is the worst kind of bug. Pull it | |
| 448 | // in on demand and redraw once it lands. | |
| 449 | if (!window.__ctLayout) { | |
| 450 | if (!NV.layoutPending) { | |
| 451 | NV.layoutPending = true | |
| 452 | import(API + '/floor/layout.js').then(function (m) { | |
| 453 | window.__ctLayout = m.layout | |
| 454 | NV.layoutPending = false | |
| 455 | nvBuild() | |
| 456 | }).catch(function () { NV.layoutPending = false }) | |
| 457 | } | |
| 458 | return nvLayoutFallback(nodes, links) | |
| 459 | } | |
| 445 | // boot() awaits the shared modules before anything renders, so this is a | |
| 446 | // guard against a genuinely broken load, not a race. | |
| 447 | if (!window.__ctLayout) return nvLayoutFallback(nodes, links) | |
| 460 | 448 | |
| 461 | 449 | // A group is a CONTAINER in the layout, not a rectangle drawn around whatever |
| 462 | 450 | // positions fell out afterwards. That is the whole difference: containers are |
| … | … | @@ -584,14 +584,6 @@ ${NODE_VIEW_HTML} |
| 584 | 584 | </div> |
| 585 | 585 | </div> |
| 586 | 586 | |
| 587 | <script type="module"> | |
| 588 | // The Nodes tab uses the SAME compound layout as /floor and the node tests. | |
| 589 | // Its group boxes used to be drawn AFTER layering, from each group's member | |
| 590 | // bounding box, so two groups whose members interleaved across columns made | |
| 591 | // crossing boxes — the overlap reported all along. | |
| 592 | import { layout } from '${apiPrefix}/floor/layout.js' | |
| 593 | window.__ctLayout = layout | |
| 594 | </script> | |
| 595 | 587 | <script> |
| 596 | 588 | const tree = ${treeJSON}; |
| 597 | 589 | const API = '${apiPrefix}'; |
| … | … | @@ -1218,56 +1210,22 @@ function getCurrentPath () { |
| 1218 | 1210 | return graphStack.filter(e => e.type === 'path').map(e => e.name).join('/') |
| 1219 | 1211 | } |
| 1220 | 1212 | |
| 1213 | // These three walked tree.dirTree here while the floor walked tree.modules for | |
| 1214 | // the same question, with nothing keeping the two answers in step. They now | |
| 1215 | // delegate to the shared module; the local names stay so every call site is | |
| 1216 | // unchanged. | |
| 1221 | 1217 | function findDirTreeNode (path) { |
| 1222 | if (!path) return tree.dirTree | |
| 1223 | const parts = path.split('/') | |
| 1224 | let node = tree.dirTree | |
| 1225 | for (const part of parts) { | |
| 1226 | if (!node.children) return null | |
| 1227 | const next = node.children.find(c => c.name === part) | |
| 1228 | if (!next) return null | |
| 1229 | node = next | |
| 1230 | } | |
| 1231 | return node | |
| 1218 | return window.__ct ? window.__ct.findDirTreeNode(tree, path) : null | |
| 1232 | 1219 | } |
| 1233 | 1220 | |
| 1234 | 1221 | function countPackagesUnder (dirEntry) { |
| 1235 | let count = 0 | |
| 1236 | function visit (n) { | |
| 1237 | if (n.module) count++ | |
| 1238 | if (n.children) for (const c of n.children) visit(c) | |
| 1239 | } | |
| 1240 | visit(dirEntry) | |
| 1241 | return count | |
| 1222 | return window.__ct ? window.__ct.countPackagesUnder(dirEntry) : 0 | |
| 1242 | 1223 | } |
| 1243 | 1224 | |
| 1244 | 1225 | // Returns: array of { name, type: 'dir'|'module', mod?, dirEntry, packageCount? } |
| 1245 | 1226 | // Each entry is one node to render at the current location. |
| 1246 | 1227 | function getNodesAtPath (path) { |
| 1247 | const node = findDirTreeNode(path) | |
| 1248 | if (!node || !node.children) return [] | |
| 1249 | const result = [] | |
| 1250 | for (const child of node.children) { | |
| 1251 | if (child.type === 'file') continue | |
| 1252 | if (child.module && tree.modules[child.module]) { | |
| 1253 | result.push({ | |
| 1254 | name: child.module, | |
| 1255 | type: 'module', | |
| 1256 | dirEntry: child, | |
| 1257 | mod: tree.modules[child.module] | |
| 1258 | }) | |
| 1259 | } else if (child.children && child.children.length > 0) { | |
| 1260 | // Pure directory with content | |
| 1261 | result.push({ | |
| 1262 | name: child.name, | |
| 1263 | type: 'dir', | |
| 1264 | dirEntry: child, | |
| 1265 | mod: null, | |
| 1266 | packageCount: countPackagesUnder(child) | |
| 1267 | }) | |
| 1268 | } | |
| 1269 | } | |
| 1270 | return result | |
| 1228 | return window.__ct ? window.__ct.nodesAtPath(tree, path) : [] | |
| 1271 | 1229 | } |
| 1272 | 1230 | |
| 1273 | 1231 | // Click handler for a directory node: descend into it. |
| … | … | @@ -1287,11 +1245,20 @@ function initGraph () { |
| 1287 | 1245 | const cx = W / 2 |
| 1288 | 1246 | const cy = H / 2 |
| 1289 | 1247 | |
| 1290 | // Compute category groups | |
| 1248 | // Category groups come from the scanner, which resolves them ONCE into | |
| 1249 | // tree.moduleCategories precisely so the CLI and the views cannot drift. This | |
| 1250 | // used to reimplement that derivation against tree.categories — a second | |
| 1251 | // answer to the same question, kept in step by nothing. | |
| 1291 | 1252 | const categories = tree.categories || {} |
| 1292 | 1253 | const moduleToCategory = {} |
| 1293 | for (const [cat, mods] of Object.entries(categories)) { | |
| 1294 | for (const m of mods) moduleToCategory[m] = cat | |
| 1254 | if (window.__ct) { | |
| 1255 | for (const name of tree.system.modules) { | |
| 1256 | moduleToCategory[name] = window.__ct.categoryOf(tree, name) | |
| 1257 | } | |
| 1258 | } else { | |
| 1259 | for (const [cat, mods] of Object.entries(categories)) { | |
| 1260 | for (const m of mods) moduleToCategory[m] = cat | |
| 1261 | } | |
| 1295 | 1262 | } |
| 1296 | 1263 | |
| 1297 | 1264 | if (gfx.groupBy === 'deps') { |
| … | … | @@ -1307,32 +1274,19 @@ function initGraph () { |
| 1307 | 1274 | for (const name of tree.system.modules) { |
| 1308 | 1275 | if (!moduleToCategory[name]) moduleToCategory[name] = 'Other' |
| 1309 | 1276 | } |
| 1310 | } else { | |
| 1311 | // Default: use SYSTEM.md categories (already in moduleToCategory). | |
| 1312 | // Uncategorized modules fall back to shared-dependency clustering — the | |
| 1313 | // strongest data-driven signal we have for relatedness without hardcoded | |
| 1314 | // module names. If no shared dep covers a module, it lands in "Other". | |
| 1315 | const shared = tree.system.sharedDeps || {} | |
| 1316 | const depToMostShared = {} | |
| 1317 | for (const [dep, users] of Object.entries(shared)) { | |
| 1318 | for (const u of users) { | |
| 1319 | const prev = depToMostShared[u] | |
| 1320 | if (!prev || users.length > shared[prev].length) depToMostShared[u] = dep | |
| 1321 | } | |
| 1322 | } | |
| 1323 | for (const name of tree.system.modules) { | |
| 1324 | if (moduleToCategory[name]) continue | |
| 1325 | const topDep = depToMostShared[name] | |
| 1326 | moduleToCategory[name] = topDep ? (topDep + ' users') : 'Other' | |
| 1327 | } | |
| 1277 | } | |
| 1278 | // No else branch any more: the shared resolution above already applies the | |
| 1279 | // curated groupings, then the shared-dependency fallback, then "Other". | |
| 1280 | for (const name of tree.system.modules) { | |
| 1281 | if (!moduleToCategory[name]) moduleToCategory[name] = 'Other' | |
| 1328 | 1282 | } |
| 1329 | 1283 | |
| 1284 | // Colour EVERY category in use, not only the curated ones: a derived category | |
| 1285 | // ("b4a users") used to fall through to a per-module colour, so the grouping | |
| 1286 | // it belonged to lost its visual cue entirely. | |
| 1330 | 1287 | const categoryColors = {} |
| 1331 | const catColorList = ['#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72', '#ffa657', '#79c0ff', '#f778ba'] | |
| 1332 | let catIdx = 0 | |
| 1333 | for (const cat of Object.keys(categories)) { | |
| 1334 | categoryColors[cat] = catColorList[catIdx % catColorList.length] | |
| 1335 | catIdx++ | |
| 1288 | for (const cat of new Set(Object.values(moduleToCategory))) { | |
| 1289 | categoryColors[cat] = window.__ct ? window.__ct.categoryColor(tree, cat) : '#58a6ff' | |
| 1336 | 1290 | } |
| 1337 | 1291 | |
| 1338 | 1292 | // Build nodes for what's at the current path. This is the file-manager |
| … | … | @@ -1368,13 +1322,12 @@ function initGraph () { |
| 1368 | 1322 | const name = item.name |
| 1369 | 1323 | const mod = item.mod |
| 1370 | 1324 | |
| 1371 | const fileCount = mod ? Object.keys(mod.files).length : 0 | |
| 1372 | let memberCount = 0 | |
| 1373 | if (mod) { | |
| 1374 | for (const f of Object.values(mod.files)) { | |
| 1375 | if (f.classes) for (const c of f.classes) memberCount += c.methods.length + (c.getters ? c.getters.length : 0) | |
| 1376 | } | |
| 1377 | } | |
| 1325 | // One definition of these counts, shared with every other view. This used | |
| 1326 | // to count only class methods and getters, so a module of free functions | |
| 1327 | // reported zero members here and a real number in the file view. | |
| 1328 | const stats = window.__ct.moduleStats(mod) | |
| 1329 | const fileCount = stats.fileCount | |
| 1330 | const memberCount = stats.memberCount | |
| 1378 | 1331 | |
| 1379 | 1332 | const cat = isDir ? 'Directories' : (moduleToCategory[name] || 'Other') |
| 1380 | 1333 | const catI = catList.indexOf(cat) |
| … | … | @@ -2424,10 +2377,8 @@ function drillIntoModule (moduleName, fromNav) { |
| 2424 | 2377 | for (let i = 0; i < files.length; i++) { |
| 2425 | 2378 | const [path, file] = files[i] |
| 2426 | 2379 | nameToIdx[path] = i |
| 2427 | let memberCount = 0 | |
| 2428 | if (file.classes) for (const c of file.classes) memberCount += c.methods.length + (c.getters ? c.getters.length : 0) | |
| 2429 | if (file.functions) memberCount += file.functions.length | |
| 2430 | let exportCount = file.exports ? file.exports.length : 0 | |
| 2380 | const memberCount = window.__ct.fileMembers(file) | |
| 2381 | const exportCount = file.exports ? file.exports.length : 0 | |
| 2431 | 2382 | fileData.push({ path, file, memberCount, exportCount, role: file.role || 'lib' }) |
| 2432 | 2383 | } |
| 2433 | 2384 | |
| … | … | @@ -3971,17 +3922,39 @@ function makeResizable (handleId, panelId, side) { |
| 3971 | 3922 | ${NODE_VIEW_JS} |
| 3972 | 3923 | |
| 3973 | 3924 | // ====== INIT ====== |
| 3974 | buildTree() | |
| 3975 | initResizable() | |
| 3925 | // | |
| 3926 | // The shared modules are a HARD dependency, not a hopeful global: they are the | |
| 3927 | // representation this page renders from — the dirTree walk, the categories, the | |
| 3928 | // layout. A deferred module script means waiting on window.__ct to | |
| 3929 | // appear is a race, and losing it renders an empty page. Await it, once, here. | |
| 3930 | ;(async function boot () { | |
| 3931 | if (!window.__ct) { | |
| 3932 | try { | |
| 3933 | const [cards, layoutMod] = await Promise.all([ | |
| 3934 | import(API + '/floor/cards.js'), | |
| 3935 | import(API + '/floor/layout.js') | |
| 3936 | ]) | |
| 3937 | window.__ct = cards | |
| 3938 | window.__ctLayout = layoutMod.layout | |
| 3939 | } catch (err) { | |
| 3940 | document.getElementById('detail').innerHTML = | |
| 3941 | '<div style="padding:20px;color:#ff7b72">Could not load the shared view modules: ' + | |
| 3942 | escapeHtml(String(err && err.message ? err.message : err)) + '</div>' | |
| 3943 | return | |
| 3944 | } | |
| 3945 | } | |
| 3976 | 3946 | |
| 3977 | // The tab is part of WHERE YOU ARE, so it belongs in the URL like every other | |
| 3978 | // view here (invariant I5). ?tab=nodes opens the Nodes tab directly, which also | |
| 3979 | // makes the tab reviewable without a human clicking it. | |
| 3980 | ;(function selectTabFromUrl () { | |
| 3947 | buildTree() | |
| 3948 | initResizable() | |
| 3949 | ||
| 3950 | // The tab is part of WHERE YOU ARE, so it belongs in the URL like every other | |
| 3951 | // view here (invariant I5). It is also the only way to review a tab without a | |
| 3952 | // human clicking it. | |
| 3981 | 3953 | const wanted = new URLSearchParams(location.search).get('tab') |
| 3982 | if (!wanted) return | |
| 3983 | const tab = document.querySelector('.main-tab[data-tab="' + wanted + '"]') | |
| 3984 | if (tab) tab.click() | |
| 3954 | if (wanted) { | |
| 3955 | const tab = document.querySelector('.main-tab[data-tab="' + wanted + '"]') | |
| 3956 | if (tab) tab.click() | |
| 3957 | } | |
| 3985 | 3958 | })() |
| 3986 | 3959 | |
| 3987 | 3960 | // ====== URL ROUTING — popstate + initial URL ====== |
| … | … | @@ -3,7 +3,8 @@ import { resolve, dirname } from 'path' |
| 3 | 3 | import { fileURLToPath } from 'url' |
| 4 | 4 | import { scan } from '../lib/scanner.js' |
| 5 | 5 | import { |
| 6 | buildSystemLevel, buildModuleLevel, bundleLinks, dependencyClosure, depthColor | |
| 6 | buildSystemLevel, buildModuleLevel, bundleLinks, dependencyClosure, depthColor, | |
| 7 | nodesAtPath, categoryOf, categoriesOf, categoryColor, moduleStats | |
| 7 | 8 | } from '../lib/floor/cards.js' |
| 8 | 9 | |
| 9 | 10 | const HERE = dirname(fileURLToPath(import.meta.url)) |
| … | … | @@ -268,3 +269,58 @@ test('gradient: colour moves with depth and is stable at the ends', (t) => { |
| 268 | 269 | t.ok(near.startsWith('hsl('), 'and they are hsl ramps: ' + near + ' -> ' + far) |
| 269 | 270 | t.is(depthColor(9, 1), depthColor(9, 1), 'deterministic') |
| 270 | 271 | }) |
| 272 | ||
| 273 | test('shared: nodesAtPath walks the dirTree, one implementation for both views', (t) => { | |
| 274 | const roots = nodesAtPath(tree, '') | |
| 275 | t.ok(roots.length > 0, 'the workspace root has entries') | |
| 276 | const names = roots.map(r => r.name).sort() | |
| 277 | t.alike(names, ['legacy-sync', 'mapped-io', 'widget-rooms', 'widget-store'], 'the four modules') | |
| 278 | ||
| 279 | for (const r of roots) { | |
| 280 | t.is(r.type, 'module', r.name + ' is a module, not a bare directory') | |
| 281 | t.ok(r.mod, 'and carries its module entry') | |
| 282 | t.ok(r.dirEntry, 'and its dirTree entry') | |
| 283 | } | |
| 284 | t.alike(nodesAtPath(tree, 'does/not/exist'), [], 'a missing path is empty, not a throw') | |
| 285 | }) | |
| 286 | ||
| 287 | test('shared: categoryOf uses the scanner`s single resolution', (t) => { | |
| 288 | for (const name of tree.system.modules) { | |
| 289 | t.is(categoryOf(tree, name), tree.moduleCategories[name], | |
| 290 | name + ' agrees with the scanner') | |
| 291 | } | |
| 292 | t.is(categoryOf(tree, 'not-a-module'), 'Other', 'an unknown module lands in Other') | |
| 293 | }) | |
| 294 | ||
| 295 | test('shared: every category in use gets a colour, derived ones included', (t) => { | |
| 296 | const cats = categoriesOf(tree) | |
| 297 | t.ok(cats.length > 0, 'categories: ' + cats.join(', ')) | |
| 298 | ||
| 299 | const derived = cats.filter(c => c.endsWith(' users')) | |
| 300 | t.ok(derived.length > 0, 'this workspace has a DERIVED category: ' + derived.join(', ')) | |
| 301 | ||
| 302 | const colors = new Set() | |
| 303 | for (const c of cats) { | |
| 304 | const col = categoryColor(tree, c) | |
| 305 | t.ok(/^#[0-9a-f]{6}$/i.test(col), c + ' has a colour: ' + col) | |
| 306 | colors.add(col) | |
| 307 | } | |
| 308 | t.is(colors.size, cats.length, 'and distinct categories get distinct colours') | |
| 309 | t.is(categoryColor(tree, cats[0]), categoryColor(tree, cats[0]), 'stable') | |
| 310 | }) | |
| 311 | ||
| 312 | test('shared: moduleStats counts what both views show', (t) => { | |
| 313 | const mod = tree.modules['widget-store'] | |
| 314 | const stats = moduleStats(mod) | |
| 315 | t.is(stats.fileCount, Object.keys(mod.files).length, 'files') | |
| 316 | t.is(stats.exportCount, mod.publicAPI.length, 'exports') | |
| 317 | // Pin the definition: functions + class methods + getters, across every file. | |
| 318 | let expected = 0 | |
| 319 | for (const f of Object.values(mod.files)) { | |
| 320 | expected += (f.functions || []).length | |
| 321 | for (const c of f.classes || []) expected += (c.methods || []).length + (c.getters || []).length | |
| 322 | } | |
| 323 | t.is(stats.memberCount, expected, 'members are functions + methods + getters: ' + expected) | |
| 324 | t.ok(expected > 0, 'and this fixture has some') | |
| 325 | t.alike(moduleStats(null), { fileCount: 0, memberCount: 0, exportCount: 0 }, 'no module is zeros') | |
| 326 | }) |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { test } from 'brittle' |
| 2 | import { readFileSync } from 'fs' | |
| 2 | 3 | import { resolve, dirname } from 'path' |
| 3 | 4 | import { fileURLToPath } from 'url' |
| 4 | 5 | import { scan } from '../lib/scanner.js' |
| … | … | @@ -7,6 +8,7 @@ import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs' |
| 7 | 8 | |
| 8 | 9 | const HERE = dirname(fileURLToPath(import.meta.url)) |
| 9 | 10 | const FIXTURE = resolve(HERE, 'fixtures/connectors') |
| 11 | const BACKTICK = String.fromCharCode(96) | |
| 10 | 12 | |
| 11 | 13 | // render.js emits a whole browser app as a template literal. Nothing type-checks |
| 12 | 14 | // it, lints it, or parses it at build time — a stray brace ships a white page. |
| … | … | @@ -33,6 +35,24 @@ test('render: the emitted page parses as JavaScript', async (t) => { |
| 33 | 35 | } |
| 34 | 36 | }) |
| 35 | 37 | |
| 38 | test('render: the page literal contains NO backtick', (t) => { | |
| 39 | // renderPage returns one enormous template literal, so a single backtick | |
| 40 | // anywhere inside it — including inside a comment — terminates the literal and | |
| 41 | // breaks the module. That happened twice in one sitting while editing | |
| 42 | // comments, and it is invisible until the file is imported. | |
| 43 | const src = readFileSync(resolve(HERE, '../lib/render.js'), 'utf-8') | |
| 44 | const start = src.indexOf('return ' + BACKTICK + '<!DOCTYPE html>') | |
| 45 | t.ok(start > 0, 'found the page literal') | |
| 46 | const body = src.slice(start + ('return ' + BACKTICK).length, src.lastIndexOf(BACKTICK)) | |
| 47 | ||
| 48 | const lines = body.split('\n') | |
| 49 | const offenders = [] | |
| 50 | for (let i = 0; i < lines.length; i++) { | |
| 51 | if (lines[i].includes(BACKTICK)) offenders.push((i + 1) + ': ' + lines[i].trim().slice(0, 60)) | |
| 52 | } | |
| 53 | t.alike(offenders, [], 'no backticks inside the page literal: ' + offenders.join(' | ')) | |
| 54 | }) | |
| 55 | ||
| 36 | 56 | test('render: no duplicate top-level function declarations', async (t) => { |
| 37 | 57 | const tree = await scan(FIXTURE) |
| 38 | 58 | const js = inlineScript(renderPage(tree, '')) |