1package main
2
3import (
4 "embed"
5 "fmt"
6 "image"
7 "image/color"
8 "os"
9 "strings"
10
11 "gioui.org/app"
12 "gioui.org/font/opentype"
13 "gioui.org/io/system"
14 "gioui.org/layout"
15 "gioui.org/op"
16 "gioui.org/op/clip"
17 "gioui.org/op/paint"
18 "gioui.org/text"
19 "gioui.org/unit"
20 "gioui.org/widget/material"
21 "github.com/adrg/frontmatter"
22 "github.com/go-git/go-git/v5"
23 flag "github.com/spf13/pflag"
24 "gopkg.in/yaml.v3"
25)
26
27var (
28 flagHelp *bool = flag.BoolP("help", "h", false, "Show the help message")
29 flagInput *string = flag.StringP("input", "i", "", "Path to input Markdown")
30 flagOutput *string = flag.StringP("output", "o", "", "Path to output PNG")
31 flagMetaSize *int = flag.IntP("metasize", "m", 30, "Size of font for meta information")
32 flagPostTitleSize *int = flag.IntP("posttitlesize", "p", 50, "Size of font for post title")
33 flagSiteTitleSize *int = flag.IntP("sitetitlesize", "s", 40, "Size of font for site title")
34)
35
36//go:embed fonts
37var fontEmbed embed.FS
38
39func main() {
40 flag.Parse()
41
42 if *flagHelp {
43 help()
44 os.Exit(0)
45 }
46
47 validateFlags()
48
49 postTitle, postSubtitle, postDate, postContent := getPostInfo(*flagInput)
50 postReadTime := getReadTime(postContent)
51 siteTitle := getSiteTitle()
52 dateEdited := getGitDate(*flagInput)
53
54 // TODO: Render information to image
55 fmt.Printf(`Title: %s
56Subtitle: %s
57Read Time: %d minutes
58Date Published: %s
59Date Edited: %s
60Site Title: %s
61`, postTitle, postSubtitle, postReadTime, postDate, dateEdited, siteTitle)
62
63 collection := fontCollection()
64
65 go func() {
66 w := app.NewWindow(app.Title("p2c"), app.Size(unit.Dp(1200), unit.Dp(630)))
67 var ops op.Ops
68 th := material.NewTheme(collection)
69 for e := range w.Events() {
70 switch e := e.(type) {
71 case system.FrameEvent:
72 gtx := layout.NewContext(&ops, e)
73
74 layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
75
76 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
77 return layout.Spacer{Height: unit.Dp(100)}.Layout(gtx)
78 }),
79
80 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
81 title := material.Label(th, unit.Sp(float32(*flagPostTitleSize)), postTitle)
82 title.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
83 title.Alignment = text.Middle
84 return title.Layout(gtx)
85 }),
86
87 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
88 layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
89 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
90 rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Reading Time: ")
91 rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
92 rTime.Alignment = text.Middle
93 return rTime.Layout(gtx)
94 }),
95 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
96 rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postReadTime, " minutes"))
97 rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
98 rTime.Alignment = text.Middle
99 return rTime.Layout(gtx)
100 }),
101 )
102
103 layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
104 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
105 pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Published: ")
106 pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
107 pDate.Alignment = text.Middle
108 return pDate.Layout(gtx)
109 }),
110 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
111 pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postDate))
112 pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
113 pDate.Alignment = text.Middle
114 return pDate.Layout(gtx)
115 }),
116 )
117
118 layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
119 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
120 eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Edited: ")
121 eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
122 eDate.Alignment = text.Middle
123 return eDate.Layout(gtx)
124 }),
125 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
126 eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(dateEdited))
127 eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
128 eDate.Alignment = text.Middle
129 return eDate.Layout(gtx)
130 }),
131 )
132 return layout.Dimensions{}
133 }),
134
135 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
136 size := image.Pt(700, 4)
137 defer clip.Rect{Max: size}.Push(&ops).Pop()
138 paint.ColorOp{Color: color.NRGBA{A: 0xFF}}.Add(&ops)
139 paint.PaintOp{}.Add(&ops)
140 return layout.Dimensions{Size: size}
141 }),
142
143 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
144 sTitle := material.Body1(th, siteTitle)
145 sTitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
146 sTitle.Alignment = text.Middle
147 return sTitle.Layout(gtx)
148 }),
149 )
150
151 e.Frame(gtx.Ops)
152 }
153 }
154 os.Exit(0)
155 }()
156 app.Main()
157}
158
159func fontCollection() []text.FontFace {
160 regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
161 if err != nil {
162 fmt.Println("Error: Could not read regular font")
163 fmt.Println(err)
164 os.Exit(1)
165 }
166 regularFace, err := opentype.Parse(regularFaceBytes)
167 if err != nil {
168 fmt.Println("Error: Could not parse regular font")
169 fmt.Println(err)
170 os.Exit(1)
171 }
172
173 boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
174 if err != nil {
175 fmt.Println("Error: Could not read bold font")
176 fmt.Println(err)
177 os.Exit(1)
178 }
179 boldFace, err := opentype.Parse(boldFaceBytes)
180 if err != nil {
181 fmt.Println("Error: Could not parse bold font")
182 fmt.Println(err)
183 os.Exit(1)
184 }
185
186 italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
187 if err != nil {
188 fmt.Println("Error: Could not read italic font")
189 fmt.Println(err)
190 os.Exit(1)
191 }
192 italicFace, err := opentype.Parse(italicFaceBytes)
193 if err != nil {
194 fmt.Println("Error: Could not parse italic font")
195 fmt.Println(err)
196 os.Exit(1)
197 }
198
199 boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
200 if err != nil {
201 fmt.Println("Error: Could not read bold italic font")
202 fmt.Println(err)
203 os.Exit(1)
204 }
205 boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
206 if err != nil {
207 fmt.Println("Error: Could not parse bold italic font")
208 fmt.Println(err)
209 os.Exit(1)
210 }
211
212 return []text.FontFace{
213 {
214 Font: text.Font{
215 Typeface: "Primary font",
216 Variant: "",
217 Style: text.Regular,
218 Weight: text.Normal,
219 },
220 Face: regularFace,
221 },
222 {
223 Font: text.Font{
224 Typeface: "Primary font",
225 Variant: "",
226 Style: text.Regular,
227 Weight: text.Bold,
228 },
229 Face: boldFace,
230 },
231 {
232 Font: text.Font{
233 Typeface: "Primary font",
234 Variant: "",
235 Style: text.Italic,
236 Weight: text.Normal,
237 },
238 Face: italicFace,
239 },
240 {
241 Font: text.Font{
242 Typeface: "Primary font",
243 Variant: "",
244 Style: text.Italic,
245 Weight: text.Bold,
246 },
247 Face: boldItalicFace,
248 },
249 }
250}
251
252// Print help message
253func help() {
254 fmt.Println("\nUsage: p2c [options]")
255 fmt.Println("\nOptions:")
256 flag.PrintDefaults()
257 fmt.Print(`
258example: p2c -i input.md -o output.png
259
260p2c is meant for use with Hugo.
261
262It looks at...
263- The Markdown file's frontmatter fields for
264 - title
265 - subtitle
266 - date
267- The site's config.{toml/yaml/yml} fields for
268 - title (site title)
269- The git history to determine the date the post was last edited.
270
271`)
272}
273
274// Validate flags
275func validateFlags() {
276 if *flagInput == "" {
277 fmt.Println("Error: No input file specified")
278 os.Exit(1)
279 }
280 if *flagOutput == "" {
281 fmt.Println("Error: No output file specified")
282 os.Exit(1)
283 }
284}
285
286// Get the post's title, subtitle, and date
287func getPostInfo(input string) (string, string, string, string) {
288 if _, err := os.Stat(input); os.IsNotExist(err) {
289 fmt.Println("Error: Input file does not exist")
290 fmt.Println(err)
291 os.Exit(1)
292 }
293
294 fileContents, err := os.ReadFile(input)
295 if err != nil {
296 fmt.Println("Error: Could not read input file")
297 fmt.Println(err)
298 os.Exit(1)
299 }
300
301 var fm struct {
302 Title string `yaml:"title"`
303 Subtitle string `yaml:"subtitle"`
304 Date string `yaml:"date"`
305 }
306
307 content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
308 if err != nil {
309 fmt.Println("Error: Could not parse frontmatter")
310 fmt.Println(err)
311 os.Exit(1)
312 }
313
314 fm.Date = strings.Split(fm.Date, "T")[0]
315
316 return fm.Title, fm.Subtitle, fm.Date, string(content)
317}
318
319// Get the read time of the post
320func getReadTime(content string) int {
321 wordCount := len(strings.Fields(content))
322 return wordCount / 200
323}
324
325// Get the site's title
326func getSiteTitle() string {
327 validConf := ""
328 confs := []string{"config.toml", "config.yaml", "config.yml"}
329 for _, conf := range confs {
330 if _, err := os.Stat(conf); os.IsNotExist(err) {
331 continue
332 }
333 validConf = conf
334 }
335 if validConf == "" {
336 fmt.Println("Error: No valid config file found")
337 os.Exit(1)
338 }
339
340 var t struct {
341 Title string `yaml:"title"`
342 }
343
344 f, err := os.Open(validConf)
345 if err != nil {
346 fmt.Println("Error: Could not open config file")
347 fmt.Println(err)
348 os.Exit(1)
349 }
350 defer f.Close()
351
352 decoder := yaml.NewDecoder(f)
353 err = decoder.Decode(&t)
354 if err != nil {
355 fmt.Println("Error: Could not parse config file")
356 fmt.Println(err)
357 os.Exit(1)
358 }
359
360 return t.Title
361}
362
363// Get the date the post was last edited
364func getGitDate(input string) string {
365 if _, err := os.Stat(input); os.IsNotExist(err) {
366 fmt.Println("Error: Input file does not exist")
367 os.Exit(1)
368 }
369
370 repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
371 if err != nil {
372 fmt.Println("Error: Could not open git repository")
373 fmt.Println(err)
374 os.Exit(1)
375 }
376
377 commitIter, err := repo.Log(&git.LogOptions{FileName: &input})
378 if err != nil {
379 fmt.Println("Error: Could not get git history")
380 fmt.Println(err)
381 os.Exit(1)
382 }
383
384 commit, err := commitIter.Next()
385 if err != nil {
386 fmt.Println("Error: Could not get git history")
387 fmt.Println(err)
388 os.Exit(1)
389 }
390
391 return commit.Committer.When.Format("2006-01-02")
392}