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 rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint("Reading Time: ", postReadTime, " minutes"))
89 rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
90 rTime.Alignment = text.Middle
91 return rTime.Layout(gtx)
92 }),
93
94 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
95 pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint("Published: ", postDate))
96 pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
97 pDate.Alignment = text.Middle
98 return pDate.Layout(gtx)
99 }),
100
101 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
102 eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint("Edited: ", dateEdited))
103 eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
104 eDate.Alignment = text.Middle
105 return eDate.Layout(gtx)
106 }),
107
108 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
109 size := image.Pt(700, 4)
110 defer clip.Rect{Max: size}.Push(&ops).Pop()
111 paint.ColorOp{Color: color.NRGBA{A: 0xFF}}.Add(&ops)
112 paint.PaintOp{}.Add(&ops)
113 return layout.Dimensions{Size: size}
114 }),
115
116 layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
117 sTitle := material.Body1(th, siteTitle)
118 sTitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
119 sTitle.Alignment = text.Middle
120 return sTitle.Layout(gtx)
121 }),
122 )
123
124 e.Frame(gtx.Ops)
125 }
126 }
127 os.Exit(0)
128 }()
129 app.Main()
130}
131
132func fontCollection() []text.FontFace {
133 regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
134 if err != nil {
135 fmt.Println("Error: Could not read regular font")
136 fmt.Println(err)
137 os.Exit(1)
138 }
139 regularFace, err := opentype.Parse(regularFaceBytes)
140 if err != nil {
141 fmt.Println("Error: Could not parse regular font")
142 fmt.Println(err)
143 os.Exit(1)
144 }
145
146 boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
147 if err != nil {
148 fmt.Println("Error: Could not read bold font")
149 fmt.Println(err)
150 os.Exit(1)
151 }
152 boldFace, err := opentype.Parse(boldFaceBytes)
153 if err != nil {
154 fmt.Println("Error: Could not parse bold font")
155 fmt.Println(err)
156 os.Exit(1)
157 }
158
159 italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
160 if err != nil {
161 fmt.Println("Error: Could not read italic font")
162 fmt.Println(err)
163 os.Exit(1)
164 }
165 italicFace, err := opentype.Parse(italicFaceBytes)
166 if err != nil {
167 fmt.Println("Error: Could not parse italic font")
168 fmt.Println(err)
169 os.Exit(1)
170 }
171
172 boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
173 if err != nil {
174 fmt.Println("Error: Could not read bold italic font")
175 fmt.Println(err)
176 os.Exit(1)
177 }
178 boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
179 if err != nil {
180 fmt.Println("Error: Could not parse bold italic font")
181 fmt.Println(err)
182 os.Exit(1)
183 }
184
185 return []text.FontFace{
186 {
187 Font: text.Font{
188 Typeface: "Primary font",
189 Variant: "",
190 Style: text.Regular,
191 Weight: text.Normal,
192 },
193 Face: regularFace,
194 },
195 {
196 Font: text.Font{
197 Typeface: "Primary font",
198 Variant: "",
199 Style: text.Regular,
200 Weight: text.Bold,
201 },
202 Face: boldFace,
203 },
204 {
205 Font: text.Font{
206 Typeface: "Primary font",
207 Variant: "",
208 Style: text.Italic,
209 Weight: text.Normal,
210 },
211 Face: italicFace,
212 },
213 {
214 Font: text.Font{
215 Typeface: "Primary font",
216 Variant: "",
217 Style: text.Italic,
218 Weight: text.Bold,
219 },
220 Face: boldItalicFace,
221 },
222 }
223}
224
225// Print help message
226func help() {
227 fmt.Println("\nUsage: p2c [options]")
228 fmt.Println("\nOptions:")
229 flag.PrintDefaults()
230 fmt.Print(`
231example: p2c -i input.md -o output.png
232
233p2c is meant for use with Hugo.
234
235It looks at...
236- The Markdown file's frontmatter fields for
237 - title
238 - subtitle
239 - date
240- The site's config.{toml/yaml/yml} fields for
241 - title (site title)
242- The git history to determine the date the post was last edited.
243
244`)
245}
246
247// Validate flags
248func validateFlags() {
249 if *flagInput == "" {
250 fmt.Println("Error: No input file specified")
251 os.Exit(1)
252 }
253 if *flagOutput == "" {
254 fmt.Println("Error: No output file specified")
255 os.Exit(1)
256 }
257}
258
259// Get the post's title, subtitle, and date
260func getPostInfo(input string) (string, string, string, string) {
261 if _, err := os.Stat(input); os.IsNotExist(err) {
262 fmt.Println("Error: Input file does not exist")
263 fmt.Println(err)
264 os.Exit(1)
265 }
266
267 fileContents, err := os.ReadFile(input)
268 if err != nil {
269 fmt.Println("Error: Could not read input file")
270 fmt.Println(err)
271 os.Exit(1)
272 }
273
274 var fm struct {
275 Title string `yaml:"title"`
276 Subtitle string `yaml:"subtitle"`
277 Date string `yaml:"date"`
278 }
279
280 content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
281 if err != nil {
282 fmt.Println("Error: Could not parse frontmatter")
283 fmt.Println(err)
284 os.Exit(1)
285 }
286
287 fm.Date = strings.Split(fm.Date, "T")[0]
288
289 return fm.Title, fm.Subtitle, fm.Date, string(content)
290}
291
292// Get the read time of the post
293func getReadTime(content string) int {
294 wordCount := len(strings.Fields(content))
295 return wordCount / 200
296}
297
298// Get the site's title
299func getSiteTitle() string {
300 validConf := ""
301 confs := []string{"config.toml", "config.yaml", "config.yml"}
302 for _, conf := range confs {
303 if _, err := os.Stat(conf); os.IsNotExist(err) {
304 continue
305 }
306 validConf = conf
307 }
308 if validConf == "" {
309 fmt.Println("Error: No valid config file found")
310 os.Exit(1)
311 }
312
313 var t struct {
314 Title string `yaml:"title"`
315 }
316
317 f, err := os.Open(validConf)
318 if err != nil {
319 fmt.Println("Error: Could not open config file")
320 fmt.Println(err)
321 os.Exit(1)
322 }
323 defer f.Close()
324
325 decoder := yaml.NewDecoder(f)
326 err = decoder.Decode(&t)
327 if err != nil {
328 fmt.Println("Error: Could not parse config file")
329 fmt.Println(err)
330 os.Exit(1)
331 }
332
333 return t.Title
334}
335
336// Get the date the post was last edited
337func getGitDate(input string) string {
338 if _, err := os.Stat(input); os.IsNotExist(err) {
339 fmt.Println("Error: Input file does not exist")
340 os.Exit(1)
341 }
342
343 repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
344 if err != nil {
345 fmt.Println("Error: Could not open git repository")
346 fmt.Println(err)
347 os.Exit(1)
348 }
349
350 commitIter, err := repo.Log(&git.LogOptions{FileName: &input})
351 if err != nil {
352 fmt.Println("Error: Could not get git history")
353 fmt.Println(err)
354 os.Exit(1)
355 }
356
357 commit, err := commitIter.Next()
358 if err != nil {
359 fmt.Println("Error: Could not get git history")
360 fmt.Println(err)
361 os.Exit(1)
362 }
363
364 return commit.Committer.When.Format("2006-01-02")
365}