Skip to content

Instantly share code, notes, and snippets.

@dmsnell
Created June 11, 2026 23:20
Show Gist options
  • Select an option

  • Save dmsnell/107642d8791e5bb88c4a3f9c521dad3d to your computer and use it in GitHub Desktop.

Select an option

Save dmsnell/107642d8791e5bb88c4a3f9c521dad3d to your computer and use it in GitHub Desktop.
Gutenberg package activity chart.

Generate an Interactive Gutenberg package activity chart.

node gutenberg-package-activity.mjs path/to/gutenberg

When run without a path it will default to the current directory.

import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const D3_VERSION = '7.9.0';
const D3_URL = `https://cdn.jsdelivr.net/npm/d3@${ D3_VERSION }/dist/d3.min.js`;
export const SOURCE_EXTENSIONS = new Set( [
'.php',
'.js',
'.jsx',
'.ts',
'.tsx',
'.css',
'.scss',
] );
export function parseGutenbergCliArgs( argv ) {
const positionalArgs = [];
let outputPath;
let json = false;
for ( let index = 0; index < argv.length; index += 1 ) {
const arg = argv[ index ];
if ( arg === '-h' || arg === '--help' ) {
return {
help: true,
};
}
if ( arg === '--json' ) {
json = true;
continue;
}
if ( arg === '--out' || arg.startsWith( '--out=' ) ) {
if ( arg === '--out' ) {
const value = argv[ index + 1 ];
if ( ! value || value.startsWith( '-' ) ) {
throw new Error( 'Missing value for --out' );
}
outputPath = value;
index += 1;
} else {
outputPath = arg.slice( '--out='.length );
if ( outputPath === '' ) {
throw new Error( 'Missing value for --out' );
}
}
continue;
}
if ( arg.startsWith( '-' ) ) {
throw new Error( `Unknown option: ${ arg }` );
}
positionalArgs.push( arg );
}
if ( positionalArgs.length > 1 ) {
throw new Error(
`Expected at most one Gutenberg path, received ${ positionalArgs.length }`
);
}
if ( json && outputPath ) {
throw new Error( '--out cannot be used with --json' );
}
const parsedArgs = {
help: false,
mode: json ? 'json' : 'html',
gutenbergDir: path.resolve( process.cwd(), positionalArgs[ 0 ] ?? '.' ),
};
if ( ! json ) {
parsedArgs.outputPath = path.resolve(
process.cwd(),
outputPath ?? path.join( 'public', 'index.html' )
);
}
return parsedArgs;
}
export function validateGutenbergDir( gutenbergDir ) {
let stats;
try {
stats = fs.statSync( gutenbergDir );
} catch {
throw new Error( `Gutenberg path does not exist: ${ gutenbergDir }` );
}
if ( ! stats.isDirectory() ) {
throw new Error( `Gutenberg path is not a directory: ${ gutenbergDir }` );
}
if ( ! fs.existsSync( path.join( gutenbergDir, 'packages' ) ) ) {
throw new Error(
`Gutenberg path must contain a packages directory: ${ gutenbergDir }`
);
}
}
function usage() {
return `Usage:
node scripts/gutenberg-package-activity.mjs [path/to/gutenberg] [--out path/to/index.html]
node scripts/gutenberg-package-activity.mjs [path/to/gutenberg] --json
Arguments:
path/to/gutenberg Gutenberg repository root. Defaults to the current working directory.
Options:
--out <path> Output HTML path. Defaults to public/index.html under the current working directory.
--json Print raw package activity JSON instead of building HTML.
-h, --help Show this help message.
`;
}
export function isQualifyingSourcePath( filePath ) {
return SOURCE_EXTENSIONS.has( path.posix.extname( filePath ).toLowerCase() );
}
export function packageDirFromGitPath( filePath ) {
const parts = filePath.split( '/' );
if ( parts.length < 3 || parts[ 0 ] !== 'packages' ) {
return null;
}
return parts[ 1 ];
}
export function formatUtcDateFromUnixSeconds( unixSeconds ) {
const timestamp = Number( unixSeconds );
if ( ! Number.isFinite( timestamp ) ) {
throw new Error( `Invalid Git commit timestamp: ${ unixSeconds }` );
}
return new Date( timestamp * 1000 ).toISOString().slice( 0, 10 );
}
export function loadPackageMetadata( packagesDir ) {
const packages = fs
.readdirSync( packagesDir, { withFileTypes: true } )
.filter( ( dirent ) => dirent.isDirectory() )
.map( ( dirent ) => {
const packageJsonPath = path.join(
packagesDir,
dirent.name,
'package.json'
);
if ( ! fs.existsSync( packageJsonPath ) ) {
return null;
}
const packageJson = JSON.parse(
fs.readFileSync( packageJsonPath, 'utf8' )
);
if (
typeof packageJson.name !== 'string' ||
packageJson.name.trim() === ''
) {
throw new Error(
`Missing package name in packages/${ dirent.name }/package.json`
);
}
return {
dir: dirent.name,
name: packageJson.name,
};
} )
.filter( Boolean )
.sort( ( a, b ) => a.name.localeCompare( b.name ) );
const seenNames = new Set();
const packageNameByDir = new Map();
for ( const packageInfo of packages ) {
if ( seenNames.has( packageInfo.name ) ) {
throw new Error( `Duplicate package name: ${ packageInfo.name }` );
}
seenNames.add( packageInfo.name );
packageNameByDir.set( packageInfo.dir, packageInfo.name );
}
return {
packages,
packageNameByDir,
};
}
export function packageNamesForNameStatusLine( line, packageNameByDir ) {
const fields = line.split( '\t' );
const status = fields[ 0 ];
const paths =
status.startsWith( 'R' ) || status.startsWith( 'C' )
? fields.slice( 1, 3 )
: fields.slice( 1, 2 );
const packageNames = new Set();
for ( const filePath of paths ) {
if ( ! filePath || ! isQualifyingSourcePath( filePath ) ) {
continue;
}
const packageDir = packageDirFromGitPath( filePath );
if ( packageDir && packageNameByDir.has( packageDir ) ) {
packageNames.add( packageNameByDir.get( packageDir ) );
}
}
return packageNames;
}
export function collectDatesFromGitLog(
gitLog,
packageNameByDir,
packageNames = packageNameByDir.values()
) {
const datesByPackage = {};
for ( const packageName of [ ...packageNames ].sort() ) {
datesByPackage[ packageName ] = [];
}
for ( const rawRecord of gitLog.split( '\x1e' ) ) {
const record = rawRecord.trim();
if ( record === '' ) {
continue;
}
const [ header, ...changedFileLines ] = record.split( /\r?\n/ );
const [ commitHash, unixTimestamp ] = header.split( '\t' );
if ( ! commitHash || ! unixTimestamp ) {
throw new Error( `Invalid Git log record header: ${ header }` );
}
const date = formatUtcDateFromUnixSeconds( unixTimestamp );
const packageNamesTouchedByCommit = new Set();
for ( const line of changedFileLines ) {
if ( line.trim() === '' ) {
continue;
}
for ( const packageName of packageNamesForNameStatusLine(
line,
packageNameByDir
) ) {
packageNamesTouchedByCommit.add( packageName );
}
}
for ( const packageName of packageNamesTouchedByCommit ) {
if ( Object.hasOwn( datesByPackage, packageName ) ) {
datesByPackage[ packageName ].push( date );
}
}
}
return datesByPackage;
}
export function collectPackageCommitDates( {
gutenbergDir = process.cwd(),
packagesDir = path.join( gutenbergDir, 'packages' ),
} = {} ) {
validateGutenbergDir( gutenbergDir );
const { packages, packageNameByDir } = loadPackageMetadata( packagesDir );
const git = spawnSync(
'git',
[
'-C',
gutenbergDir,
'log',
'--reverse',
'--format=%x1e%H%x09%ct',
'--name-status',
'-M',
'-C',
'--',
'packages',
],
{
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 512,
}
);
if ( git.status !== 0 ) {
throw new Error(
`Unable to read Gutenberg history: ${ git.stderr.trim() }`
);
}
return collectDatesFromGitLog(
git.stdout,
packageNameByDir,
packages.map( ( packageInfo ) => packageInfo.name )
);
}
function escapeHtml( value ) {
return String( value )
.replaceAll( '&', '&amp;' )
.replaceAll( '<', '&lt;' )
.replaceAll( '>', '&gt;' )
.replaceAll( '"', '&quot;' );
}
function escapeJsonForScript( value ) {
return JSON.stringify( value, null, 2 )
.replaceAll( '<', '\\u003c' )
.replaceAll( '>', '\\u003e' )
.replaceAll( '&', '\\u0026' );
}
function escapeScriptSource( value ) {
return String( value ).replace( /<\/script/gi, '<\\/script' );
}
function runGit( args, cwd ) {
const result = spawnSync( 'git', args, {
cwd,
encoding: 'utf8',
} );
if ( result.status !== 0 ) {
throw new Error( result.stderr.trim() );
}
return result.stdout.trim();
}
async function fetchD3Source() {
const response = await fetch( D3_URL );
if ( ! response.ok ) {
throw new Error( `Unable to download D3: ${ response.status }` );
}
return response.text();
}
function totalCommitEntries( activityData ) {
return Object.values( activityData ).reduce(
( total, dates ) => total + dates.length,
0
);
}
function dateRange( activityData ) {
const dates = Object.values( activityData ).flat();
if ( dates.length === 0 ) {
return {
start: 'n/a',
end: 'n/a',
};
}
dates.sort();
return {
start: dates[ 0 ],
end: dates[ dates.length - 1 ],
};
}
function buildHtml( { activityData, d3Source, gutenbergCommit, generatedAt } ) {
const packageCount = Object.keys( activityData ).length;
const totalEntries = totalCommitEntries( activityData );
const range = dateRange( activityData );
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gutenberg Package Activity</title>
<style>
:root {
--bg: #f7f8fb;
--ink: #202124;
--muted: #60656f;
--line: #d9dee8;
--panel: #ffffff;
--panel-alt: #f0f4f8;
--accent: #247b7b;
--accent-strong: #d1495b;
--focus: #e9c46a;
--shadow: 0 1px 2px rgb( 17 24 39 / 0.08 );
}
* {
box-sizing: border-box;
}
html {
color-scheme: light;
}
body {
margin: 0;
background: var( --bg );
color: var( --ink );
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.4;
}
header {
background: #262a2f;
color: #ffffff;
padding: 20px clamp( 16px, 3vw, 34px ) 18px;
border-bottom: 4px solid var( --accent );
}
.header-grid {
display: grid;
grid-template-columns: minmax( 0, 1fr ) auto;
gap: 20px;
align-items: end;
}
h1 {
margin: 0;
font-size: clamp( 1.35rem, 2.5vw, 2rem );
font-weight: 700;
letter-spacing: 0;
}
.subtitle {
margin: 6px 0 0;
color: #d7dce4;
max-width: 760px;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
justify-content: flex-end;
color: #d7dce4;
font-size: 0.85rem;
}
main {
width: 100%;
}
.summary {
display: grid;
grid-template-columns: repeat( 4, minmax( 130px, 1fr ) );
gap: 1px;
background: var( --line );
border-bottom: 1px solid var( --line );
}
.summary-item {
background: var( --panel );
padding: 14px clamp( 14px, 3vw, 30px );
min-width: 0;
}
.summary-label {
display: block;
color: var( --muted );
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.summary-value {
display: block;
margin-top: 3px;
font-size: clamp( 1rem, 2vw, 1.35rem );
font-weight: 700;
white-space: nowrap;
}
.controls {
position: sticky;
top: 0;
z-index: 5;
display: grid;
grid-template-columns: minmax( 220px, 380px ) auto minmax( 220px, 1fr );
gap: 12px;
align-items: center;
padding: 12px clamp( 16px, 3vw, 34px );
background: rgb( 247 248 251 / 0.96 );
border-bottom: 1px solid var( --line );
backdrop-filter: blur( 8px );
}
.search-field {
display: grid;
gap: 4px;
}
.search-field span,
.legend-title {
color: var( --muted );
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
input[type="search"] {
width: 100%;
min-height: 38px;
border: 1px solid #bac2cf;
border-radius: 6px;
background: #ffffff;
color: var( --ink );
padding: 8px 10px;
font: inherit;
outline: none;
}
input[type="search"]:focus {
border-color: var( --accent );
box-shadow: 0 0 0 3px rgb( 36 123 123 / 0.18 );
}
button {
min-height: 38px;
border: 1px solid #aeb7c5;
border-radius: 6px;
background: #ffffff;
color: var( --ink );
padding: 8px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:hover {
border-color: var( --accent );
}
button:focus-visible {
outline: 3px solid rgb( 233 196 106 / 0.75 );
outline-offset: 2px;
}
.legend {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
min-width: 0;
}
.legend-ramp {
display: grid;
grid-template-columns: repeat( 7, 20px );
height: 14px;
border: 1px solid rgb( 32 33 36 / 0.16 );
box-shadow: var( --shadow );
}
.legend-swatch {
min-width: 0;
}
.legend-note {
color: var( --muted );
font-size: 0.82rem;
white-space: nowrap;
}
.chart-wrap {
width: 100%;
overflow: auto;
background: var( --panel );
border-bottom: 1px solid var( --line );
}
svg {
display: block;
min-width: 1080px;
font-family: inherit;
}
.axis path,
.axis line {
stroke: #aab2bf;
}
.axis text {
fill: var( --muted );
font-size: 0.75rem;
}
.package-row-bg {
fill: var( --panel );
}
.package-row-bg.alt {
fill: var( --panel-alt );
}
.package-name {
fill: var( --ink );
font-size: 0.78rem;
font-weight: 650;
}
.package-total {
fill: var( --muted );
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
}
.week-cell {
stroke: rgb( 255 255 255 / 0.55 );
stroke-width: 0.5;
shape-rendering: crispEdges;
}
.week-cell:hover {
stroke: #111827;
stroke-width: 1.2;
}
.row-divider {
stroke: #e4e8ef;
stroke-width: 1;
shape-rendering: crispEdges;
}
.empty-state {
padding: 34px;
color: var( --muted );
text-align: center;
}
.tooltip {
position: fixed;
z-index: 20;
pointer-events: none;
max-width: min( 320px, calc( 100vw - 28px ) );
background: #202124;
color: #ffffff;
padding: 9px 10px;
border-radius: 6px;
box-shadow: 0 10px 30px rgb( 0 0 0 / 0.22 );
font-size: 0.82rem;
}
.tooltip strong {
display: block;
font-size: 0.9rem;
margin-bottom: 2px;
}
footer {
padding: 16px clamp( 16px, 3vw, 34px ) 24px;
color: var( --muted );
font-size: 0.82rem;
}
@media ( max-width: 820px ) {
.header-grid,
.controls {
grid-template-columns: 1fr;
align-items: stretch;
}
.meta,
.legend {
justify-content: flex-start;
}
.summary {
grid-template-columns: repeat( 2, minmax( 0, 1fr ) );
}
}
@media ( max-width: 480px ) {
.summary {
grid-template-columns: 1fr;
}
.legend {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<header>
<div class="header-grid">
<div>
<h1>Gutenberg Package Activity</h1>
<p class="subtitle">Weekly commit activity for source files in Gutenberg packages.</p>
</div>
<div class="meta" aria-label="Build metadata">
<span>Gutenberg ${ escapeHtml( gutenbergCommit ) }</span>
<span>Generated ${ escapeHtml( generatedAt ) }</span>
</div>
</div>
</header>
<main>
<section class="summary" aria-label="Activity summary">
<div class="summary-item">
<span class="summary-label">Packages</span>
<span class="summary-value" id="visible-package-count">${ packageCount }</span>
</div>
<div class="summary-item">
<span class="summary-label">Commit Entries</span>
<span class="summary-value" id="visible-entry-count">${ totalEntries.toLocaleString( 'en-US' ) }</span>
</div>
<div class="summary-item">
<span class="summary-label">Range</span>
<span class="summary-value">${ escapeHtml( range.start ) } to ${ escapeHtml( range.end ) }</span>
</div>
<div class="summary-item">
<span class="summary-label">Grain</span>
<span class="summary-value">Weekly</span>
</div>
</section>
<section class="controls" aria-label="Chart controls">
<label class="search-field">
<span>Package</span>
<input id="package-search" type="search" autocomplete="off" placeholder="@wordpress/block-editor">
</label>
<button id="reset-search" type="button">Reset</button>
<div class="legend" aria-label="Weekly commit count legend">
<span class="legend-title">Activity</span>
<div class="legend-ramp" id="legend-ramp"></div>
<span class="legend-note" id="legend-note"></span>
</div>
</section>
<div id="chart-wrap" class="chart-wrap">
<svg id="timeline-chart" role="img" aria-label="Stacked package activity timelines"></svg>
<div id="empty-state" class="empty-state" hidden>No matching packages.</div>
</div>
</main>
<footer>
D3 v${ D3_VERSION } is embedded inline from jsDelivr. Package data is a static snapshot of the checked-out Gutenberg submodule.
</footer>
<div id="tooltip" class="tooltip" role="tooltip" hidden></div>
<script id="package-activity-data" type="application/json">${ escapeJsonForScript( activityData ) }</script>
<script>
/*! D3 v${ D3_VERSION } | https://d3js.org | BSD-3-Clause */
${ escapeScriptSource( d3Source ) }
</script>
<script>
( function () {
'use strict';
const activityData = JSON.parse(
document.getElementById( 'package-activity-data' ).textContent
);
const searchInput = document.getElementById( 'package-search' );
const resetButton = document.getElementById( 'reset-search' );
const visiblePackageCount = document.getElementById( 'visible-package-count' );
const visibleEntryCount = document.getElementById( 'visible-entry-count' );
const chartWrap = document.getElementById( 'chart-wrap' );
const emptyState = document.getElementById( 'empty-state' );
const tooltip = document.getElementById( 'tooltip' );
const svg = d3.select( '#timeline-chart' );
const parseDate = d3.utcParse( '%Y-%m-%d' );
const formatDate = d3.utcFormat( '%Y-%m-%d' );
const formatMonth = d3.utcFormat( '%b %Y' );
const formatInteger = d3.format( ',' );
const rows = Object.entries( activityData )
.sort( function ( a, b ) {
return d3.ascending( a[ 0 ], b[ 0 ] );
} )
.map( function ( entry ) {
const dates = entry[ 1 ].map( parseDate ).filter( Boolean );
const weeklyCounts = d3.rollups(
dates,
function ( values ) {
return values.length;
},
function ( date ) {
return +d3.utcMonday.floor( date );
}
);
return {
name: entry[ 0 ],
total: entry[ 1 ].length,
weeks: weeklyCounts.map( function ( weekEntry ) {
return {
date: new Date( weekEntry[ 0 ] ),
count: weekEntry[ 1 ],
};
} ),
};
} );
const allDates = rows.flatMap( function ( row ) {
return row.weeks.map( function ( week ) {
return week.date;
} );
} );
const minDate = d3.min( allDates ) || new Date();
const maxDate = d3.max( allDates ) || new Date();
const startDate = d3.utcMonday.floor( minDate );
const endDate = d3.utcMonday.offset( d3.utcMonday.ceil( maxDate ), 1 );
const maxWeeklyCount = d3.max(
rows.flatMap( function ( row ) {
return row.weeks.map( function ( week ) {
return week.count;
} );
} )
) || 1;
const color = d3
.scaleSequentialSqrt( d3.interpolateTurbo )
.domain( [ 1, maxWeeklyCount ] );
function renderLegend() {
const ramp = d3.select( '#legend-ramp' );
const stops = d3.range( 7 ).map( function ( index ) {
return 1 + ( index / 6 ) * ( maxWeeklyCount - 1 );
} );
ramp
.selectAll( '.legend-swatch' )
.data( stops )
.join( 'span' )
.attr( 'class', 'legend-swatch' )
.style( 'background-color', function ( value ) {
return color( value );
} );
document.getElementById( 'legend-note' ).textContent =
'1 to ' + formatInteger( maxWeeklyCount ) + ' commits/week';
}
function truncatedPackageName( packageName, maxChars ) {
if ( packageName.length <= maxChars ) {
return packageName;
}
return packageName.slice( 0, Math.max( 0, maxChars - 3 ) ) + '...';
}
function showTooltip( event, packageName, week ) {
tooltip.hidden = false;
tooltip.innerHTML =
'<strong>' +
packageName +
'</strong><span>' +
formatInteger( week.count ) +
' commits in week of ' +
formatDate( week.date ) +
'</span>';
const offset = 12;
const tooltipRect = tooltip.getBoundingClientRect();
const left = Math.min(
window.innerWidth - tooltipRect.width - offset,
event.clientX + offset
);
const top = Math.min(
window.innerHeight - tooltipRect.height - offset,
event.clientY + offset
);
tooltip.style.left = Math.max( offset, left ) + 'px';
tooltip.style.top = Math.max( offset, top ) + 'px';
}
function hideTooltip() {
tooltip.hidden = true;
}
function render() {
const query = searchInput.value.trim().toLowerCase();
const visibleRows = rows.filter( function ( row ) {
return row.name.toLowerCase().includes( query );
} );
const totalVisibleEntries = d3.sum( visibleRows, function ( row ) {
return row.total;
} );
visiblePackageCount.textContent = formatInteger( visibleRows.length );
visibleEntryCount.textContent = formatInteger( totalVisibleEntries );
emptyState.hidden = visibleRows.length > 0;
const wrapWidth = chartWrap.clientWidth || window.innerWidth;
const width = Math.max( 1080, wrapWidth );
const left = width < 1180 ? 230 : 292;
const margin = {
top: 42,
right: 26,
bottom: 28,
left: left,
};
const rowHeight = 24;
const height =
margin.top + margin.bottom + Math.max( 1, visibleRows.length ) * rowHeight;
const x = d3
.scaleUtc()
.domain( [ startDate, endDate ] )
.range( [ margin.left, width - margin.right ] );
const oneWeekWidth = Math.max(
1,
x( d3.utcMonday.offset( startDate, 1 ) ) - x( startDate ) - 1
);
const labelMaxChars = Math.max( 18, Math.floor( ( margin.left - 76 ) / 7 ) );
svg.attr( 'width', width ).attr( 'height', height );
svg.selectAll( '*' ).remove();
svg
.append( 'g' )
.attr( 'class', 'axis' )
.attr( 'transform', 'translate(0,' + margin.top + ')' )
.call(
d3
.axisTop( x )
.ticks( Math.max( 4, Math.floor( ( width - margin.left ) / 120 ) ) )
.tickFormat( formatMonth )
.tickSizeOuter( 0 )
);
const rowGroup = svg
.append( 'g' )
.attr( 'class', 'rows' )
.selectAll( 'g' )
.data( visibleRows, function ( row ) {
return row.name;
} )
.join( 'g' )
.attr( 'transform', function ( row, index ) {
return 'translate(0,' + ( margin.top + index * rowHeight ) + ')';
} );
rowGroup
.append( 'rect' )
.attr( 'class', function ( row, index ) {
return 'package-row-bg' + ( index % 2 ? ' alt' : '' );
} )
.attr( 'x', 0 )
.attr( 'y', 0 )
.attr( 'width', width )
.attr( 'height', rowHeight );
rowGroup
.append( 'line' )
.attr( 'class', 'row-divider' )
.attr( 'x1', margin.left )
.attr( 'x2', width - margin.right )
.attr( 'y1', rowHeight )
.attr( 'y2', rowHeight );
rowGroup
.append( 'text' )
.attr( 'class', 'package-name' )
.attr( 'x', 16 )
.attr( 'y', rowHeight / 2 )
.attr( 'dy', '0.35em' )
.text( function ( row ) {
return truncatedPackageName( row.name, labelMaxChars );
} )
.append( 'title' )
.text( function ( row ) {
return row.name;
} );
rowGroup
.append( 'text' )
.attr( 'class', 'package-total' )
.attr( 'x', margin.left - 12 )
.attr( 'y', rowHeight / 2 )
.attr( 'dy', '0.35em' )
.attr( 'text-anchor', 'end' )
.text( function ( row ) {
return formatInteger( row.total );
} );
rowGroup
.selectAll( '.week-cell' )
.data( function ( row ) {
return row.weeks.map( function ( week ) {
return {
packageName: row.name,
date: week.date,
count: week.count,
};
} );
} )
.join( 'rect' )
.attr( 'class', 'week-cell' )
.attr( 'x', function ( week ) {
return x( week.date );
} )
.attr( 'y', 3 )
.attr( 'width', oneWeekWidth )
.attr( 'height', rowHeight - 6 )
.attr( 'fill', function ( week ) {
return color( week.count );
} )
.on( 'mousemove', function ( event, week ) {
showTooltip( event, week.packageName, week );
} )
.on( 'mouseleave', hideTooltip );
}
searchInput.addEventListener( 'input', render );
resetButton.addEventListener( 'click', function () {
searchInput.value = '';
render();
searchInput.focus();
} );
window.addEventListener( 'resize', render );
renderLegend();
render();
} )();
</script>
</body>
</html>
`;
}
export async function buildPackageActivityPage( {
gutenbergDir = process.cwd(),
outputPath = path.resolve( process.cwd(), 'public', 'index.html' ),
} = {} ) {
validateGutenbergDir( gutenbergDir );
const activityData = collectPackageCommitDates( { gutenbergDir } );
const d3Source = await fetchD3Source();
const gutenbergCommit = runGit(
[ '-C', gutenbergDir, 'rev-parse', '--short=12', 'HEAD' ],
process.cwd()
);
const generatedAt = new Date().toISOString();
const html = buildHtml( {
activityData,
d3Source,
gutenbergCommit,
generatedAt,
} );
fs.mkdirSync( path.dirname( outputPath ), { recursive: true } );
fs.writeFileSync( outputPath, html );
return {
outputPath,
packageCount: Object.keys( activityData ).length,
totalEntries: totalCommitEntries( activityData ),
};
}
const currentFile = fileURLToPath( import.meta.url );
if ( process.argv[ 1 ] && path.resolve( process.argv[ 1 ] ) === currentFile ) {
try {
const args = parseGutenbergCliArgs( process.argv.slice( 2 ) );
if ( args.help ) {
process.stdout.write( usage() );
} else if ( args.mode === 'json' ) {
process.stdout.write(
`${ JSON.stringify(
collectPackageCommitDates( {
gutenbergDir: args.gutenbergDir,
} ),
null,
2
) }\n`
);
} else {
const result = await buildPackageActivityPage( {
gutenbergDir: args.gutenbergDir,
outputPath: args.outputPath,
} );
process.stdout.write(
`Wrote ${ result.outputPath } with ${ result.packageCount } packages and ${ result.totalEntries } commit entries.\n`
);
}
} catch ( error ) {
process.stderr.write( `${ error.message }\n` );
process.exitCode = 1;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment