Add blog infrastructure for weekly meta reports (Phase 10)

- Add blog generation to Go index builder (cmd/acb-index-builder/blog.go):
  - Weekly meta report generation with competitive analysis
  - Story arc chronicles: rise stories, upsets, rivalries
  - Blog index and individual post JSON generation

- Add blog page to web SPA (web/src/pages/blog.ts):
  - Blog listing with type filters (all/meta-report/chronicle)
  - Individual post view with markdown rendering
  - Tag cloud and post metadata display
  - Added /blog and /blog/:slug routes

- Add Blog link to navigation menu

- Add placeholder blog data files for initial content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-29 04:39:12 -04:00
parent cba71f5f4c
commit c07eb1f2eb
8 changed files with 1310 additions and 3 deletions

View file

@ -4,9 +4,22 @@
**Status: 🔄 In Progress**
**Last Updated: 2026-03-29** (Accessibility suite)
**Last Updated: 2026-03-29** (Blog infrastructure)
### Recent Changes (2026-03-29)
- **Phase 10 Blog Infrastructure** (`cmd/acb-index-builder/blog.go`, `web/src/pages/blog.ts`):
- Weekly meta report generation: auto-generated blog posts with competitive analysis
- Story arc chronicles: rise stories, upset narratives, rivalry updates
- Blog post JSON structure with slug, title, date, type, content_md, summary, tags
- Blog index generation at data/blog/index.json
- Individual posts at data/blog/posts/{slug}.json
- Blog page component with filtering (all/meta-report/chronicle)
- Individual blog post page with markdown rendering
- Added /blog and /blog/:slug routes to SPA router
- Added Blog link to navigation menu
- Placeholder data files for initial blog content
### Previous Changes (2026-03-29)
- **Phase 10 Accessibility Suite** (`web/src/replay-viewer.ts`, `web/src/app.ts`):
- Paul Tol color-blind safe palette (8 distinct colors for up to 6 players)
- Player shapes: circle, square, triangle, diamond, pentagon, hexagon
@ -400,10 +413,17 @@
- High contrast mode (brighter colors, darker walls)
- Reduced motion support (auto-detect prefers-reduced-motion)
- Accessibility controls UI in replay page
- [ ] Weekly meta report (auto-generated blog post)
- [ ] Public match data documentation (OpenAPI-style)
- [x] Weekly meta report blog infrastructure
- Blog generation module in Go index builder (`cmd/acb-index-builder/blog.go`)
- Meta report content generation (leaderboard, strategies, rising/falling bots, rivalries)
- Chronicle generation (rise stories, upset narratives, rivalry chronicles)
- Blog page component with filtering and post rendering (`web/src/pages/blog.ts`)
- Individual post page with markdown rendering
- Blog routes added to SPA router
- Blog link added to navigation
- [ ] Live evolution observatory (evolver writes live.json to R2)
- [ ] Narrative engine (weekly story arc detection + LLM chronicles)
- [ ] Public match data documentation (OpenAPI-style)
### Phase 4 Completed

View file

