caniuse/main.go

286 lines
5.9 KiB
Go

package main
import (
"errors"
"flag"
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/peterbourgon/ff/v3"
)
var (
resultmap = map[string]string{
"y": "✔",
"n": "✘",
"a": "◒",
"u": "‽",
"i": "ⓘ",
"w": "⚠",
}
supernums = map[int]string{
0: "⁰",
1: "¹",
2: "²",
3: "³",
4: "⁴",
5: "⁵",
6: "⁶",
7: "⁷",
8: "⁸",
9: "⁹",
}
refresh = time.Hour * 24 * 30
)
func main() {
fs := flag.NewFlagSet("caniuse", flag.ContinueOnError)
if err := ff.Parse(fs, os.Args[1:],
ff.WithEnvVarPrefix("CANIUSE"),
); err != nil {
panic(err)
}
if fs.NArg() != 1 {
fmt.Println("caniuse requires one argument")
return
}
data, err := loadData()
if err != nil {
fmt.Printf("could not load data: %v", err)
}
query := fs.Arg(0)
// Find a single feature
if feat, ok := data.Data[query]; ok {
fmt.Println(formatFeat(feat, data, false))
return
}
// Find multiple features
features := search(query, data)
switch len(features) {
case 0:
fmt.Printf("%q not found", query)
case 1:
fmt.Println(formatFeat(data.Data[features[0]], data, false))
default:
for _, feat := range features {
fmt.Println(formatFeat(data.Data[feat], data, true))
}
}
}
var clumpRe = regexp.MustCompile(`\W*`)
func search(query string, data Data) []string {
var results []string
query = strings.ToLower(query)
for key, dat := range data.Data {
matcher := clumpRe.ReplaceAllString(strings.ToLower(key+dat.Title+dat.Description+dat.Keywords+strings.Join(dat.Categories, "")), "")
if strings.Contains(matcher, query) {
results = append(results, key)
}
}
return results
}
var replaceNoteRe = regexp.MustCompile(`[\r\n]+`)
func formatFeat(feat data, d Data, short bool) string {
bold := color.New(color.Bold)
var out strings.Builder
out.WriteString(bold.Sprint(feat.Title))
var p []string
if feat.UsagePercY > 0 {
p = append(p, fmt.Sprintf("%s %s%%", resultmap["y"], color.GreenString("%.2f", feat.UsagePercY)))
}
if feat.UsagePercA > 0 {
p = append(p, fmt.Sprintf("%s %s%%", resultmap["a"], color.YellowString("%.2f", feat.UsagePercA)))
}
percentages := strings.Join(p, " ")
out.WriteString(" " + percentages)
status := fmt.Sprintf(" [%s]\n", d.Statuses[feat.Status])
out.WriteString(status)
if !short {
out.WriteString("\t" + strings.TrimSpace(feat.Description) + "\n\n")
}
if short {
out.WriteString("\t")
}
needNote := make(map[int]struct{})
for browser, stats := range feat.Stats {
if d.Agents[browser].Type != "desktop" {
continue
}
if !short {
out.WriteString("\t")
}
out.WriteString(d.Agents[browser].Browser + " ")
results := makeResults(d.Agents[browser], stats)
if len(results) == 1 {
results[0].version = ""
}
for _, result := range results {
out.WriteString(makeResult(result, needNote))
}
if !short {
out.WriteString("\n")
}
}
if !short {
out.WriteString("\n")
}
if !short {
nums := make([]string, 0, len(feat.NotesByNum))
for num := range feat.NotesByNum {
nums = append(nums, num)
}
sort.Strings(nums)
for _, num := range nums {
n, _ := strconv.Atoi(num)
if _, ok := needNote[n]; !ok {
continue
}
out.WriteString(fmt.Sprintf("\t\t%s%s\n", color.YellowString(supernums[n]), feat.NotesByNum[num]))
}
if feat.Notes != "" {
out.WriteString(fmt.Sprintf("\t %s %s", resultmap["i"], replaceNoteRe.ReplaceAllString(feat.Notes, " ")))
}
}
return out.String()
}
var supportRe = regexp.MustCompile(`#(\d+)`)
func makeResult(stat browserStat, nums map[int]struct{}) string {
support := string(stat.support[0])
var out strings.Builder
bace := "\u2800"
if s, ok := resultmap[support]; ok {
out.WriteString(s)
} else {
out.WriteString(support)
}
out.WriteString(bace)
if stat.version != "" {
out.WriteString(stat.version)
}
if strings.Contains(string(stat.support), "x") {
out.WriteString("ᵖ")
}
match := supportRe.FindStringSubmatch(string(stat.support))
if match != nil {
num, _ := strconv.Atoi(match[1])
nums[num] = struct{}{}
out.WriteString(supernums[num])
}
if stat.usage > 0 {
str := out.String()
if string(str[len(str)-1]) != bace {
out.WriteString(" ")
}
out.WriteString(fmt.Sprintf("(%f) ", math.Round(stat.usage*1)/1))
}
out.WriteString(" ")
str := out.String()
switch support {
case "y":
return color.GreenString(str)
case "n":
return color.RedString(str)
case "a":
return color.YellowString(str)
default:
return str
}
}
func makeResults(browser browser, stats map[string]support) []browserStat {
var results []browserStat
var current browserStat
for idx, version := range browser.Versions {
if version == "" {
continue
}
support := stats[version]
var usage float64
if u, ok := browser.UsageGlobal[version]; ok {
usage = u
}
ver := version
if len(browser.Versions) > idx {
ver += "+"
}
if support[0] == 'p' {
support = "n" + support[1:]
}
if current.version == "" || current.support != support {
current = browserStat{
version: ver,
support: support,
usage: 0,
}
results = append(results, current)
}
current.usage += usage
}
return results
}
func loadData() (Data, error) {
var data Data
cache, err := os.UserCacheDir()
if err != nil {
return data, fmt.Errorf("could not determine cache dir: %v", err)
}
caniuseCache := filepath.Join(cache, "caniuse")
if err := os.MkdirAll(caniuseCache, os.ModePerm); err != nil {
return data, fmt.Errorf("could not create dir %q: %v", caniuseCache, err)
}
dataPath := filepath.Join(caniuseCache, "data.json")
fi, err := os.Stat(dataPath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return data, fmt.Errorf("could not stat %q: %v", dataPath, err)
} else if errors.Is(err, fs.ErrNotExist) || time.Since(fi.ModTime()) > refresh {
if err := Download(dataPath); err != nil {
return data, fmt.Errorf("could not download data: %v", err)
}
}
return Load(dataPath)
}