Skip to content

feat: add sidebar management and theme utilities#29

Merged
pphatdev merged 4 commits into
latestfrom
feat-improvement
Apr 7, 2026
Merged

feat: add sidebar management and theme utilities#29
pphatdev merged 4 commits into
latestfrom
feat-improvement

Conversation

@pphatdev
Copy link
Copy Markdown
Owner

@pphatdev 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.
@pphatdev pphatdev requested a review from Copilot April 7, 2026 09:41
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 7, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/modules/graphs/graphs.service.ts
Comment on lines +54 to +63
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)
};
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment on lines +23 to +26
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._-]+$/;
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment on lines +213 to +220
* 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>
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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>

Copilot uses AI. Check for mistakes.
Comment on lines +269 to +313
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;
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment thread src/server.ts
Comment on lines +34 to +78
// 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;
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment thread src/index.ts
Comment on lines +6 to 12
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);
});
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment thread src/app.ts
app.use(cors({
origin: env.APP_ENV === 'production'
? ['https://stats.pphat.top', 'https://pphat.top']
: '*',
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
: '*',
: true,

Copilot uses AI. Check for mistakes.
Comment thread src/server-cluster.ts
Comment on lines 20 to 24
startCluster(workerFile, {
workers,
respawnDelay: 1000,
maxRestarts: 5
maxRestarts: 1
});
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment thread drizzle.config.ts
Comment on lines +3 to +10
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: "./data/stats.db",
},
});
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

@pphatdev pphatdev merged commit 9d42841 into latest Apr 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants