|
423 | 423 |
|
424 | 424 | const detail = document.getElementById('detail'); |
425 | 425 |
|
426 | | - function escapeHtml(value) { |
427 | | - return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`); |
| 426 | + // Everything below builds DOM nodes and sets `textContent` rather than assembling an |
| 427 | + // HTML string. The graph payload is read out of the page (`textContent` of an inline |
| 428 | + // JSON block), which makes any interpolation of it into `innerHTML` a text-to-HTML |
| 429 | + // reinterpretation — CodeQL flagged four, and hand-escaping only moves the problem to |
| 430 | + // "did every interpolation remember to escape". With no HTML sink there is nothing to |
| 431 | + // escape: a file path containing `<` renders as that path. |
| 432 | + function el(tag, props, children) { |
| 433 | + const node = document.createElement(tag); |
| 434 | + for (const [key, value] of Object.entries(props ?? {})) { |
| 435 | + if (value === undefined || value === false) continue; |
| 436 | + if (key === 'text') node.textContent = String(value); |
| 437 | + else if (key === 'class') node.className = value; |
| 438 | + else if (key === 'style') node.setAttribute('style', value); |
| 439 | + else if (key.startsWith('data-') || key.startsWith('aria-')) { |
| 440 | + node.setAttribute(key, String(value)); |
| 441 | + } else node[key] = value; |
| 442 | + } |
| 443 | + for (const child of children ?? []) { |
| 444 | + if (child === null || child === undefined || child === false) continue; |
| 445 | + node.append(child); |
| 446 | + } |
| 447 | + return node; |
| 448 | + } |
| 449 | + |
| 450 | + function replaceChildren(target, children) { |
| 451 | + target.replaceChildren(...children.filter(Boolean)); |
428 | 452 | } |
429 | 453 |
|
430 | 454 | function edgeTags(edge, index) { |
431 | 455 | const tags = []; |
432 | | - if (edge[2] !== 0) tags.push(`<span class="tag ${KIND[edge[2]]}">${KIND[edge[2]]}</span>`); |
433 | | - if (edge[3] & FLAG_BACK) tags.push('<span class="tag back">back-edge</span>'); |
| 456 | + if (edge[2] !== 0) |
| 457 | + tags.push(el('span', { class: `tag ${KIND[edge[2]]}`, text: KIND[edge[2]] })); |
| 458 | + if (edge[3] & FLAG_BACK) tags.push(el('span', { class: 'tag back', text: 'back-edge' })); |
434 | 459 | if (edge[3] & FLAG_TYPE_INVERSION) { |
435 | | - tags.push('<span class="tag redundant">type inversion</span>'); |
| 460 | + tags.push(el('span', { class: 'tag redundant', text: 'type inversion' })); |
436 | 461 | } |
437 | | - if (edge[3] & FLAG_REDUNDANT) tags.push('<span class="tag redundant">redundant</span>'); |
438 | | - if (cycleEdges.has(index)) tags.push('<span class="tag back">cycle</span>'); |
439 | | - return tags.join(''); |
| 462 | + if (edge[3] & FLAG_REDUNDANT) { |
| 463 | + tags.push(el('span', { class: 'tag redundant', text: 'redundant' })); |
| 464 | + } |
| 465 | + if (cycleEdges.has(index)) tags.push(el('span', { class: 'tag back', text: 'cycle' })); |
| 466 | + return tags; |
440 | 467 | } |
441 | 468 |
|
442 | 469 | function depList(title, edgeIndices, endpoint) { |
| 470 | + const heading = el('h3', { text: `${title} — ${edgeIndices.length}` }); |
443 | 471 | if (edgeIndices.length === 0) { |
444 | | - return `<div class="deps"><h3>${title} — 0</h3><p class="empty">none</p></div>`; |
| 472 | + return el('div', { class: 'deps' }, [heading, el('p', { class: 'empty', text: 'none' })]); |
445 | 473 | } |
446 | 474 | const rows = edgeIndices |
447 | 475 | .map((index) => ({ index, other: edges[index][endpoint] })) |
448 | 476 | .sort((left, right) => nodes[left.other].id.localeCompare(nodes[right.other].id)) |
449 | | - .map( |
450 | | - ({ index, other }) => |
451 | | - `<li><button type="button" data-goto="${other}">${escapeHtml( |
452 | | - nodes[other].id, |
453 | | - )}${edgeTags(edges[index], index)}</button></li>`, |
454 | | - ) |
455 | | - .join(''); |
456 | | - return `<div class="deps"><h3>${title} — ${edgeIndices.length}</h3><ul>${rows}</ul></div>`; |
| 477 | + .map(({ index, other }) => |
| 478 | + el('li', {}, [ |
| 479 | + el('button', { type: 'button', 'data-goto': other }, [ |
| 480 | + el('span', { text: nodes[other].id }), |
| 481 | + ...edgeTags(edges[index], index), |
| 482 | + ]), |
| 483 | + ]), |
| 484 | + ); |
| 485 | + return el('div', { class: 'deps' }, [heading, el('ul', {}, rows)]); |
| 486 | + } |
| 487 | + |
| 488 | + function fact(term, value) { |
| 489 | + return el('div', { class: 'fact' }, [ |
| 490 | + el('dt', { text: term }), |
| 491 | + el('dd', { text: String(value) }), |
| 492 | + ]); |
457 | 493 | } |
458 | 494 |
|
459 | 495 | function select(index) { |
460 | 496 | state.selected = index; |
461 | 497 | if (index === null) { |
462 | 498 | detail.classList.remove('open'); |
463 | | - detail.innerHTML = ''; |
| 499 | + detail.replaceChildren(); |
464 | 500 | syncToggle(); |
465 | 501 | schedule(); |
466 | 502 | return; |
|
473 | 509 | const redundantOut = outEdges[index].filter((edge) => edges[edge][3] & FLAG_REDUNDANT).length; |
474 | 510 |
|
475 | 511 | const pills = [ |
476 | | - `<span class="pill rank">${escapeHtml(zone.id)}${ |
477 | | - zone.rank === null ? ' · unranked' : ` · rank ${zone.rank}` |
478 | | - }</span>`, |
| 512 | + el('span', { |
| 513 | + class: 'pill rank', |
| 514 | + text: `${zone.id}${zone.rank === null ? ' · unranked' : ` · rank ${zone.rank}`}`, |
| 515 | + }), |
479 | 516 | ]; |
480 | 517 | if (cycleIndex !== undefined) { |
481 | 518 | pills.push( |
482 | | - `<span class="pill critical">${escapeHtml(data.cycles[cycleIndex].kind)} cycle</span>`, |
| 519 | + el('span', { class: 'pill critical', text: `${data.cycles[cycleIndex].kind} cycle` }), |
483 | 520 | ); |
484 | 521 | } |
485 | | - if (backOut > 0) pills.push(`<span class="pill critical">${backOut} back-edge</span>`); |
| 522 | + if (backOut > 0) { |
| 523 | + pills.push(el('span', { class: 'pill critical', text: `${backOut} back-edge` })); |
| 524 | + } |
486 | 525 | if (redundantOut > 0) { |
487 | | - pills.push(`<span class="pill warning">${redundantOut} redundant</span>`); |
| 526 | + pills.push(el('span', { class: 'pill warning', text: `${redundantOut} redundant` })); |
488 | 527 | } |
489 | 528 |
|
490 | | - detail.innerHTML = ` |
491 | | - <div class="detail-head"> |
492 | | - <h2>src/${escapeHtml(node.id)}</h2> |
493 | | - <button type="button" class="close" data-close aria-label="Close details">×</button> |
494 | | - </div> |
495 | | - <div>${pills.join(' ')}</div> |
496 | | - <dl class="facts"> |
497 | | - <div class="fact"><dt>Dependents</dt><dd>${node.in}</dd></div> |
498 | | - <div class="fact"><dt>Dependencies</dt><dd>${node.out}</dd></div> |
499 | | - <div class="fact"><dt>Lines</dt><dd>${node.loc}</dd></div> |
500 | | - <div class="fact"><dt>Depth to sink</dt><dd>${node.lvl}</dd></div> |
501 | | - </dl> |
502 | | - ${depList('Imported by', inEdges[index], 0)} |
503 | | - ${depList('Imports', outEdges[index], 1)} |
504 | | - `; |
| 529 | + replaceChildren(detail, [ |
| 530 | + el('div', { class: 'detail-head' }, [ |
| 531 | + el('h2', { text: `src/${node.id}` }), |
| 532 | + el('button', { |
| 533 | + type: 'button', |
| 534 | + class: 'close', |
| 535 | + 'data-close': '', |
| 536 | + 'aria-label': 'Close details', |
| 537 | + text: '×', |
| 538 | + }), |
| 539 | + ]), |
| 540 | + el('div', {}, pills), |
| 541 | + el('dl', { class: 'facts' }, [ |
| 542 | + fact('Dependents', node.in), |
| 543 | + fact('Dependencies', node.out), |
| 544 | + fact('Lines', node.loc), |
| 545 | + fact('Depth to sink', node.lvl), |
| 546 | + ]), |
| 547 | + depList('Imported by', inEdges[index], 0), |
| 548 | + depList('Imports', outEdges[index], 1), |
| 549 | + ]); |
505 | 550 | detail.classList.add('open'); |
506 | 551 | syncToggle(); |
507 | 552 | schedule(); |
|
554 | 599 | tone: redundant === 0 ? 'is-ok' : 'is-warning', |
555 | 600 | }, |
556 | 601 | ]; |
557 | | - document.getElementById('stats').innerHTML = cells |
558 | | - .map((cell) => `<div class="stat ${cell.tone}"><b>${cell.value}</b>${cell.label}</div>`) |
559 | | - .join(''); |
| 602 | + replaceChildren( |
| 603 | + document.getElementById('stats'), |
| 604 | + cells.map((cell) => |
| 605 | + el('div', { class: `stat ${cell.tone}` }, [ |
| 606 | + el('b', { text: String(cell.value) }), |
| 607 | + document.createTextNode(cell.label), |
| 608 | + ]), |
| 609 | + ), |
| 610 | + ); |
560 | 611 | document.getElementById('commit').textContent = `@ ${data.generated.commit}`; |
561 | 612 | } |
562 | 613 |
|
|
566 | 617 | const rankRight = zones[right].rank ?? 99; |
567 | 618 | return rankLeft - rankRight || zones[left].id.localeCompare(zones[right].id); |
568 | 619 | }); |
569 | | - document.getElementById('legend').innerHTML = ordered |
570 | | - .map((index) => { |
| 620 | + replaceChildren( |
| 621 | + document.getElementById('legend'), |
| 622 | + ordered.map((index) => { |
571 | 623 | const zone = zones[index]; |
572 | 624 | const rank = zone.rank === null ? 'unranked' : `rank ${zone.rank}`; |
573 | | - const hidden = state.hiddenZones.has(index); |
574 | | - return `<button type="button" class="legend-row" data-zone="${index}" aria-pressed="${!hidden}"> |
575 | | - <span class="swatch" style="background:${zoneColor(index)}"></span> |
576 | | - <span class="zone">${escapeHtml(zone.id)} <i>${rank}</i></span> |
577 | | - <span class="count">${zone.files}</span> |
578 | | - </button>`; |
579 | | - }) |
580 | | - .join(''); |
| 625 | + return el( |
| 626 | + 'button', |
| 627 | + { |
| 628 | + type: 'button', |
| 629 | + class: 'legend-row', |
| 630 | + 'data-zone': index, |
| 631 | + 'aria-pressed': String(!state.hiddenZones.has(index)), |
| 632 | + }, |
| 633 | + [ |
| 634 | + el('span', { class: 'swatch', style: `background:${zoneColor(index)}` }), |
| 635 | + el('span', { class: 'zone' }, [ |
| 636 | + document.createTextNode(`${zone.id} `), |
| 637 | + el('i', { text: rank }), |
| 638 | + ]), |
| 639 | + el('span', { class: 'count', text: String(zone.files) }), |
| 640 | + ], |
| 641 | + ); |
| 642 | + }), |
| 643 | + ); |
581 | 644 | } |
582 | 645 |
|
583 | 646 | function renderHotspots() { |
584 | 647 | const top = [...nodes.keys()] |
585 | 648 | .sort((left, right) => nodes[right].in - nodes[left].in || nodes[right].out - nodes[left].out) |
586 | 649 | .slice(0, 14); |
587 | | - document.getElementById('hotspots').innerHTML = top |
588 | | - .map( |
589 | | - (index) => |
590 | | - `<button type="button" class="hotspot" data-focus="${index}"><span>${escapeHtml( |
591 | | - nodes[index].id, |
592 | | - )}</span><span class="metric">${nodes[index].in}</span></button>`, |
593 | | - ) |
594 | | - .join(''); |
| 650 | + replaceChildren( |
| 651 | + document.getElementById('hotspots'), |
| 652 | + top.map((index) => |
| 653 | + el('button', { type: 'button', class: 'hotspot', 'data-focus': index }, [ |
| 654 | + el('span', { text: nodes[index].id }), |
| 655 | + el('span', { class: 'metric', text: String(nodes[index].in) }), |
| 656 | + ]), |
| 657 | + ), |
| 658 | + ); |
595 | 659 | } |
596 | 660 |
|
597 | 661 | const layoutHint = document.getElementById('layout-hint'); |
|
0 commit comments