Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/page-asset-sizes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Page Asset Size Report

on:
pull_request:
types: [opened, synchronize, reopened]

Comment thread
srungta marked this conversation as resolved.
permissions:
contents: read
pull-requests: write

Comment on lines +7 to +10
jobs:
Comment on lines +10 to +11
analyze:
name: Analyze page weights
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1"

- name: Install Jekyll
run: |
gem install jekyll -v 3.9.0
gem install kramdown-parser-gfm -v 1.1.0

- name: Build site
run: jekyll build
env:
JEKYLL_ENV: production

- name: Analyze page sizes
run: node scripts/analyze-page-sizes.mjs > /tmp/page-size-report.md

- name: Find existing report comment
id: find-comment
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: "📦 Page Asset Size Report"

- name: Post or update report comment
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body-file: /tmp/page-size-report.md
edit-mode: replace
10 changes: 8 additions & 2 deletions dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# ./dev.sh covers
# ./dev.sh social-images [_posts/path/to/post.md ...]
# ./dev.sh check-assets
# ./dev.sh page-sizes [--site <path>]
# ./dev.sh git-setup

set -euo pipefail
Expand Down Expand Up @@ -80,6 +81,10 @@ cmd_check_assets() {
node scripts/check-assets.mjs "$@"
}

cmd_page_sizes() {
node scripts/analyze-page-sizes.mjs "$@"
}

cmd_init() {
local script_path
script_path="$(cd "$(dirname "$0")" && pwd)/dev.sh"
Expand All @@ -99,7 +104,7 @@ cmd_init() {
cmd_completion() {
cat <<'EOF'
_dev_sh_completion() {
local commands=(build run draft covers social-images check-assets git-setup)
local commands=(build run draft covers social-images check-assets page-sizes git-setup)
local cur="${words[CURRENT]}"
if (( CURRENT == 2 )); then
compadd -a commands
Expand All @@ -125,11 +130,12 @@ case "$command" in
covers) cmd_covers ;;
social-images) cmd_social_images "$@" ;;
check-assets) cmd_check_assets "$@" ;;
page-sizes) cmd_page_sizes "$@" ;;
git-setup) cmd_git_setup ;;
init) cmd_init ;;
completion) cmd_completion ;;
*)
echo "Unknown command '${command}'. Available: build, run, draft, covers, social-images, check-assets, git-setup"
echo "Unknown command '${command}'. Available: build, run, draft, covers, social-images, check-assets, page-sizes, git-setup"
exit 1
;;
esac
234 changes: 234 additions & 0 deletions scripts/analyze-page-sizes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
#!/usr/bin/env node
// Analyze the per-page download weight of the built Jekyll site and print a
// Markdown report sorted by decreasing total page size (HTML + CSS + JS +
// images).
//
// Usage:
// node scripts/analyze-page-sizes.mjs [--site <path-to-_site>]
//
// Writes the Markdown report to stdout.

import { readFileSync, readdirSync, statSync } from "node:fs";
import { extname, join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");

// ---------------------------------------------------------------------------
// CLI argument helper
// ---------------------------------------------------------------------------

function parseFlag(flag) {
const idx = process.argv.indexOf(flag);
return idx !== -1 ? process.argv[idx + 1] : null;
}

const siteDir = resolve(parseFlag("--site") ?? join(root, "_site"));

// ---------------------------------------------------------------------------
// File system helpers
// ---------------------------------------------------------------------------

function safeStatSize(filePath) {
try {
return statSync(filePath).size;
} catch {
return 0;
}
}

function walkHtml(dir) {
const results = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkHtml(full));
} else if (entry.name.endsWith(".html")) {
results.push(full);
}
}
return results;
}

// ---------------------------------------------------------------------------
// Asset URL extraction (no external deps — regex over raw HTML)
// ---------------------------------------------------------------------------

const EXTERNAL_RE = /^(?:https?:)?\/\//;
const DATA_RE = /^data:/;

function isLocal(url) {
return url && !EXTERNAL_RE.test(url) && !DATA_RE.test(url);
}

function cleanUrl(url) {
return url.split("?")[0].split("#")[0];
}

