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}