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 { 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({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
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) => {
|
mermaidPres.forEach(async (pre, index) => {
|
||||||
const code = pre.querySelector("code");
|
const code = pre.querySelector("code");
|
||||||
if (!code) return;
|
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 type { HLJSApi } from "highlight.js";
|
||||||
import MermaidRenderer from "./MermaidRenderer";
|
|
||||||
|
const MermaidRenderer = lazy(() => import("./MermaidRenderer"));
|
||||||
|
|
||||||
export interface PostBodyClientProps {
|
export interface PostBodyClientProps {
|
||||||
body: string;
|
body: string;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { ConditionalInline } from "./extensions/ConditionalInline";
|
|||||||
import TextAlign from "@tiptap/extension-text-align";
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
import Superscript from "@tiptap/extension-superscript";
|
import Superscript from "@tiptap/extension-superscript";
|
||||||
import Subscript from "@tiptap/extension-subscript";
|
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 css from "highlight.js/lib/languages/css";
|
||||||
import js from "highlight.js/lib/languages/javascript";
|
import js from "highlight.js/lib/languages/javascript";
|
||||||
import ts from "highlight.js/lib/languages/typescript";
|
import ts from "highlight.js/lib/languages/typescript";
|
||||||
@@ -699,8 +699,14 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
let bubbleMenuRef!: HTMLDivElement;
|
let bubbleMenuRef!: HTMLDivElement;
|
||||||
let containerRef!: HTMLDivElement;
|
let containerRef!: HTMLDivElement;
|
||||||
|
|
||||||
onMount(() => {
|
const [mermaid, setMermaid] = createSignal<typeof MermaidType | null>(null);
|
||||||
mermaid.initialize({
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Lazy load mermaid only when editor is mounted
|
||||||
|
const mermaidModule = await import("mermaid");
|
||||||
|
const mermaidInstance = mermaidModule.default;
|
||||||
|
|
||||||
|
mermaidInstance.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
securityLevel: "loose",
|
securityLevel: "loose",
|
||||||
@@ -715,6 +721,8 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
tertiaryColor: "#505469"
|
tertiaryColor: "#505469"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setMermaid(() => mermaidInstance);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showBubbleMenu, setShowBubbleMenu] = createSignal(false);
|
const [showBubbleMenu, setShowBubbleMenu] = createSignal(false);
|
||||||
@@ -1017,6 +1025,9 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validateAndPreviewMermaid = async (code: string) => {
|
const validateAndPreviewMermaid = async (code: string) => {
|
||||||
|
const mermaidInstance = mermaid();
|
||||||
|
if (!mermaidInstance) return; // Wait for mermaid to load
|
||||||
|
|
||||||
if (!code.trim()) {
|
if (!code.trim()) {
|
||||||
setMermaidValidation({ valid: true, error: null });
|
setMermaidValidation({ valid: true, error: null });
|
||||||
setMermaidPreviewSvg("");
|
setMermaidPreviewSvg("");
|
||||||
@@ -1024,10 +1035,10 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mermaid.parse(code);
|
await mermaidInstance.parse(code);
|
||||||
|
|
||||||
const id = `mermaid-preview-${Date.now()}`;
|
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 });
|
setMermaidValidation({ valid: true, error: null });
|
||||||
setMermaidPreviewSvg(svg);
|
setMermaidPreviewSvg(svg);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Show } from "solid-js";
|
import { Show, lazy } from "solid-js";
|
||||||
import { query, redirect } from "@solidjs/router";
|
import { query, redirect } from "@solidjs/router";
|
||||||
import { Title, Meta } from "@solidjs/meta";
|
import { Title, Meta } from "@solidjs/meta";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { getEvent } from "vinxi/http";
|
import { getEvent } from "vinxi/http";
|
||||||
import PostForm from "~/components/blog/PostForm";
|
import { Spinner } from "~/components/Spinner";
|
||||||
import "../post.css";
|
import "../post.css";
|
||||||
|
|
||||||
|
const PostForm = lazy(() => import("~/components/blog/PostForm"));
|
||||||
|
|
||||||
const getAuthState = query(async () => {
|
const getAuthState = query(async () => {
|
||||||
"use server";
|
"use server";
|
||||||
const { getPrivilegeLevel, getUserID } = await import("~/server/utils");
|
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."
|
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} />
|
<PostForm mode="create" userID={authState()!.userID} />
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Show } from "solid-js";
|
import { Show, lazy } from "solid-js";
|
||||||
import { useParams, query } from "@solidjs/router";
|
import { useParams, query } from "@solidjs/router";
|
||||||
import { Title, Meta } from "@solidjs/meta";
|
import { Title, Meta } from "@solidjs/meta";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { getEvent } from "vinxi/http";
|
import { getEvent } from "vinxi/http";
|
||||||
import PostForm from "~/components/blog/PostForm";
|
|
||||||
import "../post.css";
|
import "../post.css";
|
||||||
|
|
||||||
|
const PostForm = lazy(() => import("~/components/blog/PostForm"));
|
||||||
|
|
||||||
const getPostForEdit = query(async (id: string) => {
|
const getPostForEdit = query(async (id: string) => {
|
||||||
"use server";
|
"use server";
|
||||||
const { getPrivilegeLevel, getUserID, ConnectionFactory } =
|
const { getPrivilegeLevel, getUserID, ConnectionFactory } =
|
||||||
|
|||||||
Reference in New Issue
Block a user