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						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}