Files
awesome-ai-dev-tools/readme.go
T
tiennm99 eb12bcc2cb feat: replace category with a multi-facet tag vocabulary
A single category could not describe tools that ship as a CLI, an editor
plugin and a desktop app at once, and 7 of the 40 entries were filed
under a surface they only partly match — cline is "an SDK, IDE extension
or CLI assistant" in one `extension` slot, Reasonix ships CLI, desktop
and VS Code under `cli`, warp is a terminal filed as `ide`.

Tags cover five facets: surface (at least one), model access, workflow,
integration, and origin (at most one). tagVocabulary in validate.go is
the single source of truth — the validator, its error messages, and the
dashboard's filter chips all derive from it, the last via a new facets
field in site/data.json.

The dashboard now filters on tags with multi-select chips grouped by
facet: OR within a facet, AND across facets, plus a clear-filters
control, and search matches tags as well as name and description. Only
tags some row actually carries get a chip, so chips and rows cannot
disagree. Tag pills are tinted per facet, replacing the category badge.

All 40 entries are tagged from each repo's own README, topics and docs;
CONTRIBUTING documents every tag and the evidence rule for applying one.
A leftover `category:` key now fails validation with a message naming
its replacement, rather than being silently ignored.

README.md and data/history.jsonl are the regenerated updater output.
2026-09-11 15:40:18 +07:00

113 lines
3.1 KiB
Go

package main
import (
"fmt"
"io"
"path/filepath"
"strings"
"text/template"
)
type Row struct {
Rank int
NameWithOwner string
URL string
Stars int
Delta7d int
HasDelta bool
Language string
PushedAt string
Description string
}
// TopMover is the biggest 7-day gainer, surfaced as a one-line callout above
// the README table. HasMover is false when no repo has a 7-day delta yet
// (e.g. history younger than 7 days) — the template omits the line then.
type TopMover struct {
NameWithOwner string
URL string
Delta int
HasMover bool
}
func renderReadme(tmplPath, outPath string, stats []Stat, deltas map[string]int) error {
rows := make([]Row, len(stats))
var topMover TopMover
for i, s := range stats {
// Deltas are keyed by CanonicalKey (owner/repo from agents.yml).
delta, has := deltas[s.CanonicalKey]
rows[i] = Row{
Rank: i + 1,
NameWithOwner: s.NameWithOwner,
URL: s.URL,
Stars: s.Stars,
Delta7d: delta,
HasDelta: has,
Language: s.Language,
PushedAt: s.PushedAt.Format("2006-01-02"),
Description: sanitizeCell(s.Description),
}
if has && (!topMover.HasMover || delta > topMover.Delta) {
topMover = TopMover{NameWithOwner: s.NameWithOwner, URL: s.URL, Delta: delta, HasMover: true}
}
}
funcs := template.FuncMap{
"formatDelta": func(d int, has bool) string {
if !has {
return "—"
}
if d > 0 {
return fmt.Sprintf("+%d", d)
}
if d < 0 {
return fmt.Sprintf("%d", d)
}
return "0"
},
"formatStars": func(n int) string {
switch {
case n < 1000:
return fmt.Sprintf("%d", n)
case n < 1_000_000:
return fmt.Sprintf("%.1fk", float64(n)/1000)
default:
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
}
},
}
tmpl, err := template.New("").Funcs(funcs).ParseFiles(tmplPath)
if err != nil {
return err
}
return atomicWriteFile(outPath, func(w io.Writer) error {
return tmpl.ExecuteTemplate(w, filepath.Base(tmplPath), map[string]any{
"Rows": rows,
"UpdatedAt": timeNow().UTC().Format("2006-01-02 15:04 UTC"),
"Total": len(rows),
"TopMover": topMover,
})
})
}
// sanitizeCell makes a third-party repo description safe to embed in a
// single Markdown table cell. Order matters:
// 1. backslash first, so escaping added by later steps isn't re-escaped
// (also fixes `\|` rendering as a literal backslash + unescaped pipe,
// which broke the table row).
// 2. pipe, so the cell can't inject a table column boundary.
// 3. angle brackets → HTML entities, so raw HTML/script tags in a
// description can't be injected into the rendered README.
// 4. newlines/carriage returns → space, so the cell stays one line.
func sanitizeCell(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
return strings.TrimSpace(s)
}