aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Mathes <adam@trenchant.org>2017-02-09 19:40:44 -0800
committerAdam Mathes <adam@trenchant.org>2017-02-09 19:40:44 -0800
commitded6e3b25bca083cabe21bda6f4d01f6766587bc (patch)
tree2855fa24e8b2f939bdf091f5faca411e7884ac83
downloadsnkt-ded6e3b25bca083cabe21bda6f4d01f6766587bc.tar.gz
snkt-ded6e3b25bca083cabe21bda6f4d01f6766587bc.tar.bz2
snkt-ded6e3b25bca083cabe21bda6f4d01f6766587bc.zip
initial commit (fmstatic -> snkt)
-rw-r--r--LICENSE7
-rw-r--r--README.md157
-rw-r--r--TODO30
-rw-r--r--archive/archive.go51
-rw-r--r--archive/paged.go74
-rw-r--r--config/config.go81
-rw-r--r--main.go65
-rw-r--r--post/post.go260
-rw-r--r--render/render.go139
-rw-r--r--site/init.go164
-rw-r--r--site/site.go135
-rw-r--r--text/text.go22
-rw-r--r--web/web.go11
13 files changed, 1196 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..551bced
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,7 @@
+Copyright (c) 2017 Adam Mathes All rights reserved.
+
+Redistribution and use in source and binary forms are permitted provided that the above copyright notice and this paragraph are duplicated in all such forms and that any documentation, advertising materials, and other materials related to such distribution and use acknowledge that the software was developed by Adam Mathes.
+
+The name of "Adam Mathes" and "snkt" may not be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED ``AS IS'' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cc099c8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,157 @@
+# snkt
+
+Snkt is a static web site generator for with a focus on simplicity and efficiency.
+
+## Simplicity
+
+snkt only does a few things, but strives to do them well, in a coherent manner.
+
+## Efficiency
+
+snkt generates my [personal web site of ~2000 articles in under a second](https://trenchant.org/daily/2017/1/31/). Additional work may be done to increase efficiency, but it should be fast enough to regularly regenerate your site without concern in near real-time if needed.
+
+## Status
+
+Currently in development. It powers [trenchant.org](https://trenchant.org) but is "alpha" quality and parts may change.
+
+## What
+
+Takes a bunch of plain text files, processes them via templates, and generates HTML. Pretty much like you'd expect of a static site generator.
+
+## Why
+
+Every 5-10 years I throw out the software for my site and rewrite it.
+
+This time it's in Go. Maybe you'll find it useful.
+
+I found it fun to get myself thinking in Go. Also, it's 10x faster than the old version in Python.
+
+
+## Getting snkt
+
+Install Go https://golang.org and set up $GOPATH if you haven't already
+
+ $ mkdir $HOME/go
+ $ export GOPATH=$HOME/go
+
+Add $GOPATH/bin to your PATH
+
+ $ export PATH=$PATH:$HOME/go/bin
+
+Install dependencies
+
+ $ go get gopkg.in/yaml.v2
+ $ go get github.com/russross/blackfriday
+
+Install snkt
+
+ $ go get adammathes.com/snkt
+
+This should should download and build `snkt` and place it in $HOME/go/bin
+
+## Setting up a site
+
+Use the "-init" option to create the skeleton for a new site -
+
+ $ snkt -init blogadu
+
+This will create:
+
+ * `txt` -- a directory for plain text input
+ * `html` -- a directory for HTML output
+ * `tmpl` -- a directory for templates with basic templates:
+ * `base` -- basic HTML structure for all pages
+ * `post` -- single post page
+ * `index` -- a home page showing the most recent entires
+ * `archive` -- a list of all post
+ * `rss` -- tempalte for an RSS 2.0 archive
+ * `config.yml` -- configuration file
+
+
+## Writing Your First Post
+
+A one line plaint text file is a valid post.
+
+ user@host:~/blogadu$ echo "hello world" >> txt/hi
+
+Build the site with --
+
+ $ snkt -b
+
+Output should now be in the `html` directory -- including an index.html, archive.html, rss.xml, and `hi/index.html`
+
+You can run a preview server with
+
+ $ snkt -p
+
+Loading http://localhost:8000 in a web browser should now show you the site.
+
+Snkt will use `config.yml` by default if it's in the working directory. Otherwise you can specify it with an explicit `-c /path/config.yml` flag.
+
+## Command Line Usage
+
+```
+Usage of snkt:
+ -b build the site
+ -c string
+ configuration file (default "config.yml")
+ -h help
+ -init directory
+ initialize new site at directory
+ -p start local HTTP server for preview
+ -v print version number
+ -verbose
+ log more actions while building
+```
+
+## Configuration File
+
+The configuration is in [YAML](http://yaml.org)
+
+For most purposes, it should just be a listing of attribute : value
+
+Configuration options --
+
+ * `input_dir` -- absolute path of directory for text input files
+ * `output_dir` -- absolute path of directory for html output files
+ * `tmpl_dir` -- absolute path of directory for template files
+ * `site_title` -- string for the site's title (used in templates)
+ * `site_url` -- absolute URL for the site (used in templates)
+ * `filters` -- tools of the dark arts I haven't documented yet
+ * `permalink_fmt` -- format string for permalinks (see #permalinks)
+ * `post_file_fmt` -- format string for post filenames (see #permalinks)
+ * `show_future` -- include posts with dates in the future or hide them
+ * `preview_server` -- host:port to spawn the preview server (default: localhost:8000)
+ * `preview_dir` -- root directory of preview server (default: same as output_dir)
+
+## Posts
+
+Post inputs are stored as plain text files. (I have only tested UTF-8 and ASCII.)
+
+Posts have an optional metadata preamble, and a markdown formatted body. The preamble is just a series of name value pairings separated by a colon (:) character.
+
+Minimal complete and valid post --
+
+ this is a totally valid post
+
+Post with a preamble --
+
+ title: also a valid post
+ date: 2017-02-08
+ valid: totes
+
+ This post will have an explicitly set title (ooh! fancy!) instead of inferred from the filename.
+
+ It will also have an explicitly set date instead of inferring it from the file creation/modification time.
+
+ `totes` will be stored in the post's `meta` map under `valid.` You don't have to worry about that right now, I'll explain later. Maybe.
+
+## Templates
+
+## Advanced Features
+
+### Permalink and filename formatter
+
+### Filters
+
+### Auto-rebuild/deployment
diff --git a/TODO b/TODO
new file mode 100644
index 0000000..2e3ff8d
--- /dev/null
+++ b/TODO
@@ -0,0 +1,30 @@
+set up github.com/adammathes/snkt
+set up adammathes.com/snkt
+
+
+ensure
+$ snkt -init foo ; snkt -b
+works as intended
+
+lists (home/archive/category)
+paged (yearly/monthly/daily/N) archive structures
+*.txt|*.md|anything globbed
+
+document my workflow
+
+
+archives return filename instead of hardcoded in templates?
+format string documentation / function
+
+auto-tweeter
+
+github or adammathes.com paths paths
+
+docs
+---
+document all functions, make some internal
+usage notes
+readme.md
+example / explanation / site
+
+
diff --git a/archive/archive.go b/archive/archive.go
new file mode 100644
index 0000000..ce1b45e
--- /dev/null
+++ b/archive/archive.go
@@ -0,0 +1,51 @@
+package archive
+
+import (
+ "snkt/config"
+ "snkt/post"
+ "snkt/render"
+ "path"
+)
+
+var archiveTmplName = "archive"
+var archiveName = "archive.html"
+
+/*
+ListArchive
+*/
+type ListArchive struct {
+ Posts post.Posts
+ Tgt string
+ Template string
+
+ Site interface{}
+}
+
+func NewListArchive(posts post.Posts) *ListArchive {
+ la := ListArchive{ Posts: posts }
+ return &la
+}
+
+func (a ListArchive) Target() string {
+ if a.Tgt == "" {
+ a.Tgt = path.Join(config.Config.HtmlDir, archiveName)
+ }
+ return a.Tgt
+}
+
+func (a ListArchive) Render() []byte {
+ if a.Template == "" {
+ a.Template = archiveTmplName
+ }
+ return render.Render(a.Template, a)
+}
+
+
+/*
+NewRssArchive takes posts and returns an archive ready for RSS output
+*/
+func NewRssArchive(posts post.Posts) *ListArchive {
+ ra := ListArchive{ Posts: posts, Template: "rss" }
+ ra.Tgt = path.Join(config.Config.HtmlDir, "rss.xml")
+ return &ra
+}
diff --git a/archive/paged.go b/archive/paged.go
new file mode 100644
index 0000000..217e6b3
--- /dev/null
+++ b/archive/paged.go
@@ -0,0 +1,74 @@
+package archive
+
+import (
+ "snkt/config"
+ "snkt/post"
+ "snkt/render"
+ "fmt"
+ "math"
+ "path"
+ "sort"
+)
+
+var pagedTmplName = "paged"
+
+/*
+Paged archive shows set of posts broken up over multiple pages
+Output goes to Config.HtmlDir/page/{pageNum}.html
+*/
+type PagedArchive struct {
+ Posts post.Posts
+ PageNum int
+ NextPage int
+ PrevPage int
+
+ Site interface{}
+}
+
+func (pa PagedArchive) Render() []byte {
+ return render.Render(pagedTmplName, pa)
+}
+
+/* TODO: make this configurable */
+func (pa PagedArchive) Target() string {
+ return path.Join(config.Config.HtmlDir, "/page/", fmt.Sprintf("%d.html", pa.PageNum))
+}
+
+type PagedArchives []*PagedArchive
+
+func CreatePaged(perPage int, posts post.Posts) *PagedArchives {
+
+ if !render.TmplExists(pagedTmplName) {
+ fmt.Printf("no page template\n")
+ return nil
+ }
+
+ var pas PagedArchives
+
+ sort.Sort(sort.Reverse(posts))
+
+ numPages := int(math.Ceil(float64(len(posts)) / float64(perPage)))
+ for i := 0; i < numPages; i++ {
+ var pa PagedArchive
+
+ var m int
+ if (i+1)*perPage > len(posts) {
+ m = len(posts)
+ } else {
+ m = (i + 1) * perPage
+ }
+
+ pa.Posts = posts[i*perPage : m]
+ pa.PrevPage = i
+ pa.PageNum = i + 1
+ pa.NextPage = i + 2
+ pas = append(pas, &pa)
+ }
+ return &pas
+}
+
+func (pas *PagedArchives) Write() {
+ for _, pa := range *pas {
+ render.Write(pa)
+ }
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..c8b702b
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,81 @@
+package config
+
+import (
+ // "encoding/yaml"
+ "gopkg.in/yaml.v2"
+ "io/ioutil"
+ "log"
+)
+
+type Settings struct {
+ // required
+ TxtDir string `yaml:"input_dir"`
+ HtmlDir string `yaml:"output_dir"`
+ TmplDir string `yaml:"tmpl_dir"`
+
+ // optional
+ SiteTitle string `yaml:"site_title,omitempty"`
+ SiteURL string `yaml:"site_url,omitempty"`
+ Filters []RegexFilter `yaml:"filters,omitempty"`
+
+ // required -- set defaults
+ PermalinkFmt string `yaml:"permalink_fmt,omitempty"`
+ PostFileFmt string `yaml:"post_file_fmt,omitempty"`
+ ShowFuture bool `yaml:"show_future,omitempty"`
+
+ PreviewServer string `yaml:"preview_server,omitempty"`
+ PreviewDir string `yaml:"preview_dir,omitempty"`
+
+ Verbose bool `yaml:"verbose,omitempty"`
+}
+
+type RegexFilter struct {
+ S string `yaml:"s"`
+ R string `yaml:"r"`
+}
+
+var Config Settings
+
+func Init(filename string) {
+ readConfig(filename)
+ checkRequired()
+ addDefaults()
+}
+
+func readConfig(filename string) {
+ file, e := ioutil.ReadFile(filename)
+ if e != nil {
+ log.Fatal("Can not read config file: ", e)
+ }
+ e = yaml.Unmarshal(file, &Config)
+ if e != nil {
+ log.Fatal("Config read error: ", e)
+ }
+}
+
+func checkRequired() {
+ if Config.TxtDir == "" {
+ log.Fatal("Error: input_dir not set in configuration")
+ }
+ if Config.HtmlDir == "" {
+ log.Fatal("Error: output_dir not set in configuration")
+ }
+ if Config.TmplDir == "" {
+ log.Fatal("Error: tmpl_dir not set in configuration")
+ }
+}
+
+func addDefaults() {
+ if Config.PermalinkFmt == "" {
+ Config.PermalinkFmt = "/%F/"
+ }
+ if Config.PostFileFmt == "" {
+ Config.PostFileFmt = "%F/index.html"
+ }
+ if Config.PreviewServer == "" {
+ Config.PreviewServer = "127.0.0.1:8000"
+ }
+ if Config.PreviewDir == "" {
+ Config.PreviewDir = Config.HtmlDir
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..221a514
--- /dev/null
+++ b/main.go
@@ -0,0 +1,65 @@
+/*
+snkt is a static site generator for simple blog-like sites with a focus on simplicity and efficiency.
+*/
+package main
+
+import (
+ "flag"
+ "snkt/config"
+ "snkt/render"
+ "snkt/site"
+ "snkt/web"
+ "fmt"
+ "log"
+)
+
+func main() {
+
+ var configFile, init_dir string
+ var build, preview, version, verbose, help bool
+
+ flag.StringVar(&configFile, "c", "config.yml", "configuration file")
+ flag.StringVar(&init_dir, "init", "", "initialize new site at `directory`")
+ flag.BoolVar(&build, "b", false, "build the site")
+ flag.BoolVar(&preview, "p", false, "start local HTTP server for preview")
+ flag.BoolVar(&version, "v", false, "print version number")
+ flag.BoolVar(&help, "h", false, "help")
+ flag.BoolVar(&verbose, "verbose", false, "log more actions while building")
+ flag.Parse()
+
+ if !help && !build && !preview && !version && init_dir=="" {
+ flag.Usage()
+ return
+ }
+ if(init_dir != "") {
+ fmt.Printf("Initializing new site in %s\n", init_dir)
+ site.Init(init_dir)
+ return
+ }
+ if(version) {
+ fmt.Printf("0.1 alpha\n")
+ return
+ }
+ if(help) {
+ fmt.Printf("in case of emergency, break computer \n")
+ return
+ }
+ config.Init(configFile)
+ if(verbose) {
+ config.Config.Verbose = true
+ }
+
+ render.Init()
+ if build {
+ log.Printf("Building site...\n")
+ var s site.Site
+ s.Read()
+ s.Write()
+ }
+
+ if preview {
+ log.Printf("Spawning preview at [%s] of [%s]\n",
+ config.Config.PreviewDir, config.Config.PreviewServer)
+ web.Serve(config.Config.PreviewServer, config.Config.PreviewDir)
+ }
+}
diff --git a/post/post.go b/post/post.go
new file mode 100644
index 0000000..19a8e73
--- /dev/null
+++ b/post/post.go
@@ -0,0 +1,260 @@
+/*
+Package post provides the data and behavior for the fundamental atomic
+unit of a site: a post. Posts are represented as text files, then converted to HTML and other formats
+*/
+package post
+
+import (
+ "snkt/config"
+ "snkt/render"
+ "snkt/text"
+ "github.com/russross/blackfriday"
+ "io/ioutil"
+ "log"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var Template = "post"
+
+type Post struct {
+ // Representations of the entire post text
+ Raw []byte
+ Unparsed string
+
+ // Metadata
+ Meta map[string]string
+ SourceFile string
+ Title string `json:"title"`
+ Permalink string `json:"permalink"`
+ Time time.Time
+ Year int
+ Month time.Month
+ Day int
+ InFuture bool
+
+ // Content text -- raw, unprocessed, unfiltered markdown
+ Text string
+
+ // Content text -- processed into HTML via markdown and other filters
+ Content string
+
+ // Content with sources and references resolved to absolute URLs
+ AbsoluteContent string
+
+ // Post following chronologically (later)
+ Next *Post
+ // Post preceding chronologically (earlier)
+ Prev *Post
+
+ // Precomputed dates as strings
+ Date string
+ RssDate string
+
+ FileInfo os.FileInfo
+
+ Site sitemeta
+}
+
+type sitemeta interface {
+ GetURL() string
+ GetTitle() string
+}
+
+type Posts []*Post
+
+func (posts Posts) Len() int {
+ return len(posts)
+}
+
+func (posts Posts) Less(i, j int) bool {
+ return posts[i].Time.Before(posts[j].Time)
+}
+
+func (s Posts) Swap(i, j int) {
+ s[i], s[j] = s[j], s[i]
+}
+
+func NewPost(s sitemeta) *Post {
+ var p Post
+ p.Site = s
+ return &p
+}
+
+/*
+Read reads a post from file fi, and parses it into the Post struct, performing any work needed to fully populate the struct
+*/
+func (p *Post) Read(fi os.FileInfo) {
+ p.Meta = make(map[string]string)
+ p.FileInfo = fi
+ p.SourceFile = p.FileInfo.Name()
+ var err error
+ p.Raw, err = ioutil.ReadFile(path.Join(config.Config.TxtDir, fi.Name()))
+ if err != nil {
+ log.Println(err)
+ }
+ p.Unparsed = string(p.Raw)
+ p.parse()
+}
+
+/*
+Parse parses the metadata prefix from the top of the post file's raw bytes, and puts the rest in the text segment. Meta is a name:value mapping
+Title, date and other metadata are derived
+*/
+func (p *Post) parse() {
+ //
+ // Text + Meta[string][string]
+ //
+ p.splitTextMeta()
+
+ //
+ // Title
+ //
+ p.Title = p.Meta["title"]
+ // Use filename as backup fi we have no explicit title
+ if p.Title == "" {
+ p.Title = p.SourceFile
+ }
+
+ //
+ // Dates
+ //
+ // we only deal with yyyy-mm-dd [some legacy dates from my archives have times tacked on]
+ // TODO: recover from empty dates/titles
+ // TODO: probably should actually use times when present and clean up my archives
+ var date_str = ""
+ ds := strings.Fields(p.Meta["date"])
+ if len(ds) > 0 {
+ date_str = ds[0]
+ }
+ var err error
+ p.Time, err = time.Parse("2006-1-2", date_str)
+ if err != nil {
+
+ if config.Config.Verbose {
+ log.Println(err)
+ }
+
+ // fallback is to use file modtime
+ // should use create time but that doesn't seem to be in stdlib
+ // TODO: figure out how to use file birth time
+ p.Time = p.FileInfo.ModTime()
+ }
+
+ p.Year, p.Month, p.Day = p.Time.Date()
+ /* golang date format refresher
+ 1 2 3 4 5 7 6
+ Mon Jan 2 15:04:05 MST 2006 */
+
+ p.Date = p.Time.Format("January 2, 2006")
+ p.RssDate = p.Time.Format("Mon, 2 Jan 2006 15:04:05 GMT")
+ p.InFuture = time.Now().Before(p.Time)
+ p.Permalink = p.GenPermalink()
+
+ //
+ // Content
+ //
+ p.Content = string(p.Filter([]byte(p.Text)))
+ p.AbsoluteContent = render.ResolveURLs(p.Content, p.Site.GetURL())
+}
+
+/*
+splitText splits up p.Unparsed into p.Text and p.Meta[attr][value]
+*/
+func (p *Post) splitTextMeta() {
+ SEPARATOR := ":"
+ lines := strings.Split(p.Unparsed, "\n")
+ for _, line := range lines {
+ if !strings.Contains(line, SEPARATOR) {
+ break
+ }
+ splitdex := strings.Index(line, SEPARATOR)
+ attr := strings.ToLower(strings.TrimSpace(line[0:splitdex]))
+ value := strings.TrimSpace(line[splitdex+1:])
+ p.Meta[attr] = value
+ }
+ p.Text = strings.Join(lines[len(p.Meta):], "\n")
+}
+
+func (p *Post) ParseFmt(s string) string {
+ // TODO: document and add strftime like formats
+ s = strings.Replace(s, "%Y", strconv.Itoa(p.Year), -1)
+ s = strings.Replace(s, "%M", strconv.Itoa(int(p.Month)), -1)
+ s = strings.Replace(s, "%D", strconv.Itoa(p.Day), -1)
+ s = strings.Replace(s, "%F", p.CleanFilename(), -1)
+ s = strings.Replace(s, "%T", p.CleanTitle(), -1)
+
+ s = strings.Replace(s, "$Y", strconv.Itoa(p.Year), -1)
+ s = strings.Replace(s, "$M", strconv.Itoa(int(p.Month)), -1)
+ s = strings.Replace(s, "$D", strconv.Itoa(p.Day), -1)
+ s = strings.Replace(s, "$F", p.CleanFilename(), -1)
+ s = strings.Replace(s, "$T", p.CleanTitle(), -1)
+
+ s = strings.Replace(s, ".File", p.CleanFilename(), -1)
+ s = strings.Replace(s, ".Title", p.CleanTitle(), -1)
+ s = strings.Replace(s, ".Year", strconv.Itoa(p.Year), -1)
+ s = strings.Replace(s, ".Month", strconv.Itoa(int(p.Month)), -1)
+ s = strings.Replace(s, ".Day", strconv.Itoa(p.Day), -1)
+
+ return s
+}
+
+func (p *Post) CleanFilename() string {
+ return text.SanitizeFilename(text.RemoveExt(p.SourceFile))
+}
+
+func (p *Post) CleanTitle() string {
+ return text.SanitizeFilename(p.Title)
+}
+
+
+
+/*
+GenPermalink generates the permalink for the post given the PermalinkFmt format specified in the configuration file.
+*/
+func (p *Post) GenPermalink() string {
+ pl := config.Config.PermalinkFmt
+ return p.ParseFmt(pl)
+}
+
+/*
+Target returns a string representing the file system location to write the output file representing the post.
+*/
+func (p Post) Target() string {
+ pf := config.Config.PostFileFmt
+ return path.Join(config.Config.HtmlDir, p.ParseFmt(pf))
+}
+
+/*
+Render returns the post rendered as HTML via the post template with Post and Site as context.
+*/
+func (p Post) Render() []byte {
+ data := struct {
+ Post interface{}
+ Site interface{}
+ }{&p, &p.Site}
+ return render.Render(Template, data)
+}
+
+/*
+Filter runs the text through filters defined by render.Filter and marddown, returning text suitable for HTML output.
+*/
+func (p *Post) Filter(txt []byte) []byte {
+ txt = render.Filter(txt)
+ txt = blackfriday.MarkdownCommon(txt)
+ return txt
+}
+
+/*
+Limit returns a slice of Posts up to the int limit provided. If the limit is larger than the slice, it just returns the whole slice.
+*/
+func (posts Posts) Limit(limit int) Posts {
+ if len(posts) < limit {
+ return posts
+ } else {
+ return posts[0:limit]
+ }
+}
diff --git a/render/render.go b/render/render.go
new file mode 100644
index 0000000..3661607
--- /dev/null
+++ b/render/render.go
@@ -0,0 +1,139 @@
+package render
+
+import (
+ "bytes"
+ "snkt/config"
+ "io/ioutil"
+ "log"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "text/template"
+)
+
+var templates map[string]*template.Template
+var BASE_TEMPLATE = "base"
+var rel_href *regexp.Regexp
+var rel_src *regexp.Regexp
+
+/*
+Renderable interface - objects that render themeslves to a []byte and
+know where they should end up in the filesystem
+*/
+type Renderable interface {
+ Render() []byte
+ Target() string
+}
+
+func Write(a Renderable) {
+ if config.Config.Verbose {
+ log.Printf("Writing to %s\n", a.Target())
+ }
+ os.MkdirAll(path.Dir(a.Target()), 0755)
+ err := ioutil.WriteFile(a.Target(), a.Render(), 0755)
+ if err != nil {
+ log.Println(err)
+ }
+}
+
+/*
+Initializes templates from config.TmplDir
+Templates are mapped by filename
+*/
+func Init() {
+ templates = make(map[string]*template.Template)
+ ts, err := filepath.Glob(config.Config.TmplDir + "/*")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ tmplFuncs := template.FuncMap{
+ "ResolveURLs": ResolveURLs,
+ "SiteTitle": SiteTitle,
+ "SiteURL": SiteURL,
+ }
+
+
+ base := path.Join(config.Config.TmplDir, BASE_TEMPLATE)
+ for _, t := range ts {
+ tf := filepath.Base(t)
+
+ // Duly Noted: set funcs before parsefiles or you get no funcs
+ // templates[tf] = template.Must(template.ParseFiles(t, base))
+
+ tx := template.New("t").Funcs(tmplFuncs)
+ templates[tf], err = tx.ParseFiles(base, t)
+ if err != nil {
+ panic(err)
+ }
+ }
+ rel_href = regexp.MustCompile(`href="/(.+)"`)
+ rel_src = regexp.MustCompile(`src="/(.+)"`)
+}
+
+/*
+Render fills the template "name" using data via the BASE_TEMPLATE
+*/
+func Render(name string, data interface{}) []byte {
+ return RenderNameVia(name, BASE_TEMPLATE, data)
+}
+
+/*
+Render fills the template "name" using data
+*/
+func RenderOnly(name string, data interface{}) []byte {
+ return RenderNameVia(name, name, data)
+}
+
+/*
+Render fills the template "name" using data through via (ex: BASE_TEMPLATE)
+*/
+func RenderNameVia(name string, via string, data interface{}) []byte {
+ t, ok := templates[name]
+ if !ok {
+ log.Printf("can not find template named %s\n", name)
+ }
+
+ var buf bytes.Buffer
+ err := t.ExecuteTemplate(&buf, via, data)
+ if err != nil {
+ log.Println(err)
+ }
+ return buf.Bytes()
+}
+
+/*
+Finds any relative links/images in html and resolves by adding prefix
+*/
+func ResolveURLs(html, prefix string) string {
+ bts := []byte(html)
+ bts = rel_href.ReplaceAll(bts, []byte(`href="`+prefix+`/$1"`))
+ bts = rel_src.ReplaceAll(bts, []byte(`src="`+prefix+`/$1"`))
+ return string(bts)
+}
+
+/*
+Runs all regex filters specified in config.Config.Filters
+*/
+func Filter(txt []byte) []byte {
+ for _, f := range config.Config.Filters {
+ // TODO: only compile these once at init
+ re := regexp.MustCompile(f.S)
+ txt = re.ReplaceAll(txt, []byte(f.R))
+ }
+ return txt
+}
+
+func TmplExists(t string) bool {
+ _, ok := templates[t]
+ return ok
+}
+
+func SiteTitle() string {
+ return config.Config.SiteTitle
+}
+
+func SiteURL() string {
+ return config.Config.SiteURL
+}
diff --git a/site/init.go b/site/init.go
new file mode 100644
index 0000000..dc17cfa
--- /dev/null
+++ b/site/init.go
@@ -0,0 +1,164 @@
+package site
+
+import (
+ "path"
+ "snkt/render"
+ "snkt/config"
+ "os"
+ "log"
+ "gopkg.in/yaml.v2"
+)
+
+type skeleton struct {
+ Dir string
+ Filename string
+ Content []byte
+}
+
+var skeletons = []skeleton {
+ {
+ Dir: "tmpl",
+ Filename: "base",
+ Content: []byte(
+`{{define "base"}}
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>{{template "title" .}}</title>
+ </head>
+ <body>
+ <h1>{{template "title"}}</h1>
+ {{template "content" .}}
+ <hr />
+ <p><a href="{{.Site.URL}}/index.html">Home</a></p>
+ <p><a href="{{.Site.URL}}/archive.html">Archive</a></p>
+ </body>
+</html>
+{{end}}
+{{define "title"}}{{end}}
+{{define "content"}}{{end}}
+`)},
+ {
+ Dir: "tmpl",
+ Filename: "home",
+ Content: []byte(
+`{{define "title"}}{{.Site.Title}}{{end}}
+{{define "content"}}
+<h1>{{.Site.Title}}</h1>
+{{range .Posts.Limit 15}}
+<h2><a href="{{.Permalink}}">{{.Title}}</a></h2>
+{{.Content}}
+{{end}}
+{{end}}
+`)},
+ {
+ Dir: "tmpl",
+ Filename: "post",
+ Content: []byte(
+`{{define "title"}}{{ .Post.Title }}{{end}}
+{{define "content"}}
+<h2>{{ .Post.Title }}</h2>
+{{.Post.Content}}
+{{if .Post.Next.Title}}
+<p><a href="{{.Post.Next.Permalink}}">{{.Post.Next.Title}}</a></p>
+{{end}}
+ {{if .Post.Prev.Title}}
+<p><a href="{{.Post.Prev.Permalink}}">{{.Post.Prev.Title}}</a></p>
+{{end}}
+{{end}}
+`)},
+ {
+ Dir: "tmpl",
+ Filename: "archive",
+ Content: []byte(
+`{{define "title"}}{{.Site.Title}} Archives{{end}}
+{{define "content"}}
+<h1>{{ .Site.Title }}</h1>
+{{range .Posts}}
+{{if .}}<h2><a href="{{.Permalink}}">{{.Title}}</a></h2>{{end}}
+{{end}}
+{{end}}
+`)},
+ {
+ Dir: "tmpl",
+ Filename: "rss",
+ Content: []byte(
+`{{define "base"}}
+<?xml version="1.0" encoding="UTF-8" ?>
+<rss version="2.0">
+<channel>
+ <title>{{ .Site.Title }}</title>
+ <link>{{ .Site.URL }}</link>
+ <description></description>
+ {{ range .Posts.Limit 15 }}
+ <item>
+ <link>{{ .Permalink }}</link>
+ <title><![CDATA[{{ .Title }}]]></title>
+ <pubDate>{{ .RssDate }}</pubDate>
+ <description><![CDATA[
+ {{ .AbsoluteContent }}
+ ]]>
+ </description>
+ </item>
+ {{ end }}
+</channel>
+</rss>
+{{end}}
+`)}}
+
+/*
+Init initializes a new site in `directory` This includes: create `directory` and populate with:
+config.json with sane defaults
+txt, html directories for input/output
+tmpl directory with barebones [base,post,archive,rss] templates
+*/
+var init_dir = ""
+func Init(directory string) {
+ init_dir = directory
+
+ if init_dir[0] != '/' {
+ wd, err := os.Getwd()
+ if err == nil {
+ init_dir = path.Join(wd, init_dir)
+ }
+ }
+
+ var cfg = config.Settings{
+ TxtDir: path.Join(init_dir, "txt"),
+ HtmlDir: path.Join(init_dir, "html"),
+ TmplDir: path.Join(init_dir, "tmpl"),
+ }
+
+ cyaml, err := yaml.Marshal(cfg)
+ if err != nil {
+ log.Fatal("marshalling yaml error: ", err)
+ }
+
+ c := skeleton{
+ Dir: "",
+ Filename: "config.yml",
+ Content: cyaml,
+ }
+ skeletons = append(skeletons, c)
+
+ os.MkdirAll( cfg.TxtDir , 0755)
+ os.MkdirAll( cfg.HtmlDir , 0755)
+ os.MkdirAll( cfg.TmplDir , 0755)
+
+ writeSkeletons()
+}
+
+
+func (s skeleton) Render() []byte {
+ return s.Content
+}
+
+func (s skeleton) Target() string {
+ return path.Join(init_dir, s.Dir, s.Filename)
+}
+
+func writeSkeletons() {
+ for _,skeleton := range skeletons {
+ render.Write(skeleton)
+ }
+}
diff --git a/site/site.go b/site/site.go
new file mode 100644
index 0000000..87aa7fc
--- /dev/null
+++ b/site/site.go
@@ -0,0 +1,135 @@
+package site
+
+import (
+ "snkt/archive"
+ "snkt/config"
+ "snkt/post"
+ "snkt/render"
+ "io/ioutil"
+ "log"
+ "path"
+ "sort"
+)
+
+type Site struct {
+ Title string
+ URL string
+
+ Posts post.Posts
+
+ // all archives are optional based on presence of template
+ Archive *archive.ListArchive
+ Home *archive.ListArchive
+ Rss *archive.ListArchive
+ Paged *archive.PagedArchives
+}
+
+/*
+Read reads post data from the filesystem and populates posts and archives
+*/
+func (s *Site) Read() {
+ s.Title = config.Config.SiteTitle
+ s.URL = config.Config.SiteURL
+ s.ReadPosts()
+
+ if render.TmplExists("archive") {
+ s.Archive = archive.NewListArchive(s.Posts)
+ s.Archive.Site = *s
+ sort.Sort(sort.Reverse(s.Archive.Posts))
+ }
+ if render.TmplExists("rss") {
+ s.Rss = archive.NewRssArchive(s.Posts)
+ s.Rss.Site = *s
+ }
+ if render.TmplExists("paged") {
+ s.Paged = archive.CreatePaged(15, s.Posts)
+ }
+ if render.TmplExists("home") {
+ s.Home = archive.NewListArchive(s.Posts)
+ s.Home.Tgt = path.Join(config.Config.HtmlDir, "index.html")
+ s.Home.Template = "home"
+ s.Home.Site = s
+ }
+}
+
+/*
+ReadPosts reads all files from the Config.TxtDir, parses them and stores in s.Posts
+*/
+func (s *Site) ReadPosts() {
+ // TODO: filter this as needed
+ files, err := ioutil.ReadDir(config.Config.TxtDir)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, file := range files {
+ p := post.NewPost(s)
+ p.Read(file)
+
+ // Ignore post-dated posts unless overriden in config
+ if !config.Config.ShowFuture && p.InFuture {
+ log.Printf("Skipping future dated post: %s\n", p.SourceFile)
+ } else {
+ s.Posts = append(s.Posts, p)
+ }
+ }
+
+ // Sort the posts by date, earliest first
+ sort.Sort(s.Posts)
+
+ // Add next/previous to each post.
+ // An allocated but empty post is set at start/end
+ // This prevents templates from failing at start/end if nil checks are not made properly
+ for i, p := range s.Posts {
+ if i > 0 {
+ p.Prev = s.Posts[i-1]
+ } else {
+ p.Prev = new(post.Post)
+ }
+ if i+1 < len(s.Posts) {
+ p.Next = s.Posts[i+1]
+ } else {
+ p.Next = new(post.Post)
+ }
+ }
+}
+
+/*
+Write writes posts and archives to the filesystem
+*/
+func (s *Site) Write() {
+ s.WritePosts()
+ s.WriteArchives()
+}
+
+func (s *Site) WriteArchives() {
+ if render.TmplExists("archive") {
+ render.Write(s.Archive)
+ }
+ if render.TmplExists("rss") {
+ render.Write(s.Rss)
+ }
+ if render.TmplExists("home") {
+ render.Write(s.Home)
+ }
+ if render.TmplExists("paged") {
+ for _, p := range *s.Paged {
+ p.Site = s
+ render.Write(p)
+ }
+ }
+}
+
+func (s *Site) WritePosts() {
+ for _, p := range s.Posts {
+ render.Write(p)
+ }
+}
+
+func (s Site) GetTitle() string {
+ return s.Title
+}
+
+func (s Site) GetURL() string {
+ return s.URL
+}
diff --git a/text/text.go b/text/text.go
new file mode 100644
index 0000000..7666e91
--- /dev/null
+++ b/text/text.go
@@ -0,0 +1,22 @@
+// package text is a utility library of various text/string manipulations
+package text
+
+import (
+ "regexp"
+ "strings"
+ "path"
+)
+
+// Sanitize filename string for FILE/URL output but removing non-alphanumerics and trimming space
+func SanitizeFilename(s string) string {
+ fileNoNos := regexp.MustCompile(`[^[:alnum:]-]`)
+ s = strings.Trim(s, " ")
+ s = strings.Replace(s, " ", "-", -1)
+ s = fileNoNos.ReplaceAllString(s, "-")
+ return s
+}
+
+// Remove the filename extension
+func RemoveExt(src string) string {
+ return strings.TrimSuffix(src, path.Ext(src))
+}
diff --git a/web/web.go b/web/web.go
new file mode 100644
index 0000000..2a320ea
--- /dev/null
+++ b/web/web.go
@@ -0,0 +1,11 @@
+package web
+
+import (
+ "net/http"
+)
+
+func Serve(addr string, webroot string) {
+ fs := http.FileServer(http.Dir(webroot))
+ http.Handle("/", fs)
+ http.ListenAndServe(addr, nil)
+}