ai-code-battle/web/src/app.ts
jedarden c5a83cbe32 feat(web): code splitting per §16.7 — reduce app entry chunk 84% (10KB → 1.6KB gz)
Extract all inline page renderers from app.ts into lazy-loaded modules and
remove 500+ lines of duplicated replay viewer code. Every route now uses
dynamic import() so page code loads on-demand.

Changes:
- Remove duplicated replay viewer (renderReplayPage, initReplayViewer) from
  app.ts — now uses lazy import from pages/replay.ts
- Extract Watch Hub, Compete Hub, Season Detail, Docs, and 404 pages into
  their own modules (pages/watch-hub.ts, compete-hub.ts, season-detail.ts,
  docs.ts, not-found.ts)
- app.ts is now a pure routing module (~120 lines) with only lazy loaders
- Update vite.config.ts manualChunks: add replay-page, home, leaderboard
  chunks; add node_modules guard to prevent vendor code in page chunks
- All §16.7 budget targets pass: app.js 1.6KB (target 30KB), replay 13KB
  (target 80KB), sandbox 8.4KB (target 20KB), agentation separate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 12:41:33 -04:00

173 lines
7.7 KiB
TypeScript

// Main SPA entry point with routing
// Code splitting: all pages are loaded on-demand via dynamic import() to keep
// the initial bundle small. The app entry chunk contains only the router,
// navigation, and lazy-loading wrappers — no page renderers.
import { router } from './router';
import type { RouteHandler } from './router';
// ─── Lazy loaders for code splitting ─────────────────────────────────────────────
// Each loader creates its own chunk, loaded only when the route is visited
// Core pages - loaded frequently
const loadHomePage = () => import('./pages/home').then(m => m.renderHomePage);
const loadLeaderboardPage = () => import('./pages/leaderboard').then(m => m.renderLeaderboardPage);
// Watch section - replay viewer and related pages
const loadMatchesPage = () => import('./pages/matches').then(m => m.renderMatchesPage);
const loadSeriesPage = () => import('./pages/series').then(m => m.renderSeriesPage);
const loadPredictionsPage = () => import('./pages/predictions').then(m => m.renderPredictionsPage);
const loadReplayPage = () => import('./pages/replay').then(m => m.renderReplayPage);
const loadWatchHubPage = () => import('./pages/watch-hub').then(m => m.renderWatchHubPage);
// Compete section - sandbox, register, docs
const loadSandboxPage = () => import('./pages/sandbox').then(m => m.renderSandboxPage);
const loadRegisterPage = () => import('./pages/register').then(m => m.renderRegisterPage);
const loadCompeteHubPage = () => import('./pages/compete-hub').then(m => m.renderCompeteHubPage);
const loadDocsPage = () => import('./pages/docs').then(m => m.renderDocsPage);
// Bot-related pages
const loadBotProfilePage = () => import('./pages/bot-profile').then(m => m.renderBotProfilePage);
const loadEvolutionPage = () => import('./pages/evolution').then(m => m.renderEvolutionPage);
// Blog & seasons
const loadBlogPages = () => import('./pages/blog').then(m => ({ renderBlogPage: m.renderBlogPage, renderBlogPostPage: m.renderBlogPostPage }));
const loadSeasonsPage = () => import('./pages/seasons').then(m => m.renderSeasonsPage);
const loadSeasonDetailPage = () => import('./pages/season-detail').then(m => m.renderSeasonDetailPage);
// Feedback & docs (separate chunk - includes replay viewer for feedback page)
// Feedback page lazy-loads with agentation (loaded on /#/feedback or explicit enable)
// Agentation is NOT imported here — only loaded when feedback page is visited
const loadFeedbackPage = () => import('./pages/feedback').then(async m => {
const { initAgentation } = await import('./agentation-overlay');
initAgentation();
return m.renderFeedbackPage;
});
// Docs API page (separate chunk from compete docs)
const loadDocsApiPage = () => import('./pages/docs-api').then(m => m.renderDocsApiPage);
// 404
const loadNotFoundPage = () => import('./pages/not-found').then(m => m.renderNotFoundPage);
// ─── Helper: wrap async page loader in sync RouteHandler ────────────────────────
function lazyRoute(loader: () => Promise<(params: Record<string, string>) => void>): RouteHandler {
return (params: Record<string, string>) => {
loader().then(handler => handler(params));
};
}
// ─── Backwards compatibility redirects ────────────────────────────────────────────
function redirect(to: string): RouteHandler {
return (params: Record<string, string>) => {
const fullPath = Object.entries(params).reduce(
(path, [key, value]) => path.replace(`:${key}`, encodeURIComponent(value)),
to
);
router.navigate(fullPath);
};
}
// ─── Navigation & UI ───────────────────────────────────────────────────────────────
function updateActiveNavLink(): void {
const currentPath = router.getCurrentPath();
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
document.querySelectorAll('.nav-link').forEach(link => {
const href = link.getAttribute('href');
if (href) {
const linkPath = href.slice(2);
if (currentPath === linkPath ||
(linkPath !== '' && currentPath.startsWith(linkPath)) ||
(linkPath === '/watch' && currentPath.startsWith('/watch')) ||
(linkPath === '/compete' && currentPath.startsWith('/compete'))) {
link.classList.add('active');
}
}
});
}
function initMobileMenu(): void {
const toggle = document.getElementById('mobile-menu-toggle');
const menu = document.getElementById('mobile-menu');
if (!toggle || !menu) return;
toggle.addEventListener('click', () => {
menu.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!menu.contains(e.target as Node) && !toggle.contains(e.target as Node)) {
menu.classList.remove('open');
}
});
const originalNavigate = router.navigate.bind(router);
router.navigate = (path: string) => {
originalNavigate(path);
menu.classList.remove('open');
};
}
initMobileMenu();
const originalNavigate = router.navigate.bind(router);
router.navigate = (path: string) => {
originalNavigate(path);
updateActiveNavLink();
};
// ─── Route definitions ─────────────────────────────────────────────────────────────
router
// Main routes
.on('/', lazyRoute(loadHomePage))
.on('/watch', lazyRoute(loadWatchHubPage))
.on('/watch/replays', lazyRoute(loadMatchesPage))
.on('/watch/replay/:id', lazyRoute(loadReplayPage))
.on('/watch/series/:id', lazyRoute(loadSeriesPage))
.on('/watch/predictions', lazyRoute(loadPredictionsPage))
.on('/watch/series', lazyRoute(loadSeriesPage))
.on('/compete', lazyRoute(loadCompeteHubPage))
.on('/compete/sandbox', lazyRoute(loadSandboxPage))
.on('/compete/register', lazyRoute(loadRegisterPage))
.on('/compete/bot/:id', lazyRoute(loadBotProfilePage))
.on('/compete/docs', lazyRoute(loadDocsPage))
.on('/leaderboard', lazyRoute(loadLeaderboardPage))
.on('/evolution', lazyRoute(loadEvolutionPage))
.on('/blog', lazyRoute(async () => (await loadBlogPages()).renderBlogPage))
.on('/blog/:slug', lazyRoute(async () => (await loadBlogPages()).renderBlogPostPage))
.on('/season/:id', lazyRoute(loadSeasonDetailPage))
.on('/seasons', lazyRoute(loadSeasonsPage))
.on('/bot/:id', lazyRoute(loadBotProfilePage))
// Backwards compatibility redirects
.on('/matches', redirect('/watch/replays'))
.on('/playlists', redirect('/watch/replays'))
.on('/replay', redirect('/watch/replay'))
.on('/predictions', redirect('/watch/predictions'))
.on('/series', redirect('/watch/series'))
.on('/sandbox', redirect('/compete/sandbox'))
.on('/register', redirect('/compete/register'))
.on('/bots', redirect('/leaderboard'))
.on('/docs', redirect('/compete/docs'))
.on('/docs/api', redirect('/compete/docs'))
.on('/clip-maker', redirect('/watch/replays'))
.on('/rivalries', redirect('/watch/replays'))
.on('/feedback', lazyRoute(loadFeedbackPage))
.on('/compete/feedback', lazyRoute(loadFeedbackPage))
.on('/compete/docs/api', lazyRoute(loadDocsApiPage))
.notFound(lazyRoute(loadNotFoundPage));
// ─── Initialization ────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
updateActiveNavLink();
router.start();
});
window.addEventListener('load', () => {
updateActiveNavLink();
});