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:
parent
cba71f5f4c
commit
c07eb1f2eb
8 changed files with 1310 additions and 3 deletions
26
PROGRESS.md
26
PROGRESS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
582
cmd/acb-index-builder/blog.go
Normal file
582
cmd/acb-index-builder/blog.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
13
web/public/data/blog/index.json
Normal file
13
web/public/data/blog/index.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
9
web/public/data/blog/posts/meta-week-13-season-1.json
Normal file
9
web/public/data/blog/posts/meta-week-13-season-1.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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
673
web/src/pages/blog.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue