From 30dd2794cfa06319618b665b7116d2908fb78311 Mon Sep 17 00:00:00 2001 From: Niklas Fasching Date: Fri, 26 Jun 2020 16:40:32 +0200 Subject: [PATCH] Introduce blorg: MVP static site generator hugo is nice - but it's huge. I've never built a static site generator before and thought the world could use one more - it's not like there's already enough choice out there! No, but seriously. I love hugo and it has all the bells and whistles and you should definitely use that and not this. I just like reinventing the wheel to learn about stuff - and I like the 80/20 rule. This gives like 60% of what I want already and is tiny fraction of hugo in terms of LOC (hugo without it's bazillion dependencies is like 80k+ - this is like 500 and very likely won't ever grow above let's say 5k). Also org mode is awesome and why not use it as a configuration format as well. Let's see where this goes. YOLO. --- README.org | 26 +-- blorg/config.go | 239 ++++++++++++++++++++ blorg/config_test.go | 14 ++ blorg/page.go | 83 +++++++ blorg/testdata/blorg.org | 100 ++++++++ blorg/testdata/content/about.org | 4 + blorg/testdata/content/post.org | 2 + blorg/testdata/public/about.html | 37 +++ blorg/testdata/public/index.html | 24 ++ blorg/testdata/public/post.html | 34 +++ blorg/testdata/public/tag/post/index.html | 34 +++ blorg/testdata/public/tag/static/index.html | 34 +++ blorg/util.go | 71 ++++++ etc/generate-fixtures | 4 +- etc/generate-gh-pages | 4 +- main.go | 83 +++++-- 16 files changed, 760 insertions(+), 33 deletions(-) create mode 100644 blorg/config.go create mode 100644 blorg/config_test.go create mode 100644 blorg/page.go create mode 100644 blorg/testdata/blorg.org create mode 100644 blorg/testdata/content/about.org create mode 100644 blorg/testdata/content/post.org create mode 100644 blorg/testdata/public/about.html create mode 100644 blorg/testdata/public/index.html create mode 100644 blorg/testdata/public/post.html create mode 100644 blorg/testdata/public/tag/post/index.html create mode 100644 blorg/testdata/public/tag/static/index.html create mode 100644 blorg/util.go diff --git a/README.org b/README.org index 33bad8f..e8adfa6 100644 --- a/README.org +++ b/README.org @@ -1,25 +1,23 @@ * go-org [[https://travis-ci.org/niklasfasching/go-org.svg?branch=master]] -An Org mode parser in go. -Take a look at [[https://niklasfasching.github.io/go-org/][github pages]] for some examples and an online org -> html demo (wasm based). +An Org mode parser in go. And soon a blog generator. +Take a look at [[https://niklasfasching.github.io/go-org/][github pages]] for some examples and to try it out live in your browser. [[https://raw.githubusercontent.com/niklasfasching/go-org/master/etc/example.png]] -Please note that the goal for the html export is to produce sensible html output, not to exactly reproduce output the output of =org-html-export=. +Please note +- the goal for the html export is to produce sensible html output, not to exactly reproduce the output of =org-html-export=. +- the goal for the parser is to support a reasonable subset of Org mode. Org mode is *huge* and I like to follow the 80/20 rule. +* usage +** command line +#+begin_src +go-org +#+end_src +** as a library +see [[https://github.com/niklasfasching/go-org/blob/master/main.go][main.go]] and hugo [[https://github.com/gohugoio/hugo/blob/master/markup/org/convert.go][org/convert.go]] * development 1. =make setup install= 2. change things 3. =make preview= (regenerates fixtures & shows output in a browser) in general, have a look at the Makefile - it's short enough. -* not yet implemented -** deadlines and scheduling -see https://orgmode.org/manual/Deadlines-and-scheduling.html -** more types of links -see https://orgmode.org/manual/External-links.html & https://orgmode.org/manual/Internal-links.html -- radio target <<>> -- link target: <> -- link: [[go-org]] -- link to headline -- links with image as description -- MyTarget <- this will automatically become a link - not sure i want this... * resources - test files - [[https://raw.githubusercontent.com/kaushalmodi/ox-hugo/master/test/site/content-org/all-posts.org][ox-hugo all-posts.org]] diff --git a/blorg/config.go b/blorg/config.go new file mode 100644 index 0000000..65c3e59 --- /dev/null +++ b/blorg/config.go @@ -0,0 +1,239 @@ +// blorg is a very minimal and broken static site generator. Don't use this. I initially wrote go-org to use Org mode in hugo +// and non crazy people should keep using hugo. I just like the idea of not having dependencies / following 80/20 rule. And blorg gives me what I need +// for a blog in a fraction of the LOC (hugo is a whooping 80k+ excluding dependencies - this will very likely stay <5k). +package blorg + +import ( + "fmt" + "html/template" + "log" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/niklasfasching/go-org/org" +) + +type Config struct { + ConfigFile string + ContentDir string + PublicDir string + Address string + Template *template.Template + OrgConfig *org.Configuration +} + +var DefaultConfigFile = "blorg.org" + +var DefaultConfig = ` +#+CONTENT: content +#+PUBLIC: public + +* templates +** item +#+name: item +#+begin_src html +{{ . }} +#+end_src + +** list +#+name: list +#+begin_src html +{{ . }} +#+end_src` + +var TemplateFuncs = map[string]interface{}{ + "Slugify": slugify, +} + +func ReadConfig(configFile string) (*Config, error) { + address, publicDir, contentDir, workingDir := ":3000", "public", "content", filepath.Dir(configFile) + f, err := os.Open(configFile) + if err != nil { + return nil, err + } + orgConfig := org.New() + document := orgConfig.Parse(f, configFile) + if document.Error != nil { + return nil, document.Error + } + m := document.BufferSettings + if v, exists := m["AUTO_LINK"]; exists { + orgConfig.AutoLink = v == "true" + delete(m, "AUTO_LINK") + } + if v, exists := m["ADDRESS"]; exists { + address = v + delete(m, "ADDRESS") + } + if v, exists := m["PUBLIC"]; exists { + publicDir = v + delete(m, "PUBLIC") + } + if v, exists := m["CONTENT"]; exists { + contentDir = v + delete(m, "CONTENT") + } + if v, exists := m["MAX_EMPHASIS_NEW_LINES"]; exists { + i, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("MAX_EMPHASIS_NEW_LINES: %v %w", v, err) + } + orgConfig.MaxEmphasisNewLines = i + delete(m, "MAX_EMPHASIS_NEW_LINES") + } + for k, v := range m { + if k == "OPTIONS" { + orgConfig.DefaultSettings[k] = v + " " + orgConfig.DefaultSettings[k] + } else { + orgConfig.DefaultSettings[k] = v + } + } + config := &Config{ + ConfigFile: configFile, + ContentDir: filepath.Join(workingDir, contentDir), + PublicDir: filepath.Join(workingDir, publicDir), + Address: address, + Template: template.New("_").Funcs(TemplateFuncs), + OrgConfig: orgConfig, + } + for name, node := range document.NamedNodes { + if block, ok := node.(org.Block); ok { + if block.Parameters[0] != "html" { + continue + } + if _, err := config.Template.New(name).Parse(org.String(block.Children)); err != nil { + return nil, err + } + } + } + return config, nil +} + +func (c *Config) Serve() error { + http.Handle("/", http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + if strings.HasSuffix(req.URL.Path, ".html") || strings.HasSuffix(req.URL.Path, "/") { + start := time.Now() + if c, err := ReadConfig(c.ConfigFile); err != nil { + log.Fatal(err) + } else { + if err := c.Render(); err != nil { + log.Fatal(err) + } + } + log.Printf("render took %s", time.Since(start)) + } + http.ServeFile(res, req, filepath.Join(c.PublicDir, path.Clean(req.URL.Path))) + })) + log.Printf("listening on: %s", c.Address) + return http.ListenAndServe(c.Address, nil) +} + +func (c *Config) Render() error { + if err := os.RemoveAll(c.PublicDir); err != nil { + return err + } + if err := os.MkdirAll(c.PublicDir, os.ModePerm); err != nil { + return err + } + pages, err := c.RenderContent() + if err != nil { + return err + } + return c.RenderLists(pages) +} + +func (c *Config) RenderContent() ([]*Page, error) { + pages := []*Page{} + err := filepath.Walk(c.ContentDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relPath, err := filepath.Rel(c.ContentDir, path) + if err != nil { + return err + } + publicPath := filepath.Join(c.PublicDir, relPath) + publicInfo, err := os.Stat(publicPath) + if err != nil && !os.IsNotExist(err) { + return err + } + if info.IsDir() { + return os.MkdirAll(publicPath, info.Mode()) + } + if filepath.Ext(path) != ".org" && (os.IsNotExist(err) || info.ModTime().After(publicInfo.ModTime())) { + return os.Link(path, publicPath) + } + p, err := NewPage(c, path, info) + if err != nil { + return err + } + pages = append(pages, p) + p.PermaLink = "/" + relPath[:len(relPath)-len(".org")] + ".html" + return p.Render(publicPath[:len(publicPath)-len(".org")] + ".html") + }) + sort.Slice(pages, func(i, j int) bool { return pages[i].Date.After(pages[j].Date) }) + return pages, err +} + +func (c *Config) RenderLists(pages []*Page) error { + ms := toMap(c.OrgConfig.DefaultSettings, nil) + ms["Pages"] = pages + lists := map[string]map[string][]interface{}{"": map[string][]interface{}{"": nil}} + for _, p := range pages { + mp := toMap(p.BufferSettings, p) + if p.BufferSettings["DATE"] != "" { + lists[""][""] = append(lists[""][""], mp) + } + for k, v := range p.BufferSettings { + if strings.HasSuffix(k, "[]") { + list := strings.ToLower(k[:len(k)-2]) + if lists[list] == nil { + lists[list] = map[string][]interface{}{} + } + for _, sublist := range strings.Fields(v) { + lists[list][sublist] = append(lists[list][strings.ToLower(sublist)], mp) + } + } + } + } + for list, sublists := range lists { + for sublist, pages := range sublists { + ms["Title"] = strings.Title(sublist) + ms["Pages"] = pages + if err := c.RenderList(list, sublist, ms); err != nil { + return err + } + } + } + return nil +} + +func (c *Config) RenderList(list, sublist string, m map[string]interface{}) error { + t := c.Template.Lookup(list) + if list == "" { + m["Title"] = c.OrgConfig.DefaultSettings["TITLE"] + t = c.Template.Lookup("index") + } + if t == nil { + t = c.Template.Lookup("list") + } + if t == nil { + return fmt.Errorf("cannot render list: neither template %s nor list", list) + } + path := filepath.Join(c.PublicDir, slugify(list), slugify(sublist)) + if err := os.MkdirAll(path, os.ModePerm); err != nil { + return err + } + f, err := os.Create(filepath.Join(path, "index.html")) + if err != nil { + return err + } + defer f.Close() + return t.Execute(f, m) +} diff --git a/blorg/config_test.go b/blorg/config_test.go new file mode 100644 index 0000000..03451eb --- /dev/null +++ b/blorg/config_test.go @@ -0,0 +1,14 @@ +package blorg + +import "testing" + +func TestBlorg(t *testing.T) { + config, err := ReadConfig("testdata/blorg.org") + if err != nil { + t.Errorf("Could not read config: %s", err) + return + } + if err := config.Render(); err != nil { + t.Errorf("Could not render: %s", err) + } +} diff --git a/blorg/page.go b/blorg/page.go new file mode 100644 index 0000000..b3ff45d --- /dev/null +++ b/blorg/page.go @@ -0,0 +1,83 @@ +package blorg + +import ( + "fmt" + "html/template" + "os" + "time" + + "github.com/niklasfasching/go-org/org" +) + +type Page struct { + *Config + Document *org.Document + Info os.FileInfo + PermaLink string + Date time.Time + Content template.HTML + BufferSettings map[string]string +} + +func NewPage(c *Config, path string, info os.FileInfo) (*Page, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + d := c.OrgConfig.Parse(f, path) + content, err := d.Write(getWriter()) + if err != nil { + return nil, err + } + date, err := time.Parse("2006-01-02", d.Get("DATE")) + if err != nil { + date, _ = time.Parse("2006-01-02", "1970-01-01") + } + return &Page{ + Config: c, + Document: d, + Info: info, + Date: date, + Content: template.HTML(content), + BufferSettings: d.BufferSettings, + }, nil +} + +func (p *Page) Render(path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + templateName := "item" + if v, ok := p.BufferSettings["TEMPLATE"]; ok { + templateName = v + } + t := p.Template.Lookup(templateName) + if t == nil { + return fmt.Errorf("cannot render page %s: unknown template %s", p.Info.Name(), templateName) + } + return t.Execute(f, toMap(p.BufferSettings, p)) +} + +func (p *Page) Summary() template.HTML { + for _, n := range p.Document.Nodes { + switch n := n.(type) { + case org.Block: + if n.Name == "SUMMARY" { + w := getWriter() + org.WriteNodes(w, n.Children...) + return template.HTML(w.String()) + } + } + } + for i, n := range p.Document.Nodes { + switch n.(type) { + case org.Headline: + w := getWriter() + org.WriteNodes(w, p.Document.Nodes[:i]...) + return template.HTML(w.String()) + } + } + return "" +} diff --git a/blorg/testdata/blorg.org b/blorg/testdata/blorg.org new file mode 100644 index 0000000..8e2b53a --- /dev/null +++ b/blorg/testdata/blorg.org @@ -0,0 +1,100 @@ +#+AUTHOR: author +#+TITLE: blog +#+BASE_URL: https://www.example.com +#+OPTIONS: toc:nil +#+CONTENT: ./content +#+PUBLIC: ./public + +* templates +** head +#+name: head +#+begin_src html + + + + + {{ .Title }} + +#+end_src +** header +#+name: header +#+begin_src html +
+ + +
+#+end_src +** index +#+name: index +#+begin_src html + + + {{ template "head" . }} + + {{ template "header" . }} +

{{ .Title }}

+ + + +#+end_src +** item +#+name: item +#+begin_src html + + + {{ template "head" . }} + + {{ template "header" . }} +
+

{{ .Title }} +
+ {{ .Subtitle }} +

+
    + {{ range .Tags }} +
  • {{ . }}
  • + {{ end }} +
+ {{ .Content }} +
+ + +#+end_src + +** list +#+name: list +#+begin_src html + + + {{ template "head" . }} + + {{ template "header" . }} +
+

{{ .Title }}

+ +
    +
+ + +#+end_src diff --git a/blorg/testdata/content/about.org b/blorg/testdata/content/about.org new file mode 100644 index 0000000..fc65d68 --- /dev/null +++ b/blorg/testdata/content/about.org @@ -0,0 +1,4 @@ +#+TITLE: About +#+TAG[]: static + +and some content diff --git a/blorg/testdata/content/post.org b/blorg/testdata/content/post.org new file mode 100644 index 0000000..fb0de80 --- /dev/null +++ b/blorg/testdata/content/post.org @@ -0,0 +1,2 @@ +#+TITLE: some post +#+TAG[]: post diff --git a/blorg/testdata/public/about.html b/blorg/testdata/public/about.html new file mode 100644 index 0000000..22918e8 --- /dev/null +++ b/blorg/testdata/public/about.html @@ -0,0 +1,37 @@ + + + + + + + About + + + +
+ + +
+ +
+

About +
+ +

+
    + +
+

+About +

+

+

+and some content +

+ +
+ + diff --git a/blorg/testdata/public/index.html b/blorg/testdata/public/index.html new file mode 100644 index 0000000..d3cb34e --- /dev/null +++ b/blorg/testdata/public/index.html @@ -0,0 +1,24 @@ + + + + + + + blog + + + +
+ + +
+ +

blog

+
    + +
+ + diff --git a/blorg/testdata/public/post.html b/blorg/testdata/public/post.html new file mode 100644 index 0000000..c800cc4 --- /dev/null +++ b/blorg/testdata/public/post.html @@ -0,0 +1,34 @@ + + + + + + + some post + + + +
+ + +
+ +
+

some post +
+ +

+
    + +
+

+some post +

+

+ +
+ + diff --git a/blorg/testdata/public/tag/post/index.html b/blorg/testdata/public/tag/post/index.html new file mode 100644 index 0000000..45fb0d4 --- /dev/null +++ b/blorg/testdata/public/tag/post/index.html @@ -0,0 +1,34 @@ + + + + + + + Post + + + +
+ + +
+ +
+

Post

+ +
    +
+ + diff --git a/blorg/testdata/public/tag/static/index.html b/blorg/testdata/public/tag/static/index.html new file mode 100644 index 0000000..5bab489 --- /dev/null +++ b/blorg/testdata/public/tag/static/index.html @@ -0,0 +1,34 @@ + + + + + + + Static + + + +
+ + +
+ +
+

Static

+ +
    +
+ + diff --git a/blorg/util.go b/blorg/util.go new file mode 100644 index 0000000..9f88ee0 --- /dev/null +++ b/blorg/util.go @@ -0,0 +1,71 @@ +package blorg + +import ( + "reflect" + "regexp" + "strings" + + "github.com/alecthomas/chroma" + "github.com/alecthomas/chroma/formatters/html" + "github.com/alecthomas/chroma/lexers" + "github.com/alecthomas/chroma/styles" + "github.com/niklasfasching/go-org/org" +) + +var snakeCaseRegexp = regexp.MustCompile(`(^[A-Za-z])|_([A-Za-z])`) +var whitespaceRegexp = regexp.MustCompile(`\s+`) +var nonWordCharRegexp = regexp.MustCompile(`[^\w-]`) + +func toMap(bufferSettings map[string]string, x interface{}) map[string]interface{} { + m := map[string]interface{}{} + for k, v := range bufferSettings { + k = toCamelCase(k) + if strings.HasSuffix(k, "[]") { + m[k[:len(k)-2]] = strings.Fields(v) + } else { + m[k] = v + } + } + if x == nil { + return m + } + v := reflect.ValueOf(x).Elem() + for i := 0; i < v.NumField(); i++ { + m[v.Type().Field(i).Name] = v.Field(i).Interface() + } + return m +} + +func toCamelCase(s string) string { + return snakeCaseRegexp.ReplaceAllStringFunc(strings.ToLower(s), func(s string) string { + return strings.ToUpper(strings.Replace(s, "_", "", -1)) + }) +} + +func slugify(s string) string { + s = strings.ToLower(s) + s = whitespaceRegexp.ReplaceAllString(s, "-") + s = nonWordCharRegexp.ReplaceAllString(s, "") + return strings.Trim(s, "-") +} + +func getWriter() org.Writer { + w := org.NewHTMLWriter() + w.HighlightCodeBlock = highlightCodeBlock + return w +} + +func highlightCodeBlock(source, lang string, inline bool) string { + var w strings.Builder + l := lexers.Get(lang) + if l == nil { + l = lexers.Fallback + } + l = chroma.Coalesce(l) + it, _ := l.Tokenise(nil, source) + _ = html.New().Format(&w, styles.Get("github"), it) + if inline { + return `
` + "\n" + w.String() + "\n" + `
` + } + return `
` + "\n" + w.String() + "\n" + `
` +} diff --git a/etc/generate-fixtures b/etc/generate-fixtures index 86fc480..7a5be42 100755 --- a/etc/generate-fixtures +++ b/etc/generate-fixtures @@ -2,6 +2,6 @@ for org_file in org/testdata/*.org; do echo $org_file - ./go-org $org_file html > org/testdata/$(basename $org_file .org).html - ./go-org $org_file org > org/testdata/$(basename $org_file .org).pretty_org + ./go-org render $org_file html > org/testdata/$(basename $org_file .org).html + ./go-org render $org_file org > org/testdata/$(basename $org_file .org).pretty_org done diff --git a/etc/generate-gh-pages b/etc/generate-gh-pages index d0abbc3..50e5879 100755 --- a/etc/generate-gh-pages +++ b/etc/generate-gh-pages @@ -34,7 +34,7 @@ for org_file in $org_files; do

${name}

$(sed 's/&/\&/g; s//\>/g;' $org_file)
-
$(./go-org $org_file html-chroma)
+
$(./go-org render $org_file html-chroma)
" done @@ -143,7 +143,7 @@ for org_file in $org_files; do

${name}

$(./gh-pages/goorgeous $org_file)
-
$(./go-org $org_file html-chroma)
+
$(./go-org render $org_file html-chroma)
" done go_org_vs_goorgeous_examples+="" diff --git a/main.go b/main.go index 3f31f3c..1c23434 100644 --- a/main.go +++ b/main.go @@ -12,38 +12,91 @@ import ( "github.com/alecthomas/chroma/formatters/html" "github.com/alecthomas/chroma/lexers" "github.com/alecthomas/chroma/styles" + "github.com/niklasfasching/go-org/blorg" "github.com/niklasfasching/go-org/org" ) func main() { log := log.New(os.Stderr, "", 0) - if len(os.Args) < 3 { - log.Println("USAGE: org FILE OUTPUT_FORMAT") - log.Fatal("Supported output formats: org, html, html-chroma") + if len(os.Args) < 2 { + log.Println("USAGE: org COMMAND [ARGS]") + log.Println("- org render FILE OUTPUT_FORMAT") + log.Println(" OUTPUT_FORMAT: org, html, html-chroma") + log.Println("- org blorg init") + log.Println("- org blorg build") + log.Println("- org blorg serve") + os.Exit(1) } - path := os.Args[1] + + switch cmd := strings.ToLower(os.Args[1]); cmd { + case "render": + if len(os.Args) < 4 { + log.Fatal("USAGE: org render FILE OUTPUT_FORMAT") + } + out, err := render(os.Args[2], os.Args[3]) + if err != nil { + log.Fatalf("Error: %v", err) + } + fmt.Fprint(os.Stdout, out) + case "blorg": + if err := runBlorg(os.Args[2]); err != nil { + log.Fatalf("Error: %v", err) + } + default: + log.Fatalf("Unsupported command: %s", cmd) + } +} + +func runBlorg(cmd string) error { + switch strings.ToLower(cmd) { + case "init": + if _, err := os.Stat(blorg.DefaultConfigFile); !os.IsNotExist(err) { + return err + } + if err := ioutil.WriteFile(blorg.DefaultConfigFile, []byte(blorg.DefaultConfig), os.ModePerm); err != nil { + return err + } + log.Printf("blorg init finished: Wrote ./%s", blorg.DefaultConfigFile) + return nil + case "build": + config, err := blorg.ReadConfig(blorg.DefaultConfigFile) + if err != nil { + return err + } + if err := config.Render(); err != nil { + return err + } + log.Println("blorg build finished") + return nil + case "serve": + config, err := blorg.ReadConfig(blorg.DefaultConfigFile) + if err != nil { + return err + } + return config.Serve() + default: + return fmt.Errorf("Supported commands: init build serve") + } +} + +func render(path, format string) (string, error) { bs, err := ioutil.ReadFile(path) if err != nil { - log.Fatal(err) + return "", err } - out, err := "", nil d := org.New().Parse(bytes.NewReader(bs), path) - switch strings.ToLower(os.Args[2]) { + switch strings.ToLower(format) { case "org": - out, err = d.Write(org.NewOrgWriter()) + return d.Write(org.NewOrgWriter()) case "html": - out, err = d.Write(org.NewHTMLWriter()) + return d.Write(org.NewHTMLWriter()) case "html-chroma": writer := org.NewHTMLWriter() writer.HighlightCodeBlock = highlightCodeBlock - out, err = d.Write(writer) + return d.Write(writer) default: - log.Fatal("Unsupported output format") + return "", fmt.Errorf("unsupported output format: %s", format) } - if err != nil { - log.Fatal(err) - } - fmt.Fprint(os.Stdout, out) } func highlightCodeBlock(source, lang string, inline bool) string {