Files
awesome-ai-dev-tools/build.go
T
tiennm99 00c90c5be9 feat: split data update from site build; publish via Cloudflare Pages
The updater fetched GitHub metadata and rendered the dashboard payload in
a single step, so anything that published the site needed a GITHUB_TOKEN.
Cloudflare Pages builds from a Git webhook and has no business holding
one.

Split the tool into three modes. The default update step still fetches
GitHub and now records every API-sourced field in data/metadata.json,
which is committed alongside README.md and data/history.jsonl. The new
-build mode joins that snapshot with data/agents.yml and
data/history.jsonl to render dist/ with no network access and no token;
-check is unchanged.

Supporting changes:

- computeDeltaAt anchors the delta windows to when the data was fetched
  rather than the wall clock, so a redeploy days later reproduces the
  same Δ7d instead of sliding the window past its slack allowance.
- writeSiteData takes updatedAt explicitly for the same reason: the
  timestamp labels data freshness, not build time.
- sortStats is extracted from fetchStats so the build step re-ranks
  identically from committed metadata.
- An agents.yml entry with no metadata yet is omitted with a warning
  instead of failing the build, which would otherwise block every deploy
  between merging a new entry and the next nightly run.
- update.yml drops the GitHub Pages deploy steps and commits
  data/metadata.json; ci.yml runs `go run . -build` so a build that would
  break on deploy breaks in CI first.
- site/_headers stops the edge serving a stale data.json after a refresh.
- docs/DEPLOY.md covers the Cloudflare setup, including the one-time
  bootstrap of data/metadata.json that the build depends on.

Also carries the in-progress curation work already in the tree: the
module rename to awesome-ai-dev-tools, retagged entries, and removal of
the archived Roo-Code, void, continue and suna entries.
2026-09-16 22:24:48 +07:00

109 lines
3.1 KiB
Go

package main
import (
"fmt"
"io"
"os"
"path/filepath"
)
// runBuild renders the static dashboard into distDir from committed inputs
// only — data/agents.yml, data/metadata.json and data/history.jsonl. It makes
// no network calls and needs no GITHUB_TOKEN, which is what lets an untrusted
// build environment (Cloudflare Pages) run it.
//
// Splitting this out from the update step also means a pure curation change
// (retagging an entry, editing a note) republishes immediately on push,
// reusing the last fetched star figures instead of waiting for the nightly run.
func runBuild(agentsPath, metadataPath, historyPath, siteDir, distDir string) error {
agents, err := loadAgents(agentsPath)
if err != nil {
return err
}
meta, err := readMetadata(metadataPath)
if err != nil {
return err
}
stats := statsFromMetadata(agents, meta)
stats = enforceStarFloor(stats)
if len(stats) == 0 {
return fmt.Errorf("no entries left to publish after joining %s with %s", agentsPath, metadataPath)
}
sortStats(stats)
history, err := readSnapshots(historyPath)
if err != nil {
return err
}
// Anchor the delta windows to when the data was fetched, not to wall clock.
// A rebuild triggered days later (a docs push, a manual redeploy) must show
// the same "Δ7d" the nightly run computed, not a window silently slid
// forward past its slack allowance.
current := Snapshot{
Date: meta.FetchedAt.UTC().Format("2006-01-02"),
Stars: make(map[string]int, len(stats)),
}
for _, s := range stats {
current.Stars[s.CanonicalKey] = s.Stars
}
deltas7 := computeDeltaAt(history, current, meta.FetchedAt, 7, 3)
deltas30 := computeDeltaAt(history, current, meta.FetchedAt, 30, 5)
if err := copyDir(siteDir, distDir); err != nil {
return fmt.Errorf("copy %s to %s: %w", siteDir, distDir, err)
}
updatedAt := meta.FetchedAt.UTC().Format("2006-01-02 15:04 UTC")
if err := writeSiteData(filepath.Join(distDir, "data.json"), updatedAt, stats, deltas7, deltas30, history); err != nil {
return err
}
fmt.Printf("built %s: %d entries, data fetched %s\n", distDir, len(stats), updatedAt)
return nil
}
// copyDir replaces dst with a fresh copy of src. The wipe matters: dist/ is
// build output, and a file deleted from site/ must not survive in a deploy
// because a previous build left it there.
func copyDir(src, dst string) error {
if err := os.RemoveAll(dst); err != nil {
return err
}
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
if !d.Type().IsRegular() {
return nil // skip symlinks and other irregular entries
}
return copyFile(path, target)
})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }() // read-only; close error is not actionable
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return atomicWriteFile(dst, func(w io.Writer) error {
_, err := io.Copy(w, in)
return err
})
}