| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "html/template" |
| 7 | "net/http" |
| 8 | "regexp" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | type parsedAttributes struct { |
| 15 | Title string |
| 16 | Slug string |
| 17 | Alias string |
| 18 | PublishedDate string |
| 19 | MetaDescription string |
| 20 | MetaImage string |
| 21 | Lang string |
| 22 | Tags string |
| 23 | Status string |
| 24 | Weather string |
| 25 | } |
| 26 | |
| 27 | type WeatherPointResponse struct { |
| 28 | Properties struct { |
| 29 | ForecastHourly string `json:"forecastHourly"` |
| 30 | RelativeLocation struct { |
| 31 | Properties struct { |
| 32 | City string `json:"city"` |
| 33 | State string `json:"state"` |
| 34 | } `json:"properties"` |
| 35 | } `json:"relativeLocation"` |
| 36 | } `json:"properties"` |
| 37 | } |
| 38 | |
| 39 | type Period struct { |
| 40 | Temperature int `json:"temperature"` |
| 41 | ShortForecast string `json:"shortForecast"` |
| 42 | } |
| 43 | |
| 44 | type WeatherForecastResponse struct { |
| 45 | Properties struct { |
| 46 | Periods []Period `json:"periods"` |
| 47 | } `json:"properties"` |
| 48 | } |
| 49 | |
| 50 | type Weather struct { |
| 51 | Icon template.HTML |
| 52 | Conditions string |
| 53 | Temperature string |
| 54 | Location string |
| 55 | } |
| 56 | |
| 57 | func parseAttributes(text string) parsedAttributes { |
| 58 | var a parsedAttributes |
| 59 | for _, line := range strings.Split(text, "\n") { |
| 60 | i := strings.Index(line, ":") |
| 61 | if i < 0 { |
| 62 | continue |
| 63 | } |
| 64 | key := strings.ToLower(strings.TrimSpace(line[:i])) |
| 65 | value := strings.TrimSpace(line[i+1:]) |
| 66 | switch key { |
| 67 | case "title": |
| 68 | a.Title = value |
| 69 | case "slug": |
| 70 | a.Slug = value |
| 71 | case "alias": |
| 72 | a.Alias = value |
| 73 | case "published_date": |
| 74 | a.PublishedDate = value |
| 75 | case "description", "meta_description": |
| 76 | a.MetaDescription = value |
| 77 | case "meta_image": |
| 78 | a.MetaImage = value |
| 79 | case "lang": |
| 80 | a.Lang = value |
| 81 | case "tags": |
| 82 | a.Tags = value |
| 83 | case "status": |
| 84 | a.Status = value |
| 85 | case "weather": |
| 86 | a.Weather = value |
| 87 | } |
| 88 | } |
| 89 | return a |
| 90 | } |
| 91 | |
| 92 | type parsedPageAttributes struct { |
| 93 | Title string |
| 94 | Slug string |
| 95 | IsPublished bool |
| 96 | } |
| 97 | |
| 98 | func parsePageAttributes(text string) parsedPageAttributes { |
| 99 | var a parsedPageAttributes |
| 100 | for _, line := range strings.Split(text, "\n") { |
| 101 | i := strings.Index(line, ":") |
| 102 | if i < 0 { |
| 103 | continue |
| 104 | } |
| 105 | key := strings.ToLower(strings.TrimSpace(line[:i])) |
| 106 | value := strings.TrimSpace(line[i+1:]) |
| 107 | switch key { |
| 108 | case "title": |
| 109 | a.Title = value |
| 110 | case "slug": |
| 111 | a.Slug = value |
| 112 | case "published": |
| 113 | a.IsPublished = value == "true" |
| 114 | } |
| 115 | } |
| 116 | return a |
| 117 | } |
| 118 | |
| 119 | func slugify(s string) string { |
| 120 | var b strings.Builder |
| 121 | for _, r := range strings.ToLower(s) { |
| 122 | if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { |
| 123 | b.WriteRune(r) |
| 124 | } else { |
| 125 | b.WriteByte('-') |
| 126 | } |
| 127 | } |
| 128 | parts := strings.Split(b.String(), "-") |
| 129 | out := parts[:0] |
| 130 | for _, p := range parts { |
| 131 | if p != "" { |
| 132 | out = append(out, p) |
| 133 | } |
| 134 | } |
| 135 | return strings.Join(out, "-") |
| 136 | } |
| 137 | |
| 138 | func optStr(s string) *string { |
| 139 | t := strings.TrimSpace(s) |
| 140 | if t == "" { |
| 141 | return nil |
| 142 | } |
| 143 | return &t |
| 144 | } |
| 145 | |
| 146 | func deriveSlug(title, slug string) string { |
| 147 | if slug != "" { |
| 148 | return slug |
| 149 | } |
| 150 | if from := slugify(title); from != "" { |
| 151 | return from |
| 152 | } |
| 153 | id, _ := generateID() |
| 154 | return id |
| 155 | } |
| 156 | |
| 157 | func generateID() (string, error) { |
| 158 | // Imported from auth crate at call site; this is a fallback path. |
| 159 | const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" |
| 160 | buf := make([]byte, 10) |
| 161 | for i := range buf { |
| 162 | buf[i] = alphabet[time.Now().UnixNano()%int64(len(alphabet))] |
| 163 | time.Sleep(time.Nanosecond) |
| 164 | } |
| 165 | return string(buf), nil |
| 166 | } |
| 167 | |
| 168 | var reservedPageSlugs = map[string]bool{ |
| 169 | "posts": true, "admin": true, "feed.xml": true, |
| 170 | "custom-styles.css": true, "static": true, "files": true, |
| 171 | } |
| 172 | |
| 173 | func isReservedPageSlug(slug string) bool { |
| 174 | return reservedPageSlugs[slug] |
| 175 | } |
| 176 | |
| 177 | func parseNavLinks(input string) []NavLink { |
| 178 | var out []NavLink |
| 179 | rest := input |
| 180 | for { |
| 181 | open := strings.Index(rest, "[") |
| 182 | if open < 0 { |
| 183 | break |
| 184 | } |
| 185 | close := strings.Index(rest[open:], "]") |
| 186 | if close < 0 { |
| 187 | break |
| 188 | } |
| 189 | close += open |
| 190 | label := rest[open+1 : close] |
| 191 | if close+1 >= len(rest) || rest[close+1] != '(' { |
| 192 | rest = rest[close+1:] |
| 193 | continue |
| 194 | } |
| 195 | urlEnd := strings.Index(rest[close+2:], ")") |
| 196 | if urlEnd < 0 { |
| 197 | break |
| 198 | } |
| 199 | urlEnd += close + 2 |
| 200 | url := rest[close+2 : urlEnd] |
| 201 | if label != "" && url != "" { |
| 202 | out = append(out, NavLink{Label: label, URL: url}) |
| 203 | } |
| 204 | rest = rest[urlEnd+1:] |
| 205 | } |
| 206 | return out |
| 207 | } |
| 208 | |
| 209 | var pubDateLayouts = []string{ |
| 210 | time.RFC3339, |
| 211 | "2006-01-02T15:04:05", |
| 212 | "2006-01-02 15:04:05", |
| 213 | "2006-01-02", |
| 214 | } |
| 215 | |
| 216 | // parsePubDate accepts RFC3339, naive datetime, or date-only input and returns |
| 217 | // the value normalized to RFC3339 UTC. Returns ok=false if no layout matches. |
| 218 | func parsePubDate(s string) (string, bool) { |
| 219 | s = strings.TrimSpace(s) |
| 220 | if s == "" { |
| 221 | return "", false |
| 222 | } |
| 223 | for _, l := range pubDateLayouts { |
| 224 | if t, err := time.Parse(l, s); err == nil { |
| 225 | return t.UTC().Format(time.RFC3339), true |
| 226 | } |
| 227 | } |
| 228 | return "", false |
| 229 | } |
| 230 | |
| 231 | func toRFC2822(ts string) string { |
| 232 | for _, l := range pubDateLayouts { |
| 233 | if t, err := time.Parse(l, ts); err == nil { |
| 234 | return t.UTC().Format(time.RFC1123Z) |
| 235 | } |
| 236 | } |
| 237 | return ts |
| 238 | } |
| 239 | |
| 240 | func xmlEscape(s string) string { |
| 241 | r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") |
| 242 | return r.Replace(s) |
| 243 | } |
| 244 | |
| 245 | func mimeFromPath(path string) string { |
| 246 | i := strings.LastIndex(path, ".") |
| 247 | if i < 0 { |
| 248 | return "application/octet-stream" |
| 249 | } |
| 250 | switch strings.ToLower(path[i+1:]) { |
| 251 | case "css": |
| 252 | return "text/css" |
| 253 | case "js": |
| 254 | return "application/javascript" |
| 255 | case "html": |
| 256 | return "text/html" |
| 257 | case "png": |
| 258 | return "image/png" |
| 259 | case "jpg", "jpeg": |
| 260 | return "image/jpeg" |
| 261 | case "gif": |
| 262 | return "image/gif" |
| 263 | case "webp": |
| 264 | return "image/webp" |
| 265 | case "ico": |
| 266 | return "image/x-icon" |
| 267 | case "svg": |
| 268 | return "image/svg+xml" |
| 269 | case "woff", "woff2": |
| 270 | return "font/woff2" |
| 271 | case "ttf": |
| 272 | return "font/ttf" |
| 273 | case "otf": |
| 274 | return "font/otf" |
| 275 | case "json", "webmanifest": |
| 276 | return "application/json" |
| 277 | case "pdf": |
| 278 | return "application/pdf" |
| 279 | case "mp4": |
| 280 | return "video/mp4" |
| 281 | case "webm": |
| 282 | return "video/webm" |
| 283 | } |
| 284 | return "application/octet-stream" |
| 285 | } |
| 286 | |
| 287 | func postToMarkdown(p *Post) string { |
| 288 | var b strings.Builder |
| 289 | b.WriteString("---") |
| 290 | if p.Title != nil { |
| 291 | b.WriteString("\ntitle: " + *p.Title) |
| 292 | } |
| 293 | b.WriteString("\nslug: " + p.Slug) |
| 294 | b.WriteString("\nstatus: " + p.Status) |
| 295 | if p.PublishedDate != nil { |
| 296 | b.WriteString("\npublished_date: " + *p.PublishedDate) |
| 297 | } |
| 298 | if p.Tags != nil { |
| 299 | b.WriteString("\ntags: " + *p.Tags) |
| 300 | } |
| 301 | b.WriteString("\nlang: " + p.Lang) |
| 302 | if p.Alias != nil { |
| 303 | b.WriteString("\nalias: " + *p.Alias) |
| 304 | } |
| 305 | if p.MetaImage != nil { |
| 306 | b.WriteString("\nmeta_image: " + *p.MetaImage) |
| 307 | } |
| 308 | if p.MetaDescription != nil { |
| 309 | b.WriteString("\ndescription: " + *p.MetaDescription) |
| 310 | } |
| 311 | b.WriteString("\n---\n\n") |
| 312 | b.WriteString(p.Content) |
| 313 | return b.String() |
| 314 | } |
| 315 | |
| 316 | func splitFrontmatter(content string) (string, string) { |
| 317 | trimmed := strings.TrimPrefix(content, "\ufeff") |
| 318 | var afterOpen string |
| 319 | if strings.HasPrefix(trimmed, "---\n") { |
| 320 | afterOpen = trimmed[4:] |
| 321 | } else if strings.HasPrefix(trimmed, "---\r\n") { |
| 322 | afterOpen = trimmed[5:] |
| 323 | } else { |
| 324 | return "", content |
| 325 | } |
| 326 | for _, sep := range []string{"\r\n---\r\n", "\r\n---\n", "\n---\r\n", "\n---\n"} { |
| 327 | if i := strings.Index(afterOpen, sep); i >= 0 { |
| 328 | body := afterOpen[i+len(sep):] |
| 329 | body = strings.TrimLeft(body, "\r\n") |
| 330 | return afterOpen[:i], body |
| 331 | } |
| 332 | } |
| 333 | if strings.HasSuffix(afterOpen, "\n---") { |
| 334 | return strings.TrimSuffix(afterOpen, "\n---"), "" |
| 335 | } |
| 336 | if strings.HasSuffix(afterOpen, "\r\n---") { |
| 337 | return strings.TrimSuffix(afterOpen, "\r\n---"), "" |
| 338 | } |
| 339 | return "", content |
| 340 | } |
| 341 | |
| 342 | func titleFromFilename(name string) string { |
| 343 | stem := name |
| 344 | if i := strings.LastIndex(name, "."); i > 0 { |
| 345 | stem = name[:i] |
| 346 | } |
| 347 | cleaned := strings.Map(func(r rune) rune { |
| 348 | if r == '-' || r == '_' { |
| 349 | return ' ' |
| 350 | } |
| 351 | return r |
| 352 | }, stem) |
| 353 | cleaned = strings.TrimSpace(cleaned) |
| 354 | if cleaned == "" { |
| 355 | return "" |
| 356 | } |
| 357 | return strings.ToUpper(cleaned[:1]) + cleaned[1:] |
| 358 | } |
| 359 | |
| 360 | var weatherClient = &http.Client{Timeout: 10 * time.Second} |
| 361 | |
| 362 | var qualifierRE = regexp.MustCompile(`(?i)^(slight chance|chance|isolated|scattered|numerous|widespread|patchy|areas|periods|occasional|frequent)\s+(of\s+)?`) |
| 363 | var trailingQualifierRE = regexp.MustCompile(`(?i)\s+(likely)$`) |
| 364 | |
| 365 | func getWeather(location string) string { |
| 366 | if location == "" { |
| 367 | return "" |
| 368 | } |
| 369 | // Fetch Points data using lat,long |
| 370 | pointURL := fmt.Sprintf("https://api.weather.gov/points/%s", location) |
| 371 | resp, err := weatherClient.Get(pointURL) |
| 372 | if err != nil { |
| 373 | fmt.Printf("Error fetching pointUrl: %s", err.Error()) |
| 374 | return "" |
| 375 | } |
| 376 | defer resp.Body.Close() |
| 377 | var weatherPoint WeatherPointResponse |
| 378 | if err := json.NewDecoder(resp.Body).Decode(&weatherPoint); err != nil { |
| 379 | fmt.Printf("Error decoding pointUrl: %s", err.Error()) |
| 380 | return "" |
| 381 | } |
| 382 | forecastURL := weatherPoint.Properties.ForecastHourly |
| 383 | city := weatherPoint.Properties.RelativeLocation.Properties.City |
| 384 | state := weatherPoint.Properties.RelativeLocation.Properties.State |
| 385 | |
| 386 | // Forcast using points data |
| 387 | forecastResp, err := weatherClient.Get(forecastURL) |
| 388 | if err != nil { |
| 389 | fmt.Printf("Error fetching forecast: %s", err.Error()) |
| 390 | return "" |
| 391 | } |
| 392 | defer forecastResp.Body.Close() |
| 393 | var weatherForecast WeatherForecastResponse |
| 394 | if err := json.NewDecoder(forecastResp.Body).Decode(&weatherForecast); err != nil { |
| 395 | fmt.Printf("Error decoding forecast: %s", err.Error()) |
| 396 | return "" |
| 397 | } |
| 398 | if len(weatherForecast.Properties.Periods) == 0 { |
| 399 | fmt.Printf("Error: no forecast periods returned for %q", location) |
| 400 | return "" |
| 401 | } |
| 402 | period := weatherForecast.Properties.Periods[0] |
| 403 | temp := strconv.Itoa(period.Temperature) |
| 404 | stripped := qualifierRE.ReplaceAllString(period.ShortForecast, "") |
| 405 | formattedConditions := strings.TrimSpace(trailingQualifierRE.ReplaceAllString(stripped, "")) |
| 406 | weather := fmt.Sprintf("%s,%s,%s,%s", formattedConditions, temp, city, state) |
| 407 | return weather |
| 408 | } |
| 409 | |
| 410 | func formatWeather(weather string) (Weather, error) { |
| 411 | parts := strings.Split(weather, ",") |
| 412 | if len(parts) < 4 { |
| 413 | return Weather{}, fmt.Errorf("unexpected weather format: %q", weather) |
| 414 | } |
| 415 | for i := range parts { |
| 416 | parts[i] = strings.TrimSpace(parts[i]) // handles ", " vs "," |
| 417 | } |
| 418 | |
| 419 | conditions := parts[0] |
| 420 | return Weather{ |
| 421 | Conditions: conditions, |
| 422 | Temperature: fmt.Sprintf("%s°F", parts[1]), |
| 423 | Location: fmt.Sprintf("%s, %s", parts[2], parts[3]), |
| 424 | Icon: weatherIcons[categorize(conditions)], |
| 425 | }, nil |
| 426 | } |
| 427 | |
| 428 | type WeatherCategory int |
| 429 | |
| 430 | const ( |
| 431 | CatStorm WeatherCategory = iota |
| 432 | CatSleet |
| 433 | CatSnow |
| 434 | CatRain |
| 435 | CatFog |
| 436 | CatPartlyCloudy |
| 437 | CatCloudy |
| 438 | CatClear |
| 439 | CatUnknown |
| 440 | ) |
| 441 | |
| 442 | var categoryRules = []struct { |
| 443 | category WeatherCategory |
| 444 | keywords []string |
| 445 | }{ |
| 446 | {CatStorm, []string{"thunder", "tstorm", "storm"}}, |
| 447 | {CatSleet, []string{"sleet", "freez", "frzg", "mix"}}, |
| 448 | {CatSnow, []string{"snow", "flurr"}}, |
| 449 | {CatRain, []string{"rain", "shower", "drizzle"}}, |
| 450 | {CatFog, []string{"fog", "mist", "haze"}}, |
| 451 | {CatPartlyCloudy, []string{"partly", "variable"}}, |
| 452 | {CatCloudy, []string{"overcast", "cloud"}}, |
| 453 | {CatClear, []string{"clear", "sunny", "sun", "fair"}}, |
| 454 | } |
| 455 | |
| 456 | func categorize(conditions string) WeatherCategory { |
| 457 | c := strings.ToLower(conditions) |
| 458 | for _, rule := range categoryRules { |
| 459 | for _, kw := range rule.keywords { |
| 460 | if strings.Contains(c, kw) { |
| 461 | return rule.category |
| 462 | } |
| 463 | } |
| 464 | } |
| 465 | return CatUnknown |
| 466 | } |
| 467 | |
| 468 | var weatherIcons = map[WeatherCategory]template.HTML{ |
| 469 | CatPartlyCloudy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M164 76a71.9 71.9 0 0 0-22.14 3.48A51.8 51.8 0 0 0 129 63.83l11.56-16.51a4 4 0 0 0-6.56-4.59l-11.55 16.51A52 52 0 0 0 96 52c-1.71 0-3.4.09-5.06.25l-3.5-19.85a4 4 0 0 0-7.88 1.39l3.5 19.84A52.2 52.2 0 0 0 55.85 71L39.32 59.42A4 4 0 0 0 34.73 66l16.53 11.54A51.63 51.63 0 0 0 44 104c0 1.69.09 3.37.25 5l-19.85 3.5a4 4 0 0 0 .69 7.94a4 4 0 0 0 .7-.06l19.85-3.5A52.1 52.1 0 0 0 54 134.6A48 48 0 0 0 84 220h80a72 72 0 0 0 0-144M52 104a44 44 0 0 1 82.33-21.61a72.23 72.23 0 0 0-38.82 43A48.3 48.3 0 0 0 84 124a47.76 47.76 0 0 0-23.4 6.11A44 44 0 0 1 52 104m112 108H84a40 40 0 1 1 9.43-78.88A71.6 71.6 0 0 0 92 143.77a4 4 0 0 0 8 .46a64.3 64.3 0 0 1 2-12.67c0-.12.07-.24.09-.36A64.06 64.06 0 1 1 164 212"/></svg>`, |
| 470 | CatClear: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M124 40v-8a4 4 0 0 1 8 0v8a4 4 0 0 1-8 0m64 88a60 60 0 1 1-60-60a60.07 60.07 0 0 1 60 60m-8 0a52 52 0 1 0-52 52a52.06 52.06 0 0 0 52-52M61.17 66.83a4 4 0 0 0 5.66-5.66l-8-8a4 4 0 0 0-5.66 5.66Zm0 122.34l-8 8a4 4 0 0 0 5.66 5.66l8-8a4 4 0 0 0-5.66-5.66m136-136l-8 8a4 4 0 0 0 5.66 5.66l8-8a4 4 0 1 0-5.66-5.66m-2.34 136a4 4 0 0 0-5.66 5.66l8 8a4 4 0 0 0 5.66-5.66ZM40 124h-8a4 4 0 0 0 0 8h8a4 4 0 0 0 0-8m88 88a4 4 0 0 0-4 4v8a4 4 0 0 0 8 0v-8a4 4 0 0 0-4-4m96-88h-8a4 4 0 0 0 0 8h8a4 4 0 0 0 0-8"/></svg>`, |
| 471 | CatCloudy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M160 44a84.11 84.11 0 0 0-76.41 49.12A60.7 60.7 0 0 0 72 92a60 60 0 0 0 0 120h88a84 84 0 0 0 0-168m0 160H72a52 52 0 1 1 8.55-103.3A83.7 83.7 0 0 0 76 128a4 4 0 0 0 8 0a76 76 0 1 1 76 76"/></svg>`, |
| 472 | CatStorm: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M156 20a72.19 72.19 0 0 0-68.49 49.39A48 48 0 1 0 76 164h44.94l-20.37 33.94A4 4 0 0 0 104 204h32.94l-20.37 33.94a4 4 0 0 0 6.86 4.12l24-40A4 4 0 0 0 144 196h-32.94l19.2-32H156a72 72 0 0 0 0-144m0 136H76a40 40 0 1 1 9.43-78.88A71.6 71.6 0 0 0 84 87.77a4 4 0 0 0 8 .46A64.06 64.06 0 1 1 156 156"/></svg>`, |
| 473 | CatRain: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="m155.33 194.22l-32 48a4 4 0 1 1-6.66-4.44l32-48a4 4 0 0 1 6.66 4.44M228 92a72.08 72.08 0 0 1-72 72h-25.86l-30.81 46.22a4 4 0 1 1-6.66-4.44L120.53 164H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
| 474 | CatSnow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M84 196a8 8 0 1 1-8-8a8 8 0 0 1 8 8m32 8a8 8 0 1 0 8 8a8 8 0 0 0-8-8m48-16a8 8 0 1 0 8 8a8 8 0 0 0-8-8m-96 40a8 8 0 1 0 8 8a8 8 0 0 0-8-8m88 0a8 8 0 1 0 8 8a8 8 0 0 0-8-8m72-136a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
| 475 | CatSleet: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M84 196a8 8 0 1 1-8-8a8 8 0 0 1 8 8m32 8a8 8 0 1 0 8 8a8 8 0 0 0-8-8m48-16a8 8 0 1 0 8 8a8 8 0 0 0-8-8m-96 40a8 8 0 1 0 8 8a8 8 0 0 0-8-8m88 0a8 8 0 1 0 8 8a8 8 0 0 0-8-8m72-136a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
| 476 | CatFog: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M120 204H72a4 4 0 0 1 0-8h48a4 4 0 0 1 0 8m64-8h-24a4 4 0 0 0 0 8h24a4 4 0 0 0 0-8m-24 32h-56a4 4 0 0 0 0 8h56a4 4 0 0 0 0-8m68-128a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 100m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 164h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
| 477 | } |