Files
kjol/go/jsbundler/faicons.go
2026-07-14 16:49:56 -04:00

236 lines
8.3 KiB
Go

package jsbundler
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 the icon roots. Icons.tsx looks up that
// registry exactly like it used to call FontAwesome.findIconDefinition.
// FA prefix -> <iconRoot>/<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",
}
// The FA SVG source roots (iconsDirs — the app's kit, then kjol's) and the
// generated registry output path (faOutPath) are resolved from Config — see
// config.go. Only the styles in faStyleDirs are read; the rest of the kit is
// unused.
var (
// icon="name" / icon: "name"
reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`)
// icon={ ... } — a dynamic expression; pull any string literals out of it
// (ternaries etc.). The `$` is optional because both authoring styles in this
// codebase have to be seen: JSX writes icon={"bell"}, while a solid-js/html
// tagged template writes icon=${"bell"}. Missing the second form drops the
// icon from the registry silently — it simply renders blank at runtime.
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-]*)"`)
)
// presentIconsDirs is iconsDirs filtered to the roots that actually exist, so a
// missing kit is skipped rather than failing every icon lookup against it.
func presentIconsDirs() []string {
var roots []string
for _, d := range iconsDirs() {
if _, err := os.Stat(d); err == nil {
roots = append(roots, d)
}
}
return roots
}
// readIconSVG returns the first <root>/<style>/<name>.svg that exists, walking
// roots in precedence order (app kit before kjol's — see iconsDirs).
func readIconSVG(roots []string, style, name string) (string, bool) {
for _, root := range roots {
if svg, err := os.ReadFile(filepath.Join(root, style, name+".svg")); err == nil {
return string(svg), true
}
}
return "", false
}
// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
// no-op when no kit is present (CI builds use the committed registry).
func generateFAIcons() error {
roots := presentIconsDirs()
if len(roots) == 0 {
return nil // SVGs absent — keep the committed registry
}
// Scan both the app pages and the shared kit so every icon either tree
// references ends up in this app's registry.
names, custom, err := scanIconNames([]string{filepath.Join(frontendDir, "src"), uikitDir()})
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, ok := readIconSVG(roots, dir, name)
if !ok {
continue
}
x, y, w, h, d, ok := parseFASvg(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("// an icon kit 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(faOutPath()), 0755); err != nil {
return err
}
if err := os.WriteFile(faOutPath(), []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(dirs []string) (names []string, custom map[string]bool, err error) {
set := map[string]bool{}
custom = map[string]bool{}
walk := 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
}
for _, dir := range dirs {
if _, e := os.Stat(dir); e != nil {
continue // tree not present (e.g. no separate kit in single-tree mode)
}
if err = filepath.WalkDir(dir, walk); 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) }