refactor(dev): unify frontend-bundle-visualizer-report.mjs and frontend-js-size.mjs

This commit is contained in:
syuilo 2026-06-23 14:48:25 +09:00
commit 8186742c0f
4 changed files with 331 additions and 391 deletions

View file

@ -1,276 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises';
const [beforeFile, afterFile, outputFile] = process.argv.slice(2);
if (beforeFile == null || afterFile == null || outputFile == null) {
console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs <before-stats.json> <after-stats.json> <report.md>');
process.exit(1);
}
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function formatNumber(value) {
return numberFormatter.format(value);
}
function formatPercent(value) {
return `${Math.round(value)}%`;
}
function sharePercent(value, total) {
if (total === 0) return '0%';
return formatPercent((value / total) * 100);
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
function formatColoredDiff(text, diff) {
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
}
function formatDiff(before, after, formatter) {
const diff = after - before;
if (diff === 0) return formatter(0);
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatter(Math.abs(diff))}`, diff);
}
function formatDiffPercent(before, after) {
if (before === 0 && after === 0) return '0%';
if (before === 0) return '-';
const diff = after - before;
if (diff === 0) return '0%';
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatPercent(Math.abs(diff / before) * 100)}`, diff);
}
function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
}
function code(value) {
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
const backtickRuns = sanitized.match(/`+/g) ?? [];
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
const fence = '`'.repeat(fenceLength);
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
return `${fence}${padding}${sanitized}${padding}${fence}`;
}
function tableCode(value) {
return tableCell(code(value));
}
function collectReport(data) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function renderSummaryTable(before, after) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
];
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
];
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`,
`<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${formatDiff(before.summary[key], after.summary[key], formatNumber)}</td>`),
...metrics.map((key) => `<td>${formatDiff(before.metrics[key], after.metrics[key], formatBytes)}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
];
}
const beforeData = JSON.parse(await readFile(beforeFile, 'utf8'));
const afterData = JSON.parse(await readFile(afterFile, 'utf8'));
const before = collectReport(beforeData);
const after = collectReport(afterData);
const lines = [
'## Frontend Bundle Report',
'',
...renderSummaryTable(before, after),
'',
'<details>',
'<summary>Top 10</summary>',
'',
];
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
'',
'</details>',
);
lines.push(
'',
'<details>',
'<summary>Hot Modules (Self Size)</summary>',
'',
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
lines.push(
'',
'</details>',
);
await writeFile(outputFile, `${lines.join('\n')}\n`);

View file

@ -1,8 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
const marker = '<!-- misskey-frontend-js-size -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
@ -33,18 +40,26 @@ async function* walk(dir) {
}
}
function formatNumber(value) {
return numberFormatter.format(value);
}
function formatBytes(size) {
if (size == null) return '-';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${stripTrailingZeros((size / 1024).toFixed(1))} KiB`;
return `${stripTrailingZeros((size / 1024 / 1024).toFixed(2))} MiB`;
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function stripTrailingZeros(value) {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
}
function formatMathText(text) {
function escapeLatex(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
@ -52,28 +67,60 @@ function formatMathText(text) {
.replaceAll('%', '\\%');
}
function formatDiff(diff) {
if (diff == null) return '-';
if (diff === 0) return '0 B';
const sign = diff > 0 ? '+' : '-';
const text = `${sign}${formatBytes(Math.abs(diff))}`;
function formatColoredDiff(text, diff) {
if (diff === 0) return text;
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text).replaceAll('\\%', '\\\\%')}}}$`;
const sign = diff > 0 ? '+' : '-';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text).replaceAll('\\%', '\\\\%')}}}$`;
}
function formatDiffPercent(beforeSize, afterSize) {
if (beforeSize == null || beforeSize === 0 || afterSize == null || afterSize === 0) return '-';
const diff = afterSize - beforeSize;
function formatNumberDiff(before, after) {
if (before == null || after == null) return '-';
const diff = after - before;
return formatColoredDiff(formatNumber(Math.abs(diff)), diff);
}
function formatBytesDiff(diff) {
if (diff == null) return '-';
if (diff === 0) return '0 B';
return formatColoredDiff(formatBytes(Math.abs(diff)), diff);
}
function formatDiffPercent(before, after) {
if (before == null || before === 0 || after == null || after === 0) return '-';
const diff = after - before;
if (diff === 0) return `0%`;
const percent = Math.round(diff / beforeSize * 100);
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(percent.toString() + '%').replaceAll('\\%', '\\\\%')}}}$`;
const percent = Math.round(diff / before * 100);
return formatColoredDiff(`${percent}%`, diff);
}
function sharePercent(value, total) {
if (total === 0) return '0%';
return Math.round((value / total) * 100) + '%';
}
function escapeCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
}
function code(value) {
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
const backtickRuns = sanitized.match(/`+/g) ?? [];
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
const fence = '`'.repeat(fenceLength);
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
return `${fence}${padding}${sanitized}${padding}${fence}`;
}
function tableCode(value) {
return tableCell(code(value));
}
function entryDisplayName(entry) {
if (!entry) return '';
return entry.displayName || entry.file;
@ -174,28 +221,165 @@ async function collectReport(repoDir) {
};
}
function commonKeys(before, after) {
function collectVisualizerReport(data) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function renderVisualizerSummaryTable(before, after) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
];
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
];
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`,
`<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${formatNumberDiff(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytesDiff(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
];
}
function commonChunkKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] != null);
}
function addedKeys(before, after) {
function addedChunkKeys(before, after) {
return Object.keys(after.chunks)
.filter((key) => before.chunks[key] == null);
}
function removedKeys(before, after) {
function removedChunkKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] == null);
}
function rowChangeType(beforeEntry, afterEntry, beforeSize, afterSize) {
if (beforeEntry == null) return 'added';
if (afterEntry == null) return 'removed';
if (beforeSize !== afterSize) return 'updated';
return 'unchanged';
}
function getChunkComparisonRows(keys, before, after) {
return keys.map((key) => {
const beforeEntry = before.chunks[key];
@ -208,13 +392,13 @@ function getChunkComparisonRows(keys, before, after) {
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeSize,
afterSize,
changeType: rowChangeType(beforeEntry, afterEntry, beforeSize, afterSize),
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
sortSize: Math.max(beforeSize, afterSize),
};
});
}
function summarizeChanges(rows) {
function summarizeChunkChanges(rows) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
added: rows.filter((row) => row.changeType === 'added').length,
@ -222,18 +406,18 @@ function summarizeChanges(rows) {
};
}
function formatChangeSummary(label, summary) {
function formatChunkChangeSummary(label, summary) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
function compareComparisonRows(a, b) {
function compareChunkComparisonRows(a, b) {
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
function markdownTable(rows, total) {
function chunkMarkdownTable(rows, total) {
if (rows.length === 0) return '_No data_';
const lines = [
@ -241,91 +425,138 @@ function markdownTable(rows, total) {
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatDiff(total.afterSize - total.beforeSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatBytesDiff(total.afterSize - total.beforeSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | $\\color{orange}{\\text{(+)}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.afterSize - row.beforeSize)} | $\\color{orange}{\\text{(+)}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | $\\color{green}{\\text{(-)}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.afterSize - row.beforeSize)} | $\\color{green}{\\text{(-)}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize)} |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.afterSize - row.beforeSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize)} |`);
}
}
return lines.join('\n');
}
const beforeDir = process.argv[2];
const afterDir = process.argv[3];
const outFile = process.argv[4];
const beforeSha = process.env.BASE_SHA;
const afterSha = process.env.HEAD_SHA;
function renderFrontendChunkReport(before, after) {
const commonChunkKeys = commonChunkKeys(before, after);
const allChunkKeys = [
...commonChunkKeys,
...addedChunkKeys(before, after),
...removedChunkKeys(before, after),
];
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChunkChanges(changedRows);
const diffTotal = {
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows.sort(compareChunkComparisonRows);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
// .slice(0, 30);
return [
'<details open>',
`<summary>${formatChunkChangeSummary('Diffs', diffSummary)}</summary>`,
'',
chunkMarkdownTable(diffRows, diffTotal),
'',
'</details>',
'',
'<details>',
`<summary>${formatChunkChangeSummary('Startup', startupSummary)}</summary>`,
'',
chunkMarkdownTable(startupRows, startupTotal),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
//'<details>',
//`<summary>Largest</summary>`,
//'',
//markdownTable(largeRows),
//'',
//'</details>',
//'',
].join('\n');
}
function renderFrontendBundleReport(before, after) {
const lines = [
...renderVisualizerSummaryTable(before, after),
'',
'<details>',
'<summary>Top 10</summary>',
'',
];
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
'',
'</details>',
);
lines.push(
'',
'<details>',
'<summary>Hot Modules (Self Size)</summary>',
'',
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
lines.push(
'',
'</details>',
);
return lines.join('\n');
}
const args = process.argv.slice(2);
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const commonChunkKeys = commonKeys(before, after);
const allChunkKeys = [
...commonChunkKeys,
...addedKeys(before, after),
...removedKeys(before, after),
];
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChanges(changedRows);
const diffTotal = {
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const diffRows = changedRows.sort(compareComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows
.sort(compareComparisonRows);
const startupSummary = summarizeChanges(startupComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
// .slice(0, 30);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8'));
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8'));
const body = [
marker,
'',
`## Frontend Chunk Report`,
'',
'<details open>',
`<summary>${formatChangeSummary('Diffs', diffSummary)}</summary>`,
renderFrontendChunkReport(before, after),
'',
markdownTable(diffRows, diffTotal),
'## Frontend Bundle Report',
'',
'</details>',
'',
'<details>',
`<summary>${formatChangeSummary('Startup', startupSummary)}</summary>`,
'',
markdownTable(startupRows, startupTotal),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
//'<details>',
//`<summary>Largest</summary>`,
//'',
//markdownTable(largeRows),
//'',
//'</details>',
//'',
renderFrontendBundleReport(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)),
].join('\n');
await fs.writeFile(outFile, body);

View file

@ -26,7 +26,6 @@ on:
- pnpm-workspace.yaml
- .node-version
- .github/scripts/frontend-js-size.mjs
- .github/scripts/frontend-bundle-visualizer-report.mjs
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
@ -185,7 +184,6 @@ jobs:
const reportMarkers = [jsSizeMarker, visualizerMarker];
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
const visualizerReportPath = path.join(reportDir, 'frontend-bundle-visualizer-report.md');
const prNumberPath = path.join(reportDir, 'pr-number.txt');
const headShaPath = path.join(reportDir, 'head-sha.txt');
const workflowRun = context.payload.workflow_run;
@ -197,10 +195,6 @@ jobs:
core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.');
return;
}
if (!fs.existsSync(visualizerReportPath)) {
core.setFailed('The frontend bundle report artifact does not contain frontend-bundle-visualizer-report.md.');
return;
}
const artifactHeadSha = fs.existsSync(headShaPath)
? fs.readFileSync(headShaPath, 'utf8').trim()
@ -274,11 +268,7 @@ jobs:
core.setFailed('The frontend JS size report is missing the expected marker.');
return;
}
const visualizerReport = fs.readFileSync(visualizerReportPath, 'utf8').trim();
let body = [
jsSizeReport,
visualizerReport,
].join('\n\n') + '\n';
let body = `${jsSizeReport}\n`;
const maxCommentLength = 65_000;
if (body.length > maxCommentLength) {

View file

@ -21,7 +21,6 @@ on:
- pnpm-workspace.yaml
- .node-version
- .github/scripts/frontend-js-size.mjs
- .github/scripts/frontend-bundle-visualizer-report.mjs
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
@ -134,8 +133,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/frontend-js-size-report.md"
node after/.github/scripts/frontend-bundle-visualizer-report.mjs "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-visualizer-report.md"
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
@ -148,10 +146,7 @@ jobs:
test -s "$REPORT_DIR/before-stats.json"
test -s "$REPORT_DIR/after-stats.json"
test -s "$REPORT_DIR/frontend-js-size-report.md"
test -s "$REPORT_DIR/frontend-bundle-visualizer-report.md"
cat "$REPORT_DIR/frontend-js-size-report.md" >> "$GITHUB_STEP_SUMMARY"
printf '\n\n' >> "$GITHUB_STEP_SUMMARY"
cat "$REPORT_DIR/frontend-bundle-visualizer-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report
if: steps.check-base-visualizer.outputs.supported == 'true'