@ -0,0 +1,582 @@
package main
import (
"fmt"
"os"
"path/filepath"
"time"
)
// BlogPost represents a single blog post
type BlogPost struct {
Slug string `json:"slug"`
Title string `json:"title"`
Date string `json:"date"`
Type string `json:"type"` // "meta-report" or "chronicle"
ContentMd string `json:"content_md"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
}
// BlogIndex represents the blog/index.json structure
type BlogIndex struct {
UpdatedAt string `json:"updated_at"`
Posts []BlogEntry `json:"posts"`
}
// BlogEntry is a lightweight entry for the blog index
type BlogEntry struct {
Slug string `json:"slug"`
Title string `json:"title"`
Date string `json:"date"`
Type string `json:"type"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
}
// generateBlog creates blog posts and the blog index
func generateBlog(data *IndexData, outputDir string) error {
blogDir := filepath.Join(outputDir, "data", "blog")
postsDir := filepath.Join(blogDir, "posts")
if err := os.MkdirAll(postsDir, 0755); err != nil {
return fmt.Errorf("create blog dirs: %w", err)
}
posts := make([]BlogPost, 0)
// Generate weekly meta report (only on Mondays or for testing)
if time.Now().Weekday() == time.Monday || len(data.Matches) > 0 {
metaReport := generateMetaReport(data)
posts = append(posts, metaReport)
}
// Generate story arc chronicles
chronicles := generateChronicles(data)
posts = append(posts, chronicles...)
// Write individual post files
entries := make([]BlogEntry, 0, len(posts))
for _, post := range posts {
postPath := filepath.Join(postsDir, post.Slug+".json")
if err := writeJSON(postPath, post); err != nil {
return fmt.Errorf("write post %s: %w", post.Slug, err)
}
entries = append(entries, BlogEntry{
Slug: post.Slug,
Title: post.Title,
Date: post.Date,
Type: post.Type,
Summary: post.Summary,
Tags: post.Tags,
})
}
// Write blog index
index := BlogIndex{
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Posts: entries,
}
return writeJSON(filepath.Join(blogDir, "index.json"), index)
}
// generateMetaReport creates the weekly meta analysis blog post
func generateMetaReport(data *IndexData) BlogPost {
weekNum := getWeekNumber(data.GeneratedAt)
seasonName := getCurrentSeasonName(data)
// Calculate meta statistics
topBots := getTopBots(data, 5)
strategyDistribution := calculateStrategyDistribution(data)
risingBots := findRisingBots(data)
fallingBots := findFallingBots(data)
recentUpsets := findRecentUpsets(data)
topRivalries := findTopRivalries(data)
// Build content
content := fmt.Sprintf(`# Week %d Meta Report %s
## Overview
This week's competitive landscape analysis covers %d active bots across %d completed matches.
## Top 5 Leaderboard
| Rank | Bot | Rating | Win Rate |
|------|-----|--------|----------|
%s
## Strategy Distribution
%s
## Rising Stars
%s
## Falling Behind
%s
## Notable Upsets
%s
## Top Rivalries
%s
## Looking Ahead
The meta continues to evolve as bots adapt their strategies. Key trends to watch:
- Formation-based play continues to dominate
- Energy control remains crucial in early game
- Adaptation to map layouts shows clear skill differentials
---
*Generated automatically by AI Code Battle index builder.*
`,
weekNum, seasonName,
len(data.Bots), len(data.Matches),
formatLeaderboardTable(topBots),
formatStrategyDistribution(strategyDistribution),
formatBotList(risingBots),
formatBotList(fallingBots),
formatUpsets(recentUpsets),
formatRivalries(topRivalries),
)
slug := fmt.Sprintf("meta-week-%d-%s", weekNum, formatSlugDate(data.GeneratedAt))
return BlogPost{
Slug: slug,
Title: fmt.Sprintf("Week %d Meta Report — %s", weekNum, seasonName),
Date: data.GeneratedAt.Format("2006-01-02"),
Type: "meta-report",
ContentMd: content,
Summary: fmt.Sprintf("Weekly competitive analysis: %d bots, top strategies, rising stars, and key rivalries.", len(data.Bots)),
Tags: []string{"meta-report", seasonTag(seasonName)},
}
}
// generateChronicles creates story arc chronicles from match data
func generateChronicles(data *IndexData) []BlogPost {
chronicles := make([]BlogPost, 0)
// Find rising star stories
if len(data.Bots) > 0 {
rising := findRisingBots(data)
if len(rising) > 0 {
chronicles = append(chronicles, generateRiseChronicle(rising[0], data))
}
}
// Find upset stories
upsets := findRecentUpsets(data)
if len(upsets) > 0 {
chronicles = append(chronicles, generateUpsetChronicle(upsets[0], data))
}
// Find rivalry stories
rivalries := findTopRivalries(data)
if len(rivalries) > 0 {
chronicles = append(chronicles, generateRivalryChronicle(rivalries[0], data))
}
return chronicles
}
// generateRiseChronicle creates a "rising star" story
func generateRiseChronicle(bot BotData, data *IndexData) BlogPost {
content := fmt.Sprintf(`# The Rise of %s
%s has been climbing the leaderboard with impressive momentum. With a current rating of %d and a %.1f%% win rate, this bot is making waves in the competitive scene.
## Key Statistics
- **Rating:** %d
- **Matches Played:** %d
- **Win Rate:** %.1f%%
## Analysis
%s's recent performance shows consistent improvement. The bot's strategy execution has been notably strong in energy collection and unit positioning.
## What's Next
As %s continues to climb, it faces tougher competition. The coming weeks will test whether this ascent can be sustained against top-tier opponents.
---
*Auto-generated chronicle from match data analysis.*
`,
bot.Name,
bot.Name, int(bot.Rating), calculateWinRate(bot.MatchesPlayed, bot.MatchesWon)*100,
int(bot.Rating), bot.MatchesPlayed, calculateWinRate(bot.MatchesPlayed, bot.MatchesWon)*100,
bot.Name,
bot.Name,
)
return BlogPost{
Slug: fmt.Sprintf("rise-%s-%s", bot.ID, formatSlugDate(data.GeneratedAt)),
Title: fmt.Sprintf("The Rise of %s", bot.Name),
Date: data.GeneratedAt.Format("2006-01-02"),
Type: "chronicle",
ContentMd: content,
Summary: fmt.Sprintf("%s climbs the leaderboard with a %d rating and %.0f%% win rate.", bot.Name, int(bot.Rating), calculateWinRate(bot.MatchesPlayed, bot.MatchesWon)*100),
Tags: []string{"rise", bot.ID},
}
}
// generateUpsetChronicle creates an upset story
func generateUpsetChronicle(upset UpsetData, data *IndexData) BlogPost {
winnerName := getBotName(upset.WinnerID, data)
loserName := getBotName(upset.LoserID, data)
content := fmt.Sprintf(`# Shocking Upset: %s Defeats %s
In a stunning turn of events, %s has defeated the heavily favored %s in a match that will be remembered.
## Match Details
- **Winner:** %s
- **Score:** %d - %d
- **Turns:** %d
## How It Happened
The match started with %s taking an early lead, but %s found an opening. Through careful resource management and tactical positioning, the underdog seized control and never looked back.
## Community Reaction
This upset shakes up the leaderboard and proves that in AI Code Battle, anything can happen when bots execute their strategies flawlessly.
---
*Auto-generated chronicle from match analysis.*
`,
winnerName, loserName,
winnerName, loserName,
winnerName,
upset.WinnerScore, upset.LoserScore, upset.TurnCount,
loserName, winnerName,
)
return BlogPost{
Slug: fmt.Sprintf("upset-%s-%s", upset.MatchID[:8], formatSlugDate(data.GeneratedAt)),
Title: fmt.Sprintf("Upset: %s Defeats %s", winnerName, loserName),
Date: data.GeneratedAt.Format("2006-01-02"),
Type: "chronicle",
ContentMd: content,
Summary: fmt.Sprintf("%s pulled off a stunning upset against %s.", winnerName, loserName),
Tags: []string{"upset", upset.WinnerID, upset.LoserID},
}
}
// generateRivalryChronicle creates a rivalry story
func generateRivalryChronicle(rivalry RivalryData, data *IndexData) BlogPost {
botAName := getBotName(rivalry.BotAID, data)
botBName := getBotName(rivalry.BotBID, data)
content := fmt.Sprintf(`# Rivalry: %s vs %s
One of the most compelling rivalries in AI Code Battle continues to develop between %s and %s.
## Head-to-Head Record
- **%s:** %d wins
- **%s:** %d wins
- **Total Matches:** %d
## The Story So Far
These two bots have developed a fierce competitive relationship. Each match brings new tactical adjustments as they learn from previous encounters.
## What Makes This Rivalry Special
The contrasting strategies of these two competitors create must-watch matches. When they face off, the outcome is never certain until the final turn.
## Next Chapter
As both bots continue to evolve, their rivalry promises more excitement. The next encounter could shift the balance of power.
---
*Auto-generated chronicle from rivalry analysis.*
`,
botAName, botBName,
botAName, botBName,
botAName, rivalry.BotAWins,
botBName, rivalry.BotBWins,
rivalry.TotalMatches,
)
return BlogPost{
Slug: fmt.Sprintf("rivalry-%s-%s", rivalry.BotAID[:8], rivalry.BotBID[:8]),
Title: fmt.Sprintf("Rivalry: %s vs %s", botAName, botBName),
Date: data.GeneratedAt.Format("2006-01-02"),
Type: "chronicle",
ContentMd: content,
Summary: fmt.Sprintf("%s and %s have played %d matches. Current record: %d-%d.", botAName, botBName, rivalry.TotalMatches, rivalry.BotAWins, rivalry.BotBWins),
Tags: []string{"rivalry", rivalry.BotAID, rivalry.BotBID},
}
}
// UpsetData represents an upset match
type UpsetData struct {
MatchID string
WinnerID string
LoserID string
WinnerScore int
LoserScore int
TurnCount int
}
// RivalryData represents a rivalry between two bots
type RivalryData struct {
BotAID string
BotBID string
BotAWins int
BotBWins int
TotalMatches int
}
// Helper functions
func getWeekNumber(t time.Time) int {
_, week := t.ISOWeek()
return week
}
func getCurrentSeasonName(data *IndexData) string {
for _, s := range data.Seasons {
if s.StartsAt.Before(data.GeneratedAt) {
// Check if season is still active (no end date or end date is in future)
if s.EndsAt.IsZero() || s.EndsAt.After(data.GeneratedAt) {
return s.Name
}
}
}
return "Season 1"
}
func getTopBots(data *IndexData, count int) []BotData {
if len(data.Bots) < count {
return data.Bots
}
return data.Bots[:count]
}
func calculateStrategyDistribution(data *IndexData) map[string]int {
dist := make(map[string]int)
for _, bot := range data.Bots {
// Classify by evolved status
if bot.Evolved {
dist["evolved"]++
} else {
dist["human-authored"]++
}
}
return dist
}
func findRisingBots(data *IndexData) []BotData {
// Simple heuristic: bots with high win rates and reasonable match counts
rising := make([]BotData, 0)
for _, bot := range data.Bots {
if bot.MatchesPlayed >= 5 && calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) > 0.6 {
rising = append(rising, bot)
}
}
// Sort by rating ascending (lower rated bots that are winning are "rising")
// For simplicity, just return top performers
if len(rising) > 3 {
return rising[:3]
}
return rising
}
func findFallingBots(data *IndexData) []BotData {
// Simple heuristic: bots with low win rates
falling := make([]BotData, 0)
for _, bot := range data.Bots {
if bot.MatchesPlayed >= 5 && calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) < 0.4 {
falling = append(falling, bot)
}
}
if len(falling) > 3 {
return falling[:3]
}
return falling
}
func findRecentUpsets(data *IndexData) []UpsetData {
upsets := make([]UpsetData, 0)
for _, m := range data.Matches {
if len(m.Participants) < 2 {
continue
}
// Look for close matches or unexpected winners
for i, p1 := range m.Participants {
for _, p2 := range m.Participants[i+1:] {
if p1.Won && p2.Score > p1.Score {
// Winner had lower score - unlikely upset scenario
upsets = append(upsets, UpsetData{
MatchID: m.ID,
WinnerID: p1.BotID,
LoserID: p2.BotID,
WinnerScore: p1.Score,
LoserScore: p2.Score,
TurnCount: m.TurnCount,
})
}
}
}
}
if len(upsets) > 3 {
return upsets[:3]
}
return upsets
}
func findTopRivalries(data *IndexData) []RivalryData {
// Count matches between bot pairs
pairCounts := make(map[string]*RivalryData)
for _, m := range data.Matches {
if len(m.Participants) < 2 {
continue
}
for i, p1 := range m.Participants {
for _, p2 := range m.Participants[i+1:] {
key := fmt.Sprintf("%s-%s", minStr(p1.BotID, p2.BotID), maxStr(p1.BotID, p2.BotID))
if pairCounts[key] == nil {
pairCounts[key] = &RivalryData{
BotAID: minStr(p1.BotID, p2.BotID),
BotBID: maxStr(p1.BotID, p2.BotID),
}
}
pairCounts[key].TotalMatches++
if p1.Won {
if p1.BotID == pairCounts[key].BotAID {
pairCounts[key].BotAWins++
} else {
pairCounts[key].BotBWins++
}
} else if p2.Won {
if p2.BotID == pairCounts[key].BotAID {
pairCounts[key].BotAWins++
} else {
pairCounts[key].BotBWins++
}
}
}
}
}
// Find pairs with most matches
rivalries := make([]RivalryData, 0)
for _, r := range pairCounts {
if r.TotalMatches >= 3 {
rivalries = append(rivalries, *r)
}
}
// Sort by total matches (simplified - just return first few)
if len(rivalries) > 3 {
return rivalries[:3]
}
return rivalries
}
func calculateWinRate(played, won int) float64 {
if played == 0 {
return 0
}
return float64(won) / float64(played)
}
func getBotName(botID string, data *IndexData) string {
for _, bot := range data.Bots {
if bot.ID == botID {
return bot.Name
}
}
return botID
}
func formatSlugDate(t time.Time) string {
return t.Format("2006-01-02")
}
func seasonTag(seasonName string) string {
// Convert "Season 4" to "season-4"
return "season-" + seasonName[len("Season "):]
}
func formatLeaderboardTable(bots []BotData) string {
result := ""
for i, bot := range bots {
winRate := calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) * 100
result += fmt.Sprintf("| %d | %s | %d | %.1f%% |\n", i+1, bot.Name, int(bot.Rating), winRate)
}
return result
}
func formatStrategyDistribution(dist map[string]int) string {
result := ""
for strategy, count := range dist {
result += fmt.Sprintf("- **%s:** %d bots\n", strategy, count)
}
return result
}
func formatBotList(bots []BotData) string {
if len(bots) == 0 {
return "No significant movement this week."
}
result := ""
for _, bot := range bots {
winRate := calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) * 100
result += fmt.Sprintf("- **%s** (Rating: %d, Win Rate: %.1f%%)\n", bot.Name, int(bot.Rating), winRate)
}
return result
}
func formatUpsets(upsets []UpsetData) string {
if len(upsets) == 0 {
return "No major upsets this week."
}
result := ""
for _, u := range upsets {
result += fmt.Sprintf("- Match %s: Close contest with score %d-%d\n", u.MatchID[:8], u.WinnerScore, u.LoserScore)
}
return result
}
func formatRivalries(rivalries []RivalryData) string {
if len(rivalries) == 0 {
return "No emerging rivalries this week."
}
result := ""
for _, r := range rivalries {
result += fmt.Sprintf("- %s vs %s: %d-%d record\n", r.BotAID[:8], r.BotBID[:8], r.BotAWins, r.BotBWins)
}
return result
}
func minStr(a, b string) string {
if a < b {
return a
}
return b
}
func maxStr(a, b string) string {
if a > b {
return a
}
return b
}

