Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

1package main 2 3import ( 4 "bytes" 5 _ "embed" 6 "flag" 7 "fmt" 8 "image" 9 "image/color" 10 "image/png" 11 "math" 12 "os" 13 "path/filepath" 14 "strconv" 15 "strings" 16 "text/template" 17 18 "github.com/srwiley/oksvg" 19 "github.com/srwiley/rasterx" 20 "golang.org/x/image/draw" 21 "tangled.org/core/ico" 22) 23 24func main() { 25 var ( 26 size string 27 fillColor string 28 output string 29 templatePath string 30 kind string 31 favicon bool 32 ) 33 34 flag.StringVar(&templatePath, "template", "", "Path to a dolly go-html template file, or a directory of templates") 35 flag.StringVar(&size, "size", "512", "Output size as WIDTH (height derived from aspect ratio, e.g., 512) or WIDTHxHEIGHT (e.g., 512x512)") 36 flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)") 37 flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)") 38 flag.StringVar(&kind, "kind", "logo", "Asset to generate: logo (dolly only) or logotype (dolly + wordmark)") 39 flag.BoolVar(&favicon, "favicon", false, "Embed a prefers-color-scheme style block so the SVG reacts to dark mode (SVG output only)") 40 flag.Parse() 41 42 if templatePath == "" { 43 fmt.Fprintf(os.Stderr, "Empty template path") 44 os.Exit(1) 45 } 46 47 if kind != "logo" && kind != "logotype" { 48 fmt.Fprintf(os.Stderr, "Invalid kind: %s. Must be logo or logotype\n", kind) 49 os.Exit(1) 50 } 51 52 width, height, err := parseSize(size) 53 if err != nil { 54 fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err) 55 os.Exit(1) 56 } 57 58 // Detect format from file extension 59 ext := strings.ToLower(filepath.Ext(output)) 60 format := strings.TrimPrefix(ext, ".") 61 62 if format != "svg" && format != "png" && format != "ico" { 63 fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext) 64 os.Exit(1) 65 } 66 67 if fillColor != "currentColor" && !isValidHexColor(fillColor) { 68 fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor) 69 os.Exit(1) 70 } 71 72 tpl, err := loadTemplates(templatePath) 73 if err != nil { 74 fmt.Fprintf(os.Stderr, "Failed to load templates from path %s: %v\n", templatePath, err) 75 os.Exit(1) 76 } 77 78 if favicon && format != "svg" { 79 fmt.Fprintf(os.Stderr, "-favicon is only supported for .svg output\n") 80 os.Exit(1) 81 } 82 83 svgData, err := dolly(tpl, "fragments/dolly/"+kind, fillColor, favicon) 84 if err != nil { 85 fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err) 86 os.Exit(1) 87 } 88 89 // Derive height from the SVG's aspect ratio when only a width was given 90 if height == 0 && format != "svg" { 91 height, err = deriveHeight(svgData, width) 92 if err != nil { 93 fmt.Fprintf(os.Stderr, "Error deriving height: %v\n", err) 94 os.Exit(1) 95 } 96 } 97 98 // Create output directory if it doesn't exist 99 dir := filepath.Dir(output) 100 if dir != "" && dir != "." { 101 if err := os.MkdirAll(dir, 0755); err != nil { 102 fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) 103 os.Exit(1) 104 } 105 } 106 107 switch format { 108 case "svg": 109 err = saveSVG(svgData, output, width, height) 110 case "png": 111 err = savePNG(svgData, output, width, height) 112 case "ico": 113 err = saveICO(svgData, output, width, height) 114 } 115 116 if err != nil { 117 fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) 118 os.Exit(1) 119 } 120 121 if format == "svg" { 122 // size is irrelevant for svg output; it scales to its viewBox 123 fmt.Printf("Successfully generated %s\n", output) 124 } else { 125 fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height) 126 } 127} 128 129func loadTemplates(path string) (*template.Template, error) { 130 info, err := os.Stat(path) 131 if err != nil { 132 return nil, err 133 } 134 135 if info.IsDir() { 136 return template.ParseGlob(filepath.Join(path, "*.html")) 137 } 138 139 return template.ParseFiles(path) 140} 141 142func dolly(tpl *template.Template, name, hexColor string, favicon bool) ([]byte, error) { 143 var svgData bytes.Buffer 144 if err := tpl.ExecuteTemplate(&svgData, name, map[string]any{ 145 "FillColor": hexColor, 146 "Classes": "", 147 "Favicon": favicon, 148 }); err != nil { 149 return nil, err 150 } 151 152 return svgData.Bytes(), nil 153} 154 155func svgToImage(svgData []byte, w, h int) (image.Image, error) { 156 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 157 if err != nil { 158 return nil, fmt.Errorf("error parsing SVG: %v", err) 159 } 160 161 icon.SetTarget(0, 0, float64(w), float64(h)) 162 rgba := image.NewRGBA(image.Rect(0, 0, w, h)) 163 draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) 164 scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) 165 raster := rasterx.NewDasher(w, h, scanner) 166 icon.Draw(raster, 1.0) 167 168 return rgba, nil 169} 170 171// parseSize parses WIDTH or WIDTHxHEIGHT. A height of 0 means "derive 172// from the SVG's aspect ratio". 173func parseSize(size string) (int, int, error) { 174 if !strings.Contains(size, "x") { 175 width, err := strconv.Atoi(size) 176 if err != nil { 177 return 0, 0, fmt.Errorf("invalid width: %v", err) 178 } 179 if width <= 0 { 180 return 0, 0, fmt.Errorf("width must be positive") 181 } 182 return width, 0, nil 183 } 184 185 parts := strings.Split(size, "x") 186 if len(parts) != 2 { 187 return 0, 0, fmt.Errorf("invalid size format, use WIDTH or WIDTHxHEIGHT") 188 } 189 190 width, err := strconv.Atoi(parts[0]) 191 if err != nil { 192 return 0, 0, fmt.Errorf("invalid width: %v", err) 193 } 194 195 height, err := strconv.Atoi(parts[1]) 196 if err != nil { 197 return 0, 0, fmt.Errorf("invalid height: %v", err) 198 } 199 200 if width <= 0 || height <= 0 { 201 return 0, 0, fmt.Errorf("width and height must be positive") 202 } 203 204 return width, height, nil 205} 206 207func deriveHeight(svgData []byte, width int) (int, error) { 208 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 209 if err != nil { 210 return 0, fmt.Errorf("error parsing SVG: %v", err) 211 } 212 213 if icon.ViewBox.W <= 0 || icon.ViewBox.H <= 0 { 214 return 0, fmt.Errorf("SVG has an invalid viewBox (%gx%g)", icon.ViewBox.W, icon.ViewBox.H) 215 } 216 217 return int(math.Round(float64(width) * icon.ViewBox.H / icon.ViewBox.W)), nil 218} 219 220func isValidHexColor(hex string) bool { 221 if len(hex) != 7 || hex[0] != '#' { 222 return false 223 } 224 _, err := strconv.ParseUint(hex[1:], 16, 32) 225 return err == nil 226} 227 228func saveSVG(svgData []byte, filepath string, _, _ int) error { 229 return os.WriteFile(filepath, svgData, 0644) 230} 231 232func savePNG(svgData []byte, filepath string, width, height int) error { 233 img, err := svgToImage(svgData, width, height) 234 if err != nil { 235 return err 236 } 237 238 f, err := os.Create(filepath) 239 if err != nil { 240 return err 241 } 242 defer f.Close() 243 244 return png.Encode(f, img) 245} 246 247func saveICO(svgData []byte, filepath string, width, height int) error { 248 img, err := svgToImage(svgData, width, height) 249 if err != nil { 250 return err 251 } 252 253 icoData, err := ico.ImageToIco(img) 254 if err != nil { 255 return err 256 } 257 258 return os.WriteFile(filepath, icoData, 0644) 259}