package webbundler 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/. 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 dir (iconsDir) 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={ ... } — 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(`]*\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(iconsDir()); err != nil { 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, err := os.ReadFile(filepath.Join(iconsDir(), 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 = {\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) }