node gutenberg-package-activity.mjs path/to/gutenbergWhen 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( '&', '&' ) | |
| .replaceAll( '<', '<' ) | |
| .replaceAll( '>', '>' ) | |
| .replaceAll( '"', '"' ); | |
| } | |
| 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; | |
| } | |
| } |