diff --git a/.bundle-limits.json b/.bundle-limits.json index cc679ea..a3ef741 100644 --- a/.bundle-limits.json +++ b/.bundle-limits.json @@ -2,5 +2,6 @@ "maxMainBundle": 250, "maxPageBundle": 100, "maxTotalGzipped": 500, - "maxIndividualGzipped": 100 + "maxIndividualGzipped": 100, + "maxCssTotalGzipped": 25 } diff --git a/package.json b/package.json index 840f43c..bc935d4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "next dev", "build": "node scripts/generate-pwa-icons.js && next build && node scripts/check-bundle-size.js", "build:analyze": "ANALYZE=true next build", - "build:strict": "next build && node scripts/check-bundle-size.js --strict", + "build:strict": "next build && node scripts/check-bundle-size.js --strict && node scripts/check-css-size.js --strict", "start": "next start", "lint": "eslint", "test": "node scripts/test-lang-cache.js", diff --git a/scripts/check-css-size.js b/scripts/check-css-size.js new file mode 100644 index 0000000..5550cb2 --- /dev/null +++ b/scripts/check-css-size.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); + +const STRICT_MODE = process.argv.includes("--strict"); +const BUILD_DIR = path.join(process.cwd(), ".next"); +const CONFIG_FILE = path.join(process.cwd(), ".bundle-limits.json"); +const OUTPUT_FILE = path.join(process.cwd(), ".css-bundle-report.json"); + +const DEFAULT_LIMITS = { + maxCssTotalGzipped: 25, +}; + +function loadConfig() { + try { + if (fs.existsSync(CONFIG_FILE)) { + const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); + return { + ...DEFAULT_LIMITS, + ...parsed, + }; + } + } catch { + } + return DEFAULT_LIMITS; +} + +function getFileSizeKb(filePath) { + try { + const stats = fs.statSync(filePath); + return stats.size / 1024; + } catch { + return 0; + } +} + +function getGzippedSizeKb(filePath) { + try { + const data = fs.readFileSync(filePath); + const gzipped = zlib.gzipSync(data); + return gzipped.length / 1024; + } catch { + return 0; + } +} + +function walk(dir, out) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath, out); + } else if (entry.isFile() && fullPath.endsWith(".css")) { + out.push(fullPath); + } + } +} + +function analyzeCss() { + const limits = loadConfig(); + const report = { + timestamp: new Date().toISOString(), + limits: { + maxCssTotalGzipped: limits.maxCssTotalGzipped, + }, + files: [], + totalGzipped: 0, + violations: [], + passed: true, + }; + + const staticDir = path.join(BUILD_DIR, "static"); + if (!fs.existsSync(staticDir)) { + console.error('āŒ Build directory not found. Run "npm run build" first.'); + process.exit(1); + } + + const cssFiles = []; + walk(staticDir, cssFiles); + + let totalGzipped = 0; + for (const filePath of cssFiles) { + const size = getFileSizeKb(filePath); + const gzipped = getGzippedSizeKb(filePath); + totalGzipped += gzipped; + report.files.push({ + name: path.relative(BUILD_DIR, filePath).replace(/\\/g, "/"), + size: Number(size.toFixed(2)), + gzipped: Number(gzipped.toFixed(2)), + }); + } + + report.totalGzipped = Number(totalGzipped.toFixed(2)); + + if (report.totalGzipped > limits.maxCssTotalGzipped) { + report.violations.push( + `Total CSS gzipped size exceeds limit (${report.totalGzipped}KB > ${limits.maxCssTotalGzipped}KB)` + ); + report.passed = false; + } + + return report; +} + +function printReport(report) { + console.log("\n" + "=".repeat(60)); + console.log("šŸŽØ CSS Bundle Size Analysis Report"); + console.log("=".repeat(60) + "\n"); + + console.log("šŸ“‹ Configuration:"); + console.log(` • Max total CSS: ${report.limits.maxCssTotalGzipped}KB (gzipped)\n`); + + if (report.files.length === 0) { + console.log("āš ļø No CSS assets found in build output."); + } else { + console.log("šŸ“Š CSS Asset Breakdown:"); + report.files + .sort((a, b) => b.gzipped - a.gzipped) + .forEach((file) => { + console.log(` • ${file.name}`); + console.log(` └─ ${file.size}KB raw | ${file.gzipped}KB gzipped`); + }); + } + + console.log(`\nšŸ“ˆ Total CSS (gzipped): ${report.totalGzipped}KB`); + + if (report.violations.length > 0) { + console.log("\n" + "āœ—".repeat(60)); + console.log("āŒ CSS SIZE LIMIT VIOLATION:\n"); + report.violations.forEach((v, i) => console.log(` ${i + 1}. ${v}`)); + console.log("āœ—".repeat(60) + "\n"); + } else { + console.log("\n" + "āœ“".repeat(60)); + console.log("āœ… CSS bundle is within size limits!"); + console.log("āœ“".repeat(60) + "\n"); + } + + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(report, null, 2)); + console.log(`šŸ“„ Detailed report saved to: ${OUTPUT_FILE}\n`); +} + +try { + const report = analyzeCss(); + printReport(report); + if (!report.passed) { + if (STRICT_MODE) { + console.error("ā›” Build blocked due to CSS size limit violation (strict mode enabled)"); + process.exit(1); + } else { + console.warn('āš ļø CSS size violation detected. Use "node scripts/check-css-size.js --strict" to block builds.\n'); + } + } +} catch (err) { + console.error("āŒ Error analyzing CSS assets:", err.message); + process.exit(1); +} + diff --git a/src/app/globals.css b/src/app/globals.css index 8e344d2..0c860e1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@config "../../tailwind.config.js"; :root { --background: #ffffff; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..a2a63cb --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,13 @@ +module.exports = { + content: [ + "./src/**/*.{js,jsx,ts,tsx,mdx}", + "!./src/**/__tests__/**", + "!./src/**/*.{test,spec}.{js,jsx,ts,tsx}", + "!./src/**/*.stories.{js,jsx,ts,tsx,mdx}", + ], + theme: { + extend: {}, + }, + plugins: [], +}; +