202 lines
7.0 KiB
Go
202 lines
7.0 KiB
Go
package bundler
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and
|
|
// looking icons up by runtime string, we scan the app for the icon names it
|
|
// actually references and emit a registry of just those icons' SVG data, pulled
|
|
// from the FontAwesome SVGs under frontend/icons/. Icons.tsx looks up that
|
|
// registry exactly like it used to call FontAwesome.findIconDefinition.
|
|
|
|
// FA prefix -> frontend/icons/<dir>. cdrateline renders classic far/fas; a Sharp
|
|
// project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid".
|
|
var faStyleDirs = map[string]string{
|
|
"far": "regular",
|
|
"fas": "solid",
|
|
}
|
|
|
|
// faIconsDir holds the FontAwesome SVGs (the kit's svgs-full/, relocated here),
|
|
// grouped by style. Only the styles in faStyleDirs are read; the rest are unused.
|
|
const faIconsDir = "frontend/icons"
|
|
|
|
var faOutFile = filepath.Join(frontendDir, "src", "ui", "generated", "faIcons.ts")
|
|
|
|
var (
|
|
// icon="name" / icon: "name"
|
|
reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`)
|
|
// icon={ ... } — dynamic expressions; pull any string literals (ternaries etc.)
|
|
reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\{([^}]*)\}`)
|
|
reStrLit = regexp.MustCompile(`"([a-z0-9][a-z0-9-]*)"`)
|
|
reViewBox = regexp.MustCompile(`viewBox="0 0 ([0-9.]+) ([0-9.]+)"`)
|
|
rePathD = regexp.MustCompile(`<path[^>]*\bd="([^"]+)"`)
|
|
// registerIcon("name", …) — custom (non-FontAwesome) icons defined in-app.
|
|
reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`)
|
|
)
|
|
|
|
// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
|
|
// no-op when the kit isn't present (CI builds use the committed registry).
|
|
func generateFAIcons() error {
|
|
if _, err := os.Stat(faIconsDir); err != nil {
|
|
return nil // SVGs absent — keep the committed registry
|
|
}
|
|
|
|
names, custom, err := scanIconNames(filepath.Join(frontendDir, "src"))
|
|
if err != nil {
|
|
return fmt.Errorf("scanning icon names: %w", err)
|
|
}
|
|
|
|
type entry struct {
|
|
key, x, y, w, h, path string
|
|
}
|
|
var entries []entry
|
|
var missing []string
|
|
for _, name := range names {
|
|
found := false
|
|
for prefix, dir := range faStyleDirs {
|
|
svg, err := os.ReadFile(filepath.Join(faIconsDir, dir, name+".svg"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
x, y, w, h, d, ok := parseFASvg(string(svg))
|
|
if !ok {
|
|
continue
|
|
}
|
|
entries = append(entries, entry{prefix + ":" + name, x, y, w, h, d})
|
|
found = true
|
|
}
|
|
if !found {
|
|
missing = append(missing, name)
|
|
}
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key })
|
|
|
|
var b strings.Builder
|
|
b.WriteString("// AUTO-GENERATED by cmd/bundle (generateFAIcons) — do not edit.\n")
|
|
b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n")
|
|
b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n")
|
|
b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n")
|
|
b.WriteString("// frontend/icons/ is present; committed so CI needs no SVGs.\n")
|
|
b.WriteString("export const FA_ICONS: Record<string, readonly [number, number, number, number, string]> = {\n")
|
|
for _, e := range entries {
|
|
b.WriteString(fmt.Sprintf(" %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path))
|
|
}
|
|
b.WriteString("};\n")
|
|
|
|
if err := os.MkdirAll(filepath.Dir(faOutFile), 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(faOutFile, []byte(b.String()), 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A referenced name that's not in the FA kit is either a registered custom icon
|
|
// (expected) or an unknown name — almost always a typo (report separately).
|
|
var customUsed, unknown []string
|
|
for _, name := range missing {
|
|
if custom[name] {
|
|
customUsed = append(customUsed, name)
|
|
} else {
|
|
unknown = append(unknown, name)
|
|
}
|
|
}
|
|
|
|
fmt.Printf(" FA icons: %d defs for %d names", len(entries), len(names))
|
|
if len(customUsed) > 0 {
|
|
sort.Strings(customUsed)
|
|
fmt.Printf(" (%d custom: %s)", len(customUsed), strings.Join(customUsed, ", "))
|
|
}
|
|
if len(unknown) > 0 {
|
|
sort.Strings(unknown)
|
|
fmt.Printf(" (%d unknown, likely typos: %s)", len(unknown), strings.Join(unknown, ", "))
|
|
}
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
// scanIconNames walks dir once and returns two things: the sorted list of icon
|
|
// names referenced anywhere (static `icon="x"`/`icon: "x"` plus string literals
|
|
// inside `icon={...}` expressions), and the set of custom icon names registered
|
|
// via registerIcon("name", …). The latter lets the caller tell a legitimate
|
|
// custom icon apart from a typo when a referenced name isn't in the FA kit.
|
|
func scanIconNames(dir string) (names []string, custom map[string]bool, err error) {
|
|
set := map[string]bool{}
|
|
custom = map[string]bool{}
|
|
err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
switch filepath.Ext(path) {
|
|
case ".ts", ".tsx", ".js", ".jsx":
|
|
default:
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s := string(data)
|
|
for _, m := range reIconAttr.FindAllStringSubmatch(s, -1) {
|
|
set[m[1]] = true
|
|
}
|
|
for _, bm := range reIconBrace.FindAllStringSubmatch(s, -1) {
|
|
for _, sm := range reStrLit.FindAllStringSubmatch(bm[1], -1) {
|
|
set[sm[1]] = true
|
|
}
|
|
}
|
|
for _, m := range reRegisterIcon.FindAllStringSubmatch(s, -1) {
|
|
custom[m[1]] = true
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
names = make([]string, 0, len(set))
|
|
for n := range set {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
return names, custom, nil
|
|
}
|
|
|
|
// parseFASvg pulls the concatenated path data (solid/regular icons are
|
|
// single-path; joining is safe) and a viewBox out of a FontAwesome kit SVG.
|
|
//
|
|
// The kit's "full" SVGs keep FA's 512-unit icon design centred inside a 640x640
|
|
// canvas — a uniform 10% margin (square-full/circle/bars all span 64..576). We
|
|
// inset the viewBox to that 512 design box so icons render at their intended
|
|
// (FA6-equivalent) size instead of ~20% small on the padded canvas. The inset is
|
|
// UNIFORM across every icon, so proportions are preserved — a caret stays a small
|
|
// glyph. (A per-glyph bounding-box crop was wrong: it can't tell an icon meant to
|
|
// fill its box from one deliberately padded, so it blew small glyphs up to fill it.)
|
|
func parseFASvg(svg string) (x, y, w, h, path string, ok bool) {
|
|
var ds []string
|
|
for _, m := range rePathD.FindAllStringSubmatch(svg, -1) {
|
|
ds = append(ds, m[1])
|
|
}
|
|
if len(ds) == 0 {
|
|
return "", "", "", "", "", false
|
|
}
|
|
vb := reViewBox.FindStringSubmatch(svg)
|
|
if vb == nil {
|
|
return "", "", "", "", "", false
|
|
}
|
|
vw, e1 := strconv.ParseFloat(vb[1], 64)
|
|
vh, e2 := strconv.ParseFloat(vb[2], 64)
|
|
if e1 != nil || e2 != nil {
|
|
return "0", "0", vb[1], vb[2], strings.Join(ds, " "), true // unparseable dims — use as-is
|
|
}
|
|
mx, my := vw/10, vh/10
|
|
return numStr(mx), numStr(my), numStr(vw-2*mx), numStr(vh-2*my), strings.Join(ds, " "), true
|
|
}
|
|
|
|
func numStr(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
|