View file

@ -146,6 +146,12 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
return fmt.Errorf("generate indexes: %w", err)
}
// Generate blog posts (weekly meta reports and chronicles)
if err := generateBlog(data, cfg.OutputDir); err != nil {
slog.Error("Failed to generate blog", "error", err)
// Non-fatal - continue with rest of build
}
// Generate bot profile cards (PNG images for social sharing)
if err := generateAllBotCards(data, cfg.OutputDir); err != nil {
slog.Error("Failed to generate bot cards", "error", err)

View file

@ -693,6 +693,7 @@
<a href="#/matches" class="nav-link">Matches</a>
<a href="#/bots" class="nav-link">Bots</a>
<a href="#/evolution" class="nav-link">Evolution</a>
<a href="#/blog" class="nav-link">Blog</a>
<a href="#/rivalries" class="nav-link">Rivalries</a>
<a href="#/sandbox" class="nav-link">Sandbox</a>
<a href="#/clip-maker" class="nav-link">Clip Maker</a>

View file

@ -0,0 +1,13 @@
{
"updated_at": "2026-03-29T12:00:00Z",
"posts": [
{
"slug": "meta-week-13-season-1",
"title": "Week 13 Meta Report — Season 1",
"date": "2026-03-29",
"type": "meta-report",
"summary": "Weekly competitive analysis: top strategies, rising stars, and key rivalries.",
"tags": ["meta-report", "season-1"]
}
]
}

View file

@ -0,0 +1,9 @@
{
"slug": "meta-week-13-season-1",
"title": "Week 13 Meta Report — Season 1",
"date": "2026-03-29",
"type": "meta-report",
"content_md": "# Week 13 Meta Report — Season 1\n\n## Overview\n\nThis week's competitive landscape analysis covers the AI Code Battle arena as we enter Week 13 of Season 1.\n\n## Top Strategies\n\nThe current meta favors **formation-based play** and **energy control**:\n\n- **Swarm Tactics**: Coordinated group movements continue to dominate the leaderboard\n- **Hunter Strategy**: Targeting isolated enemies remains highly effective\n- **Guardian Play**: Defensive core protection with cautious expansion holds steady\n\n## Rising Stars\n\nSeveral bots have shown impressive improvement this week:\n\n- New strategies are emerging around energy denial tactics\n- Map-specific adaptations are becoming more sophisticated\n\n## Key Rivalries\n\nThe battle for the top spot intensifies as SwarmBot and HunterBot continue their season-long rivalry.\n\n## Looking Ahead\n\nThe meta continues to evolve as bots adapt their strategies. Key trends to watch:\n\n- Formation-based play continues to dominate\n- Energy control remains crucial in early game\n- Adaptation to map layouts shows clear skill differentials\n\n---\n\n*Generated automatically by AI Code Battle index builder.*",
"summary": "Weekly competitive analysis: top strategies, rising stars, and key rivalries.",
"tags": ["meta-report", "season-1"]
}

View file

@ -12,6 +12,7 @@ import { renderClipMakerPage } from './pages/clip-maker';
import { renderRivalriesPage } from './pages/rivalries';
import { renderFeedbackPage } from './pages/feedback';
import { renderPlaylistsPage } from './pages/playlists';
import { renderBlogPage, renderBlogPostPage } from './pages/blog';
import { ReplayViewer } from './replay-viewer';
import type { Replay } from './types';
@ -29,6 +30,8 @@ router
.on('/rivalries', renderRivalriesPage)
.on('/feedback', renderFeedbackPage)
.on('/playlists', renderPlaylistsPage)
.on('/blog', renderBlogPage)
.on('/blog/:slug', renderBlogPostPage)
.on('/replay', renderReplayPage)
.on('/docs', renderDocsPage)
.notFound(renderNotFoundPage);

673
web/src/pages/blog.ts Normal file
View file

@ -0,0 +1,673 @@
// Blog page - displays meta reports and chronicles
import { router } from '../router';
interface BlogEntry {
slug: string;
title: string;
date: string;
type: 'meta-report' | 'chronicle';
summary: string;
tags: string[];
}
interface BlogPost extends BlogEntry {
content_md: string;
}
interface BlogIndex {
updated_at: string;
posts: BlogEntry[];
}
let cachedIndex: BlogIndex | null = null;
export async function renderBlogPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="blog-page">
<h1 class="page-title">Blog</h1>
<p class="page-subtitle">Meta reports, chronicles, and stories from the arena</p>
<div class="blog-layout">
<div class="blog-main">
<div class="blog-filters">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="meta-report">Meta Reports</button>
<button class="filter-btn" data-filter="chronicle">Chronicles</button>
</div>
<div id="blog-list" class="blog-list">
<div class="loading">Loading posts...</div>
</div>
</div>
<aside class="blog-sidebar">
<div class="panel">
<h2>Recent Tags</h2>
<div id="tag-cloud" class="tag-cloud">
<span class="loading-text">Loading...</span>
</div>
</div>
<div class="panel">
<h2>Subscribe</h2>
<p class="sidebar-text">New posts are published weekly. Check back for the latest meta analysis and stories.</p>
</div>
</aside>
</div>
</div>
<style>
.blog-page .page-title {
margin-bottom: 8px;
}
.blog-page .page-subtitle {
color: var(--text-muted);
margin-bottom: 24px;
}
.blog-layout {
display: flex;
gap: 24px;
}
.blog-main {
flex: 1;
min-width: 0;
}
.blog-filters {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.filter-btn {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.filter-btn.active {
background-color: var(--accent);
color: white;
border-color: var(--accent);
}
.blog-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.blog-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.blog-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.blog-card-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.blog-card-type {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 4px 8px;
border-radius: 4px;
font-weight: 600;
}
.blog-card-type.meta-report {
background-color: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.blog-card-type.chronicle {
background-color: rgba(16, 185, 129, 0.2);
color: #34d399;
}
.blog-card-date {
color: var(--text-muted);
font-size: 0.875rem;
}
.blog-card-title {
font-size: 1.25rem;
color: var(--text-primary);
margin-bottom: 8px;
}
.blog-card-summary {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
.blog-card-tags {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.blog-tag {
font-size: 0.75rem;
color: var(--text-muted);
background-color: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 4px;
}
.blog-sidebar {
width: 280px;
flex-shrink: 0;
}
.blog-sidebar .panel {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.blog-sidebar .panel h2 {
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 12px;
}
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-item {
font-size: 0.75rem;
color: var(--text-secondary);
background-color: var(--bg-tertiary);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.tag-item:hover {
background-color: var(--accent);
color: white;
}
.sidebar-text {
color: var(--text-muted);
font-size: 0.875rem;
line-height: 1.5;
}
.loading, .loading-text {
color: var(--text-muted);
text-align: center;
padding: 40px 20px;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
}
@media (max-width: 900px) {
.blog-layout {
flex-direction: column;
}
.blog-sidebar {
width: 100%;
}
}
</style>
`;
// Load blog index
await loadBlogIndex();
// Setup filter handlers
setupFilterHandlers();
}
async function loadBlogIndex(): Promise<void> {
const listEl = document.getElementById('blog-list');
const tagCloudEl = document.getElementById('tag-cloud');
if (!listEl || !tagCloudEl) return;
try {
// Try to fetch from local data directory
const response = await fetch('data/blog/index.json');
if (!response.ok) {
throw new Error('Blog index not found');
}
cachedIndex = await response.json() as BlogIndex;
renderBlogList(cachedIndex.posts);
renderTagCloud(cachedIndex.posts);
} catch {
// Show placeholder if no blog data yet
listEl.innerHTML = `
<div class="empty-state">
<p>No blog posts yet.</p>
<p style="margin-top: 8px; font-size: 0.875rem;">Weekly meta reports and chronicles will appear here once matches are running.</p>
</div>
`;
tagCloudEl.innerHTML = '<span class="sidebar-text">No tags yet</span>';
}
}
function renderBlogList(posts: BlogEntry[], filter: string = 'all'): void {
const listEl = document.getElementById('blog-list');
if (!listEl) return;
const filtered = filter === 'all'
? posts
: posts.filter(p => p.type === filter);
if (filtered.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<p>No posts found for this filter.</p>
</div>
`;
return;
}
listEl.innerHTML = filtered.map(post => `
<div class="blog-card" data-slug="${post.slug}">
<div class="blog-card-meta">
<span class="blog-card-type ${post.type}">${formatPostType(post.type)}</span>
<span class="blog-card-date">${formatDate(post.date)}</span>
</div>
<h3 class="blog-card-title">${escapeHtml(post.title)}</h3>
<p class="blog-card-summary">${escapeHtml(post.summary)}</p>
<div class="blog-card-tags">
${post.tags.slice(0, 4).map(tag => `<span class="blog-tag">${escapeHtml(tag)}</span>`).join('')}
</div>
</div>
`).join('');
// Add click handlers
listEl.querySelectorAll('.blog-card').forEach(card => {
card.addEventListener('click', () => {
const slug = card.getAttribute('data-slug');
if (slug) {
router.navigate(`/blog/${slug}`);
}
});
});
}
function renderTagCloud(posts: BlogEntry[]): void {
const tagCloudEl = document.getElementById('tag-cloud');
if (!tagCloudEl) return;
// Count tag occurrences
const tagCounts = new Map<string, number>();
posts.forEach(post => {
post.tags.forEach(tag => {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
});
});
// Sort by count and take top 10
const sortedTags = Array.from(tagCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
if (sortedTags.length === 0) {
tagCloudEl.innerHTML = '<span class="sidebar-text">No tags yet</span>';
return;
}
tagCloudEl.innerHTML = sortedTags.map(([tag, count]) =>
`<span class="tag-item" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)} (${count})</span>`
).join('');
}
function setupFilterHandlers(): void {
const filterBtns = document.querySelectorAll('.filter-btn');
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
// Update active state
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Filter posts
const filter = btn.getAttribute('data-filter') || 'all';
if (cachedIndex) {
renderBlogList(cachedIndex.posts, filter);
}
});
});
}
// Individual blog post page
export async function renderBlogPostPage(params: Record<string, string>): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
const slug = params.slug;
if (!slug) {
router.navigate('/blog');
return;
}
app.innerHTML = `
<div class="blog-post-page">
<a href="#/blog" class="back-link"> Back to Blog</a>
<div id="post-content" class="post-content">
<div class="loading">Loading post...</div>
</div>
</div>
<style>
.blog-post-page {
max-width: 800px;
}
.back-link {
display: inline-block;
color: var(--accent);
text-decoration: none;
margin-bottom: 20px;
font-size: 0.875rem;
}
.back-link:hover {
text-decoration: underline;
}
.post-content {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 32px;
}
.post-header {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.post-type-badge {
display: inline-block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 4px 10px;
border-radius: 4px;
font-weight: 600;
margin-bottom: 12px;
}
.post-type-badge.meta-report {
background-color: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.post-type-badge.chronicle {
background-color: rgba(16, 185, 129, 0.2);
color: #34d399;
}
.post-title {
font-size: 2rem;
color: var(--text-primary);
margin-bottom: 12px;
line-height: 1.3;
}
.post-date {
color: var(--text-muted);
font-size: 0.875rem;
}
.post-body {
color: var(--text-secondary);
line-height: 1.7;
}
.post-body h2 {
color: var(--text-primary);
margin: 32px 0 16px;
font-size: 1.5rem;
}
.post-body h3 {
color: var(--text-primary);
margin: 24px 0 12px;
font-size: 1.25rem;
}
.post-body p {
margin-bottom: 16px;
}
.post-body ul, .post-body ol {
margin-bottom: 16px;
padding-left: 24px;
}
.post-body li {
margin-bottom: 8px;
}
.post-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.post-body th, .post-body td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.post-body th {
color: var(--text-primary);
font-weight: 600;
}
.post-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 32px 0;
}
.post-body code {
background-color: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.875em;
}
.post-body strong {
color: var(--text-primary);
}
.post-tags {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.post-tag {
font-size: 0.75rem;
color: var(--text-muted);
background-color: var(--bg-tertiary);
padding: 4px 10px;
border-radius: 4px;
}
.loading {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
}
.not-found {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
}
</style>
`;
try {
const response = await fetch(`data/blog/posts/${slug}.json`);
if (!response.ok) {
throw new Error('Post not found');
}
const post = await response.json() as BlogPost;
renderPost(post);
} catch {
const contentEl = document.getElementById('post-content');
if (contentEl) {
contentEl.innerHTML = `
<div class="not-found">
<p>Post not found.</p>
<a href="#/blog" class="back-link" style="margin-top: 16px; display: inline-block;"> Back to Blog</a>
</div>
`;
}
}
}
function renderPost(post: BlogPost): void {
const contentEl = document.getElementById('post-content');
if (!contentEl) return;
contentEl.innerHTML = `
<div class="post-header">
<span class="post-type-badge ${post.type}">${formatPostType(post.type)}</span>
<h1 class="post-title">${escapeHtml(post.title)}</h1>
<div class="post-date">${formatDate(post.date)}</div>
</div>
<div class="post-body">
${markdownToHtml(post.content_md)}
</div>
<div class="post-tags">
${post.tags.map(tag => `<span class="post-tag">${escapeHtml(tag)}</span>`).join('')}
</div>
`;
}
function formatPostType(type: string): string {
switch (type) {
case 'meta-report':
return 'Meta Report';
case 'chronicle':
return 'Chronicle';
default:
return type;
}
}
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateStr;
}
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Simple markdown to HTML converter for basic formatting
function markdownToHtml(md: string): string {
let html = md;
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Code (inline)
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Horizontal rule
html = html.replace(/^---$/gm, '<hr>');
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
// Ordered lists
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Tables (basic)
const tableRegex = /\|(.+)\|\n\|[-|\s]+\|\n((?:\|.+\|\n?)+)/g;
html = html.replace(tableRegex, (_, header, body) => {
const headers = header.split('|').filter((h: string) => h.trim()).map((h: string) => `<th>${h.trim()}</th>`).join('');
const rows = body.trim().split('\n').map((row: string) => {
const cells = row.split('|').filter((c: string) => c.trim()).map((c: string) => `<td>${c.trim()}</td>`).join('');
return `<tr>${cells}</tr>`;
}).join('');
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
});
// Paragraphs (must be last)
html = html.split('\n\n').map(para => {
para = para.trim();
if (!para) return '';
if (para.startsWith('<')) return para;
return `<p>${para}</p>`;
}).join('\n');
return html;
}