Optimize Mermaid and blog component loading with lazy imports
This commit is contained in:
1137
perf-results-2026-01-04.json
Normal file
1137
perf-results-2026-01-04.json
Normal file
File diff suppressed because it is too large
Load Diff
1137
perf-results-baseline-7e89e6d.json
Normal file
1137
perf-results-baseline-7e89e6d.json
Normal file
File diff suppressed because it is too large
Load Diff
391
scripts/perf-compare.ts
Normal file
391
scripts/perf-compare.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Performance Test Comparison Tool
|
||||
*
|
||||
* Compares two performance test results and shows the differences
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
interface PerformanceMetrics {
|
||||
fcp: number;
|
||||
lcp: number;
|
||||
cls: number;
|
||||
fid: number;
|
||||
ttfb: number;
|
||||
domContentLoaded: number;
|
||||
loadComplete: number;
|
||||
totalRequests: number;
|
||||
totalBytes: number;
|
||||
jsBytes: number;
|
||||
cssBytes: number;
|
||||
imageBytes: number;
|
||||
fontBytes: number;
|
||||
jsRequests: number;
|
||||
cssRequests: number;
|
||||
imageRequests: number;
|
||||
jsExecutionTime: number;
|
||||
taskDuration: number;
|
||||
layoutDuration: number;
|
||||
paintDuration: number;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
page: string;
|
||||
url: string;
|
||||
median: PerformanceMetrics;
|
||||
}
|
||||
|
||||
interface TestOutput {
|
||||
timestamp: string;
|
||||
baseUrl: string;
|
||||
runsPerPage: number;
|
||||
results: TestResult[];
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes.toFixed(0)}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
|
||||
}
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function formatDiff(value: number, unit: "ms" | "bytes" | "count"): string {
|
||||
const sign = value > 0 ? "+" : "";
|
||||
|
||||
if (unit === "ms") {
|
||||
return value === 0 ? "→" : `${sign}${formatTime(Math.abs(value))}`;
|
||||
} else if (unit === "bytes") {
|
||||
return value === 0 ? "→" : `${sign}${formatBytes(Math.abs(value))}`;
|
||||
} else {
|
||||
return value === 0 ? "→" : `${sign}${value.toFixed(0)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getImpact(value: number, threshold: number = 5): string {
|
||||
const percentChange = Math.abs(value);
|
||||
if (percentChange < threshold) return "";
|
||||
if (value < 0) return " 🎉"; // Improvement
|
||||
if (value > 0) return " ⚠️"; // Regression
|
||||
return "";
|
||||
}
|
||||
|
||||
function calculatePercentChange(before: number, after: number): number {
|
||||
if (before === 0) return after === 0 ? 0 : 100;
|
||||
return ((after - before) / before) * 100;
|
||||
}
|
||||
|
||||
function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
console.log("\n");
|
||||
console.log(
|
||||
"═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
console.log(
|
||||
" PERFORMANCE COMPARISON REPORT "
|
||||
);
|
||||
console.log(
|
||||
"═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
console.log(`Baseline: ${baseline.timestamp}`);
|
||||
console.log(`Optimized: ${optimized.timestamp}`);
|
||||
console.log(
|
||||
"───────────────────────────────────────────────────────────────────\n"
|
||||
);
|
||||
|
||||
// Compare each page
|
||||
for (const baseResult of baseline.results) {
|
||||
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
||||
if (!optResult) continue;
|
||||
|
||||
const base = baseResult.median;
|
||||
const opt = optResult.median;
|
||||
|
||||
console.log(`\n📄 ${baseResult.page}`);
|
||||
console.log("─".repeat(70));
|
||||
|
||||
// Core Web Vitals
|
||||
console.log("\n Core Web Vitals:");
|
||||
|
||||
const fcpDiff = opt.fcp - base.fcp;
|
||||
const fcpPercent = calculatePercentChange(base.fcp, opt.fcp);
|
||||
console.log(
|
||||
` FCP: ${formatTime(base.fcp)} → ${formatTime(opt.fcp)} (${formatDiff(fcpDiff, "ms")}, ${fcpPercent.toFixed(1)}%)${getImpact(fcpPercent)}`
|
||||
);
|
||||
|
||||
const clsDiff = opt.cls - base.cls;
|
||||
console.log(
|
||||
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
||||
);
|
||||
|
||||
// Loading Metrics
|
||||
console.log("\n Loading Metrics:");
|
||||
|
||||
const ttfbDiff = opt.ttfb - base.ttfb;
|
||||
const ttfbPercent = calculatePercentChange(base.ttfb, opt.ttfb);
|
||||
console.log(
|
||||
` TTFB: ${formatTime(base.ttfb)} → ${formatTime(opt.ttfb)} (${formatDiff(ttfbDiff, "ms")}, ${ttfbPercent.toFixed(1)}%)${getImpact(ttfbPercent)}`
|
||||
);
|
||||
|
||||
const dclDiff = opt.domContentLoaded - base.domContentLoaded;
|
||||
const dclPercent = calculatePercentChange(
|
||||
base.domContentLoaded,
|
||||
opt.domContentLoaded
|
||||
);
|
||||
console.log(
|
||||
` DCL: ${formatTime(base.domContentLoaded)} → ${formatTime(opt.domContentLoaded)} (${formatDiff(dclDiff, "ms")}, ${dclPercent.toFixed(1)}%)${getImpact(dclPercent)}`
|
||||
);
|
||||
|
||||
const loadDiff = opt.loadComplete - base.loadComplete;
|
||||
const loadPercent = calculatePercentChange(
|
||||
base.loadComplete,
|
||||
opt.loadComplete
|
||||
);
|
||||
console.log(
|
||||
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
||||
);
|
||||
|
||||
// Resource Loading
|
||||
console.log("\n Resources:");
|
||||
|
||||
const reqDiff = opt.totalRequests - base.totalRequests;
|
||||
const reqPercent = calculatePercentChange(
|
||||
base.totalRequests,
|
||||
opt.totalRequests
|
||||
);
|
||||
console.log(
|
||||
` Requests: ${base.totalRequests} → ${opt.totalRequests} (${formatDiff(reqDiff, "count")}, ${reqPercent.toFixed(1)}%)${getImpact(reqPercent, 10)}`
|
||||
);
|
||||
|
||||
const bytesDiff = opt.totalBytes - base.totalBytes;
|
||||
const bytesPercent = calculatePercentChange(
|
||||
base.totalBytes,
|
||||
opt.totalBytes
|
||||
);
|
||||
console.log(
|
||||
` Total Size: ${formatBytes(base.totalBytes)} → ${formatBytes(opt.totalBytes)} (${formatDiff(bytesDiff, "bytes")}, ${bytesPercent.toFixed(1)}%)${getImpact(bytesPercent, 10)}`
|
||||
);
|
||||
|
||||
const jsDiff = opt.jsBytes - base.jsBytes;
|
||||
const jsPercent = calculatePercentChange(base.jsBytes, opt.jsBytes);
|
||||
console.log(
|
||||
` JS Size: ${formatBytes(base.jsBytes)} → ${formatBytes(opt.jsBytes)} (${formatDiff(jsDiff, "bytes")}, ${jsPercent.toFixed(1)}%)${getImpact(jsPercent, 10)}`
|
||||
);
|
||||
|
||||
const jsReqDiff = opt.jsRequests - base.jsRequests;
|
||||
const jsReqPercent = calculatePercentChange(
|
||||
base.jsRequests,
|
||||
opt.jsRequests
|
||||
);
|
||||
console.log(
|
||||
` JS Requests: ${base.jsRequests} → ${opt.jsRequests} (${formatDiff(jsReqDiff, "count")}, ${jsReqPercent.toFixed(1)}%)${getImpact(jsReqPercent, 10)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Overall Summary
|
||||
console.log(
|
||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
console.log(
|
||||
" OVERALL SUMMARY "
|
||||
);
|
||||
console.log(
|
||||
"═══════════════════════════════════════════════════════════════════\n"
|
||||
);
|
||||
|
||||
const baseAvg = {
|
||||
fcp:
|
||||
baseline.results.reduce((sum, r) => sum + r.median.fcp, 0) /
|
||||
baseline.results.length,
|
||||
ttfb:
|
||||
baseline.results.reduce((sum, r) => sum + r.median.ttfb, 0) /
|
||||
baseline.results.length,
|
||||
dcl:
|
||||
baseline.results.reduce((sum, r) => sum + r.median.domContentLoaded, 0) /
|
||||
baseline.results.length,
|
||||
load:
|
||||
baseline.results.reduce((sum, r) => sum + r.median.loadComplete, 0) /
|
||||
baseline.results.length,
|
||||
requests:
|
||||
baseline.results.reduce((sum, r) => sum + r.median.totalRequests, 0) /
|
||||
baseline.results.length,
|
||||
bytes:
|
||||
baseline.results.reduce((sum, r) => sum + r.median.totalBytes, 0) /
|
||||
baseline.results.length,
|
||||
jsBytes:
|
||||
baseline.results.reduce((sum, r) => sum + r.median.jsBytes, 0) /
|
||||
baseline.results.length,
|
||||
jsRequests:
|
||||
baseline.results.reduce((sum, r) => sum + r.median.jsRequests, 0) /
|
||||
baseline.results.length
|
||||
};
|
||||
|
||||
const optAvg = {
|
||||
fcp:
|
||||
optimized.results.reduce((sum, r) => sum + r.median.fcp, 0) /
|
||||
optimized.results.length,
|
||||
ttfb:
|
||||
optimized.results.reduce((sum, r) => sum + r.median.ttfb, 0) /
|
||||
optimized.results.length,
|
||||
dcl:
|
||||
optimized.results.reduce((sum, r) => sum + r.median.domContentLoaded, 0) /
|
||||
optimized.results.length,
|
||||
load:
|
||||
optimized.results.reduce((sum, r) => sum + r.median.loadComplete, 0) /
|
||||
optimized.results.length,
|
||||
requests:
|
||||
optimized.results.reduce((sum, r) => sum + r.median.totalRequests, 0) /
|
||||
optimized.results.length,
|
||||
bytes:
|
||||
optimized.results.reduce((sum, r) => sum + r.median.totalBytes, 0) /
|
||||
optimized.results.length,
|
||||
jsBytes:
|
||||
optimized.results.reduce((sum, r) => sum + r.median.jsBytes, 0) /
|
||||
optimized.results.length,
|
||||
jsRequests:
|
||||
optimized.results.reduce((sum, r) => sum + r.median.jsRequests, 0) /
|
||||
optimized.results.length
|
||||
};
|
||||
|
||||
console.log(" Average Across All Pages:\n");
|
||||
|
||||
const metrics = [
|
||||
{ name: "FCP", base: baseAvg.fcp, opt: optAvg.fcp, unit: "ms" as const },
|
||||
{ name: "TTFB", base: baseAvg.ttfb, opt: optAvg.ttfb, unit: "ms" as const },
|
||||
{
|
||||
name: "DOM Content Loaded",
|
||||
base: baseAvg.dcl,
|
||||
opt: optAvg.dcl,
|
||||
unit: "ms" as const
|
||||
},
|
||||
{
|
||||
name: "Load Complete",
|
||||
base: baseAvg.load,
|
||||
opt: optAvg.load,
|
||||
unit: "ms" as const
|
||||
},
|
||||
{
|
||||
name: "Total Requests",
|
||||
base: baseAvg.requests,
|
||||
opt: optAvg.requests,
|
||||
unit: "count" as const
|
||||
},
|
||||
{
|
||||
name: "Total Size",
|
||||
base: baseAvg.bytes,
|
||||
opt: optAvg.bytes,
|
||||
unit: "bytes" as const
|
||||
},
|
||||
{
|
||||
name: "JS Size",
|
||||
base: baseAvg.jsBytes,
|
||||
opt: optAvg.jsBytes,
|
||||
unit: "bytes" as const
|
||||
},
|
||||
{
|
||||
name: "JS Requests",
|
||||
base: baseAvg.jsRequests,
|
||||
opt: optAvg.jsRequests,
|
||||
unit: "count" as const
|
||||
}
|
||||
];
|
||||
|
||||
metrics.forEach((metric) => {
|
||||
const diff = metric.opt - metric.base;
|
||||
const percent = calculatePercentChange(metric.base, metric.opt);
|
||||
const baseStr =
|
||||
metric.unit === "bytes"
|
||||
? formatBytes(metric.base)
|
||||
: metric.unit === "ms"
|
||||
? formatTime(metric.base)
|
||||
: metric.base.toFixed(1);
|
||||
const optStr =
|
||||
metric.unit === "bytes"
|
||||
? formatBytes(metric.opt)
|
||||
: metric.unit === "ms"
|
||||
? formatTime(metric.opt)
|
||||
: metric.opt.toFixed(1);
|
||||
|
||||
console.log(
|
||||
` ${metric.name.padEnd(20)} ${baseStr.padEnd(10)} → ${optStr.padEnd(10)} (${formatDiff(diff, metric.unit).padEnd(12)}, ${percent.toFixed(1).padStart(6)}%)${getImpact(percent, 5)}`
|
||||
);
|
||||
});
|
||||
|
||||
console.log("\n Key Findings:\n");
|
||||
|
||||
let improvements = 0;
|
||||
let regressions = 0;
|
||||
|
||||
metrics.forEach((metric) => {
|
||||
const percent = calculatePercentChange(metric.base, metric.opt);
|
||||
if (Math.abs(percent) >= 5) {
|
||||
if (percent < 0) improvements++;
|
||||
else regressions++;
|
||||
}
|
||||
});
|
||||
|
||||
if (improvements > 0) {
|
||||
console.log(
|
||||
` ✅ ${improvements} significant improvement${improvements === 1 ? "" : "s"}`
|
||||
);
|
||||
}
|
||||
if (regressions > 0) {
|
||||
console.log(
|
||||
` ⚠️ ${regressions} significant regression${regressions === 1 ? "" : "s"}`
|
||||
);
|
||||
}
|
||||
|
||||
// Specific findings
|
||||
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
||||
if (reqPercent < -5) {
|
||||
console.log(
|
||||
` 🎯 Reduced HTTP requests by ${Math.abs(reqPercent).toFixed(1)}%`
|
||||
);
|
||||
}
|
||||
|
||||
const jsPercent = calculatePercentChange(baseAvg.jsBytes, optAvg.jsBytes);
|
||||
if (jsPercent < -5) {
|
||||
console.log(
|
||||
` 📦 Reduced JS bundle size by ${Math.abs(jsPercent).toFixed(1)}%`
|
||||
);
|
||||
}
|
||||
|
||||
const loadPercent = calculatePercentChange(baseAvg.load, optAvg.load);
|
||||
if (Math.abs(loadPercent) < 5) {
|
||||
console.log(
|
||||
` ⚖️ Load time remained stable (${Math.abs(loadPercent).toFixed(1)}% change)`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length !== 2) {
|
||||
console.error("Usage: bun run compare.ts <baseline.json> <optimized.json>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [baselinePath, optimizedPath] = args;
|
||||
|
||||
try {
|
||||
const baseline: TestOutput = JSON.parse(
|
||||
readFileSync(baselinePath, "utf-8")
|
||||
);
|
||||
const optimized: TestOutput = JSON.parse(
|
||||
readFileSync(optimizedPath, "utf-8")
|
||||
);
|
||||
|
||||
compareResults(baseline, optimized);
|
||||
} catch (error) {
|
||||
console.error("Error reading or parsing files:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
586
scripts/perf-test.ts
Normal file
586
scripts/perf-test.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Comprehensive Page Load Performance Testing Suite
|
||||
*
|
||||
* Measures:
|
||||
* - First Contentful Paint (FCP)
|
||||
* - Largest Contentful Paint (LCP)
|
||||
* - Time to Interactive (TTI)
|
||||
* - Total Blocking Time (TBT)
|
||||
* - Cumulative Layout Shift (CLS)
|
||||
* - First Input Delay (FID)
|
||||
* - Network requests and bundle sizes
|
||||
* - JavaScript execution time
|
||||
*/
|
||||
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
interface PageTestConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
fcp: number;
|
||||
lcp: number;
|
||||
cls: number;
|
||||
fid: number;
|
||||
ttfb: number;
|
||||
domContentLoaded: number;
|
||||
loadComplete: number;
|
||||
totalRequests: number;
|
||||
totalBytes: number;
|
||||
jsBytes: number;
|
||||
cssBytes: number;
|
||||
imageBytes: number;
|
||||
fontBytes: number;
|
||||
jsRequests: number;
|
||||
cssRequests: number;
|
||||
imageRequests: number;
|
||||
jsExecutionTime: number;
|
||||
taskDuration: number;
|
||||
layoutDuration: number;
|
||||
paintDuration: number;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
page: string;
|
||||
url: string;
|
||||
runs: PerformanceMetrics[];
|
||||
average: PerformanceMetrics;
|
||||
median: PerformanceMetrics;
|
||||
p95: PerformanceMetrics;
|
||||
min: PerformanceMetrics;
|
||||
max: PerformanceMetrics;
|
||||
}
|
||||
|
||||
const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
||||
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
||||
const WARMUP_RUNS = 1;
|
||||
|
||||
// Pages to test
|
||||
const TEST_PAGES: PageTestConfig[] = [
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "About", path: "/about" },
|
||||
{ name: "Blog Index", path: "/blog" },
|
||||
{ name: "Resume", path: "/resume" },
|
||||
{ name: "Contact", path: "/contact" }
|
||||
];
|
||||
|
||||
// Add blog post path if provided
|
||||
if (process.env.TEST_BLOG_POST) {
|
||||
TEST_PAGES.push({ name: "Blog Post", path: process.env.TEST_BLOG_POST });
|
||||
}
|
||||
|
||||
async function collectPerformanceMetrics(
|
||||
page: Page
|
||||
): Promise<PerformanceMetrics> {
|
||||
// Wait for page to be fully loaded
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Collect comprehensive performance metrics
|
||||
const metrics = await page.evaluate(() => {
|
||||
const perf = performance.getEntriesByType(
|
||||
"navigation"
|
||||
)[0] as PerformanceNavigationTiming;
|
||||
const paint = performance.getEntriesByType("paint");
|
||||
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
||||
|
||||
// Get LCP using PerformanceObserver
|
||||
let lcp = 0;
|
||||
let cls = 0;
|
||||
let fid = 0;
|
||||
|
||||
// Try to get LCP from existing entries
|
||||
const lcpEntries = performance.getEntriesByType(
|
||||
"largest-contentful-paint"
|
||||
) as any[];
|
||||
if (lcpEntries.length > 0) {
|
||||
lcp =
|
||||
lcpEntries[lcpEntries.length - 1].renderTime ||
|
||||
lcpEntries[lcpEntries.length - 1].loadTime;
|
||||
}
|
||||
|
||||
// Get layout shift entries
|
||||
const layoutShiftEntries = performance.getEntriesByType(
|
||||
"layout-shift"
|
||||
) as any[];
|
||||
cls = layoutShiftEntries
|
||||
.filter((entry: any) => !entry.hadRecentInput)
|
||||
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
||||
|
||||
// Get resource timing
|
||||
const resources = performance.getEntriesByType(
|
||||
"resource"
|
||||
) as PerformanceResourceTiming[];
|
||||
|
||||
let totalBytes = 0;
|
||||
let jsBytes = 0;
|
||||
let cssBytes = 0;
|
||||
let imageBytes = 0;
|
||||
let fontBytes = 0;
|
||||
let jsRequests = 0;
|
||||
let cssRequests = 0;
|
||||
let imageRequests = 0;
|
||||
|
||||
resources.forEach((resource) => {
|
||||
const size = resource.transferSize || resource.encodedBodySize || 0;
|
||||
totalBytes += size;
|
||||
|
||||
const isJS =
|
||||
resource.name.includes(".js") ||
|
||||
resource.name.includes("/_build/") ||
|
||||
resource.initiatorType === "script";
|
||||
const isCSS =
|
||||
resource.name.includes(".css") || resource.initiatorType === "css";
|
||||
const isImage =
|
||||
resource.initiatorType === "img" ||
|
||||
/\.(jpg|jpeg|png|gif|svg|webp|avif)/.test(resource.name);
|
||||
const isFont = /\.(woff|woff2|ttf|otf|eot)/.test(resource.name);
|
||||
|
||||
if (isJS) {
|
||||
jsBytes += size;
|
||||
jsRequests++;
|
||||
} else if (isCSS) {
|
||||
cssBytes += size;
|
||||
cssRequests++;
|
||||
} else if (isImage) {
|
||||
imageBytes += size;
|
||||
imageRequests++;
|
||||
} else if (isFont) {
|
||||
fontBytes += size;
|
||||
}
|
||||
});
|
||||
|
||||
// Get performance measure entries for JS execution
|
||||
const measures = performance.getEntriesByType("measure");
|
||||
let jsExecutionTime = 0;
|
||||
let taskDuration = 0;
|
||||
let layoutDuration = 0;
|
||||
let paintDuration = 0;
|
||||
|
||||
measures.forEach((entry) => {
|
||||
if (entry.name.includes("script") || entry.name.includes("js")) {
|
||||
jsExecutionTime += entry.duration;
|
||||
}
|
||||
});
|
||||
|
||||
// Try to get long task entries
|
||||
const longTasks = performance.getEntriesByType("longtask") as any[];
|
||||
longTasks.forEach((task: any) => {
|
||||
taskDuration += task.duration;
|
||||
});
|
||||
|
||||
return {
|
||||
fcp: fcp?.startTime || 0,
|
||||
lcp,
|
||||
cls,
|
||||
fid,
|
||||
ttfb: perf.responseStart - perf.requestStart,
|
||||
domContentLoaded: perf.domContentLoadedEventEnd - perf.fetchStart,
|
||||
loadComplete: perf.loadEventEnd - perf.fetchStart,
|
||||
totalRequests: resources.length,
|
||||
totalBytes,
|
||||
jsBytes,
|
||||
cssBytes,
|
||||
imageBytes,
|
||||
fontBytes,
|
||||
jsRequests,
|
||||
cssRequests,
|
||||
imageRequests,
|
||||
jsExecutionTime,
|
||||
taskDuration,
|
||||
layoutDuration,
|
||||
paintDuration
|
||||
};
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async function testPagePerformance(
|
||||
browser: Browser,
|
||||
pageConfig: PageTestConfig
|
||||
): Promise<TestResult> {
|
||||
const url = `${BASE_URL}${pageConfig.path}`;
|
||||
const runs: PerformanceMetrics[] = [];
|
||||
|
||||
console.log(`\n📊 Testing: ${pageConfig.name} (${url})`);
|
||||
console.log(
|
||||
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
||||
);
|
||||
|
||||
// Warmup runs (not counted)
|
||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.close();
|
||||
await context.close();
|
||||
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
||||
}
|
||||
|
||||
// Measured runs
|
||||
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
||||
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
||||
|
||||
// Create new context for each run to ensure clean state
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate and collect metrics
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
const metrics = await collectPerformanceMetrics(page);
|
||||
|
||||
await page.close();
|
||||
await context.close();
|
||||
|
||||
runs.push(metrics);
|
||||
console.log(
|
||||
` FCP: ${metrics.fcp.toFixed(0)}ms | LCP: ${metrics.lcp.toFixed(0)}ms | CLS: ${metrics.cls.toFixed(3)} | Requests: ${metrics.totalRequests}`
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const average = calculateAverage(runs);
|
||||
const median = calculateMedian(runs);
|
||||
const p95 = calculatePercentile(runs, 95);
|
||||
const min = calculateMin(runs);
|
||||
const max = calculateMax(runs);
|
||||
|
||||
return {
|
||||
page: pageConfig.name,
|
||||
url,
|
||||
runs,
|
||||
average,
|
||||
median,
|
||||
p95,
|
||||
min,
|
||||
max
|
||||
};
|
||||
}
|
||||
|
||||
function calculateAverage(runs: PerformanceMetrics[]): PerformanceMetrics {
|
||||
const sum = runs.reduce((acc, run) => {
|
||||
Object.keys(run).forEach((key) => {
|
||||
acc[key] = (acc[key] || 0) + run[key as keyof PerformanceMetrics];
|
||||
});
|
||||
return acc;
|
||||
}, {} as any);
|
||||
|
||||
Object.keys(sum).forEach((key) => {
|
||||
sum[key] /= runs.length;
|
||||
});
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
function calculateMedian(runs: PerformanceMetrics[]): PerformanceMetrics {
|
||||
const sorted = runs.slice().sort((a, b) => a.lcp - b.lcp);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted[mid];
|
||||
}
|
||||
|
||||
function calculatePercentile(
|
||||
runs: PerformanceMetrics[],
|
||||
percentile: number
|
||||
): PerformanceMetrics {
|
||||
const sorted = runs.slice().sort((a, b) => a.lcp - b.lcp);
|
||||
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
function calculateMin(runs: PerformanceMetrics[]): PerformanceMetrics {
|
||||
return runs.reduce(
|
||||
(min, run) => {
|
||||
const result: any = {};
|
||||
Object.keys(run).forEach((key) => {
|
||||
const k = key as keyof PerformanceMetrics;
|
||||
result[k] = Math.min(min[k], run[k]);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{ ...runs[0] }
|
||||
);
|
||||
}
|
||||
|
||||
function calculateMax(runs: PerformanceMetrics[]): PerformanceMetrics {
|
||||
return runs.reduce(
|
||||
(max, run) => {
|
||||
const result: any = {};
|
||||
Object.keys(run).forEach((key) => {
|
||||
const k = key as keyof PerformanceMetrics;
|
||||
result[k] = Math.max(max[k], run[k]);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{ ...runs[0] }
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes.toFixed(0)}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
|
||||
}
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function getWebVitalRating(
|
||||
metric: "lcp" | "fcp" | "cls" | "fid",
|
||||
value: number
|
||||
): string {
|
||||
const thresholds = {
|
||||
lcp: { good: 2500, needsImprovement: 4000 },
|
||||
fcp: { good: 1800, needsImprovement: 3000 },
|
||||
cls: { good: 0.1, needsImprovement: 0.25 },
|
||||
fid: { good: 100, needsImprovement: 300 }
|
||||
};
|
||||
|
||||
const t = thresholds[metric];
|
||||
if (value <= t.good) return "🟢 Good";
|
||||
if (value <= t.needsImprovement) return "🟡 Needs Improvement";
|
||||
return "🔴 Poor";
|
||||
}
|
||||
|
||||
function printResults(results: TestResult[]) {
|
||||
console.log("\n\n");
|
||||
console.log(
|
||||
"═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
console.log(
|
||||
" PERFORMANCE TEST RESULTS "
|
||||
);
|
||||
console.log(
|
||||
"═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
console.log(`Base URL: ${BASE_URL}`);
|
||||
console.log(`Runs per page: ${RUNS_PER_PAGE}`);
|
||||
console.log(`Date: ${new Date().toLocaleString()}`);
|
||||
console.log(
|
||||
"───────────────────────────────────────────────────────────────────\n"
|
||||
);
|
||||
|
||||
results.forEach((result) => {
|
||||
console.log(`\n📄 ${result.page} - ${result.url}`);
|
||||
console.log("─".repeat(70));
|
||||
|
||||
console.log("\n Core Web Vitals (Median | Min → Max):");
|
||||
console.log(
|
||||
` LCP (Largest Contentful Paint): ${formatTime(result.median.lcp).padEnd(8)} | ${formatTime(result.min.lcp)} → ${formatTime(result.max.lcp)} ${getWebVitalRating("lcp", result.median.lcp)}`
|
||||
);
|
||||
console.log(
|
||||
` FCP (First Contentful Paint): ${formatTime(result.median.fcp).padEnd(8)} | ${formatTime(result.min.fcp)} → ${formatTime(result.max.fcp)} ${getWebVitalRating("fcp", result.median.fcp)}`
|
||||
);
|
||||
console.log(
|
||||
` CLS (Cumulative Layout Shift): ${result.median.cls.toFixed(3).padEnd(8)} | ${result.min.cls.toFixed(3)} → ${result.max.cls.toFixed(3)} ${getWebVitalRating("cls", result.median.cls)}`
|
||||
);
|
||||
|
||||
console.log("\n Loading Metrics (Median):");
|
||||
console.log(
|
||||
` TTFB (Time to First Byte): ${formatTime(result.median.ttfb)}`
|
||||
);
|
||||
console.log(
|
||||
` DOM Content Loaded: ${formatTime(result.median.domContentLoaded)}`
|
||||
);
|
||||
console.log(
|
||||
` Load Complete: ${formatTime(result.median.loadComplete)}`
|
||||
);
|
||||
|
||||
console.log("\n Resource Loading (Median):");
|
||||
console.log(
|
||||
` Total Requests: ${result.median.totalRequests.toFixed(0)}`
|
||||
);
|
||||
console.log(
|
||||
` Total Transfer Size: ${formatBytes(result.median.totalBytes)}`
|
||||
);
|
||||
console.log(
|
||||
` ├─ JavaScript (${result.median.jsRequests.toFixed(0)} req): ${formatBytes(result.median.jsBytes)}`
|
||||
);
|
||||
console.log(
|
||||
` ├─ CSS (${result.median.cssRequests.toFixed(0)} req): ${formatBytes(result.median.cssBytes)}`
|
||||
);
|
||||
console.log(
|
||||
` ├─ Images (${result.median.imageRequests.toFixed(0)} req): ${formatBytes(result.median.imageBytes)}`
|
||||
);
|
||||
console.log(
|
||||
` └─ Fonts: ${formatBytes(result.median.fontBytes)}`
|
||||
);
|
||||
|
||||
if (result.median.jsExecutionTime > 0) {
|
||||
console.log("\n Performance Details (Median):");
|
||||
console.log(
|
||||
` JS Execution Time: ${formatTime(result.median.jsExecutionTime)}`
|
||||
);
|
||||
if (result.median.taskDuration > 0) {
|
||||
console.log(
|
||||
` Long Task Duration: ${formatTime(result.median.taskDuration)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n Variability (Standard Deviation):");
|
||||
const lcpStdDev = Math.sqrt(
|
||||
result.runs.reduce(
|
||||
(sum, run) => sum + Math.pow(run.lcp - result.average.lcp, 2),
|
||||
0
|
||||
) / result.runs.length
|
||||
);
|
||||
const fcpStdDev = Math.sqrt(
|
||||
result.runs.reduce(
|
||||
(sum, run) => sum + Math.pow(run.fcp - result.average.fcp, 2),
|
||||
0
|
||||
) / result.runs.length
|
||||
);
|
||||
console.log(` LCP: ±${formatTime(lcpStdDev)}`);
|
||||
console.log(` FCP: ±${formatTime(fcpStdDev)}`);
|
||||
});
|
||||
|
||||
console.log(
|
||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
console.log(
|
||||
" SUMMARY "
|
||||
);
|
||||
console.log(
|
||||
"═══════════════════════════════════════════════════════════════════\n"
|
||||
);
|
||||
|
||||
// Overall averages
|
||||
const overallAverage = {
|
||||
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
||||
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
||||
cls: results.reduce((sum, r) => sum + r.median.cls, 0) / results.length,
|
||||
ttfb: results.reduce((sum, r) => sum + r.median.ttfb, 0) / results.length,
|
||||
totalBytes:
|
||||
results.reduce((sum, r) => sum + r.median.totalBytes, 0) / results.length,
|
||||
jsBytes:
|
||||
results.reduce((sum, r) => sum + r.median.jsBytes, 0) / results.length,
|
||||
totalRequests:
|
||||
results.reduce((sum, r) => sum + r.median.totalRequests, 0) /
|
||||
results.length
|
||||
};
|
||||
|
||||
console.log(" Overall Averages (Median across all pages):");
|
||||
console.log(` LCP: ${formatTime(overallAverage.lcp)}`);
|
||||
console.log(` FCP: ${formatTime(overallAverage.fcp)}`);
|
||||
console.log(` CLS: ${overallAverage.cls.toFixed(3)}`);
|
||||
console.log(` TTFB: ${formatTime(overallAverage.ttfb)}`);
|
||||
console.log(
|
||||
` Total Size: ${formatBytes(overallAverage.totalBytes)}`
|
||||
);
|
||||
console.log(` JS Size: ${formatBytes(overallAverage.jsBytes)}`);
|
||||
console.log(
|
||||
` Total Requests: ${overallAverage.totalRequests.toFixed(0)}`
|
||||
);
|
||||
|
||||
console.log("\n Page Rankings (by LCP):");
|
||||
const sortedResults = [...results].sort(
|
||||
(a, b) => a.median.lcp - b.median.lcp
|
||||
);
|
||||
sortedResults.forEach((result, index) => {
|
||||
const rating =
|
||||
result.median.lcp <= 2500
|
||||
? "🟢"
|
||||
: result.median.lcp <= 4000
|
||||
? "🟡"
|
||||
: "🔴";
|
||||
console.log(
|
||||
` ${index + 1}. ${rating} ${result.page.padEnd(20)} ${formatTime(result.median.lcp)}`
|
||||
);
|
||||
});
|
||||
|
||||
console.log("\n Optimization Opportunities:");
|
||||
|
||||
// Find pages with highest JS bytes
|
||||
const highestJS = [...results].sort(
|
||||
(a, b) => b.median.jsBytes - a.median.jsBytes
|
||||
)[0];
|
||||
if (highestJS.median.jsBytes > 500 * 1024) {
|
||||
// > 500KB
|
||||
console.log(
|
||||
` 📦 ${highestJS.page}: High JS bundle (${formatBytes(highestJS.median.jsBytes)}) - consider code splitting`
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with slow LCP
|
||||
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
||||
if (slowLCP.length > 0) {
|
||||
console.log(
|
||||
` 🐌 ${slowLCP.length} page(s) with LCP > 2.5s - optimize largest content element`
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with high CLS
|
||||
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
||||
if (highCLS.length > 0) {
|
||||
console.log(
|
||||
` 📐 ${highCLS.length} page(s) with CLS > 0.1 - add size attributes to images/elements`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🚀 Starting Performance Testing Suite...\n");
|
||||
console.log(`Target: ${BASE_URL}`);
|
||||
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
||||
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
||||
|
||||
// Check if server is running
|
||||
try {
|
||||
const response = await fetch(BASE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
console.log("✅ Server is reachable\n");
|
||||
} catch (error) {
|
||||
console.error(`❌ Error: Cannot connect to ${BASE_URL}`);
|
||||
console.error(" Make sure the dev server is running with: bun run dev");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--disable-dev-shm-usage"]
|
||||
});
|
||||
const results: TestResult[] = [];
|
||||
|
||||
for (const pageConfig of TEST_PAGES) {
|
||||
try {
|
||||
const result = await testPagePerformance(browser, pageConfig);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error testing ${pageConfig.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Print results
|
||||
printResults(results);
|
||||
|
||||
// Save results to JSON file
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.split("T")[0];
|
||||
const filename = `perf-results-${timestamp}.json`;
|
||||
const output = {
|
||||
timestamp: new Date().toISOString(),
|
||||
baseUrl: BASE_URL,
|
||||
runsPerPage: RUNS_PER_PAGE,
|
||||
results
|
||||
};
|
||||
writeFileSync(filename, JSON.stringify(output, null, 2));
|
||||
console.log(`📁 Detailed results saved to: ${filename}\n`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,5 +1,13 @@
|
||||
import { onMount } from "solid-js";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
export default function MermaidRenderer() {
|
||||
onMount(async () => {
|
||||
const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]');
|
||||
|
||||
// Only load mermaid if there are diagrams to render
|
||||
if (mermaidPres.length === 0) return;
|
||||
|
||||
const mermaid = (await import("mermaid")).default;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
@@ -17,10 +25,6 @@ mermaid.initialize({
|
||||
}
|
||||
});
|
||||
|
||||
export default function MermaidRenderer() {
|
||||
onMount(() => {
|
||||
const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]');
|
||||
|
||||
mermaidPres.forEach(async (pre, index) => {
|
||||
const code = pre.querySelector("code");
|
||||
if (!code) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createEffect, createSignal, onMount } from "solid-js";
|
||||
import { createEffect, createSignal, onMount, lazy } from "solid-js";
|
||||
import type { HLJSApi } from "highlight.js";
|
||||
import MermaidRenderer from "./MermaidRenderer";
|
||||
|
||||
const MermaidRenderer = lazy(() => import("./MermaidRenderer"));
|
||||
|
||||
export interface PostBodyClientProps {
|
||||
body: string;
|
||||
|
||||
@@ -31,7 +31,7 @@ import { ConditionalInline } from "./extensions/ConditionalInline";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Superscript from "@tiptap/extension-superscript";
|
||||
import Subscript from "@tiptap/extension-subscript";
|
||||
import mermaid from "mermaid";
|
||||
import type { default as MermaidType } from "mermaid";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import js from "highlight.js/lib/languages/javascript";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
@@ -699,8 +699,14 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
let bubbleMenuRef!: HTMLDivElement;
|
||||
let containerRef!: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
mermaid.initialize({
|
||||
const [mermaid, setMermaid] = createSignal<typeof MermaidType | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
// Lazy load mermaid only when editor is mounted
|
||||
const mermaidModule = await import("mermaid");
|
||||
const mermaidInstance = mermaidModule.default;
|
||||
|
||||
mermaidInstance.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "dark",
|
||||
securityLevel: "loose",
|
||||
@@ -715,6 +721,8 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
tertiaryColor: "#505469"
|
||||
}
|
||||
});
|
||||
|
||||
setMermaid(() => mermaidInstance);
|
||||
});
|
||||
|
||||
const [showBubbleMenu, setShowBubbleMenu] = createSignal(false);
|
||||
@@ -1017,6 +1025,9 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
};
|
||||
|
||||
const validateAndPreviewMermaid = async (code: string) => {
|
||||
const mermaidInstance = mermaid();
|
||||
if (!mermaidInstance) return; // Wait for mermaid to load
|
||||
|
||||
if (!code.trim()) {
|
||||
setMermaidValidation({ valid: true, error: null });
|
||||
setMermaidPreviewSvg("");
|
||||
@@ -1024,10 +1035,10 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
await mermaid.parse(code);
|
||||
await mermaidInstance.parse(code);
|
||||
|
||||
const id = `mermaid-preview-${Date.now()}`;
|
||||
const { svg } = await mermaid.render(id, code);
|
||||
const { svg } = await mermaidInstance.render(id, code);
|
||||
|
||||
setMermaidValidation({ valid: true, error: null });
|
||||
setMermaidPreviewSvg(svg);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Show } from "solid-js";
|
||||
import { Show, lazy } from "solid-js";
|
||||
import { query, redirect } from "@solidjs/router";
|
||||
import { Title, Meta } from "@solidjs/meta";
|
||||
import { createAsync } from "@solidjs/router";
|
||||
import { getEvent } from "vinxi/http";
|
||||
import PostForm from "~/components/blog/PostForm";
|
||||
import { Spinner } from "~/components/Spinner";
|
||||
import "../post.css";
|
||||
|
||||
const PostForm = lazy(() => import("~/components/blog/PostForm"));
|
||||
|
||||
const getAuthState = query(async () => {
|
||||
"use server";
|
||||
const { getPrivilegeLevel, getUserID } = await import("~/server/utils");
|
||||
@@ -36,7 +38,7 @@ export default function CreatePost() {
|
||||
content="Create a new blog post with rich text editing, image uploads, and tag management."
|
||||
/>
|
||||
|
||||
<Show when={authState()?.userID}>
|
||||
<Show when={authState()?.userID} fallback={<Spinner />}>
|
||||
<PostForm mode="create" userID={authState()!.userID} />
|
||||
</Show>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Show } from "solid-js";
|
||||
import { Show, lazy } from "solid-js";
|
||||
import { useParams, query } from "@solidjs/router";
|
||||
import { Title, Meta } from "@solidjs/meta";
|
||||
import { createAsync } from "@solidjs/router";
|
||||
import { getEvent } from "vinxi/http";
|
||||
import PostForm from "~/components/blog/PostForm";
|
||||
import "../post.css";
|
||||
|
||||
const PostForm = lazy(() => import("~/components/blog/PostForm"));
|
||||
|
||||
const getPostForEdit = query(async (id: string) => {
|
||||
"use server";
|
||||
const { getPrivilegeLevel, getUserID, ConnectionFactory } =
|
||||
|
||||
Reference in New Issue
Block a user