apps/posts/util.go 15.6 K raw
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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
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
}