feat: add sidebar management and theme utilities#29
Conversation
pphatdev
commented
Apr 7, 2026
- Implement SidebarManager class for responsive sidebar handling.
- Create theme management utilities including base themes and badge themes.
- Add validation schemas for API requests using Zod.
- Update types and fix import paths for badge types.
- Remove unused types from tsconfig.json.
- Implement SidebarManager class for responsive sidebar handling. - Create theme management utilities including base themes and badge themes. - Add validation schemas for API requests using Zod. - Update types and fix import paths for badge types. - Remove unused types from tsconfig.json.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
stats | ee4a824 | Commit Preview URL Branch Preview URL |
Apr 07 2026, 10:05 AM |
There was a problem hiding this comment.
Pull request overview
This PR introduces a modularized server architecture and expands shared utilities (themes, sidebar handling, validations), while reorganizing several legacy controllers/routes/services into module-based implementations.
Changes:
- Add a new modular server/app bootstrap (
src/server.ts,src/app.ts) and new feature modules for stats, languages, graphs, badges, icons, and health. - Introduce shared theme utilities (base/graph/badge themes), shared badge type definitions, and Zod validation schemas/helpers.
- Remove or replace multiple legacy routes/controllers/services and adjust import paths/types to match the new folder layout.
Reviewed changes
Copilot reviewed 87 out of 102 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Removes explicit TS types override. |
| src/types.ts | Re-exports badge types from new shared location. |
| src/shared/validations/validation.ts | Adds Zod schemas and validation helpers. |
| src/shared/utils/themes/graph.ts | Fixes Theme import path for graph themes. |
| src/shared/utils/themes/badge.ts | Updates BadgeTheme import to shared badge types. |
| src/shared/utils/themes.ts | Repoints Theme imports to src/types.ts and composes themes. |
| src/shared/utils/sidebar.ts | Adds SidebarManager for responsive sidebar toggling. |
| src/shared/utils/index.ts | New barrel export for shared utilities. |
| src/shared/utils/global-error.ts | Adds optional HTTP server close to graceful shutdown. |
| src/shared/utils/github-client.ts | Updates types import path to new root types. |
| src/shared/utils/cache.ts | Reformat/no-op functional changes to cache helpers. |
| src/shared/utils/cache-middleware.ts | Reformat/no-op functional changes to middleware/coalescing logic. |
| src/shared/utils/badge-cache-manager.ts | Updates imports to new paths/logging. |
| src/shared/types/badge.types.ts | New shared badge type/theme/route-doc definitions. |
| src/shared/middlewares/index.ts | New barrel export for shared middleware. |
| src/shared/errors/index.ts | Adds alternate AppError-style error classes/utilities. |
| src/shared/errors/errors.ts | Keeps existing ErrorCode/AppError types (format-only). |
| src/shared/constants.ts | Adds shared HTTP/cache/API constants. |
| src/shared/components/icons-gallery/types.ts | Formatting/no-op. |
| src/shared/components/icons-gallery/svg-utils.ts | Formatting/no-op. |
| src/shared/components/icons-gallery/index.ts | Formatting/no-op barrel export alignment. |
| src/shared/components/icons-gallery/icon-card.ts | Formatting/no-op. |
| src/shared/components/icons-gallery/gallery.ts | Formatting/no-op. |
| src/shared/components/card-renderer.ts | Updates types import path to new root types. |
| src/services/github-graphql-optimizer.service.ts | Removes legacy GraphQL batching optimizer service. |
| src/services/cache.service.ts | Removes legacy Redis/Hybrid cache service implementation. |
| src/services/base.service.ts | Updates logger import path to shared logs. |
| src/services/badge-cache.service.ts | Updates logger import path to shared logs. |
| src/server.ts | New modular server bootstrap and service initialization. |
| src/server-cluster.ts | Lowers cluster restart budget. |
| src/routes/user-badge.routes.ts | Removes legacy user badge route registrations/docs. |
| src/routes/register.routes.ts | Removes legacy controller init + route registration. |
| src/routes/redis-cached.routes.ts | Removes legacy cached route registration and warmup logic. |
| src/routes/project-badge.routes.ts | Removes legacy project badge route registrations/docs. |
| src/routes/icons.routes.ts | Removes legacy icons routes/docs. |
| src/routes/docs.routes.ts | Simplifies route enumeration (drops embedded routeDocs). |
| src/routes/badge-collection.routes.ts | Removes legacy badge collection route/docs. |
| src/routes/badge-cache.routes.ts | Removes legacy cache health/stats endpoints. |
| src/modules/stats/stats.types.ts | New stats module request/options/cache types. |
| src/modules/stats/stats.service.ts | New stats service (SVG generation + WebP conversion + caching). |
| src/modules/stats/stats.routes.ts | Re-export of stats router factory. |
| src/modules/stats/stats.controller.ts | New stats controller with DB request logging and format selection. |
| src/modules/stats/index.ts | Stats module exports + router factory. |
| src/modules/languages/languages.types.ts | New languages module types. |
| src/modules/languages/languages.service.ts | New languages service for card/pie rendering + caching. |
| src/modules/languages/languages.routes.ts | New languages router factory. |
| src/modules/languages/languages.controller.ts | New languages controller with route docs and param parsing. |
| src/modules/languages/index.ts | Languages module exports. |
| src/modules/icons/index.ts | Icons module exports. |
| src/modules/icons/icons.types.ts | New icon query/cache types. |
| src/modules/icons/icons.service.ts | New icon FS loader with caching, ETag, and color override support. |
| src/modules/icons/icons.routes.ts | New icons router factory. |
| src/modules/icons/icons.controller.ts | New icons controller (list/demo/get with ETag support). |
| src/modules/health/index.ts | Health module exports. |
| src/modules/health/health.types.ts | New health check types. |
| src/modules/health/health.service.ts | New health service (DB/cache/memory checks). |
| src/modules/health/health.routes.ts | New health router factory (health/ready/live/ping). |
| src/modules/health/health.controller.ts | New health controller. |
| src/modules/graphs/index.ts | Graphs module exports. |
| src/modules/graphs/graphs.types.ts | New graphs query/options/cache types. |
| src/modules/graphs/graphs.service.ts | New graphs service (SVG generation + raster conversion + caching). |
| src/modules/graphs/graphs.routes.ts | New graphs router factory with format switching. |
| src/modules/graphs/graphs.controller.ts | New graphs controller (svg/png/webp endpoints). |
| src/modules/badges/index.ts | Badges module exports. |
| src/modules/badges/badges.types.ts | New badge query/options/types. |
| src/modules/badges/badges.service.ts | New badge service (currently placeholder rendering + DB integration). |
| src/modules/badges/badges.routes.ts | New badge router factory (user + project badge routes). |
| src/modules/badges/badges.controller.ts | New badge controller with route docs and query parsing. |
| src/index.ts | Replaces monolithic bootstrap with startServer(). |
| src/index.refactored.ts | Removes older refactored bootstrap file. |
| src/db/pool.ts | Updates logger import path to shared logs. |
| src/controllers/stats.controller.ts | Removes legacy stats controller. |
| src/controllers/languages.controller.ts | Removes legacy languages controller. |
| src/controllers/health.controller.ts | Removes legacy health controller. |
| src/controllers/graph.controller.ts | Removes legacy graph controller. |
| src/config/swagger.ts | Adds Swagger/OpenAPI config helpers. |
| src/config/logger.ts | Adds config-level structured logger implementation. |
| src/config/index.ts | Reworks config exports; adds backward-compatible getConfig(). |
| src/config/env.ts | Adds Zod-validated environment parsing. |
| src/config/db.ts | Adds centralized DB initialization helpers. |
| src/cluster.ts | Updates logger import path to shared logs. |
| src/app.ts | New modular Express app setup + module route mounting + error handlers. |
| scripts/clear-redis-cache.ts | Updates redis-client import path to shared utils. |
| package.json | Adds dev:modular script. |
| drizzle.config.ts | Simplifies drizzle-kit config to local SQLite-only. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const graphCardOptions = { | ||
| ...options, | ||
| year: dateRange.displayYear, | ||
| show_title: options.showTitle, | ||
| show_total_contribution: options.showTotalContribution, | ||
| show_background: options.showBackground, | ||
| animate: (params.animate as 'none' | 'glow' | 'wave' | 'pulse' | undefined), | ||
| as: (params.format as 'svg' | 'webp' | 'png' | 'gif' | undefined), | ||
| size: (params.size as 'small' | 'medium' | 'large' | 'default' | undefined) | ||
| }; |
There was a problem hiding this comment.
graphCardOptions.as is being set from params.format, but callers may provide the output format via the as query parameter (and the route also checks req.query.as). This means the renderer may not receive the intended output format. Consider setting as from params.as (or params.as ?? params.format) to match the routing behavior.
| private readonly MAX_CACHE_ITEMS = 2000; | ||
| private readonly HTTP_CACHE_CONTROL = 'public, max-age=31536000, immutable'; | ||
| private readonly COLOR_REGEX = /^(#[0-9A-Fa-f]{3,8}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|hsla\([^)]+\)|[a-zA-Z]+|currentColor)$/; | ||
| private readonly ICON_NAME_REGEX = /^[a-zA-Z0-9._-]+$/; |
There was a problem hiding this comment.
The COLOR_REGEX accepts values like rgb(...)/hsl(...) with arbitrary characters (including quotes/angle brackets). Since color is interpolated directly into SVG attributes in applyColor(), this can enable SVG/HTML attribute injection (XSS) when serving image/svg+xml. Tighten validation to only allow safe color syntaxes (e.g. strict numeric rgb/rgba/hsl/hsla patterns) and/or escape attribute values before inserting into the SVG.
| * Generate a simple badge SVG | ||
| */ | ||
| private generateSimpleBadge(label: string, value: string, options: BadgeOptions): string { | ||
| const theme = options.theme || 'default'; | ||
| // Simple badge generation - this is a placeholder | ||
| // You may want to use a proper badge renderer or library | ||
| return `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"> | ||
| <text x="10" y="15">${label}: ${value}</text> |
There was a problem hiding this comment.
generateSimpleBadge() interpolates label and value directly into SVG text without escaping. Since customLabel comes from the request query, this can allow SVG/script injection in the rendered badge. Escape XML entities (at minimum & < > " ') before inserting user-controlled strings, or use a safe SVG/text rendering helper.
| * Generate a simple badge SVG | |
| */ | |
| private generateSimpleBadge(label: string, value: string, options: BadgeOptions): string { | |
| const theme = options.theme || 'default'; | |
| // Simple badge generation - this is a placeholder | |
| // You may want to use a proper badge renderer or library | |
| return `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"> | |
| <text x="10" y="15">${label}: ${value}</text> | |
| * Escape XML entities for safe insertion into SVG/XML text content | |
| */ | |
| private escapeXml(value: string): string { | |
| return value | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| /** | |
| * Generate a simple badge SVG | |
| */ | |
| private generateSimpleBadge(label: string, value: string, options: BadgeOptions): string { | |
| const theme = options.theme || 'default'; | |
| const safeLabel = this.escapeXml(label); | |
| const safeValue = this.escapeXml(value); | |
| // Simple badge generation - this is a placeholder | |
| // You may want to use a proper badge renderer or library | |
| return `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"> | |
| <text x="10" y="15">${safeLabel}: ${safeValue}</text> |
| await db.insert(badges) | ||
| .values({ | ||
| username, | ||
| repositories: 0, // Not available in GitHubStats, needs separate fetch | ||
| organization: 0, // Not available in GitHubStats | ||
| languages: 0, // Not available in GitHubStats | ||
| followers: 0, // Not available in GitHubStats | ||
| total_stars: userData.totalStars || 0, | ||
| total_contributors: 0, // Not available | ||
| total_commits: userData.totalCommits || 0, | ||
| total_code_reviews: 0, // Not available | ||
| total_issues: userData.totalIssues || 0, | ||
| total_pull_requests: userData.totalPRs || 0, | ||
| total_joined_years: 0, // Need createdAt from user data | ||
| updated_at: now | ||
| }) | ||
| .onConflictDoUpdate({ | ||
| target: badges.username, | ||
| set: { | ||
| total_stars: userData.totalStars || 0, | ||
| total_commits: userData.totalCommits || 0, | ||
| total_issues: userData.totalIssues || 0, | ||
| total_pull_requests: userData.totalPRs || 0, | ||
| updated_at: now | ||
| } | ||
| }); | ||
|
|
||
| logger.info('User badge data refreshed', { username }); | ||
| } | ||
|
|
||
| /** | ||
| * Get visitor count | ||
| */ | ||
| private async getVisitorCount(username: string): Promise<number> { | ||
| // Implement visitor counting logic | ||
| return 0; | ||
| } | ||
|
|
||
| /** | ||
| * Get project metric | ||
| */ | ||
| private async getProjectMetric(owner: string, repo: string, type: ProjectBadgeType): Promise<number> { | ||
| // Implement project metric fetching logic | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
Several badge values are currently placeholders: refreshUserBadgeData() persists zeros for many user metrics, and getVisitorCount() / getProjectMetric() always return 0. With the new badge routes mounted, this will cause badges to return incorrect values in production. Either implement the missing metric fetches (DB queries/GitHub calls) or temporarily disable these routes until the values are correctly populated.
| // Initialize Redis (optional) | ||
| let cacheService; | ||
| try { | ||
| await getRedisClient(); | ||
| cacheService = await getBadgeCacheService(); | ||
| logger.info('Redis cache initialized'); | ||
| } catch (error) { | ||
| logger.warn('Redis not available - using in-memory cache'); | ||
| } | ||
|
|
||
| return { cacheService }; | ||
| } | ||
|
|
||
| /** | ||
| * Start the server | ||
| */ | ||
| export async function startServer(): Promise<Express> { | ||
| const env = getEnv(); | ||
|
|
||
| // Initialize services | ||
| const { cacheService } = await initializeServices(); | ||
|
|
||
| // Create GitHub client | ||
| const githubClient = new GitHubClient(env.GITHUB_TOKEN); | ||
|
|
||
| // Create Express app | ||
| const app = createApp(); | ||
|
|
||
| // Initialize routes with dependencies | ||
| initializeRoutes(app, githubClient, cache, env.CACHE_DURATION, cacheService); | ||
|
|
||
| // Setup error handlers | ||
| setupErrorHandlers(app); | ||
|
|
||
| // Start listening | ||
| const port = env.PORT; | ||
| app.listen(port, () => { | ||
| logger.info(`Server started on port ${port}`, { | ||
| port, | ||
| environment: env.APP_ENV, | ||
| nodeEnv: process.env.NODE_ENV | ||
| }); | ||
| }); | ||
|
|
||
| return app; |
There was a problem hiding this comment.
The shared cacheService is set to getBadgeCacheService(), but this object isn't a generic cache (and doesn't match what HealthService expects). Also, app.listen()'s returned http.Server is currently ignored, so graceful shutdown can’t close the server cleanly. Consider (1) passing a cache implementation with the expected interface (or a dedicated health-check adapter), and (2) retaining/returning the Server instance so shutdown handlers can close it.
| import { startServer } from './server.js'; | ||
|
|
||
| import express from 'express'; | ||
| import cors from 'cors'; | ||
| import compression from 'compression'; | ||
| import { GitHubClient } from './utils/github-client.js'; | ||
| import { getRedisClient } from './utils/redis-client.js'; | ||
| import { getBadgeCacheService } from './services/badge-cache.service.js'; | ||
| import { warmupRedisCache } from './routes/redis-cached.routes.js'; | ||
| import { initializeControllers, registerRoutes } from './routes/register.routes.js'; | ||
| import path from 'path'; | ||
| import { fileURLToPath } from 'url'; | ||
| import cluster from 'cluster'; | ||
| import { getRoutes } from './routes/docs.routes.js'; | ||
|
|
||
|
|
||
| const __filename = fileURLToPath(import.meta.url); | ||
| const __dirname = path.dirname(__filename); | ||
| const publicDir = path.join(__dirname, '..', 'public'); | ||
|
|
||
| const app = express(); | ||
|
|
||
| // ⚡️ PERFORMANCE: Enable gzip compression for responses | ||
| app.use(compression({ level: 6, threshold: 1024, })); | ||
|
|
||
| // 🔒 SECURITY: Manual security headers | ||
| app.use((req, res, next) => { | ||
| res.setHeader('X-Content-Type-Options', 'nosniff'); | ||
| next(); | ||
| }); | ||
|
|
||
| // Standard middleware | ||
| app.use(cors()); | ||
| app.use(express.json({ limit: '10mb' })); | ||
| app.use(express.urlencoded({ extended: true })); | ||
| app.use(express.static(publicDir)); | ||
| app.use('/public', express.static(publicDir)); | ||
|
|
||
| const staticRoots = ['/', '/public']; | ||
|
|
||
| app.get('/', (_req, res) => { | ||
| res.json({ | ||
| routes: getRoutes(app), | ||
| staticAssets: { roots: staticRoots, example: '/sitemap.xml' } | ||
| }); | ||
| }); | ||
|
|
||
| const PORT = process.env.PORT || 3000; | ||
| const APP_ENV = process.env.APP_ENV || 'development'; | ||
| const PROTOCOL = APP_ENV === 'production' ? 'https' : 'http'; | ||
| const GITHUB_TOKEN = process.env.GITHUB_TOKEN; | ||
| const ENVIRONMENT = process.env.ENVIRONMENT || ''; | ||
| const isCloudflareEnv = ENVIRONMENT.toLowerCase().includes('cloudflare') || Boolean(process.env.CF_PAGES || process.env.CF_ACCOUNT_ID); | ||
| const runtimeTarget = isCloudflareEnv ? 'cloudflare' : 'node'; | ||
|
|
||
| // Only log warnings from worker 1 or non-cluster mode | ||
| const shouldLog = !cluster.isWorker || cluster.worker?.id === 1; | ||
|
|
||
| if (!GITHUB_TOKEN && shouldLog) { | ||
| console.warn('⚠️ WARNING: GITHUB_TOKEN is not set!'); | ||
| console.warn('⚠️ You will hit rate limits without authentication.'); | ||
| console.warn('⚠️ Create a .env file with: GITHUB_TOKEN=your_token_here'); | ||
| console.warn('⚠️ Get a token at: https://github.com/settings/tokens'); | ||
| } | ||
|
|
||
| // Initialize Redis (optional - falls back gracefully if not available) | ||
| let redis_initialized = false; | ||
| let badgeCache_initialized = false; | ||
|
|
||
| (async () => { | ||
| try { | ||
| await getRedisClient(); | ||
| redis_initialized = true; | ||
| if (shouldLog) console.log('✅ Redis cache initialized'); | ||
| } catch (error) { | ||
| if (shouldLog) { | ||
| console.warn('⚠️ Redis not available. Running with in-memory cache only.'); | ||
| console.warn('⚠️ To enable Redis: REDIS_URL=redis://localhost:6379'); | ||
| } | ||
| } | ||
|
|
||
| // Initialize badge cache service (uses Redis if available) | ||
| try { | ||
| await getBadgeCacheService(); | ||
| badgeCache_initialized = true; | ||
| if (shouldLog) console.log('✅ Persistent badge cache initialized'); | ||
| } catch (error) { | ||
| if (shouldLog) { | ||
| console.warn('⚠️ Badge cache initialization failed. Using in-memory only.'); | ||
| } | ||
| } | ||
| })(); | ||
|
|
||
| const githubClient = new GitHubClient(GITHUB_TOKEN); | ||
|
|
||
| // Cache to reduce API calls | ||
| const cache = new Map<string, { data: string; timestamp: number }>(); | ||
| // 2 hours (increased from 20 minutes for better hit rate) | ||
| const CACHE_DURATION = 2 * 60 * 60 * 1000; | ||
|
|
||
| // Initialize controllers and register routes | ||
| initializeControllers(githubClient, cache, CACHE_DURATION); | ||
| registerRoutes(app); | ||
|
|
||
| app.listen(PORT, () => { | ||
| if (shouldLog) { | ||
| const workerId = cluster.isWorker ? ` (Worker ${cluster.worker?.id})` : ''; | ||
| console.log(`🚀 GitHub Stats server running on ${PROTOCOL}://localhost:${PORT}${workerId}`); | ||
| console.log(`📊 Example: ${PROTOCOL}://localhost:${PORT}/stats?username=pphatdev`); | ||
| console.log(`🔧 Environment: ${APP_ENV}`); | ||
| console.log(`🧪 Runtime target: ${runtimeTarget} (ENVIRONMENT=${ENVIRONMENT || 'not-set'})`); | ||
| console.log(`💾 Cache: Redis ${redis_initialized ? '✅' : '⚠️'} | Badges ${badgeCache_initialized ? '✅' : '⚠️'}`); | ||
| } | ||
|
|
||
| const warmupUsername = process.env.WARMUP_USERNAME; | ||
| if (warmupUsername && redis_initialized && shouldLog) { | ||
| warmupRedisCache(warmupUsername, PORT, PROTOCOL).catch((error) => { | ||
| console.warn('⚠️ Redis warm-up failed:', error); | ||
| }); | ||
| } | ||
| // Start the server | ||
| startServer().catch((error) => { | ||
| console.error('Failed to start server:', error); | ||
| process.exit(1); | ||
| }); |
There was a problem hiding this comment.
The entry point no longer registers the global uncaughtException / unhandledRejection handlers or graceful shutdown hooks (these utilities still exist under src/shared/utils/global-error.ts). Without them, the process may exit without closing Redis/DB connections cleanly. Consider wiring these handlers back in (ideally from server.ts, after you have the http.Server instance).
| app.use(cors({ | ||
| origin: env.APP_ENV === 'production' | ||
| ? ['https://stats.pphat.top', 'https://pphat.top'] | ||
| : '*', |
There was a problem hiding this comment.
In non-production, CORS is configured with origin: '*' while also setting credentials: true. Browsers will reject credentialed CORS responses with Access-Control-Allow-Origin: *, which can break local development requests that rely on cookies/credentials. Consider using origin: true (reflect request origin) or disabling credentials when using a wildcard origin.
| : '*', | |
| : true, |
| startCluster(workerFile, { | ||
| workers, | ||
| respawnDelay: 1000, | ||
| maxRestarts: 5 | ||
| maxRestarts: 1 | ||
| }); |
There was a problem hiding this comment.
Reducing maxRestarts to 1 significantly lowers resiliency in cluster mode: a transient crash could permanently reduce worker count (or stop respawning) and impact availability. If this is intentional, consider documenting the rationale; otherwise consider keeping a higher restart budget and relying on exponential backoff / circuit breakers to avoid crash loops.
| export default defineConfig({ | ||
| schema: "./src/db/schema.ts", | ||
| out: "./drizzle", | ||
| dialect: "sqlite", | ||
| dbCredentials: { | ||
| url: "./data/stats.db", | ||
| }, | ||
| }); |
There was a problem hiding this comment.
This change removes the prior conditional Cloudflare D1 configuration and hard-codes local SQLite credentials. If the project still targets Cloudflare D1 in some environments, this will break migrations/studio workflows there. Either restore the environment-driven D1 branch or confirm via the PR description/docs that D1 support is intentionally being dropped.
Agent-Logs-Url: https://github.com/pphatdev/github-stats/sessions/5d1f2386-6f4d-4ef6-b897-dd8f7bd7af07 Co-authored-by: pphatdev <65520537+pphatdev@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pphatdev/github-stats/sessions/5d1f2386-6f4d-4ef6-b897-dd8f7bd7af07 Co-authored-by: pphatdev <65520537+pphatdev@users.noreply.github.com>