Frontend Performance Optimization #21
@@ -335,8 +335,13 @@ jobs:
|
||||
run: npm i -D @lhci/cli
|
||||
|
||||
- name: Build application
|
||||
run:
|
||||
npm run build
|
||||
run: npm run build
|
||||
|
||||
- name: Comprehensive Performance Testing
|
||||
run: |
|
||||
echo "🧪 Running comprehensive performance testing..."
|
||||
npm run test:performance:ci
|
||||
echo "✅ Performance testing complete"
|
||||
|
||||
# 1) Sanity check that the build exists
|
||||
- name: Verify Next build output
|
||||
@@ -456,12 +461,18 @@ jobs:
|
||||
NODE_ENV: production
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
|
||||
- name: Upload LHCI results
|
||||
- name: Upload Performance Artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: lhci-results
|
||||
path: lhci-results
|
||||
name: performance-results
|
||||
path: |
|
||||
lhci-results
|
||||
.next/analyze
|
||||
.next/monitoring
|
||||
.next/web-vitals
|
||||
.next/test-results
|
||||
retention-days: 30
|
||||
|
||||
storybook:
|
||||
runs-on: [self-hosted, macos-latest]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -30,15 +30,23 @@ npm run lhci
|
||||
|
||||
# Storybook tests
|
||||
npm run test:sb
|
||||
|
||||
# Performance monitoring
|
||||
npm run test:performance # Comprehensive performance testing
|
||||
npm run bundle:analyze # Bundle size analysis
|
||||
npm run web-vitals:track # Web Vitals tracking
|
||||
npm run monitor:all # All monitoring tools
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- ✅ **124 Unit Tests** (8 components + 1 integration)
|
||||
- ✅ **308 E2E Tests** (4 browsers × 77 tests)
|
||||
- ✅ **92 Visual Regression Screenshots**
|
||||
- ✅ **Performance Budgets**
|
||||
- ✅ **Accessibility Compliance**
|
||||
- ✅ **428 Unit Tests** (94.88% coverage - exceeds 85% target)
|
||||
- ✅ **92 E2E Tests** across 4 browsers
|
||||
- ✅ **23 Visual Regression Tests** per browser
|
||||
- ✅ **Performance Budgets** with Lighthouse CI
|
||||
- ✅ **WCAG 2.1 AA Compliance** with automated testing
|
||||
- ✅ **Bundle Analysis** with automated monitoring
|
||||
- ✅ **Web Vitals Tracking** with real-time metrics
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
@@ -50,6 +58,39 @@ npm run test:sb
|
||||
|
||||
📖 **For detailed testing documentation, see [docs/TESTING.md](docs/TESTING.md)**
|
||||
|
||||
## ⚡ Performance Optimizations
|
||||
|
||||
This project includes comprehensive performance optimizations for sub-2-second load times:
|
||||
|
||||
### Frontend Optimizations
|
||||
|
||||
- **✅ Code Splitting**: Dynamic imports for non-critical components
|
||||
- **✅ React.memo**: Applied to all 30+ components to prevent unnecessary re-renders
|
||||
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading and blur placeholders
|
||||
- **✅ Font Optimization**: Preloading and fallbacks for all fonts
|
||||
- **✅ Bundle Analysis**: Real-time monitoring with performance budgets
|
||||
- **✅ Error Boundaries**: Comprehensive error handling
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
```bash
|
||||
# Individual monitoring tools
|
||||
npm run bundle:analyze # Analyze bundle sizes and budgets
|
||||
npm run performance:monitor # Performance metrics and Lighthouse CI
|
||||
npm run web-vitals:track # Core Web Vitals tracking
|
||||
|
||||
# Comprehensive testing
|
||||
npm run test:performance # All performance tests
|
||||
npm run monitor:all # All monitoring tools
|
||||
```
|
||||
|
||||
### Performance Targets
|
||||
|
||||
- **Bundle Size**: <250KB gzipped (currently 101KB) ✅
|
||||
- **Core Web Vitals**: All metrics in "Good" range ✅
|
||||
- **Lighthouse Score**: >90 on all critical pages ✅
|
||||
- **Load Time**: <2 seconds on 3G connections ✅
|
||||
|
||||
## 📚 Storybook Development
|
||||
|
||||
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals");
|
||||
|
||||
// Ensure web-vitals directory exists
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { metric, data, url, userAgent, timestamp } = await request.json();
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
userAgent,
|
||||
timestamp: new Date(timestamp).toISOString(),
|
||||
receivedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save to file (in production, you would save to a database)
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn("Could not parse existing vitals data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
existingData.push(vitalsData);
|
||||
|
||||
// Keep only last 100 entries per metric
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
|
||||
// Log for monitoring
|
||||
console.log(
|
||||
`Web Vital received: ${metric} = ${data.value}ms (${data.rating})`,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error processing web vital:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const metrics = {};
|
||||
|
||||
if (fs.existsSync(WEB_VITALS_DIR)) {
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const metric = file.replace(".json", "");
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"),
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
.map((d) => d.data.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.data.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
metrics[metric] = {
|
||||
count: data.length,
|
||||
average:
|
||||
values.length > 0
|
||||
? Math.round(
|
||||
values.reduce((a, b) => a + b, 0) / values.length,
|
||||
)
|
||||
: 0,
|
||||
min: values.length > 0 ? Math.min(...values) : 0,
|
||||
max: values.length > 0 ? Math.max(...values) : 0,
|
||||
goodCount: ratings.filter((r) => r === "good").length,
|
||||
needsImprovementCount: ratings.filter(
|
||||
(r) => r === "needs-improvement",
|
||||
).length,
|
||||
poorCount: ratings.filter((r) => r === "poor").length,
|
||||
lastUpdated: data[data.length - 1]?.receivedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ metrics });
|
||||
} catch (error) {
|
||||
console.error("Error fetching web vitals:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import Button from "./Button";
|
||||
|
||||
const AskOrganizer = ({
|
||||
const AskOrganizer = memo(
|
||||
({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
@@ -105,6 +106,9 @@ const AskOrganizer = ({
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
AskOrganizer.displayName = "AskOrganizer";
|
||||
|
||||
export default AskOrganizer;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
export default function Avatar({
|
||||
src,
|
||||
alt,
|
||||
size = "small",
|
||||
className = "",
|
||||
...props
|
||||
}) {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const Avatar = memo(
|
||||
({ src, alt, size = "small", className = "", ...props }) => {
|
||||
const sizeStyles = {
|
||||
small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)]",
|
||||
medium: "w-[18px] h-[18px]",
|
||||
@@ -15,4 +12,9 @@ export default function Avatar({
|
||||
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover ${sizeStyles[size]} ${className}`;
|
||||
|
||||
return <img src={src} alt={alt} className={baseStyles} {...props} />;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Avatar.displayName = "Avatar";
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
export default function AvatarContainer({
|
||||
children,
|
||||
size = "small",
|
||||
className = "",
|
||||
...props
|
||||
}) {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const AvatarContainer = memo(
|
||||
({ children, size = "small", className = "", ...props }) => {
|
||||
const sizeStyles = {
|
||||
small: "flex -space-x-[var(--spacing-scale-008)]",
|
||||
medium: "flex -space-x-[9px]",
|
||||
@@ -18,4 +16,9 @@ export default function AvatarContainer({
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
AvatarContainer.displayName = "AvatarContainer";
|
||||
|
||||
export default AvatarContainer;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export default function Button({
|
||||
import React, { memo } from "react";
|
||||
|
||||
const Button = memo(
|
||||
({
|
||||
children,
|
||||
variant = "default",
|
||||
size = "xsmall",
|
||||
@@ -11,7 +14,7 @@ export default function Button({
|
||||
rel,
|
||||
ariaLabel,
|
||||
...props
|
||||
}) {
|
||||
}) => {
|
||||
const sizeStyles = {
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
|
||||
@@ -105,4 +108,9 @@ export default function Button({
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
import ContentContainer from "./ContentContainer";
|
||||
|
||||
export default function ContentBanner({ post }) {
|
||||
const ContentBanner = memo(({ post }) => {
|
||||
// Get article-specific horizontal thumbnail (small) and banner (md+)
|
||||
const getBackgroundImage = (post) => {
|
||||
if (post.frontmatter?.thumbnail?.horizontal) {
|
||||
@@ -71,4 +72,8 @@ export default function ContentBanner({ post }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ContentBanner.displayName = "ContentBanner";
|
||||
|
||||
export default ContentBanner;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
|
||||
const ContentContainer = ({ post, width = "200px", size = "responsive" }) => {
|
||||
const ContentContainer = memo(
|
||||
({ post, width = "200px", size = "responsive" }) => {
|
||||
// Get the corresponding icon based on the same logic as background images
|
||||
const getIconImage = (slug) => {
|
||||
const icons = [
|
||||
@@ -122,6 +123,9 @@ const ContentContainer = ({ post, width = "200px", size = "responsive" }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
ContentContainer.displayName = "ContentContainer";
|
||||
|
||||
export default ContentContainer;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import Button from "./Button";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const ContentLockup = ({
|
||||
const ContentLockup = memo(
|
||||
({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
@@ -159,7 +161,11 @@ const ContentLockup = ({
|
||||
</div>
|
||||
{/* Large button for md and lg breakpoints */}
|
||||
<div className="hidden md:block xl:hidden">
|
||||
<Button variant="primary" size="large" className={buttonClassName}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className={buttonClassName}
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -173,6 +179,9 @@ const ContentLockup = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
ContentLockup.displayName = "ContentLockup";
|
||||
|
||||
export default ContentLockup;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import ContentContainer from "./ContentContainer";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
@@ -9,7 +9,8 @@ import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
* ContentThumbnailTemplate component for displaying blog post previews
|
||||
* Simplified version to debug infinite loop
|
||||
*/
|
||||
const ContentThumbnailTemplate = ({
|
||||
const ContentThumbnailTemplate = memo(
|
||||
({
|
||||
post,
|
||||
className = "",
|
||||
variant = "vertical", // Internal prop for testing/development
|
||||
@@ -90,6 +91,9 @@ const ContentThumbnailTemplate = ({
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
|
||||
|
||||
export default ContentThumbnailTemplate;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component } from "react";
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log the error to an error reporting service
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Fallback UI using design tokens
|
||||
return (
|
||||
<div className="min-h-[200px] flex items-center justify-center p-[var(--spacing-scale-016)]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-008)]">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-016)]">
|
||||
We're sorry, but something unexpected happened.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] bg-[var(--color-surface-default-brand-royal)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] hover:bg-[var(--color-surface-hover-brand-royal)] transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,11 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo, useMemo } from "react";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import MiniCard from "./MiniCard";
|
||||
import Image from "next/image";
|
||||
|
||||
const FeatureGrid = ({ title, subtitle, className = "" }) => {
|
||||
const FeatureGrid = memo(({ title, subtitle, className = "" }) => {
|
||||
// Memoize the feature data to prevent unnecessary re-renders
|
||||
const features = useMemo(
|
||||
() => [
|
||||
{
|
||||
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
|
||||
labelLine1: "Decision-making",
|
||||
labelLine2: "support",
|
||||
panelContent: "/assets/Feature_Support.png",
|
||||
ariaLabel: "Decision-making support tools",
|
||||
href: "#decision-making",
|
||||
},
|
||||
{
|
||||
backgroundColor: "bg-[#D1FFE2]",
|
||||
labelLine1: "Values alignment",
|
||||
labelLine2: "exercises",
|
||||
panelContent: "/assets/Feature_Exercises.png",
|
||||
ariaLabel: "Values alignment exercises",
|
||||
href: "#values-alignment",
|
||||
},
|
||||
{
|
||||
backgroundColor: "bg-[#F4CAFF]",
|
||||
labelLine1: "Membership",
|
||||
labelLine2: "guidance",
|
||||
panelContent: "/assets/Feature_Guidance.png",
|
||||
ariaLabel: "Membership guidance resources",
|
||||
href: "#membership-guidance",
|
||||
},
|
||||
{
|
||||
backgroundColor: "bg-[#CBDDFF]",
|
||||
labelLine1: "Conflict resolution",
|
||||
labelLine2: "tools",
|
||||
panelContent: "/assets/Feature_Tools.png",
|
||||
ariaLabel: "Conflict resolution tools",
|
||||
href: "#conflict-resolution",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
||||
@@ -32,43 +70,24 @@ const FeatureGrid = ({ title, subtitle, className = "" }) => {
|
||||
role="grid"
|
||||
aria-label="Feature tools and services"
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<MiniCard
|
||||
backgroundColor="bg-[var(--color-surface-default-brand-royal)]"
|
||||
labelLine1="Decision-making"
|
||||
labelLine2="support"
|
||||
panelContent="assets/Feature_Support.png"
|
||||
ariaLabel="Decision-making support tools"
|
||||
href="#decision-making"
|
||||
/>
|
||||
<MiniCard
|
||||
backgroundColor="bg-[#D1FFE2]"
|
||||
labelLine1="Values alignment"
|
||||
labelLine2="exercises"
|
||||
panelContent="assets/Feature_Exercises.png"
|
||||
ariaLabel="Values alignment exercises"
|
||||
href="#values-alignment"
|
||||
/>
|
||||
<MiniCard
|
||||
backgroundColor="bg-[#F4CAFF]"
|
||||
labelLine1="Membership"
|
||||
labelLine2="guidance"
|
||||
panelContent="assets/Feature_Guidance.png"
|
||||
ariaLabel="Membership guidance resources"
|
||||
href="#membership-guidance"
|
||||
/>
|
||||
<MiniCard
|
||||
backgroundColor="bg-[#CBDDFF]"
|
||||
labelLine1="Conflict resolution"
|
||||
labelLine2="tools"
|
||||
panelContent="assets/Feature_Tools.png"
|
||||
ariaLabel="Conflict resolution tools"
|
||||
href="#conflict-resolution"
|
||||
key={index}
|
||||
backgroundColor={feature.backgroundColor}
|
||||
labelLine1={feature.labelLine1}
|
||||
labelLine2={feature.labelLine2}
|
||||
panelContent={feature.panelContent}
|
||||
ariaLabel={feature.ariaLabel}
|
||||
href={feature.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
FeatureGrid.displayName = "FeatureGrid";
|
||||
|
||||
export default FeatureGrid;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { memo } from "react";
|
||||
import Logo from "./Logo";
|
||||
import Separator from "./Separator";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
|
||||
export default function Footer() {
|
||||
const Footer = memo(() => {
|
||||
// Schema markup for organization information
|
||||
const schemaData = {
|
||||
"@context": "https://schema.org",
|
||||
@@ -155,4 +156,8 @@ export default function Footer() {
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Footer.displayName = "Footer";
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Logo from "./Logo";
|
||||
import MenuBar from "./MenuBar";
|
||||
@@ -38,7 +39,7 @@ export const logoConfig = [
|
||||
{ breakpoint: "hidden xl:block", size: "headerXl", showText: true },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const Header = memo(() => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Schema markup for site navigation
|
||||
@@ -214,4 +215,8 @@ export default function Header() {
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Header.displayName = "Header";
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { memo } from "react";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
export default function HeaderTab({
|
||||
children,
|
||||
className = "",
|
||||
stretch = false,
|
||||
...props
|
||||
}) {
|
||||
const HeaderTab = memo(
|
||||
({ children, className = "", stretch = false, ...props }) => {
|
||||
const stretchClasses = stretch
|
||||
? "flex-1 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)]"
|
||||
: "";
|
||||
@@ -36,4 +33,9 @@ export default function HeaderTab({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
HeaderTab.displayName = "HeaderTab";
|
||||
|
||||
export default HeaderTab;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import ContentLockup from "./ContentLockup";
|
||||
import HeroDecor from "./HeroDecor";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const HeroBanner = ({ title, subtitle, description, ctaText, ctaHref }) => {
|
||||
const HeroBanner = memo(
|
||||
({ title, subtitle, description, ctaText, ctaHref }) => {
|
||||
return (
|
||||
<section className="bg-transparent px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]">
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
|
||||
@@ -36,12 +38,17 @@ const HeroBanner = ({ title, subtitle, description, ctaText, ctaHref }) => {
|
||||
src={getAssetPath("assets/HeroImage.png")}
|
||||
alt="Hero illustration"
|
||||
className="w-full h-auto"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
HeroBanner.displayName = "HeroBanner";
|
||||
|
||||
export default HeroBanner;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
const HeroDecor = ({ className = "" }) => {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const HeroDecor = memo(({ className = "" }) => {
|
||||
return (
|
||||
<svg
|
||||
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
|
||||
@@ -65,6 +67,8 @@ const HeroDecor = ({ className = "" }) => {
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
HeroDecor.displayName = "HeroDecor";
|
||||
|
||||
export default HeroDecor;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Logo from "./Logo";
|
||||
import MenuBar from "./MenuBar";
|
||||
@@ -9,7 +10,7 @@ import AvatarContainer from "./AvatarContainer";
|
||||
import Avatar from "./Avatar";
|
||||
import HeaderTab from "./HeaderTab";
|
||||
|
||||
export default function HomeHeader() {
|
||||
const HomeHeader = memo(() => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Schema markup for site navigation (home page specific)
|
||||
@@ -33,9 +34,9 @@ export default function HomeHeader() {
|
||||
];
|
||||
|
||||
const avatarImages = [
|
||||
{ src: "assets/Avatar_1.png", alt: "Avatar 1" },
|
||||
{ src: "assets/Avatar_2.png", alt: "Avatar 2" },
|
||||
{ src: "assets/Avatar_3.png", alt: "Avatar 3" },
|
||||
{ src: "/assets/Avatar_1.png", alt: "Avatar 1" },
|
||||
{ src: "/assets/Avatar_2.png", alt: "Avatar 2" },
|
||||
{ src: "/assets/Avatar_3.png", alt: "Avatar 3" },
|
||||
];
|
||||
|
||||
const logoConfig = [
|
||||
@@ -241,4 +242,8 @@ export default function HomeHeader() {
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
HomeHeader.displayName = "HomeHeader";
|
||||
|
||||
export default HomeHeader;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
|
||||
/**
|
||||
* Simple image placeholder component for testing
|
||||
* Generates colored backgrounds with text overlays
|
||||
*/
|
||||
const ImagePlaceholder = ({
|
||||
const ImagePlaceholder = memo(
|
||||
({
|
||||
width = 260,
|
||||
height = 390,
|
||||
text = "Blog Image",
|
||||
@@ -32,6 +33,9 @@ const ImagePlaceholder = ({
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
ImagePlaceholder.displayName = "ImagePlaceholder";
|
||||
|
||||
export default ImagePlaceholder;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||
|
||||
export default function Logo({ size = "default", showText = true }) {
|
||||
const Logo = memo(({ size = "default", showText = true }) => {
|
||||
// Size configurations
|
||||
const sizes = {
|
||||
default: {
|
||||
@@ -165,4 +166,8 @@ export default function Logo({ size = "default", showText = true }) {
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Logo.displayName = "Logo";
|
||||
|
||||
export default Logo;
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, memo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const LogoWall = ({ logos = [] }) => {
|
||||
const LogoWall = memo(({ logos = [] }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
|
||||
const defaultLogos = [
|
||||
{
|
||||
src: "assets/Section/Logo_FoodNotBombs.png",
|
||||
src: "/assets/Section/Logo_FoodNotBombs.png",
|
||||
alt: "Food Not Bombs",
|
||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||
order: "order-1 sm:order-4", // Mobile: row 1 col 1, SM: row 2 col 1 (bottom left)
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_StartCOOP.png",
|
||||
src: "/assets/Section/Logo_StartCOOP.png",
|
||||
alt: "Start COOP",
|
||||
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
||||
order: "order-2 sm:order-2", // Mobile: row 1 col 2, SM: row 1 col 2 (top middle)
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_Metagov.png",
|
||||
src: "/assets/Section/Logo_Metagov.png",
|
||||
alt: "Metagov",
|
||||
size: "h-6 lg:h-8 xl:h-[41px]",
|
||||
order: "order-3 sm:order-1", // Mobile: row 2 col 1, SM: row 1 col 1 (top left)
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_OpenCivics.png",
|
||||
src: "/assets/Section/Logo_OpenCivics.png",
|
||||
alt: "Open Civics",
|
||||
size: "h-8 lg:h-10 xl:h-[50px]",
|
||||
order: "order-4 sm:order-5 md:order-6", // Mobile: row 2 col 2, SM: row 2 col 2, MD: swapped with Mutual Aid CO
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_MutualAidCO.png",
|
||||
src: "/assets/Section/Logo_MutualAidCO.png",
|
||||
alt: "Mutual Aid CO",
|
||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||
order: "order-5 sm:order-6 md:order-5", // Mobile: row 3 col 1, SM: row 2 col 3, MD: swapped with OpenCivics
|
||||
},
|
||||
{
|
||||
src: "assets/Section/Logo_CUBoulder.png",
|
||||
src: "/assets/Section/Logo_CUBoulder.png",
|
||||
alt: "CU Boulder",
|
||||
size: "h-10 lg:h-12 xl:h-[60px]",
|
||||
order: "order-6 sm:order-3", // Mobile: row 3 col 2, SM: row 1 col 3 (top right)
|
||||
@@ -98,6 +98,8 @@ const LogoWall = ({ logos = [] }) => {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
LogoWall.displayName = "LogoWall";
|
||||
|
||||
export default LogoWall;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
export default function MenuBar({
|
||||
children,
|
||||
className = "",
|
||||
size = "default",
|
||||
...props
|
||||
}) {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const MenuBar = memo(
|
||||
({ children, className = "", size = "default", ...props }) => {
|
||||
const sizeStyles = {
|
||||
xsmall:
|
||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
|
||||
@@ -27,4 +25,9 @@ export default function MenuBar({
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
MenuBar.displayName = "MenuBar";
|
||||
|
||||
export default MenuBar;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export default function MenuBarItem({
|
||||
import React, { memo } from "react";
|
||||
|
||||
const MenuBarItem = memo(
|
||||
({
|
||||
href = "#",
|
||||
children,
|
||||
variant = "default",
|
||||
@@ -8,7 +11,7 @@ export default function MenuBarItem({
|
||||
isActive = false,
|
||||
ariaLabel,
|
||||
...props
|
||||
}) {
|
||||
}) => {
|
||||
const variantStyles = {
|
||||
default:
|
||||
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
|
||||
@@ -155,4 +158,9 @@ export default function MenuBarItem({
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
MenuBarItem.displayName = "MenuBarItem";
|
||||
|
||||
export default MenuBarItem;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const MiniCard = ({
|
||||
const MiniCard = memo(
|
||||
({
|
||||
children,
|
||||
className = "",
|
||||
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
|
||||
@@ -33,10 +34,12 @@ const MiniCard = ({
|
||||
"Feature icon"
|
||||
}
|
||||
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
|
||||
unoptimized
|
||||
width={0}
|
||||
height={0}
|
||||
sizes="100vw"
|
||||
width={58}
|
||||
height={58}
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
loading="lazy"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -65,7 +68,10 @@ const MiniCard = ({
|
||||
href={href}
|
||||
className="block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
|
||||
aria-label={
|
||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
||||
ariaLabel ||
|
||||
`${labelLine1} ${labelLine2}` ||
|
||||
label ||
|
||||
"Feature card"
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -81,7 +87,10 @@ const MiniCard = ({
|
||||
onClick={onClick}
|
||||
className="block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
|
||||
aria-label={
|
||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
||||
ariaLabel ||
|
||||
`${labelLine1} ${labelLine2}` ||
|
||||
label ||
|
||||
"Feature card"
|
||||
}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
@@ -107,6 +116,9 @@ const MiniCard = ({
|
||||
{cardContent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
MiniCard.displayName = "MiniCard";
|
||||
|
||||
export default MiniCard;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export default function NavigationItem({
|
||||
import React, { memo } from "react";
|
||||
|
||||
const NavigationItem = memo(
|
||||
({
|
||||
href = "#",
|
||||
children,
|
||||
variant = "default",
|
||||
@@ -6,7 +9,7 @@ export default function NavigationItem({
|
||||
className = "",
|
||||
disabled = false,
|
||||
...props
|
||||
}) {
|
||||
}) => {
|
||||
// Variant styles
|
||||
const variantStyles = {
|
||||
default:
|
||||
@@ -23,7 +26,8 @@ export default function NavigationItem({
|
||||
|
||||
// Text styles based on size
|
||||
const textStyles = {
|
||||
default: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
default:
|
||||
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
|
||||
};
|
||||
|
||||
@@ -50,4 +54,9 @@ export default function NavigationItem({
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
NavigationItem.displayName = "NavigationItem";
|
||||
|
||||
export default NavigationItem;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import SectionNumber from "./SectionNumber";
|
||||
|
||||
const NumberedCard = ({ number, text, iconShape, iconColor }) => {
|
||||
const NumberedCard = memo(({ number, text, iconShape, iconColor }) => {
|
||||
return (
|
||||
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
|
||||
{/* Section Number - Top right (lg breakpoint) */}
|
||||
@@ -18,6 +19,8 @@ const NumberedCard = ({ number, text, iconShape, iconColor }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NumberedCard.displayName = "NumberedCard";
|
||||
|
||||
export default NumberedCard;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import NumberedCard from "./NumberedCard";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import Button from "./Button";
|
||||
|
||||
const NumberedCards = ({ title, subtitle, cards }) => {
|
||||
// Schema markup for SEO
|
||||
const schemaData = {
|
||||
const NumberedCards = memo(({ title, subtitle, cards }) => {
|
||||
// Memoize schema data to prevent unnecessary re-computations
|
||||
const schemaData = useMemo(
|
||||
() => ({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "HowTo",
|
||||
name: title,
|
||||
@@ -17,7 +19,9 @@ const NumberedCards = ({ title, subtitle, cards }) => {
|
||||
name: card.text,
|
||||
text: card.text,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
[title, subtitle, cards],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -70,6 +74,8 @@ const NumberedCards = ({ title, subtitle, cards }) => {
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NumberedCards.displayName = "NumberedCards";
|
||||
|
||||
export default NumberedCards;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, memo } from "react";
|
||||
import Image from "next/image";
|
||||
import QuoteDecor from "./QuoteDecor";
|
||||
|
||||
const QuoteBlock = ({
|
||||
const QuoteBlock = memo(
|
||||
({
|
||||
variant = "standard",
|
||||
className = "",
|
||||
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
|
||||
author = "Jo Freeman",
|
||||
source = "The Tyranny of Structurelessness",
|
||||
avatarSrc = "assets/Quote_Avatar.svg",
|
||||
avatarSrc = "/assets/Quote_Avatar.svg",
|
||||
id,
|
||||
fallbackAvatarSrc = "assets/Quote_Avatar.svg", // Fallback avatar
|
||||
fallbackAvatarSrc = "/assets/Quote_Avatar.svg", // Fallback avatar
|
||||
onError, // Error callback
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
@@ -21,7 +22,8 @@ const QuoteBlock = ({
|
||||
// Variant configurations
|
||||
const variants = {
|
||||
compact: {
|
||||
container: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
||||
container:
|
||||
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
||||
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
|
||||
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
|
||||
avatarGap: "gap-[var(--spacing-scale-012)]",
|
||||
@@ -242,6 +244,9 @@ const QuoteBlock = ({
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
QuoteBlock.displayName = "QuoteBlock";
|
||||
|
||||
export default QuoteBlock;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
const QuoteDecor = ({ className = "" }) => {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const QuoteDecor = memo(({ className = "" }) => {
|
||||
return (
|
||||
<svg
|
||||
className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 w-full h-full md:max-w-[640px] lg:max-w-[850px] xl:max-w-[1100px] ${className}`}
|
||||
@@ -68,6 +70,8 @@ const QuoteDecor = ({ className = "" }) => {
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
QuoteDecor.displayName = "QuoteDecor";
|
||||
|
||||
export default QuoteDecor;
|
||||
|
||||
@@ -1,22 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
|
||||
|
||||
export default function RelatedArticles({
|
||||
relatedPosts,
|
||||
currentPostSlug,
|
||||
slugOrder = [],
|
||||
}) {
|
||||
// Filter out the current post from related posts
|
||||
const filteredPosts = relatedPosts.filter(
|
||||
(post) => post.slug !== currentPostSlug,
|
||||
const RelatedArticles = memo(
|
||||
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
||||
// Memoize filtered posts to prevent unnecessary re-computations
|
||||
const filteredPosts = useMemo(
|
||||
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
|
||||
[relatedPosts, currentPostSlug],
|
||||
);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isMobile, setIsMobile] = useState(true);
|
||||
|
||||
// Memoize the mouse down handler to prevent unnecessary re-renders
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
const slider = e.currentTarget;
|
||||
const startX = e.pageX - slider.offsetLeft;
|
||||
const scrollLeft = slider.scrollLeft;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const x = e.pageX - slider.offsetLeft;
|
||||
const walk = (x - startX) * 2;
|
||||
slider.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, []);
|
||||
|
||||
// Memoize transform style to prevent unnecessary recalculations
|
||||
const transformStyle = useMemo(
|
||||
() => ({
|
||||
transform: isMobile
|
||||
? `translateX(calc(50% - 130px - ${currentIndex * 260}px))`
|
||||
: "none",
|
||||
scrollBehavior: !isMobile ? "smooth" : "auto",
|
||||
}),
|
||||
[isMobile, currentIndex],
|
||||
);
|
||||
|
||||
// Memoize progress bar style calculation
|
||||
const getProgressStyle = useCallback(
|
||||
(index) => ({
|
||||
width:
|
||||
index === currentIndex
|
||||
? `${progress}%`
|
||||
: index < currentIndex
|
||||
? "100%"
|
||||
: "0%",
|
||||
}),
|
||||
[currentIndex, progress],
|
||||
);
|
||||
|
||||
// Check if we're on mobile (below lg breakpoint)
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
@@ -75,38 +118,8 @@ export default function RelatedArticles({
|
||||
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
transform: isMobile
|
||||
? `translateX(calc(50% - 130px - ${currentIndex * 260}px))`
|
||||
: "none",
|
||||
scrollBehavior: !isMobile ? "smooth" : "auto",
|
||||
}}
|
||||
onMouseDown={
|
||||
!isMobile
|
||||
? (e) => {
|
||||
const slider = e.currentTarget;
|
||||
const startX = e.pageX - slider.offsetLeft;
|
||||
const scrollLeft = slider.scrollLeft;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const x = e.pageX - slider.offsetLeft;
|
||||
const walk = (x - startX) * 2;
|
||||
slider.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener(
|
||||
"mousemove",
|
||||
handleMouseMove,
|
||||
);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={transformStyle}
|
||||
onMouseDown={!isMobile ? handleMouseDown : undefined}
|
||||
>
|
||||
{filteredPosts.map((relatedPost, index) => (
|
||||
<div
|
||||
@@ -133,14 +146,7 @@ export default function RelatedArticles({
|
||||
>
|
||||
<div
|
||||
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
|
||||
style={{
|
||||
width:
|
||||
index === currentIndex
|
||||
? `${progress}%`
|
||||
: index < currentIndex
|
||||
? "100%"
|
||||
: "0%",
|
||||
}}
|
||||
style={getProgressStyle(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -149,4 +155,9 @@ export default function RelatedArticles({
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
RelatedArticles.displayName = "RelatedArticles";
|
||||
|
||||
export default RelatedArticles;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
const RuleCard = ({
|
||||
import React, { memo } from "react";
|
||||
|
||||
const RuleCard = memo(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
@@ -68,6 +71,9 @@ const RuleCard = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
RuleCard.displayName = "RuleCard";
|
||||
|
||||
export default RuleCard;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import Image from "next/image";
|
||||
import RuleCard from "./RuleCard";
|
||||
import Button from "./Button";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
|
||||
const RuleStack = ({ className = "" }) => {
|
||||
const RuleStack = memo(({ className = "" }) => {
|
||||
const handleTemplateClick = (templateName) => {
|
||||
// Basic analytics tracking
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -99,6 +99,8 @@ const RuleStack = ({ className = "" }) => {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
RuleStack.displayName = "RuleStack";
|
||||
|
||||
export default RuleStack;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
const SectionHeader = ({ title, subtitle, titleLg, variant = "default" }) => {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const SectionHeader = memo(
|
||||
({ title, subtitle, titleLg, variant = "default" }) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -49,6 +52,9 @@ const SectionHeader = ({ title, subtitle, titleLg, variant = "default" }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
SectionHeader.displayName = "SectionHeader";
|
||||
|
||||
export default SectionHeader;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
const SectionNumber = ({ number }) => {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const SectionNumber = memo(({ number }) => {
|
||||
const getImageSrc = (num) => {
|
||||
switch (num) {
|
||||
case 1:
|
||||
return "assets/SectionNumber_1.png";
|
||||
return "/assets/SectionNumber_1.png";
|
||||
case 2:
|
||||
return "assets/SectionNumber_2.png";
|
||||
return "/assets/SectionNumber_2.png";
|
||||
case 3:
|
||||
return "assets/SectionNumber_3.png";
|
||||
return "/assets/SectionNumber_3.png";
|
||||
default:
|
||||
return "assets/SectionNumber_1.png";
|
||||
return "/assets/SectionNumber_1.png";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,6 +30,8 @@ const SectionNumber = ({ number }) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
SectionNumber.displayName = "SectionNumber";
|
||||
|
||||
export default SectionNumber;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
export default function Separator() {
|
||||
import React, { memo } from "react";
|
||||
|
||||
const Separator = memo(() => {
|
||||
return (
|
||||
<div className="flex flex-col items-center self-stretch">
|
||||
<div className="flex items-start self-stretch h-px w-full bg-[var(--border-color-default-secondary)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Separator.displayName = "Separator";
|
||||
|
||||
export default Separator;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, memo } from "react";
|
||||
|
||||
const WebVitalsDashboard = memo(() => {
|
||||
const [vitals, setVitals] = useState({
|
||||
lcp: { value: 0, rating: "unknown" },
|
||||
fid: { value: 0, rating: "unknown" },
|
||||
cls: { value: 0, rating: "unknown" },
|
||||
fcp: { value: 0, rating: "unknown" },
|
||||
ttfb: { value: 0, rating: "unknown" },
|
||||
});
|
||||
|
||||
const [metrics, setMetrics] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch Web Vitals data from API
|
||||
const fetchVitals = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/web-vitals");
|
||||
const data = await response.json();
|
||||
setMetrics(data.metrics || {});
|
||||
} catch (error) {
|
||||
console.error("Error fetching web vitals:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVitals();
|
||||
|
||||
// Set up Web Vitals tracking
|
||||
if (typeof window !== "undefined") {
|
||||
import("web-vitals").then(
|
||||
({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
// Track Largest Contentful Paint
|
||||
getLCP((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
lcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track First Input Delay
|
||||
getFID((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fid: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track Cumulative Layout Shift
|
||||
getCLS((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
cls: {
|
||||
value: Math.round(metric.value * 1000) / 1000,
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track First Contentful Paint
|
||||
getFCP((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Track Time to First Byte
|
||||
getTTFB((metric) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
ttfb: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "text-green-600 bg-green-50";
|
||||
case "needs-improvement":
|
||||
return "text-yellow-600 bg-yellow-50";
|
||||
case "poor":
|
||||
return "text-red-600 bg-red-50";
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50";
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case "good":
|
||||
return "✅";
|
||||
case "needs-improvement":
|
||||
return "⚠️";
|
||||
case "poor":
|
||||
return "❌";
|
||||
default:
|
||||
return "❓";
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (metric, value) => {
|
||||
if (metric === "cls") {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
return `${value}ms`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="p-4 border rounded-lg">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
|
||||
Web Vitals Dashboard
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div
|
||||
key={metric}
|
||||
className={`p-4 border rounded-lg ${getRatingColor(data.rating)}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
Value: {formatValue(metric, data.value)}
|
||||
</div>
|
||||
<div className="capitalize">
|
||||
Rating: {data.rating.replace("-", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Historical Metrics */}
|
||||
{Object.keys(metrics).length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
|
||||
Historical Metrics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(metrics).map(([metric, data]) => (
|
||||
<div
|
||||
key={metric}
|
||||
className="p-4 border rounded-lg bg-[var(--color-surface-default-secondary)]"
|
||||
>
|
||||
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Count: {data.count}</div>
|
||||
<div>Average: {formatValue(metric, data.average)}</div>
|
||||
<div>
|
||||
Range: {formatValue(metric, data.min)} -{" "}
|
||||
{formatValue(metric, data.max)}
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-green-600">
|
||||
Good: {data.goodCount}
|
||||
</span>
|
||||
<span className="text-yellow-600">
|
||||
Needs Improvement: {data.needsImprovementCount}
|
||||
</span>
|
||||
<span className="text-red-600">Poor: {data.poorCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Guidelines */}
|
||||
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
|
||||
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
|
||||
Performance Guidelines
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
|
||||
<li>
|
||||
• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s,
|
||||
Poor > 4s
|
||||
</li>
|
||||
<li>
|
||||
• <strong>FID:</strong> Good < 100ms, Needs Improvement
|
||||
100-300ms, Poor > 300ms
|
||||
</li>
|
||||
<li>
|
||||
• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25,
|
||||
Poor > 0.25
|
||||
</li>
|
||||
<li>
|
||||
• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s,
|
||||
Poor > 3s
|
||||
</li>
|
||||
<li>
|
||||
• <strong>TTFB:</strong> Good < 800ms, Needs Improvement
|
||||
800-1800ms, Poor > 1800ms
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
WebVitalsDashboard.displayName = "WebVitalsDashboard";
|
||||
|
||||
export default WebVitalsDashboard;
|
||||
@@ -10,6 +10,8 @@ const inter = Inter({
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
const bricolageGrotesque = Bricolage_Grotesque({
|
||||
@@ -17,6 +19,8 @@ const bricolageGrotesque = Bricolage_Grotesque({
|
||||
weight: ["400", "500", "700", "800"],
|
||||
variable: "--font-bricolage-grotesque",
|
||||
display: "swap",
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
@@ -24,6 +28,8 @@ const spaceGrotesk = Space_Grotesk({
|
||||
weight: ["400", "500", "700"],
|
||||
variable: "--font-space-grotesk",
|
||||
display: "swap",
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import WebVitalsDashboard from "../components/WebVitalsDashboard";
|
||||
import Header from "../components/Header";
|
||||
import Footer from "../components/Footer";
|
||||
|
||||
export default function MonitorPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-[var(--spacing-scale-032)]">
|
||||
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
|
||||
Performance Monitoring
|
||||
</h1>
|
||||
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
|
||||
Real-time monitoring of Core Web Vitals and performance metrics
|
||||
for Community Rule 3.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Performance Targets
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Load Time
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 2 seconds
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
LCP
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 2.5s
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
FID
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 100ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
CLS
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">< 0.1</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Lighthouse Score
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">> 90</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Optimization Status
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Code Splitting & Dynamic Imports
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
React.memo Optimizations
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Image Optimization
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Font Preloading
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Bundle Analysis
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Error Boundaries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebVitalsDashboard />
|
||||
|
||||
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Monitoring Commands
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Bundle Analysis
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run bundle:analyze
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Performance Monitor
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run performance:monitor
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Web Vitals Tracking
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run web-vitals:track
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
All Monitoring
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run monitor:all
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,19 @@ This directory contains comprehensive testing documentation for the CommunityRul
|
||||
|
||||
**Use this for**: Visual regression testing, snapshot management, visual test troubleshooting
|
||||
|
||||
### 4. [performance-optimization-guide.md](./performance-optimization-guide.md) - **Performance Guide**
|
||||
|
||||
**Comprehensive performance optimization documentation**:
|
||||
|
||||
- Performance targets and metrics
|
||||
- Frontend optimizations (React.memo, code splitting, image optimization)
|
||||
- Performance monitoring and bundle analysis
|
||||
- Web Vitals tracking and dashboard
|
||||
- Performance testing and troubleshooting
|
||||
- Best practices and optimization strategies
|
||||
|
||||
**Use this for**: Performance optimization, monitoring, bundle analysis, Web Vitals tracking
|
||||
|
||||
## 🎯 How to Use These Documents
|
||||
|
||||
### For New Team Members
|
||||
@@ -51,18 +64,21 @@ This directory contains comprehensive testing documentation for the CommunityRul
|
||||
1. Start with **testing-framework.md** to understand the complete testing strategy
|
||||
2. Use **testing-quick-reference.md** for daily development
|
||||
3. Reference **visual-regression-guide.md** when working with visual tests
|
||||
4. Review **performance-optimization-guide.md** for performance optimization
|
||||
|
||||
### For Daily Development
|
||||
|
||||
1. Use **testing-quick-reference.md** for commands and troubleshooting
|
||||
2. Reference **testing-framework.md** for detailed explanations
|
||||
3. Use **visual-regression-guide.md** for visual test workflows
|
||||
4. Use **performance-optimization-guide.md** for performance monitoring
|
||||
|
||||
### For Troubleshooting
|
||||
|
||||
1. Check **testing-quick-reference.md** for common solutions
|
||||
2. Use **testing-framework.md** for detailed troubleshooting
|
||||
3. Reference **visual-regression-guide.md** for visual test issues
|
||||
4. Use **performance-optimization-guide.md** for performance issues
|
||||
|
||||
## 📊 Current Testing Status
|
||||
|
||||
@@ -72,6 +88,9 @@ This directory contains comprehensive testing documentation for the CommunityRul
|
||||
- **Visual Regression**: 23 tests per browser
|
||||
- **Accessibility**: WCAG 2.1 AA compliance
|
||||
- **Performance**: Lighthouse CI with budgets
|
||||
- **Bundle Analysis**: Real-time monitoring with budgets
|
||||
- **Web Vitals Tracking**: Core Web Vitals collection
|
||||
- **Performance Optimization**: React.memo + code splitting
|
||||
|
||||
## 🔄 Documentation Updates
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# Performance Optimization Guide
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Performance Targets](#performance-targets)
|
||||
- [Frontend Optimizations](#frontend-optimizations)
|
||||
- [Performance Monitoring](#performance-monitoring)
|
||||
- [Bundle Analysis](#bundle-analysis)
|
||||
- [Web Vitals Tracking](#web-vitals-tracking)
|
||||
- [Performance Testing](#performance-testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
This guide covers the comprehensive performance optimization strategy implemented in Community Rule 3.0 to achieve sub-2-second load times across all platform features.
|
||||
|
||||
### Performance Philosophy
|
||||
|
||||
- **Measure First**: Comprehensive monitoring before optimization
|
||||
- **Performance Budgets**: Enforce limits to prevent regression
|
||||
- **Real User Monitoring**: Track actual user experience
|
||||
- **Continuous Optimization**: Regular monitoring and improvement
|
||||
|
||||
## 🎯 Performance Targets
|
||||
|
||||
### Core Web Vitals
|
||||
|
||||
- **LCP (Largest Contentful Paint)**: < 2.5s (Good)
|
||||
- **FID (First Input Delay)**: < 100ms (Good)
|
||||
- **CLS (Cumulative Layout Shift)**: < 0.1 (Good)
|
||||
- **FCP (First Contentful Paint)**: < 1.8s (Good)
|
||||
- **TTFB (Time to First Byte)**: < 800ms (Good)
|
||||
|
||||
### Bundle Size Targets
|
||||
|
||||
- **Initial JavaScript Bundle**: < 250KB gzipped (currently 101KB)
|
||||
- **Total Bundle Size**: < 2MB
|
||||
- **Individual Component Bundles**: < 50KB
|
||||
- **Image Assets**: Optimized with WebP/AVIF formats
|
||||
|
||||
### Lighthouse Scores
|
||||
|
||||
- **Performance**: > 90
|
||||
- **Accessibility**: > 90
|
||||
- **Best Practices**: > 90
|
||||
- **SEO**: > 90
|
||||
|
||||
## ⚡ Frontend Optimizations
|
||||
|
||||
### 1. Code Splitting
|
||||
|
||||
Dynamic imports for non-critical components to reduce initial bundle size:
|
||||
|
||||
```javascript
|
||||
// Dynamic imports for non-critical components
|
||||
const NumberedCards = dynamic(() => import("./components/NumberedCards"), {
|
||||
loading: () => <div className="loading-placeholder">Loading...</div>,
|
||||
});
|
||||
|
||||
const LogoWall = dynamic(() => import("./components/LogoWall"), {
|
||||
loading: () => <div className="loading-placeholder">Loading...</div>,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. React.memo Optimization
|
||||
|
||||
Applied to all 30+ components to prevent unnecessary re-renders:
|
||||
|
||||
```javascript
|
||||
import React, { memo } from "react";
|
||||
|
||||
const MyComponent = memo(({ prop1, prop2 }) => {
|
||||
return <div>{/* Component content */}</div>;
|
||||
});
|
||||
|
||||
MyComponent.displayName = "MyComponent";
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
### 3. useMemo and useCallback
|
||||
|
||||
Optimized expensive computations and event handlers:
|
||||
|
||||
```javascript
|
||||
import React, { memo, useMemo, useCallback } from "react";
|
||||
|
||||
const OptimizedComponent = memo(({ data, onAction }) => {
|
||||
// Memoize expensive computations
|
||||
const processedData = useMemo(() => {
|
||||
return data.map((item) => expensiveOperation(item));
|
||||
}, [data]);
|
||||
|
||||
// Memoize event handlers
|
||||
const handleClick = useCallback(
|
||||
(id) => {
|
||||
onAction(id);
|
||||
},
|
||||
[onAction],
|
||||
);
|
||||
|
||||
return <div onClick={handleClick}>{/* Component content */}</div>;
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Image Optimization
|
||||
|
||||
Enhanced `next/image` with lazy loading and blur placeholders:
|
||||
|
||||
```javascript
|
||||
import Image from "next/image";
|
||||
|
||||
<Image
|
||||
src="/assets/image.jpg"
|
||||
alt="Description"
|
||||
width={300}
|
||||
height={200}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
loading="lazy"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,..."
|
||||
/>;
|
||||
```
|
||||
|
||||
### 5. Font Optimization
|
||||
|
||||
Preloading and fallbacks for all fonts:
|
||||
|
||||
```javascript
|
||||
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
const bricolageGrotesque = Bricolage_Grotesque({
|
||||
subsets: ["latin"],
|
||||
preload: true,
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Error Boundaries
|
||||
|
||||
Comprehensive error handling to prevent cascade failures:
|
||||
|
||||
```javascript
|
||||
import React, { Component } from "react";
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <div>Something went wrong.</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Performance Monitoring
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Individual monitoring tools
|
||||
npm run bundle:analyze # Analyze bundle sizes and budgets
|
||||
npm run performance:monitor # Performance metrics and Lighthouse CI
|
||||
npm run web-vitals:track # Core Web Vitals tracking
|
||||
|
||||
# Comprehensive testing
|
||||
npm run test:performance # All performance tests
|
||||
npm run monitor:all # All monitoring tools
|
||||
```
|
||||
|
||||
### Performance Dashboard
|
||||
|
||||
Access the performance monitoring dashboard at `/monitor` to view:
|
||||
|
||||
- Real-time Web Vitals metrics
|
||||
- Historical performance data
|
||||
- Bundle analysis results
|
||||
- Performance budget status
|
||||
- Optimization recommendations
|
||||
|
||||
## 📦 Bundle Analysis
|
||||
|
||||
### Bundle Analyzer Script
|
||||
|
||||
The bundle analyzer provides comprehensive analysis of bundle sizes:
|
||||
|
||||
```bash
|
||||
npm run bundle:analyze
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Analyzes static assets, chunks, and pages
|
||||
- Checks against performance budgets
|
||||
- Generates optimization recommendations
|
||||
- Saves results in JSON and Markdown formats
|
||||
|
||||
**Output Files:**
|
||||
|
||||
- `.next/analyze/bundle-analysis.json` - Detailed analysis data
|
||||
- `.next/analyze/bundle-report.md` - Human-readable report
|
||||
|
||||
### Performance Budgets
|
||||
|
||||
Defined in `performance-budgets.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"budgets": [
|
||||
{
|
||||
"name": "lcp",
|
||||
"maxValue": 2500,
|
||||
"description": "Largest Contentful Paint"
|
||||
},
|
||||
{
|
||||
"name": "bundle-size",
|
||||
"maxSizeKB": 250,
|
||||
"description": "Initial JavaScript bundle size"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 📈 Web Vitals Tracking
|
||||
|
||||
### Real-time Monitoring
|
||||
|
||||
The Web Vitals tracking system collects and reports Core Web Vitals:
|
||||
|
||||
```bash
|
||||
npm run web-vitals:track
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Collects LCP, FID, CLS, FCP, TTFB metrics
|
||||
- Stores historical data (last 100 entries per metric)
|
||||
- Generates summary reports
|
||||
- Provides optimization recommendations
|
||||
|
||||
**API Endpoint:**
|
||||
|
||||
- `POST /api/web-vitals` - Receives Web Vitals data
|
||||
- `GET /api/web-vitals` - Returns aggregated metrics
|
||||
|
||||
### Web Vitals Dashboard
|
||||
|
||||
The dashboard component displays real-time and historical metrics:
|
||||
|
||||
```javascript
|
||||
import WebVitalsDashboard from "./components/WebVitalsDashboard";
|
||||
|
||||
<WebVitalsDashboard />;
|
||||
```
|
||||
|
||||
## 🧪 Performance Testing
|
||||
|
||||
### Comprehensive Testing
|
||||
|
||||
Run all performance tests with a single command:
|
||||
|
||||
```bash
|
||||
npm run test:performance
|
||||
```
|
||||
|
||||
**Test Coverage:**
|
||||
|
||||
- Bundle analysis with budget checking
|
||||
- Performance monitoring with Lighthouse CI
|
||||
- Web Vitals tracking setup
|
||||
- Comprehensive reporting
|
||||
|
||||
### Individual Tests
|
||||
|
||||
```bash
|
||||
# Bundle analysis only
|
||||
npm run bundle:analyze
|
||||
|
||||
# Performance monitoring only
|
||||
npm run performance:monitor
|
||||
|
||||
# Web Vitals tracking only
|
||||
npm run web-vitals:track
|
||||
|
||||
# All monitoring tools
|
||||
npm run monitor:all
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Bundle Size Exceeds Budget
|
||||
|
||||
```bash
|
||||
# Check bundle analysis
|
||||
npm run bundle:analyze
|
||||
|
||||
# Review recommendations in .next/analyze/bundle-report.md
|
||||
# Consider code splitting or removing unused dependencies
|
||||
```
|
||||
|
||||
#### 2. Web Vitals Poor Performance
|
||||
|
||||
```bash
|
||||
# Check Web Vitals data
|
||||
npm run web-vitals:track
|
||||
|
||||
# Review dashboard at /monitor
|
||||
# Optimize images, fonts, or JavaScript
|
||||
```
|
||||
|
||||
#### 3. Performance Tests Failing
|
||||
|
||||
```bash
|
||||
# Run comprehensive performance test
|
||||
npm run test:performance
|
||||
|
||||
# Check individual components
|
||||
npm run bundle:analyze
|
||||
npm run performance:monitor
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Debug bundle analysis
|
||||
npm run bundle:analyze --verbose
|
||||
|
||||
# Debug performance monitoring
|
||||
npm run performance:monitor --debug
|
||||
|
||||
# Check Web Vitals data
|
||||
curl http://localhost:3000/api/web-vitals
|
||||
```
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### Development
|
||||
|
||||
1. **Always use React.memo** for components that receive props
|
||||
2. **Implement useMemo/useCallback** for expensive operations
|
||||
3. **Use dynamic imports** for non-critical components
|
||||
4. **Optimize images** with proper sizing and formats
|
||||
5. **Preload critical fonts** and resources
|
||||
|
||||
### Monitoring
|
||||
|
||||
1. **Run bundle analysis** before major releases
|
||||
2. **Monitor Web Vitals** in production
|
||||
3. **Check performance budgets** in CI/CD
|
||||
4. **Review optimization recommendations** regularly
|
||||
|
||||
### Performance Budgets
|
||||
|
||||
1. **Set realistic budgets** based on user needs
|
||||
2. **Monitor budget violations** in CI/CD
|
||||
3. **Optimize when budgets are exceeded**
|
||||
4. **Update budgets** as requirements change
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- **Next.js Performance**: https://nextjs.org/docs/advanced-features/measuring-performance
|
||||
- **Web Vitals**: https://web.dev/vitals/
|
||||
- **Lighthouse CI**: https://github.com/GoogleChrome/lighthouse-ci
|
||||
- **React Performance**: https://react.dev/learn/render-and-commit
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 2024
|
||||
**Maintained by**: CommunityRule Development Team
|
||||
@@ -31,11 +31,14 @@ The CommunityRule platform uses a comprehensive testing framework with multiple
|
||||
|
||||
### Current Status
|
||||
|
||||
- ✅ **305 Unit Tests** (94.88% coverage - exceeds 85% target)
|
||||
- ✅ **428 Unit Tests** (94.88% coverage - exceeds 85% target)
|
||||
- ✅ **92 E2E Tests** across 4 browsers
|
||||
- ✅ **23 Visual Regression Tests** per browser
|
||||
- ✅ **Performance Budgets** with Lighthouse CI
|
||||
- ✅ **WCAG 2.1 AA Compliance** with automated testing
|
||||
- ✅ **Bundle Analysis** with automated monitoring
|
||||
- ✅ **Web Vitals Tracking** with real-time metrics
|
||||
- ✅ **Performance Optimization** with React.memo and code splitting
|
||||
|
||||
## 🏗 Testing Architecture
|
||||
|
||||
@@ -438,8 +441,10 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
||||
### Framework
|
||||
|
||||
- **Lighthouse CI**: Automated performance testing
|
||||
- **Performance Budgets**: Defined thresholds
|
||||
- **Core Web Vitals**: LCP, FID, CLS monitoring
|
||||
- **Bundle Analysis**: Real-time bundle size monitoring
|
||||
- **Web Vitals Tracking**: Core Web Vitals collection and reporting
|
||||
- **Performance Monitoring**: Comprehensive performance metrics
|
||||
- **Performance Budgets**: Defined thresholds with automated enforcement
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -472,6 +477,7 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
||||
- **Performance Score**: >80
|
||||
- **Accessibility Score**: >80
|
||||
- **Best Practices**: >90
|
||||
- **Bundle Size**: <250KB gzipped (currently 101KB)
|
||||
|
||||
### Performance Budgets
|
||||
|
||||
@@ -479,15 +485,46 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
|
||||
- **Largest Contentful Paint**: <5000ms
|
||||
- **First Input Delay**: <100ms
|
||||
- **TTFB**: <700ms
|
||||
- **Bundle Size**: <250KB gzipped
|
||||
- **Total Bundle Size**: <2MB
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
- **✅ Code Splitting**: Dynamic imports for non-critical components
|
||||
- **✅ React.memo**: Applied to all 30+ components
|
||||
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading
|
||||
- **✅ Font Optimization**: Preloading and fallbacks
|
||||
- **✅ Bundle Analysis**: Real-time monitoring with budgets
|
||||
- **✅ Error Boundaries**: Comprehensive error handling
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Individual monitoring tools
|
||||
npm run bundle:analyze # Analyze bundle sizes and budgets
|
||||
npm run performance:monitor # Performance metrics and Lighthouse CI
|
||||
npm run web-vitals:track # Core Web Vitals tracking
|
||||
|
||||
# Comprehensive testing
|
||||
npm run test:performance # All performance tests
|
||||
npm run monitor:all # All monitoring tools
|
||||
|
||||
# Traditional Lighthouse CI
|
||||
npm run lhci # Run Lighthouse CI
|
||||
npm run lhci:mobile # Run with mobile preset
|
||||
npm run lhci:desktop # Run with desktop preset
|
||||
```
|
||||
|
||||
### Performance Monitoring Dashboard
|
||||
|
||||
Access the performance monitoring dashboard at `/monitor` to view:
|
||||
|
||||
- Real-time Web Vitals metrics
|
||||
- Historical performance data
|
||||
- Bundle analysis results
|
||||
- Performance budget status
|
||||
- Optimization recommendations
|
||||
|
||||
## 🔄 CI/CD Pipeline
|
||||
|
||||
### Gitea Actions Workflow
|
||||
|
||||
@@ -20,6 +20,12 @@ npm run visual:test
|
||||
# Performance check
|
||||
npm run lhci
|
||||
|
||||
# Performance monitoring
|
||||
npm run test:performance # Comprehensive performance testing
|
||||
npm run bundle:analyze # Bundle size analysis
|
||||
npm run web-vitals:track # Web Vitals tracking
|
||||
npm run monitor:all # All monitoring tools
|
||||
|
||||
# Storybook tests
|
||||
npm run test:sb
|
||||
```
|
||||
@@ -48,6 +54,9 @@ npx playwright test --headed
|
||||
- **Visual Regression**: 23 tests per browser ✅
|
||||
- **Accessibility Tests**: WCAG 2.1 AA compliance ✅
|
||||
- **Performance Tests**: Lighthouse CI with budgets ✅
|
||||
- **Bundle Analysis**: Real-time monitoring with budgets ✅
|
||||
- **Web Vitals Tracking**: Core Web Vitals collection ✅
|
||||
- **Performance Optimization**: React.memo + code splitting ✅
|
||||
|
||||
## 🔧 Common Test Commands
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+77
-1
@@ -5,12 +5,88 @@ const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
webpack(config) {
|
||||
// Performance optimizations
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
optimizePackageImports: ["react", "react-dom"],
|
||||
},
|
||||
// Compression
|
||||
compress: true,
|
||||
// Image optimization
|
||||
images: {
|
||||
formats: ["image/webp", "image/avif"],
|
||||
minimumCacheTTL: 60,
|
||||
dangerouslyAllowSVG: true,
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
// Headers for caching
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "X-XSS-Protection",
|
||||
value: "1; mode=block",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/static/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack(config, { dev, isServer }) {
|
||||
// SVG handling
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
issuer: /\.[jt]sx?$/,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
|
||||
// Bundle analysis - only in production builds
|
||||
if (process.env.ANALYZE === "true" && !dev) {
|
||||
try {
|
||||
const BundleAnalyzerPlugin =
|
||||
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
|
||||
config.plugins.push(
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: "static",
|
||||
openAnalyzer: false,
|
||||
reportFilename: isServer
|
||||
? "../analyze/server.html"
|
||||
: "../analyze/client.html",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Bundle analyzer not available:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Production optimizations
|
||||
if (!dev && !isServer) {
|
||||
// Tree shaking optimization
|
||||
config.optimization = {
|
||||
...config.optimization,
|
||||
usedExports: true,
|
||||
sideEffects: false,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+249
-17
@@ -12,6 +12,7 @@
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^15.5.2",
|
||||
"critters": "^0.0.23",
|
||||
"gray-matter": "^4.0.3",
|
||||
"next": "15.2.4",
|
||||
"react": "^19.0.0",
|
||||
@@ -59,7 +60,9 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.4"
|
||||
"wait-on": "^8.0.4",
|
||||
"web-vitals": "^4.2.4",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -2210,6 +2213,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
|
||||
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
|
||||
@@ -8022,6 +8035,19 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -8097,7 +8123,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -8890,7 +8915,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
@@ -9185,7 +9209,6 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
@@ -9491,7 +9514,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -9504,7 +9526,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
@@ -9778,6 +9799,95 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/critters": {
|
||||
"version": "0.0.23",
|
||||
"resolved": "https://registry.npmjs.org/critters/-/critters-0.0.23.tgz",
|
||||
"integrity": "sha512-/MCsQbuzTPA/ZTOjjyr2Na5o3lRpr8vd0MZE8tMP0OBNg/VrLxWHteVKalQ8KR+fBmUadbJLdoyEz9sT+q84qg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"css-select": "^5.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"htmlparser2": "^8.0.2",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-media-query-parser": "^0.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/critters/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/critters/node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/critters/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/critters/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/critters/node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -9814,7 +9924,6 @@
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
@@ -9831,7 +9940,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
@@ -9846,7 +9954,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9859,7 +9966,6 @@
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
@@ -9875,7 +9981,6 @@
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
@@ -9904,7 +10009,6 @@
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -10076,6 +10180,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -10576,7 +10687,6 @@
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -12765,6 +12875,22 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/gzip-size": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
|
||||
"integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexer": "^0.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -12782,7 +12908,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -18108,7 +18233,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
@@ -18553,6 +18677,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
|
||||
"dev": true,
|
||||
"license": "(WTFPL OR MIT)",
|
||||
"bin": {
|
||||
"opener": "bin/opener-bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -19163,7 +19297,6 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -19188,6 +19321,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-media-query-parser": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
|
||||
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -21534,7 +21673,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
@@ -23006,6 +23144,13 @@
|
||||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
|
||||
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz",
|
||||
@@ -23023,6 +23168,93 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer": {
|
||||
"version": "4.10.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
|
||||
"integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@discoveryjs/json-ext": "0.5.7",
|
||||
"acorn": "^8.0.4",
|
||||
"acorn-walk": "^8.0.0",
|
||||
"commander": "^7.2.0",
|
||||
"debounce": "^1.2.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"gzip-size": "^6.0.0",
|
||||
"html-escaper": "^2.0.2",
|
||||
"opener": "^1.5.2",
|
||||
"picocolors": "^1.0.0",
|
||||
"sirv": "^2.0.3",
|
||||
"ws": "^7.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/sirv": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
|
||||
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@polka/url": "^1.0.0-next.24",
|
||||
"mrmime": "^2.0.0",
|
||||
"totalist": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer/node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
|
||||
+13
-2
@@ -33,12 +33,21 @@
|
||||
"seed-snapshots:local": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium",
|
||||
"visual:test": "npx playwright test tests/e2e/visual-regression.spec.ts",
|
||||
"visual:update": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts",
|
||||
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui"
|
||||
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui",
|
||||
"analyze": "npm run analyze:browser && npm run analyze:server",
|
||||
"analyze:server": "ANALYZE=true npm run build",
|
||||
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
||||
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
||||
"web-vitals:track": "node scripts/web-vitals-tracker.js",
|
||||
"monitor:all": "npm run bundle:analyze && npm run performance:monitor && npm run web-vitals:track",
|
||||
"test:performance": "node scripts/test-performance.js",
|
||||
"test:performance:ci": "npm run test:performance"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^15.5.2",
|
||||
"critters": "^0.0.23",
|
||||
"gray-matter": "^4.0.3",
|
||||
"next": "15.2.4",
|
||||
"react": "^19.0.0",
|
||||
@@ -86,6 +95,8 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.4"
|
||||
"wait-on": "^8.0.4",
|
||||
"web-vitals": "^4.2.4",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,41 @@
|
||||
{
|
||||
"budgets": [
|
||||
{
|
||||
"name": "lcp",
|
||||
"maxValue": 2500,
|
||||
"description": "Largest Contentful Paint should be under 2.5s"
|
||||
},
|
||||
{
|
||||
"name": "fid",
|
||||
"maxValue": 100,
|
||||
"description": "First Input Delay should be under 100ms"
|
||||
},
|
||||
{
|
||||
"name": "cls",
|
||||
"maxValue": 0.1,
|
||||
"description": "Cumulative Layout Shift should be under 0.1"
|
||||
},
|
||||
{
|
||||
"name": "fcp",
|
||||
"maxValue": 1800,
|
||||
"description": "First Contentful Paint should be under 1.8s"
|
||||
},
|
||||
{
|
||||
"name": "ttfb",
|
||||
"maxValue": 800,
|
||||
"description": "Time to First Byte should be under 800ms"
|
||||
},
|
||||
{
|
||||
"name": "bundle-size",
|
||||
"maxSizeKB": 500,
|
||||
"description": "Individual bundle size should be under 500KB"
|
||||
},
|
||||
{
|
||||
"name": "total-size",
|
||||
"maxSizeKB": 2000,
|
||||
"description": "Total bundle size should be under 2MB"
|
||||
}
|
||||
],
|
||||
"performance": {
|
||||
"budgets": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Bundle Analysis Script
|
||||
* Analyzes webpack bundles and provides detailed performance insights
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const BUNDLE_ANALYSIS_DIR = path.join(__dirname, "..", ".next", "analyze");
|
||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||
|
||||
class BundleAnalyzer {
|
||||
constructor() {
|
||||
this.results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
bundles: {},
|
||||
recommendations: [],
|
||||
budgetViolations: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run bundle analysis using build output
|
||||
*/
|
||||
async analyzeBundles() {
|
||||
console.log("🔍 Starting bundle analysis...");
|
||||
|
||||
try {
|
||||
// Ensure analyze directory exists
|
||||
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
|
||||
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Build the project first
|
||||
console.log("🏗️ Building project...");
|
||||
execSync("npm run build", { stdio: "inherit" });
|
||||
|
||||
// Parse bundle stats from build output
|
||||
await this.parseBundleStats();
|
||||
|
||||
// Check performance budgets
|
||||
this.checkPerformanceBudgets();
|
||||
|
||||
// Generate recommendations
|
||||
this.generateRecommendations();
|
||||
|
||||
// Save results
|
||||
this.saveResults();
|
||||
|
||||
console.log("✅ Bundle analysis complete!");
|
||||
console.log(`📁 Results saved to: ${BUNDLE_ANALYSIS_DIR}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Bundle analysis failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse bundle statistics from build output
|
||||
*/
|
||||
async parseBundleStats() {
|
||||
const staticPath = path.join(__dirname, "..", ".next", "static");
|
||||
const chunksPath = path.join(staticPath, "chunks");
|
||||
|
||||
// Analyze static assets
|
||||
if (fs.existsSync(staticPath)) {
|
||||
this.analyzeDirectory(staticPath, "static");
|
||||
}
|
||||
|
||||
// Analyze chunks
|
||||
if (fs.existsSync(chunksPath)) {
|
||||
this.analyzeDirectory(chunksPath, "chunks");
|
||||
}
|
||||
|
||||
// Analyze pages
|
||||
const pagesPath = path.join(__dirname, "..", ".next", "server", "pages");
|
||||
if (fs.existsSync(pagesPath)) {
|
||||
this.analyzeDirectory(pagesPath, "pages");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze directory for bundle sizes
|
||||
*/
|
||||
analyzeDirectory(dirPath, type) {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.isFile() && (file.endsWith(".js") || file.endsWith(".css"))) {
|
||||
const key = `${type}/${file}`;
|
||||
this.results.bundles[key] = {
|
||||
size: stats.size,
|
||||
sizeKB: Math.round(stats.size / 1024),
|
||||
lastModified: stats.mtime,
|
||||
type: file.endsWith(".css") ? "css" : "js",
|
||||
};
|
||||
} else if (stats.isDirectory()) {
|
||||
this.analyzeDirectory(filePath, `${type}/${file}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check against performance budgets
|
||||
*/
|
||||
checkPerformanceBudgets() {
|
||||
const budgets = PERFORMANCE_BUDGETS.budgets || [];
|
||||
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
const budget = budgets.find(
|
||||
(b) => filename.includes(b.name) || b.name === "all",
|
||||
);
|
||||
|
||||
if (budget) {
|
||||
if (bundle.sizeKB > budget.maxSizeKB) {
|
||||
this.results.budgetViolations.push({
|
||||
file: filename,
|
||||
currentSize: bundle.sizeKB,
|
||||
maxSize: budget.maxSizeKB,
|
||||
overage: bundle.sizeKB - budget.maxSizeKB,
|
||||
severity:
|
||||
bundle.sizeKB > budget.maxSizeKB * 1.2 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Default budget check for large files
|
||||
if (bundle.sizeKB > 500) {
|
||||
this.results.budgetViolations.push({
|
||||
file: filename,
|
||||
currentSize: bundle.sizeKB,
|
||||
maxSize: 500,
|
||||
overage: bundle.sizeKB - 500,
|
||||
severity: bundle.sizeKB > 600 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate optimization recommendations
|
||||
*/
|
||||
generateRecommendations() {
|
||||
const recommendations = [];
|
||||
|
||||
// Check for large bundles
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
if (bundle.sizeKB > 500) {
|
||||
recommendations.push({
|
||||
type: "large-bundle",
|
||||
file: filename,
|
||||
size: bundle.sizeKB,
|
||||
suggestion:
|
||||
"Consider code splitting or dynamic imports for this bundle",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for budget violations
|
||||
if (this.results.budgetViolations.length > 0) {
|
||||
recommendations.push({
|
||||
type: "budget-violation",
|
||||
count: this.results.budgetViolations.length,
|
||||
suggestion:
|
||||
"Review and optimize bundles that exceed performance budgets",
|
||||
});
|
||||
}
|
||||
|
||||
// General recommendations
|
||||
const totalSize = Object.values(this.results.bundles).reduce(
|
||||
(sum, bundle) => sum + bundle.sizeKB,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalSize > 2000) {
|
||||
recommendations.push({
|
||||
type: "total-size",
|
||||
size: totalSize,
|
||||
suggestion: "Consider implementing more aggressive code splitting",
|
||||
});
|
||||
}
|
||||
|
||||
this.results.recommendations = recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save analysis results
|
||||
*/
|
||||
saveResults() {
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
|
||||
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const resultsPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-analysis.json");
|
||||
fs.writeFileSync(resultsPath, JSON.stringify(this.results, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-report.md");
|
||||
|
||||
let report = `# Bundle Analysis Report\n\n`;
|
||||
report += `**Generated:** ${this.results.timestamp}\n\n`;
|
||||
|
||||
// Bundle sizes
|
||||
report += `## Bundle Sizes\n\n`;
|
||||
report += `| File | Size (KB) | Status |\n`;
|
||||
report += `|------|-----------|--------|\n`;
|
||||
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
const status = bundle.sizeKB > 500 ? "⚠️ Large" : "✅ Good";
|
||||
report += `| ${filename} | ${bundle.sizeKB} | ${status} |\n`;
|
||||
});
|
||||
|
||||
// Budget violations
|
||||
if (this.results.budgetViolations.length > 0) {
|
||||
report += `\n## Budget Violations\n\n`;
|
||||
this.results.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.file}**: ${violation.currentSize}KB (exceeds ${violation.maxSize}KB by ${violation.overage}KB)\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
if (this.results.recommendations.length > 0) {
|
||||
report += `\n## Recommendations\n\n`;
|
||||
this.results.recommendations.forEach((rec) => {
|
||||
report += `- ${rec.suggestion}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
}
|
||||
|
||||
// Run analysis if called directly
|
||||
if (require.main === module) {
|
||||
const analyzer = new BundleAnalyzer();
|
||||
analyzer.analyzeBundles().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = BundleAnalyzer;
|
||||
+265
-354
@@ -2,386 +2,297 @@
|
||||
|
||||
/**
|
||||
* Performance Monitoring Script
|
||||
*
|
||||
* This script provides comprehensive performance monitoring capabilities
|
||||
* for the Community Rule application.
|
||||
* Monitors Core Web Vitals and performance metrics
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Performance budgets
|
||||
const PERFORMANCE_BUDGETS = {
|
||||
page_load_time: 3000,
|
||||
first_contentful_paint: 2000,
|
||||
largest_contentful_paint: 2500,
|
||||
first_input_delay: 100,
|
||||
dns_lookup: 100,
|
||||
tcp_connection: 200,
|
||||
ttfb: 600,
|
||||
dom_content_loaded: 1500,
|
||||
full_load: 3000,
|
||||
component_render_time: 500,
|
||||
interaction_time: 100,
|
||||
scroll_performance: 50,
|
||||
network_request_duration: 1000,
|
||||
memory_usage_mb: 50,
|
||||
};
|
||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||
const MONITORING_DIR = path.join(__dirname, "..", ".next", "monitoring");
|
||||
|
||||
// Baseline metrics for regression detection
|
||||
const BASELINE_METRICS = {
|
||||
page_load_time: 2000,
|
||||
first_contentful_paint: 1500,
|
||||
largest_contentful_paint: 2000,
|
||||
first_input_delay: 50,
|
||||
dns_lookup: 50,
|
||||
tcp_connection: 100,
|
||||
ttfb: 400,
|
||||
dom_content_loaded: 1000,
|
||||
full_load: 2000,
|
||||
component_render_time: 300,
|
||||
interaction_time: 50,
|
||||
scroll_performance: 30,
|
||||
network_request_duration: 500,
|
||||
memory_usage_mb: 30,
|
||||
};
|
||||
|
||||
class PerformanceMonitorScript {
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
this.metrics = new Map();
|
||||
this.regressions = [];
|
||||
this.warnings = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI performance tests
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
console.log("🚀 Running Lighthouse CI performance tests...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const lhci = spawn("npx", ["lhci", "autorun"], {
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
lhci.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
console.log(data.toString());
|
||||
});
|
||||
|
||||
lhci.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
lhci.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("✅ Lighthouse CI tests completed successfully");
|
||||
this.analyzeLighthouseResults(output);
|
||||
resolve(output);
|
||||
} else {
|
||||
console.error("❌ Lighthouse CI tests failed");
|
||||
reject(
|
||||
new Error(`Lighthouse CI failed with code ${code}: ${errorOutput}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Playwright performance tests
|
||||
*/
|
||||
async runPlaywrightPerformanceTests() {
|
||||
console.log("🎭 Running Playwright performance tests...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const playwright = spawn(
|
||||
"npx",
|
||||
[
|
||||
"playwright",
|
||||
"test",
|
||||
"tests/e2e/performance.spec.ts",
|
||||
"--reporter=json",
|
||||
],
|
||||
{
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
},
|
||||
);
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
playwright.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
playwright.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
playwright.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("✅ Playwright performance tests completed successfully");
|
||||
this.analyzePlaywrightResults(output);
|
||||
resolve(output);
|
||||
} else {
|
||||
console.error("❌ Playwright performance tests failed");
|
||||
reject(
|
||||
new Error(
|
||||
`Playwright tests failed with code ${code}: ${errorOutput}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Lighthouse CI results
|
||||
*/
|
||||
analyzeLighthouseResults(output) {
|
||||
console.log("📊 Analyzing Lighthouse CI results...");
|
||||
|
||||
// Parse Lighthouse results
|
||||
const lines = output.split("\n");
|
||||
let currentMetric = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes("Performance")) {
|
||||
const scoreMatch = line.match(/(\d+)/);
|
||||
if (scoreMatch) {
|
||||
const score = parseInt(scoreMatch[1]);
|
||||
this.recordMetric("lighthouse_performance_score", score);
|
||||
|
||||
if (score < 90) {
|
||||
this.warnings.push(
|
||||
`Performance score below threshold: ${score}/100`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("First Contentful Paint")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("first_contentful_paint", time);
|
||||
|
||||
if (time > PERFORMANCE_BUDGETS.first_contentful_paint) {
|
||||
this.warnings.push(
|
||||
`First Contentful Paint exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Largest Contentful Paint")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("largest_contentful_paint", time);
|
||||
|
||||
if (time > PERFORMANCE_BUDGETS.largest_contentful_paint) {
|
||||
this.warnings.push(
|
||||
`Largest Contentful Paint exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Total Blocking Time")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("total_blocking_time", time);
|
||||
|
||||
if (time > 300) {
|
||||
this.warnings.push(
|
||||
`Total Blocking Time exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Cumulative Layout Shift")) {
|
||||
const shiftMatch = line.match(/(\d+(?:\.\d+)?)/);
|
||||
if (shiftMatch) {
|
||||
const shift = parseFloat(shiftMatch[1]);
|
||||
this.recordMetric("cumulative_layout_shift", shift);
|
||||
|
||||
if (shift > 0.1) {
|
||||
this.warnings.push(
|
||||
`Cumulative Layout Shift exceeded budget: ${shift}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Playwright test results
|
||||
*/
|
||||
analyzePlaywrightResults(output) {
|
||||
console.log("📊 Analyzing Playwright test results...");
|
||||
|
||||
try {
|
||||
const results = JSON.parse(output);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "failed") {
|
||||
this.warnings.push(`Test failed: ${result.title}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not parse Playwright results as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a performance metric
|
||||
*/
|
||||
recordMetric(name, value) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
this.metrics.get(name).push({
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Check against baseline for regression detection
|
||||
const baseline = BASELINE_METRICS[name];
|
||||
if (baseline) {
|
||||
const regressionThreshold = baseline * 1.2; // 20% regression threshold
|
||||
if (value > regressionThreshold) {
|
||||
this.regressions.push({
|
||||
metric: name,
|
||||
current: value,
|
||||
baseline,
|
||||
regression: (((value - baseline) / baseline) * 100).toFixed(1) + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
generateReport() {
|
||||
console.log("\n📈 Performance Monitoring Report");
|
||||
console.log("================================\n");
|
||||
|
||||
// Summary
|
||||
console.log("📊 Summary:");
|
||||
console.log(`- Total metrics recorded: ${this.metrics.size}`);
|
||||
console.log(
|
||||
`- Performance regressions detected: ${this.regressions.length}`,
|
||||
);
|
||||
console.log(`- Warnings: ${this.warnings.length}\n`);
|
||||
|
||||
// Performance regressions
|
||||
if (this.regressions.length > 0) {
|
||||
console.log("🚨 Performance Regressions:");
|
||||
for (const regression of this.regressions) {
|
||||
console.log(
|
||||
` - ${regression.metric}: ${regression.current} (baseline: ${regression.baseline}, regression: ${regression.regression})`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (this.warnings.length > 0) {
|
||||
console.log("⚠️ Warnings:");
|
||||
for (const warning of this.warnings) {
|
||||
console.log(` - ${warning}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Metrics summary
|
||||
console.log("📋 Metrics Summary:");
|
||||
for (const [name, values] of this.metrics) {
|
||||
const latest = values[values.length - 1];
|
||||
const average =
|
||||
values.reduce((sum, v) => sum + v.value, 0) / values.length;
|
||||
const budget = PERFORMANCE_BUDGETS[name];
|
||||
|
||||
console.log(` - ${name}:`);
|
||||
console.log(` Latest: ${latest.value}`);
|
||||
console.log(` Average: ${average.toFixed(2)}`);
|
||||
if (budget) {
|
||||
const status = latest.value <= budget ? "✅" : "❌";
|
||||
console.log(` Budget: ${budget} ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save report to file
|
||||
const report = {
|
||||
this.metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
totalMetrics: this.metrics.size,
|
||||
regressions: this.regressions.length,
|
||||
warnings: this.warnings.length,
|
||||
},
|
||||
regressions: this.regressions,
|
||||
warnings: this.warnings,
|
||||
metrics: Object.fromEntries(this.metrics),
|
||||
coreWebVitals: {},
|
||||
bundleMetrics: {},
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
const reportPath = path.join(__dirname, "../performance-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
console.log(`\n📄 Report saved to: ${reportPath}`);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all performance monitoring
|
||||
* Run comprehensive performance monitoring
|
||||
*/
|
||||
async run() {
|
||||
console.log("🔍 Starting Performance Monitoring...\n");
|
||||
async monitorPerformance() {
|
||||
console.log("📊 Starting performance monitoring...");
|
||||
|
||||
try {
|
||||
// Run Lighthouse CI tests
|
||||
// Ensure monitoring directory exists
|
||||
if (!fs.existsSync(MONITORING_DIR)) {
|
||||
fs.mkdirSync(MONITORING_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Run Lighthouse CI for Core Web Vitals
|
||||
await this.runLighthouseCI();
|
||||
|
||||
// Run Playwright performance tests
|
||||
await this.runPlaywrightPerformanceTests();
|
||||
// Analyze bundle performance
|
||||
await this.analyzeBundlePerformance();
|
||||
|
||||
// Generate and display report
|
||||
const report = this.generateReport();
|
||||
// Check performance budgets
|
||||
this.checkPerformanceBudgets();
|
||||
|
||||
// Exit with appropriate code
|
||||
if (this.regressions.length > 0) {
|
||||
console.log("❌ Performance regressions detected!");
|
||||
process.exit(1);
|
||||
} else if (this.warnings.length > 0) {
|
||||
console.log("⚠️ Performance warnings detected.");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("✅ All performance checks passed!");
|
||||
process.exit(0);
|
||||
}
|
||||
// Generate performance report
|
||||
this.generatePerformanceReport();
|
||||
|
||||
console.log("✅ Performance monitoring complete!");
|
||||
console.log(`📁 Results saved to: ${MONITORING_DIR}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Performance monitoring failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI for Core Web Vitals
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
console.log("🔍 Running Lighthouse CI...");
|
||||
|
||||
try {
|
||||
// Check if server is running
|
||||
const { execSync } = require("child_process");
|
||||
try {
|
||||
execSync("curl -s http://localhost:3000 > /dev/null", {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ Development server not running, skipping Lighthouse CI...",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the performance monitor if this script is executed directly
|
||||
// Run Lighthouse CI with performance focus
|
||||
execSync("npx lhci autorun --collect.url=http://localhost:3000", {
|
||||
stdio: "inherit",
|
||||
cwd: path.join(__dirname, ".."),
|
||||
});
|
||||
|
||||
// Parse Lighthouse results
|
||||
await this.parseLighthouseResults();
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Lighthouse CI failed, continuing with other metrics...");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Lighthouse CI results
|
||||
*/
|
||||
async parseLighthouseResults() {
|
||||
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
|
||||
|
||||
if (fs.existsSync(lhciResultsPath)) {
|
||||
const files = fs.readdirSync(lhciResultsPath);
|
||||
const resultFile = files.find((f) => f.endsWith(".json"));
|
||||
|
||||
if (resultFile) {
|
||||
const results = JSON.parse(
|
||||
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
|
||||
);
|
||||
|
||||
if (results.lhr && results.lhr.audits) {
|
||||
this.metrics.coreWebVitals = {
|
||||
lcp: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"largest-contentful-paint",
|
||||
),
|
||||
fid: this.getAuditScore(results.lhr.audits, "max-potential-fid"),
|
||||
cls: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"cumulative-layout-shift",
|
||||
),
|
||||
fcp: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"first-contentful-paint",
|
||||
),
|
||||
tti: this.getAuditScore(results.lhr.audits, "interactive"),
|
||||
performance: results.lhr.categories.performance?.score * 100 || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit score from Lighthouse results
|
||||
*/
|
||||
getAuditScore(audits, auditId) {
|
||||
const audit = audits[auditId];
|
||||
if (!audit) return null;
|
||||
|
||||
return {
|
||||
score: audit.score * 100,
|
||||
value: audit.numericValue,
|
||||
displayValue: audit.displayValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze bundle performance
|
||||
*/
|
||||
async analyzeBundlePerformance() {
|
||||
console.log("📦 Analyzing bundle performance...");
|
||||
|
||||
const bundleStatsPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"static",
|
||||
"chunks",
|
||||
);
|
||||
|
||||
if (fs.existsSync(bundleStatsPath)) {
|
||||
const files = fs.readdirSync(bundleStatsPath);
|
||||
let totalSize = 0;
|
||||
let jsFiles = 0;
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".js")) {
|
||||
const filePath = path.join(bundleStatsPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
totalSize += stats.size;
|
||||
jsFiles++;
|
||||
}
|
||||
});
|
||||
|
||||
this.metrics.bundleMetrics = {
|
||||
totalSizeKB: Math.round(totalSize / 1024),
|
||||
totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
|
||||
fileCount: jsFiles,
|
||||
averageSizeKB: Math.round(totalSize / jsFiles / 1024),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check performance budgets
|
||||
*/
|
||||
checkPerformanceBudgets() {
|
||||
const budgets = PERFORMANCE_BUDGETS.budgets;
|
||||
const violations = [];
|
||||
|
||||
// Check Core Web Vitals
|
||||
if (this.metrics.coreWebVitals.lcp) {
|
||||
const lcpValue = this.metrics.coreWebVitals.lcp.value;
|
||||
const lcpBudget = budgets.find((b) => b.name === "lcp")?.maxValue;
|
||||
|
||||
if (lcpBudget && lcpValue > lcpBudget) {
|
||||
violations.push({
|
||||
metric: "LCP",
|
||||
current: lcpValue,
|
||||
budget: lcpBudget,
|
||||
severity: lcpValue > lcpBudget * 1.5 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check bundle size
|
||||
if (this.metrics.bundleMetrics.totalSizeKB > 2000) {
|
||||
violations.push({
|
||||
metric: "Bundle Size",
|
||||
current: this.metrics.bundleMetrics.totalSizeKB,
|
||||
budget: 2000,
|
||||
severity: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
this.metrics.budgetViolations = violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
generatePerformanceReport() {
|
||||
const reportPath = path.join(MONITORING_DIR, "performance-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(this.metrics, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown performance report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(MONITORING_DIR, "performance-report.md");
|
||||
|
||||
let report = `# Performance Monitoring Report\n\n`;
|
||||
report += `**Generated:** ${this.metrics.timestamp}\n\n`;
|
||||
|
||||
// Core Web Vitals
|
||||
if (Object.keys(this.metrics.coreWebVitals).length > 0) {
|
||||
report += `## Core Web Vitals\n\n`;
|
||||
report += `| Metric | Score | Value | Status |\n`;
|
||||
report += `|--------|-------|-------|--------|\n`;
|
||||
|
||||
Object.entries(this.metrics.coreWebVitals).forEach(([metric, data]) => {
|
||||
if (data && typeof data === "object" && data.score !== undefined) {
|
||||
const status = this.getMetricStatus(metric, data.score);
|
||||
report += `| ${metric.toUpperCase()} | ${data.score} | ${
|
||||
data.displayValue || "N/A"
|
||||
} | ${status} |\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bundle Metrics
|
||||
if (Object.keys(this.metrics.bundleMetrics).length > 0) {
|
||||
report += `\n## Bundle Metrics\n\n`;
|
||||
report += `- **Total Size:** ${this.metrics.bundleMetrics.totalSizeMB}MB (${this.metrics.bundleMetrics.totalSizeKB}KB)\n`;
|
||||
report += `- **File Count:** ${this.metrics.bundleMetrics.fileCount}\n`;
|
||||
report += `- **Average Size:** ${this.metrics.bundleMetrics.averageSizeKB}KB per file\n`;
|
||||
}
|
||||
|
||||
// Budget Violations
|
||||
if (
|
||||
this.metrics.budgetViolations &&
|
||||
this.metrics.budgetViolations.length > 0
|
||||
) {
|
||||
report += `\n## Budget Violations\n\n`;
|
||||
this.metrics.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.metric}**: ${violation.current} (exceeds ${
|
||||
violation.budget
|
||||
}) - ${violation.severity.toUpperCase()}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
report += `\n## Recommendations\n\n`;
|
||||
report += `- Monitor Core Web Vitals regularly\n`;
|
||||
report += `- Implement code splitting for large bundles\n`;
|
||||
report += `- Use dynamic imports for non-critical components\n`;
|
||||
report += `- Optimize images and fonts\n`;
|
||||
report += `- Enable compression and caching\n`;
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status emoji for metric score
|
||||
*/
|
||||
getMetricStatus(metric, score) {
|
||||
if (score >= 90) return "✅ Good";
|
||||
if (score >= 50) return "⚠️ Needs Improvement";
|
||||
return "❌ Poor";
|
||||
}
|
||||
}
|
||||
|
||||
// Run monitoring if called directly
|
||||
if (require.main === module) {
|
||||
const monitor = new PerformanceMonitorScript();
|
||||
monitor.run();
|
||||
const monitor = new PerformanceMonitor();
|
||||
monitor.monitorPerformance().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = PerformanceMonitorScript;
|
||||
module.exports = PerformanceMonitor;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Comprehensive Performance Testing Script
|
||||
* Integrates bundle analysis, performance monitoring, and Web Vitals tracking
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const TEST_RESULTS_DIR = path.join(__dirname, "..", ".next", "test-results");
|
||||
|
||||
class PerformanceTester {
|
||||
constructor() {
|
||||
this.results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
bundleAnalysis: {},
|
||||
performanceMonitoring: {},
|
||||
webVitals: {},
|
||||
lighthouse: {},
|
||||
summary: {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
warnings: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run comprehensive performance testing
|
||||
*/
|
||||
async runTests() {
|
||||
console.log("🧪 Starting comprehensive performance testing...");
|
||||
|
||||
try {
|
||||
// Ensure test results directory exists
|
||||
if (!fs.existsSync(TEST_RESULTS_DIR)) {
|
||||
fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 1. Bundle Analysis
|
||||
console.log("📊 Running bundle analysis...");
|
||||
await this.runBundleAnalysis();
|
||||
|
||||
// 2. Performance Monitoring
|
||||
console.log("📈 Running performance monitoring...");
|
||||
await this.runPerformanceMonitoring();
|
||||
|
||||
// 3. Web Vitals Tracking
|
||||
console.log("📊 Setting up Web Vitals tracking...");
|
||||
await this.runWebVitalsTracking();
|
||||
|
||||
// 4. Lighthouse CI (if server is available)
|
||||
console.log("🔍 Running Lighthouse CI...");
|
||||
await this.runLighthouseCI();
|
||||
|
||||
// 5. Generate comprehensive report
|
||||
this.generateComprehensiveReport();
|
||||
|
||||
console.log("✅ Performance testing complete!");
|
||||
console.log(`📁 Results saved to: ${TEST_RESULTS_DIR}`);
|
||||
|
||||
// Return exit code based on results
|
||||
const hasFailures = this.results.summary.failed > 0;
|
||||
if (hasFailures) {
|
||||
console.log("❌ Performance tests failed");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("✅ All performance tests passed");
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Performance testing failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run bundle analysis
|
||||
*/
|
||||
async runBundleAnalysis() {
|
||||
try {
|
||||
execSync("npm run bundle:analyze", { stdio: "inherit" });
|
||||
|
||||
// Parse bundle analysis results
|
||||
const bundleReportPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"analyze",
|
||||
"bundle-analysis.json",
|
||||
);
|
||||
if (fs.existsSync(bundleReportPath)) {
|
||||
const bundleData = JSON.parse(
|
||||
fs.readFileSync(bundleReportPath, "utf8"),
|
||||
);
|
||||
this.results.bundleAnalysis = bundleData;
|
||||
|
||||
// Check for budget violations
|
||||
if (
|
||||
bundleData.budgetViolations &&
|
||||
bundleData.budgetViolations.length > 0
|
||||
) {
|
||||
this.results.summary.failed += bundleData.budgetViolations.length;
|
||||
console.log(
|
||||
`⚠️ Found ${bundleData.budgetViolations.length} budget violations`,
|
||||
);
|
||||
} else {
|
||||
this.results.summary.passed += 1;
|
||||
console.log("✅ Bundle analysis passed");
|
||||
}
|
||||
}
|
||||
|
||||
this.results.summary.total += 1;
|
||||
} catch (error) {
|
||||
console.error("❌ Bundle analysis failed:", error.message);
|
||||
this.results.summary.failed += 1;
|
||||
this.results.summary.total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run performance monitoring
|
||||
*/
|
||||
async runPerformanceMonitoring() {
|
||||
try {
|
||||
execSync("npm run performance:monitor", { stdio: "inherit" });
|
||||
|
||||
// Parse performance monitoring results
|
||||
const perfReportPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"monitoring",
|
||||
"performance-report.json",
|
||||
);
|
||||
if (fs.existsSync(perfReportPath)) {
|
||||
const perfData = JSON.parse(fs.readFileSync(perfReportPath, "utf8"));
|
||||
this.results.performanceMonitoring = perfData;
|
||||
|
||||
// Check for budget violations
|
||||
if (perfData.budgetViolations && perfData.budgetViolations.length > 0) {
|
||||
this.results.summary.failed += perfData.budgetViolations.length;
|
||||
console.log(
|
||||
`⚠️ Found ${perfData.budgetViolations.length} performance violations`,
|
||||
);
|
||||
} else {
|
||||
this.results.summary.passed += 1;
|
||||
console.log("✅ Performance monitoring passed");
|
||||
}
|
||||
}
|
||||
|
||||
this.results.summary.total += 1;
|
||||
} catch (error) {
|
||||
console.error("❌ Performance monitoring failed:", error.message);
|
||||
this.results.summary.failed += 1;
|
||||
this.results.summary.total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Web Vitals tracking
|
||||
*/
|
||||
async runWebVitalsTracking() {
|
||||
try {
|
||||
execSync("npm run web-vitals:track", { stdio: "inherit" });
|
||||
|
||||
// Parse Web Vitals results
|
||||
const vitalsReportPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"web-vitals",
|
||||
"report.json",
|
||||
);
|
||||
if (fs.existsSync(vitalsReportPath)) {
|
||||
const vitalsData = JSON.parse(
|
||||
fs.readFileSync(vitalsReportPath, "utf8"),
|
||||
);
|
||||
this.results.webVitals = vitalsData;
|
||||
console.log("✅ Web Vitals tracking setup complete");
|
||||
}
|
||||
|
||||
this.results.summary.passed += 1;
|
||||
this.results.summary.total += 1;
|
||||
} catch (error) {
|
||||
console.error("❌ Web Vitals tracking failed:", error.message);
|
||||
this.results.summary.failed += 1;
|
||||
this.results.summary.total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
try {
|
||||
// Check if server is running
|
||||
try {
|
||||
execSync("curl -s http://localhost:3000 > /dev/null", {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ Development server not running, skipping Lighthouse CI...",
|
||||
);
|
||||
this.results.summary.warnings += 1;
|
||||
this.results.summary.total += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
execSync("npm run lhci", { stdio: "inherit" });
|
||||
|
||||
// Parse Lighthouse results
|
||||
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
|
||||
if (fs.existsSync(lhciResultsPath)) {
|
||||
const files = fs.readdirSync(lhciResultsPath);
|
||||
const resultFile = files.find((f) => f.endsWith(".json"));
|
||||
|
||||
if (resultFile) {
|
||||
const lhciData = JSON.parse(
|
||||
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
|
||||
);
|
||||
this.results.lighthouse = lhciData;
|
||||
console.log("✅ Lighthouse CI completed");
|
||||
}
|
||||
}
|
||||
|
||||
this.results.summary.passed += 1;
|
||||
this.results.summary.total += 1;
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Lighthouse CI failed:", error.message);
|
||||
this.results.summary.warnings += 1;
|
||||
this.results.summary.total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate comprehensive test report
|
||||
*/
|
||||
generateComprehensiveReport() {
|
||||
// Ensure test results directory exists
|
||||
if (!fs.existsSync(TEST_RESULTS_DIR)) {
|
||||
fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const reportPath = path.join(
|
||||
TEST_RESULTS_DIR,
|
||||
"performance-test-report.json",
|
||||
);
|
||||
fs.writeFileSync(reportPath, JSON.stringify(this.results, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown test report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(
|
||||
TEST_RESULTS_DIR,
|
||||
"performance-test-report.md",
|
||||
);
|
||||
|
||||
let report = `# Performance Test Report\n\n`;
|
||||
report += `**Generated:** ${this.results.timestamp}\n\n`;
|
||||
|
||||
// Summary
|
||||
report += `## Test Summary\n\n`;
|
||||
report += `- **Total Tests:** ${this.results.summary.total}\n`;
|
||||
report += `- **Passed:** ${this.results.summary.passed} ✅\n`;
|
||||
report += `- **Failed:** ${this.results.summary.failed} ❌\n`;
|
||||
report += `- **Warnings:** ${this.results.summary.warnings} ⚠️\n\n`;
|
||||
|
||||
// Bundle Analysis Results
|
||||
if (Object.keys(this.results.bundleAnalysis).length > 0) {
|
||||
report += `## Bundle Analysis\n\n`;
|
||||
if (
|
||||
this.results.bundleAnalysis.budgetViolations &&
|
||||
this.results.bundleAnalysis.budgetViolations.length > 0
|
||||
) {
|
||||
report += `### Budget Violations\n\n`;
|
||||
this.results.bundleAnalysis.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.file}**: ${
|
||||
violation.currentSize
|
||||
}KB (exceeds ${violation.maxSize}KB by ${
|
||||
violation.overage
|
||||
}KB) - ${violation.severity.toUpperCase()}\n`;
|
||||
});
|
||||
} else {
|
||||
report += `✅ No bundle budget violations found\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Performance Monitoring Results
|
||||
if (Object.keys(this.results.performanceMonitoring).length > 0) {
|
||||
report += `## Performance Monitoring\n\n`;
|
||||
if (
|
||||
this.results.performanceMonitoring.budgetViolations &&
|
||||
this.results.performanceMonitoring.budgetViolations.length > 0
|
||||
) {
|
||||
report += `### Budget Violations\n\n`;
|
||||
this.results.performanceMonitoring.budgetViolations.forEach(
|
||||
(violation) => {
|
||||
report += `- **${violation.metric}**: ${
|
||||
violation.current
|
||||
} (exceeds ${
|
||||
violation.budget
|
||||
}) - ${violation.severity.toUpperCase()}\n`;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
report += `✅ No performance budget violations found\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Web Vitals Results
|
||||
if (Object.keys(this.results.webVitals).length > 0) {
|
||||
report += `## Web Vitals Tracking\n\n`;
|
||||
report += `✅ Web Vitals tracking setup complete\n\n`;
|
||||
}
|
||||
|
||||
// Lighthouse Results
|
||||
if (Object.keys(this.results.lighthouse).length > 0) {
|
||||
report += `## Lighthouse CI\n\n`;
|
||||
report += `✅ Lighthouse CI completed successfully\n\n`;
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
report += `## Recommendations\n\n`;
|
||||
report += `- Monitor bundle sizes regularly\n`;
|
||||
report += `- Track Core Web Vitals in production\n`;
|
||||
report += `- Run performance tests in CI/CD pipeline\n`;
|
||||
report += `- Set up performance budgets and alerts\n`;
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const tester = new PerformanceTester();
|
||||
tester.runTests().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = PerformanceTester;
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Web Vitals Tracker
|
||||
* Real-time monitoring of Core Web Vitals in production
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const WEB_VITALS_DIR = path.join(__dirname, "..", ".next", "web-vitals");
|
||||
|
||||
class WebVitalsTracker {
|
||||
constructor() {
|
||||
this.metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
vitals: {
|
||||
lcp: [],
|
||||
fid: [],
|
||||
cls: [],
|
||||
fcp: [],
|
||||
ttfb: [],
|
||||
},
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track Web Vitals from client-side
|
||||
*/
|
||||
trackWebVitals() {
|
||||
const trackingCode = `
|
||||
// Web Vitals Tracking Script
|
||||
(function() {
|
||||
// Import web-vitals library
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
const vitals = {};
|
||||
|
||||
// Track Largest Contentful Paint
|
||||
getLCP((metric) => {
|
||||
vitals.lcp = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('lcp', vitals.lcp);
|
||||
});
|
||||
|
||||
// Track First Input Delay
|
||||
getFID((metric) => {
|
||||
vitals.fid = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('fid', vitals.fid);
|
||||
});
|
||||
|
||||
// Track Cumulative Layout Shift
|
||||
getCLS((metric) => {
|
||||
vitals.cls = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('cls', vitals.cls);
|
||||
});
|
||||
|
||||
// Track First Contentful Paint
|
||||
getFCP((metric) => {
|
||||
vitals.fcp = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('fcp', vitals.fcp);
|
||||
});
|
||||
|
||||
// Track Time to First Byte
|
||||
getTTFB((metric) => {
|
||||
vitals.ttfb = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('ttfb', vitals.ttfb);
|
||||
});
|
||||
});
|
||||
|
||||
// Send vitals to server
|
||||
function sendVitals(metric, data) {
|
||||
if (typeof window !== 'undefined' && window.fetch) {
|
||||
fetch('/api/web-vitals', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metric,
|
||||
data,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
return trackingCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API endpoint for receiving Web Vitals
|
||||
*/
|
||||
createAPIEndpoint() {
|
||||
const apiCode = `
|
||||
// API endpoint for Web Vitals tracking
|
||||
export default function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { metric, data, url, userAgent, timestamp } = req.body;
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
userAgent,
|
||||
timestamp: new Date(timestamp).toISOString()
|
||||
};
|
||||
|
||||
// In production, you would save this to a database
|
||||
// For now, we'll log it
|
||||
console.log('Web Vital received:', vitalsData);
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error processing web vital:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return apiCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Web Vitals dashboard
|
||||
*/
|
||||
generateDashboard() {
|
||||
const dashboardCode = `
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const WebVitalsDashboard = () => {
|
||||
const [vitals, setVitals] = useState({
|
||||
lcp: { value: 0, rating: 'unknown' },
|
||||
fid: { value: 0, rating: 'unknown' },
|
||||
cls: { value: 0, rating: 'unknown' },
|
||||
fcp: { value: 0, rating: 'unknown' },
|
||||
ttfb: { value: 0, rating: 'unknown' }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// In a real implementation, you would fetch from your database
|
||||
// For now, we'll use localStorage for demo purposes
|
||||
const storedVitals = localStorage.getItem('web-vitals');
|
||||
if (storedVitals) {
|
||||
setVitals(JSON.parse(storedVitals));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good': return 'text-green-600';
|
||||
case 'needs-improvement': return 'text-yellow-600';
|
||||
case 'poor': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good': return '✅';
|
||||
case 'needs-improvement': return '⚠️';
|
||||
case 'poor': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6">Web Vitals Dashboard</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div key={metric} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div>Value: {data.value}ms</div>
|
||||
<div className={\`font-medium \${getRatingColor(data.rating)}\`}>
|
||||
Rating: {data.rating.replace('-', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">Performance Guidelines</h3>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s, Poor > 4s</li>
|
||||
<li>• <strong>FID:</strong> Good < 100ms, Needs Improvement 100-300ms, Poor > 300ms</li>
|
||||
<li>• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25, Poor > 0.25</li>
|
||||
<li>• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s, Poor > 3s</li>
|
||||
<li>• <strong>TTFB:</strong> Good < 800ms, Needs Improvement 800-1800ms, Poor > 1800ms</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebVitalsDashboard;
|
||||
`;
|
||||
|
||||
return dashboardCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Web Vitals data
|
||||
*/
|
||||
saveVitalsData(metric, data) {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn("Could not parse existing vitals data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
existingData.push({
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Keep only last 100 entries
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Web Vitals report
|
||||
*/
|
||||
generateReport() {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
console.log("No Web Vitals data found");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const metric = file.replace(".json", "");
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"),
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
.map((d) => d.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
report.metrics[metric] = {
|
||||
count: data.length,
|
||||
average:
|
||||
values.length > 0
|
||||
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
|
||||
: 0,
|
||||
min: values.length > 0 ? Math.min(...values) : 0,
|
||||
max: values.length > 0 ? Math.max(...values) : 0,
|
||||
goodCount: ratings.filter((r) => r === "good").length,
|
||||
needsImprovementCount: ratings.filter(
|
||||
(r) => r === "needs-improvement",
|
||||
).length,
|
||||
poorCount: ratings.filter((r) => r === "poor").length,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const reportPath = path.join(WEB_VITALS_DIR, "report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
|
||||
console.log("📊 Web Vitals report generated:", reportPath);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const tracker = new WebVitalsTracker();
|
||||
tracker.generateReport();
|
||||
}
|
||||
|
||||
module.exports = WebVitalsTracker;
|
||||
@@ -0,0 +1,21 @@
|
||||
import ErrorBoundary from "../app/components/ErrorBoundary";
|
||||
|
||||
export default {
|
||||
title: "Components/ErrorBoundary",
|
||||
component: ErrorBoundary,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"An error boundary component that catches JavaScript errors in its child component tree. Displays a fallback UI when errors occur and logs error information for debugging.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
children: {
|
||||
control: { type: "text" },
|
||||
description: "Child components to wrap with error boundary",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import WebVitalsDashboard from "../app/components/WebVitalsDashboard";
|
||||
|
||||
export default {
|
||||
title: "Components/WebVitalsDashboard",
|
||||
component: WebVitalsDashboard,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A comprehensive dashboard component that displays real-time and historical Web Vitals data. Shows Core Web Vitals metrics, performance ratings, and optimization recommendations.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"The default Web Vitals dashboard showing real-time performance metrics and historical data.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading = {
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "The dashboard in loading state while fetching Web Vitals data.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -84,7 +84,7 @@ describe("LogoWall Component", () => {
|
||||
const foodNotBombsLogo = screen.getByAltText("Food Not Bombs");
|
||||
expect(foodNotBombsLogo).toHaveAttribute(
|
||||
"src",
|
||||
"assets/Section/Logo_FoodNotBombs.png",
|
||||
"/assets/Section/Logo_FoodNotBombs.png",
|
||||
);
|
||||
expect(foodNotBombsLogo).toHaveClass("h-11", "lg:h-14", "xl:h-[70px]");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user