function extractAssetUrls(html) {
const urls = new Set();

// <link href="..."> — stylesheets and preloads
for (const m of html.matchAll(/<link\s[^>]*\bhref=["']([^"']+)["'][^>]*>/gi)) {
const u = cleanUrl(m[1]);
if (isLocal(u)) urls.add(u);
}
Comment on lines +71 to +75

// <script src="...">
for (const m of html.matchAll(/<script\s[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)) {
const u = cleanUrl(m[1]);
if (isLocal(u)) urls.add(u);
}

// <img src="...">
for (const m of html.matchAll(/<img\s[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)) {
const u = cleanUrl(m[1]);
if (isLocal(u)) urls.add(u);
}

// <source src="..." or srcset="..."> — first candidate in srcset
for (const m of html.matchAll(/<source\s[^>]*\b(?:src|srcset)=["']([^"']+)["'][^>]*>/gi)) {
const first = m[1].split(",")[0].trim().split(/\s+/)[0];
const u = cleanUrl(first);
if (isLocal(u)) urls.add(u);
}

return urls;
}

// ---------------------------------------------------------------------------
// Asset type classification
// ---------------------------------------------------------------------------

const TYPE_MAP = {
css: "css",
js: "js",
png: "image",
jpg: "image",
jpeg: "image",
gif: "image",
webp: "image",
avif: "image",
svg: "image",
ico: "image",
woff: "font",
woff2: "font",
ttf: "font",
eot: "font",
otf: "font",
};

function assetType(url) {
const ext = extname(url).toLowerCase().replace(".", "");
return TYPE_MAP[ext] ?? "other";
}

// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------

function formatBytes(bytes) {
if (bytes === 0) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}

function pageSlug(htmlFile) {
const rel = htmlFile.slice(siteDir.length);
return rel.replace(/\/index\.html$/, "/").replace(/\.html$/, "") || "/";
}

// ---------------------------------------------------------------------------
// Main analysis
// ---------------------------------------------------------------------------

let htmlFiles;
try {
htmlFiles = walkHtml(siteDir);
} catch {
console.error(`analyze-page-sizes: could not read site directory: ${siteDir}`);
console.error(" Run 'jekyll build' first.");
process.exit(1);
}

const pages = [];

for (const htmlFile of htmlFiles) {
const html = readFileSync(htmlFile, "utf8");
const htmlSize = safeStatSize(htmlFile);
const slug = pageSlug(htmlFile);

const contributors = [{ label: "HTML", url: slug, size: htmlSize, type: "html" }];

for (const url of extractAssetUrls(html)) {
const fsPath = join(siteDir, url.startsWith("/") ? url.slice(1) : url);
const size = safeStatSize(fsPath);
if (size > 0) {
contributors.push({ label: url, url, size, type: assetType(url) });
}
}

const total = contributors.reduce((s, c) => s + c.size, 0);
pages.push({ slug, total, contributors });
}

// Sort descending by total size
pages.sort((a, b) => b.total - a.total);

// ---------------------------------------------------------------------------
// Markdown output
// ---------------------------------------------------------------------------

function sizeByType(contributors, type) {
return contributors.filter((c) => c.type === type).reduce((s, c) => s + c.size, 0);
}

const lines = [];

lines.push("## 📦 Page Asset Size Report");
lines.push("");
lines.push(
`Analyzed **${pages.length}** page${pages.length === 1 ? "" : "s"} — ` +
"sorted by total download weight (HTML + CSS + JS + images).",
);
lines.push("");
lines.push("| Page | Total | HTML | CSS | JS | Images |");
lines.push("|------|------:|-----:|----:|---:|-------:|");

for (const page of pages) {
const html = formatBytes(sizeByType(page.contributors, "html"));
const css = formatBytes(sizeByType(page.contributors, "css"));
const js = formatBytes(sizeByType(page.contributors, "js"));
const img = formatBytes(sizeByType(page.contributors, "image"));
lines.push(
`| \`${page.slug}\` | **${formatBytes(page.total)}** | ${html} | ${css} | ${js} | ${img} |`,
);
}

lines.push("");
lines.push("<details>");
lines.push("<summary>📋 Detailed breakdown per page</summary>");
lines.push("");

for (const page of pages) {
lines.push(`### \`${page.slug}\` — ${formatBytes(page.total)}`);
lines.push("");
lines.push("| Asset | Type | Size |");
lines.push("|-------|------|-----:|");

const sorted = [...page.contributors].sort((a, b) => b.size - a.size);
for (const c of sorted) {
lines.push(`| \`${c.label}\` | ${c.type} | ${formatBytes(c.size)} |`);
}
lines.push("");
}

lines.push("</details>");
lines.push("");
lines.push(
`> _Generated at ${new Date().toUTCString()} against the \`_site\` build._`,
);
lines.push("");

process.stdout.write(lines.join("\n"));
Loading