package main
import (
"bytes"
"fmt"
"html/template"
"log"
"math/rand"
"net/http"
"os"
"path"
"path/filepath"
"reflect"
"sort"
"strings"
"time"
yaml "gopkg.in/yaml.v3"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark-meta"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/DavidBelicza/TextRank/v2"
"github.com/alexflint/go-arg"
"github.com/gosimple/slug"
"github.com/mangoumbrella/goldmark-figure"
"github.com/microcosm-cc/bluemonday"
"github.com/tdewolff/minify/v2"
mcss "github.com/tdewolff/minify/v2/css"
mhtml "github.com/tdewolff/minify/v2/html"
mjs "github.com/tdewolff/minify/v2/js"
highlighting "github.com/yuin/goldmark-highlighting/v2"
cp "github.com/otiai10/copy"
_ "embed"
)
type ConfigExtrasItem struct {
Type string `yaml:"type"`
Template string `yaml:"template"`
URL string `yaml:"url"`
}
type Config struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
BaseURL string `yaml:"baseurl"`
Language string `yaml:"language"`
Highlighting string `yaml:"highlighting"`
Minify bool `yaml:"minify"`
Extras []ConfigExtrasItem `yaml:"extras"`
}
type Page struct {
Filepath string
Raw string
HTML template.HTML
Text string
Summary string
Meta map[string]interface{}
Title string
Type string
RelPermalink string
Created time.Time
Draft bool
}
//go:embed "files/config.yaml"
var EmbedConfig string
//go:embed "files/first.md"
var EmbedPost string
//go:embed "files/base.html"
var EmbedTemplateBase string
//go:embed "files/index.html"
var EmbedTemplateIndex string
//go:embed "files/post.html"
var EmbedTemplatePost string
//go:embed "files/index.xml"
var EmbedTemplateFeed string
// firstN returns the first n items of a slice.
func firstN(n int, items interface{}) interface{} {
v := reflect.ValueOf(items)
if v.Kind() != reflect.Slice {
panic("firstN: not a slice")
}
if v.Len() < n {
return items
}
return v.Slice(0, n).Interface()
}
// lastN returns the last n items of any slice.
func lastN(n int, items interface{}) interface{} {
v := reflect.ValueOf(items)
if v.Kind() != reflect.Slice {
panic("lastN: not a slice")
}
l := v.Len()
if l < n {
return items
}
return v.Slice(l-n, l).Interface()
}
// randomN returns n random items of any slice.
func randomN(n int, items interface{}) interface{} {
v := reflect.ValueOf(items)
if v.Kind() != reflect.Slice {
panic("randomN: not a slice")
}
l := v.Len()
if l < n {
return items
}
rand.Seed(time.Now().UnixNano())
indices := rand.Perm(l)[:n]
result := reflect.MakeSlice(v.Type(), n, n)
for i, idx := range indices {
result.Index(i).Set(v.Index(idx))
}
return result.Interface()
}
// Function to clean HTML tags using bluemonday.
func cleanHTMLTags(htmlString string) string {
p := bluemonday.StrictPolicy()
cleanString := p.Sanitize(htmlString)
return cleanString
}
func includeTemplateList(projectRoot string) []string {
var templateFiles []string
includesTemplatePathname := path.Join(projectRoot, "templates/includes")
err := filepath.Walk(includesTemplatePathname, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if filepath.Ext(path) == ".html" {
templateFiles = append(templateFiles, path)
}
return nil
})
if err != nil {
panic(err)
}
return templateFiles
}
func simpleServer(projectRoot string) {
fs := http.FileServer(http.Dir(path.Join(projectRoot, "public")))
http.Handle("/", fs)
log.Println("Server started on http://localhost:6969")
log.Fatal(http.ListenAndServe(":6969", nil))
}
func initializeProject(projectRoot string) {
log.Println("Initializing new project")
if err := os.Mkdir(path.Join(projectRoot, "templates"), 0755); err != nil && !os.IsExist(err) {
log.Println("Error creating directory:", err)
return
}
if err := os.Mkdir(path.Join(projectRoot, "templates", "includes"), 0755); err != nil && !os.IsExist(err) {
log.Println("Error creating directory:", err)
return
}
if err := os.Mkdir(path.Join(projectRoot, "content"), 0755); err != nil && !os.IsExist(err) {
log.Println("Error creating directory:", err)
return
}
if err := os.Mkdir(path.Join(projectRoot, "static"), 0755); err != nil && !os.IsExist(err) {
log.Println("Error creating directory:", err)
return
}
os.WriteFile(path.Join(projectRoot, "templates", ".gitkeep"), []byte{}, 0755)
os.WriteFile(path.Join(projectRoot, "content", ".gitkeep"), []byte{}, 0755)
os.WriteFile(path.Join(projectRoot, "static", ".gitkeep"), []byte{}, 0755)
os.WriteFile(path.Join(projectRoot, "config.yaml"), []byte(EmbedConfig), 0755)
os.WriteFile(path.Join(projectRoot, "content", "first.md"), []byte(EmbedPost), 0755)
os.WriteFile(path.Join(projectRoot, "templates", "base.html"), []byte(EmbedTemplateBase), 0755)
os.WriteFile(path.Join(projectRoot, "templates", "index.html"), []byte(EmbedTemplateIndex), 0755)
os.WriteFile(path.Join(projectRoot, "templates", "post.html"), []byte(EmbedTemplatePost), 0755)
os.WriteFile(path.Join(projectRoot, "templates", "index.xml"), []byte(EmbedTemplateFeed), 0755)
}
func buildProject(projectRoot string) {
// Read config file.
configFilepath := path.Join(projectRoot, "config.yaml")
configFile, err := os.ReadFile(configFilepath)
if err != nil {
panic(err)
}
config := Config{}
err = yaml.Unmarshal(configFile, &config)
if err != nil {
panic(err)
}
// Gets the list of all markdown files.
var files []string
err = filepath.Walk(path.Join(projectRoot, "content/"), func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.ToLower(filepath.Ext(path)) == ".md" {
files = append(files, path)
}
return nil
})
if err != nil {
fmt.Printf("No markdown files found with error `%s`.\n", err)
os.Exit(1)
}
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
extension.TaskList,
extension.Footnote,
meta.Meta,
figure.Figure,
highlighting.NewHighlighting(
highlighting.WithStyle(config.Highlighting),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithBlockParsers(),
parser.WithInlineParsers(),
parser.WithParagraphTransformers(),
parser.WithAttribute(),
),
goldmark.WithRendererOptions(
html.WithXHTML(),
html.WithUnsafe(),
),
)
// Parse all markdown files in content folder.
pages := []Page{}
for _, file := range files {
source, err := os.ReadFile(file)
if err != nil {
panic(err)
}
var buf bytes.Buffer
ctx := parser.NewContext()
if err := md.Convert(source, &buf, parser.WithContext(ctx)); err != nil {
panic(err)
}
// Rank and summarize.
tr := textrank.NewTextRank()
rule := textrank.NewDefaultRule()
language := textrank.NewDefaultLanguage()
algorithmDef := textrank.NewDefaultAlgorithm()
tr.Populate(cleanHTMLTags(buf.String()), language, rule)
tr.Ranking(algorithmDef)
sentences := textrank.FindSentencesByRelationWeight(tr, 50)
sentences = textrank.FindSentencesFrom(tr, 0, 1)
summary := ""
for _, s := range sentences {
summary = strings.ReplaceAll(s.Value, "\n", "")
}
metaData := meta.Get(ctx)
t, _ := time.Parse("2006-01-02T15:04:05-07:00", metaData["date"].(string))
pages = append(pages, Page{
Filepath: file,
Meta: metaData,
Raw: buf.String(),
HTML: template.HTML(buf.String()),
Text: cleanHTMLTags(buf.String()),
Summary: summary,
Title: metaData["title"].(string),
Type: metaData["type"].(string),
RelPermalink: metaData["url"].(string),
Created: t,
Draft: metaData["draft"].(bool),
})
}
// Sorting pages in descending created order.
sort.Slice(pages, func(i, j int) bool {
return pages[i].Created.After(pages[j].Created)
})
// Creates public folder if it doesn't exist yet.
if err := os.Mkdir(path.Join(projectRoot, "public"), 0755); err != nil && !os.IsExist(err) {
log.Println("Error creating directory:", err)
return
}
// Generate HTML files for all pages.
for _, page := range pages {
outFilepath := path.Join(projectRoot, "public", page.Meta["url"].(string))
if !page.Draft {
pageTemplateFilename := fmt.Sprintf("%s.html", page.Meta["type"].(string))
templatePathname := path.Join(projectRoot, "templates", pageTemplateFilename)
baseTemplatePathname := path.Join(projectRoot, "templates/base.html")
templates := includeTemplateList(projectRoot)
templates = append([]string{templatePathname}, templates...)
templates = append([]string{baseTemplatePathname}, templates...)
t, err := template.New("base.html").Funcs(template.FuncMap{
"first": firstN,
"last": lastN,
"random": randomN,
}).ParseFiles(templates...)
if err != nil {
panic(err)
}
type Payload struct {
Config Config
Page Page
Pages []Page
}
var buf bytes.Buffer
err = t.Execute(&buf, Payload{
Config: config,
Page: page,
Pages: pages,
})
if err != nil {
panic(err)
}
outHTML := buf.String()
if config.Minify {
m := minify.New()
m.AddFunc("text/html", mhtml.Minify)
m.AddFunc("text/css", mcss.Minify)
m.AddFunc("application/js", mjs.Minify)
outHTML, err = m.String("text/html", outHTML)
if err != nil {
panic(err)
}
}
os.WriteFile(outFilepath, []byte(outHTML), 0755)
log.Println("Wrote", outFilepath)
} else {
log.Println("Skipped", outFilepath)
}
}
// Generates index page.
{
log.Println("Writing index...")
templatePathname := path.Join(projectRoot, "templates/index.html")
baseTemplatePathname := path.Join(projectRoot, "templates/base.html")
templates := includeTemplateList(projectRoot)
templates = append([]string{templatePathname}, templates...)
templates = append([]string{baseTemplatePathname}, templates...)
t, err := template.New("base.html").Funcs(template.FuncMap{
"first": firstN,
"last": lastN,
"random": randomN,
}).ParseFiles(templates...)
if err != nil {
panic(err)
}
type Payload struct {
Config Config
Pages []Page
}
var buf bytes.Buffer
err = t.Execute(&buf, Payload{
Config: config,
Pages: pages,
})
if err != nil {
panic(err)
}
outHTML := buf.String()
if config.Minify {
m := minify.New()
m.AddFunc("text/html", mhtml.Minify)
m.AddFunc("text/css", mcss.Minify)
m.AddFunc("application/js", mjs.Minify)
outHTML, err = m.String("text/html", outHTML)
if err != nil {
panic(err)
}
}
outFilepath := path.Join(projectRoot, "public", "index.html")
os.WriteFile(outFilepath, []byte(outHTML), 0755)
}
// Copy static files.
{
log.Println("Copying static files...")
err := cp.Copy(path.Join(projectRoot, "static"), path.Join(projectRoot, "public"))
if err != nil {
panic(err)
}
}
// Generates extras.
{
for _, extra := range config.Extras {
log.Printf("Writing extras %s\n", extra.URL)
templatePathname := path.Join(projectRoot, "templates", extra.Template)
t, err := template.ParseFiles(templatePathname)
if err != nil {
panic(err)
}
type Payload struct {
Config Config
Pages []Page
}
var buf bytes.Buffer
err = t.Execute(&buf, Payload{
Config: config,
Pages: pages,
})
if err != nil {
panic(err)
}
outFilepath := path.Join(projectRoot, "public", extra.URL)
os.WriteFile(outFilepath, []byte(buf.String()), 0755)
}
}
// Guess we are done!
log.Println("Done & done...")
}
func newPage(projectRoot string, title string) {
slug := slug.Make(title)
t := time.Now()
filename := fmt.Sprintf("%s-%s.md", t.Format("2006-01-02"), slug)
var lines = []string{
"---",
fmt.Sprintf("title: \"%s\"", title),
fmt.Sprintf("url: %s.html", slug),
fmt.Sprintf("date: %s", t.Format("2006-01-02T15:04:05-07:00")),
"type: post",
"draft: true",
"---",
"",
"Content...",
}
f, err := os.Create(path.Join(projectRoot, "content", filename))
if err != nil {
log.Fatal(err)
}
defer f.Close()
for _, line := range lines {
_, err := f.WriteString(line + "\n")
if err != nil {
log.Fatal(err)
}
}
log.Printf("Page `%s` created\n", filename)
}
func main() {
projectRoot := os.Getenv("PROJECT_ROOT")
if projectRoot == "" {
projectRoot = "./"
}
var args struct {
Init bool `arg:"-i,--init" help:"initialize new project"`
Build bool `arg:"-b,--build" help:"build the website"`
Server bool `arg:"-s,--server" help:"simple embedded HTTP server"`
New bool `arg:"-n,--new" help:"create new page"`
Title string `arg:"positional"`
}
arg.MustParse(&args)
if !args.Init && !args.Build && !args.Server && !args.New {
fmt.Println("No arguments provided. Try using `jbmafp --help`")
os.Exit(0)
}
if args.Init {
initializeProject(projectRoot)
}
if args.Build {
buildProject(projectRoot)
}
if args.Server {
simpleServer(projectRoot)
}
if args.New {
if len(args.Title) == 0 {
fmt.Println("You must provide a title for the new page")
os.Exit(1)
}
newPage(projectRoot, args.Title)
}
}