1package main
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "log"
8 "net/http"
9 "os"
10 "path"
11 "path/filepath"
12 "sort"
13 "strings"
14 "time"
15
16 yaml "gopkg.in/yaml.v3"
17
18 "github.com/yuin/goldmark"
19 "github.com/yuin/goldmark-meta"
20 "github.com/yuin/goldmark/extension"
21 "github.com/yuin/goldmark/parser"
22 "github.com/yuin/goldmark/renderer/html"
23
24 "github.com/DavidBelicza/TextRank/v2"
25 "github.com/alexflint/go-arg"
26 "github.com/gosimple/slug"
27 "github.com/mangoumbrella/goldmark-figure"
28 "github.com/microcosm-cc/bluemonday"
29
30 "github.com/tdewolff/minify/v2"
31 mcss "github.com/tdewolff/minify/v2/css"
32 mhtml "github.com/tdewolff/minify/v2/html"
33 mjs "github.com/tdewolff/minify/v2/js"
34
35 highlighting "github.com/yuin/goldmark-highlighting/v2"
36
37 cp "github.com/otiai10/copy"
38
39 _ "embed"
40)
41
42type ConfigExtrasItem struct {
43 Type string `yaml:"type"`
44 Template string `yaml:"template"`
45 URL string `yaml:"url"`
46}
47
48type Config struct {
49 Title string `yaml:"title"`
50 Description string `yaml:"description"`
51 BaseURL string `yaml:"baseurl"`
52 Language string `yaml:"language"`
53 Highlighting string `yaml:"highlighting"`
54 Minify bool `yaml:"minify"`
55 Extras []ConfigExtrasItem `yaml:"extras"`
56}
57
58type Page struct {
59 Filepath string
60 Raw string
61 HTML template.HTML
62 Text string
63 Summary string
64 Meta map[string]interface{}
65 Title string
66 Type string
67 RelPermalink string
68 Created time.Time
69 Draft bool
70}
71
72//go:embed "files/config.yaml"
73var EmbedConfig string
74
75//go:embed "files/first.md"
76var EmbedPost string
77
78//go:embed "files/base.html"
79var EmbedTemplateBase string
80
81//go:embed "files/index.html"
82var EmbedTemplateIndex string
83
84//go:embed "files/post.html"
85var EmbedTemplatePost string
86
87//go:embed "files/index.xml"
88var EmbedTemplateFeed string
89
90// Function to clean HTML tags using bluemonday.
91func cleanHTMLTags(htmlString string) string {
92 p := bluemonday.StrictPolicy()
93 cleanString := p.Sanitize(htmlString)
94 return cleanString
95}
96
97func includeTemplateList(projectRoot string) []string {
98 var templateFiles []string
99 includesTemplatePathname := path.Join(projectRoot, "templates/includes")
100 err := filepath.Walk(includesTemplatePathname, func(path string, info os.FileInfo, err error) error {
101 if err != nil {
102 return err
103 }
104 if filepath.Ext(path) == ".html" {
105 templateFiles = append(templateFiles, path)
106 }
107
108 return nil
109 })
110
111 if err != nil {
112 panic(err)
113 }
114
115 return templateFiles
116}
117
118func simpleServer(projectRoot string) {
119 fs := http.FileServer(http.Dir(path.Join(projectRoot, "public")))
120 http.Handle("/", fs)
121 log.Println("Server started on http://localhost:6969")
122 log.Fatal(http.ListenAndServe(":6969", nil))
123}
124
125func initializeProject(projectRoot string) {
126 log.Println("Initializing new project")
127
128 if err := os.Mkdir(path.Join(projectRoot, "templates"), 0755); err != nil && !os.IsExist(err) {
129 log.Println("Error creating directory:", err)
130 return
131 }
132
133 if err := os.Mkdir(path.Join(projectRoot, "templates", "includes"), 0755); err != nil && !os.IsExist(err) {
134 log.Println("Error creating directory:", err)
135 return
136 }
137
138 if err := os.Mkdir(path.Join(projectRoot, "content"), 0755); err != nil && !os.IsExist(err) {
139 log.Println("Error creating directory:", err)
140 return
141 }
142
143 if err := os.Mkdir(path.Join(projectRoot, "static"), 0755); err != nil && !os.IsExist(err) {
144 log.Println("Error creating directory:", err)
145 return
146 }
147
148 os.WriteFile(path.Join(projectRoot, "templates", ".gitkeep"), []byte{}, 0755)
149 os.WriteFile(path.Join(projectRoot, "content", ".gitkeep"), []byte{}, 0755)
150 os.WriteFile(path.Join(projectRoot, "static", ".gitkeep"), []byte{}, 0755)
151
152 os.WriteFile(path.Join(projectRoot, "config.yaml"), []byte(EmbedConfig), 0755)
153 os.WriteFile(path.Join(projectRoot, "content", "first.md"), []byte(EmbedPost), 0755)
154 os.WriteFile(path.Join(projectRoot, "templates", "base.html"), []byte(EmbedTemplateBase), 0755)
155 os.WriteFile(path.Join(projectRoot, "templates", "index.html"), []byte(EmbedTemplateIndex), 0755)
156 os.WriteFile(path.Join(projectRoot, "templates", "post.html"), []byte(EmbedTemplatePost), 0755)
157 os.WriteFile(path.Join(projectRoot, "templates", "index.xml"), []byte(EmbedTemplateFeed), 0755)
158}
159
160func buildProject(projectRoot string) {
161 // Read config file.
162 configFilepath := path.Join(projectRoot, "config.yaml")
163 configFile, err := os.ReadFile(configFilepath)
164 if err != nil {
165 panic(err)
166 }
167 config := Config{}
168 err = yaml.Unmarshal(configFile, &config)
169 if err != nil {
170 panic(err)
171 }
172
173 // Gets the list of all markdown files.
174 var files []string
175 err = filepath.Walk(path.Join(projectRoot, "content/"), func(path string, info os.FileInfo, err error) error {
176 if err != nil {
177 return err
178 }
179
180 if !info.IsDir() && strings.ToLower(filepath.Ext(path)) == ".md" {
181 files = append(files, path)
182 }
183
184 return nil
185 })
186
187 if err != nil {
188 fmt.Printf("No markdown files found with error `%s`.\n", err)
189 os.Exit(1)
190 }
191
192 md := goldmark.New(
193 goldmark.WithExtensions(
194 extension.GFM,
195 extension.Table,
196 extension.TaskList,
197 extension.Footnote,
198 meta.Meta,
199 figure.Figure,
200 highlighting.NewHighlighting(
201 highlighting.WithStyle(config.Highlighting),
202 ),
203 ),
204 goldmark.WithParserOptions(
205 parser.WithAutoHeadingID(),
206 parser.WithBlockParsers(),
207 parser.WithInlineParsers(),
208 parser.WithParagraphTransformers(),
209 parser.WithAttribute(),
210 ),
211 goldmark.WithRendererOptions(
212 html.WithXHTML(),
213 html.WithUnsafe(),
214 ),
215 )
216
217 // Parse all markdown files in content folder.
218 pages := []Page{}
219 for _, file := range files {
220 source, err := os.ReadFile(file)
221 if err != nil {
222 panic(err)
223 }
224
225 var buf bytes.Buffer
226 ctx := parser.NewContext()
227 if err := md.Convert(source, &buf, parser.WithContext(ctx)); err != nil {
228 panic(err)
229 }
230
231 // Rank and summarize.
232 tr := textrank.NewTextRank()
233 rule := textrank.NewDefaultRule()
234 language := textrank.NewDefaultLanguage()
235 algorithmDef := textrank.NewDefaultAlgorithm()
236 tr.Populate(cleanHTMLTags(buf.String()), language, rule)
237 tr.Ranking(algorithmDef)
238
239 sentences := textrank.FindSentencesByRelationWeight(tr, 50)
240 sentences = textrank.FindSentencesFrom(tr, 0, 1)
241
242 summary := ""
243 for _, s := range sentences {
244 summary = strings.ReplaceAll(s.Value, "\n", "")
245 }
246
247 metaData := meta.Get(ctx)
248 t, _ := time.Parse("2006-01-02T15:04:05-07:00", metaData["date"].(string))
249 pages = append(pages, Page{
250 Filepath: file,
251 Meta: metaData,
252 Raw: buf.String(),
253 HTML: template.HTML(buf.String()),
254 Text: cleanHTMLTags(buf.String()),
255 Summary: summary,
256 Title: metaData["title"].(string),
257 Type: metaData["type"].(string),
258 RelPermalink: metaData["url"].(string),
259 Created: t,
260 Draft: metaData["draft"].(bool),
261 })
262 }
263
264 // Sorting pages in descending created order.
265 sort.Slice(pages, func(i, j int) bool {
266 return pages[i].Created.After(pages[j].Created)
267 })
268
269 // Creates public folder if it doesn't exist yet.
270 if err := os.Mkdir(path.Join(projectRoot, "public"), 0755); err != nil && !os.IsExist(err) {
271 log.Println("Error creating directory:", err)
272 return
273 }
274
275 filters := template.FuncMap{
276 "first": firstN,
277 "last": lastN,
278 "random": randomN,
279 "filterbytype": filterByType,
280 }
281
282 // Generate HTML files for all pages.
283 for _, page := range pages {
284 outFilepath := path.Join(projectRoot, "public", page.Meta["url"].(string))
285 if !page.Draft {
286 pageTemplateFilename := fmt.Sprintf("%s.html", page.Meta["type"].(string))
287 templatePathname := path.Join(projectRoot, "templates", pageTemplateFilename)
288 baseTemplatePathname := path.Join(projectRoot, "templates/base.html")
289
290 templates := includeTemplateList(projectRoot)
291 templates = append([]string{templatePathname}, templates...)
292 templates = append([]string{baseTemplatePathname}, templates...)
293
294 t, err := template.New("base.html").Funcs(filters).ParseFiles(templates...)
295 if err != nil {
296 panic(err)
297 }
298
299 type Payload struct {
300 Config Config
301 Page Page
302 Pages []Page
303 }
304
305 var buf bytes.Buffer
306 err = t.Execute(&buf, Payload{
307 Config: config,
308 Page: page,
309 Pages: pages,
310 })
311 if err != nil {
312 panic(err)
313 }
314
315 outHTML := buf.String()
316 if config.Minify {
317 m := minify.New()
318 m.AddFunc("text/html", mhtml.Minify)
319 m.AddFunc("text/css", mcss.Minify)
320 m.AddFunc("application/js", mjs.Minify)
321 outHTML, err = m.String("text/html", outHTML)
322 if err != nil {
323 panic(err)
324 }
325 }
326
327 os.WriteFile(outFilepath, []byte(outHTML), 0755)
328 log.Println("Wrote", outFilepath)
329 } else {
330 log.Println("Skipped", outFilepath)
331 }
332 }
333
334 // Generates index page.
335 {
336
337 log.Println("Writing index...")
338 templatePathname := path.Join(projectRoot, "templates/index.html")
339 baseTemplatePathname := path.Join(projectRoot, "templates/base.html")
340
341 templates := includeTemplateList(projectRoot)
342 templates = append([]string{templatePathname}, templates...)
343 templates = append([]string{baseTemplatePathname}, templates...)
344
345 t, err := template.New("base.html").Funcs(filters).ParseFiles(templates...)
346 if err != nil {
347 panic(err)
348 }
349
350 type Payload struct {
351 Config Config
352 Pages []Page
353 }
354
355 var buf bytes.Buffer
356 err = t.Execute(&buf, Payload{
357 Config: config,
358 Pages: pages,
359 })
360 if err != nil {
361 panic(err)
362 }
363
364 outHTML := buf.String()
365 if config.Minify {
366 m := minify.New()
367 m.AddFunc("text/html", mhtml.Minify)
368 m.AddFunc("text/css", mcss.Minify)
369 m.AddFunc("application/js", mjs.Minify)
370 outHTML, err = m.String("text/html", outHTML)
371 if err != nil {
372 panic(err)
373 }
374 }
375
376 outFilepath := path.Join(projectRoot, "public", "index.html")
377 os.WriteFile(outFilepath, []byte(outHTML), 0755)
378 }
379
380 // Copy static files.
381 {
382 log.Println("Copying static files...")
383 err := cp.Copy(path.Join(projectRoot, "static"), path.Join(projectRoot, "public"))
384 if err != nil {
385 panic(err)
386 }
387 }
388
389 // Generates extras.
390 {
391 for _, extra := range config.Extras {
392 log.Printf("Writing extras %s\n", extra.URL)
393 templatePathname := path.Join(projectRoot, "templates", extra.Template)
394 t, err := template.ParseFiles(templatePathname)
395 if err != nil {
396 panic(err)
397 }
398
399 type Payload struct {
400 Config Config
401 Pages []Page
402 }
403
404 var buf bytes.Buffer
405 err = t.Execute(&buf, Payload{
406 Config: config,
407 Pages: pages,
408 })
409 if err != nil {
410 panic(err)
411 }
412
413 outFilepath := path.Join(projectRoot, "public", extra.URL)
414 os.WriteFile(outFilepath, []byte(buf.String()), 0755)
415 }
416 }
417
418 // Guess we are done!
419 log.Println("Done & done...")
420}
421
422func newPage(projectRoot string, title string) {
423 slug := slug.Make(title)
424 t := time.Now()
425 filename := fmt.Sprintf("%s-%s.md", t.Format("2006-01-02"), slug)
426
427 var lines = []string{
428 "---",
429 fmt.Sprintf("title: \"%s\"", title),
430 fmt.Sprintf("url: %s.html", slug),
431 fmt.Sprintf("date: %s", t.Format("2006-01-02T15:04:05-07:00")),
432 "type: post",
433 "draft: true",
434 "---",
435 "",
436 "Content...",
437 }
438
439 f, err := os.Create(path.Join(projectRoot, "content", filename))
440 if err != nil {
441 log.Fatal(err)
442 }
443 defer f.Close()
444
445 for _, line := range lines {
446 _, err := f.WriteString(line + "\n")
447 if err != nil {
448 log.Fatal(err)
449 }
450 }
451
452 log.Printf("Page `%s` created\n", filename)
453}
454
455func main() {
456 projectRoot := os.Getenv("PROJECT_ROOT")
457 if projectRoot == "" {
458 projectRoot = "./"
459 }
460
461 var args struct {
462 Init bool `arg:"-i,--init" help:"initialize new project"`
463 Build bool `arg:"-b,--build" help:"build the website"`
464 Server bool `arg:"-s,--server" help:"simple embedded HTTP server"`
465 New bool `arg:"-n,--new" help:"create new page"`
466 Title string `arg:"positional"`
467 }
468
469 arg.MustParse(&args)
470
471 if !args.Init && !args.Build && !args.Server && !args.New {
472 fmt.Println("No arguments provided. Try using `jbmafp --help`")
473 os.Exit(0)
474 }
475
476 if args.Init {
477 initializeProject(projectRoot)
478 }
479
480 if args.Build {
481 buildProject(projectRoot)
482 }
483
484 if args.Server {
485 simpleServer(projectRoot)
486 }
487
488 if args.New {
489 if len(args.Title) == 0 {
490 fmt.Println("You must provide a title for the new page")
491 os.Exit(1)
492 }
493 newPage(projectRoot, args.Title)
494 }
495}