main.go

  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}