summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLaria Carolin Chabowski <laria@laria.me>2021-01-01 14:46:54 +0100
committerLaria Carolin Chabowski <laria@laria.me>2021-01-01 14:46:54 +0100
commit85473656174b1b1d6221d3bb76cc12fa5f7f7e8d (patch)
tree37349eee4f64781f06e8597dc4c457801eb03a47
downloadlaria.me-85473656174b1b1d6221d3bb76cc12fa5f7f7e8d.tar.gz
laria.me-85473656174b1b1d6221d3bb76cc12fa5f7f7e8d.tar.bz2
laria.me-85473656174b1b1d6221d3bb76cc12fa5f7f7e8d.zip
Initial commit
-rw-r--r--article/article.go293
-rw-r--r--atom/atom.go40
-rw-r--r--config/config.go49
-rw-r--r--database.sql24
-rw-r--r--dbutils/dbutils.go60
-rw-r--r--environment/environment.go58
-rw-r--r--main.go53
-rw-r--r--markdown/markdown.go35
-rw-r--r--menu/menu.go92
-rw-r--r--serve.go836
-rw-r--r--templates/archive-day.html13
-rw-r--r--templates/archive-month.html16
-rw-r--r--templates/archive-year.html15
-rw-r--r--templates/archive.html12
-rw-r--r--templates/article.html7
-rw-r--r--templates/blog.html12
-rw-r--r--templates/content.html3
-rw-r--r--templates/root.html66
-rw-r--r--templates/search.html21
-rw-r--r--templates/start.html8
-rw-r--r--templates/tag.html13
-rw-r--r--templates/tags.html6
-rw-r--r--update.go90
-rw-r--r--views.go526
24 files changed, 2348 insertions, 0 deletions
diff --git a/article/article.go b/article/article.go
new file mode 100644
index 0000000..0a219a1
--- /dev/null
+++ b/article/article.go
@@ -0,0 +1,293 @@
+package article
+
+import (
+ "bufio"
+ "database/sql"
+ "errors"
+ "html"
+ "io"
+ "os"
+ "path"
+ "regexp"
+ "strings"
+ "time"
+
+ "code.laria.me/laria.me/dbutils"
+ "code.laria.me/laria.me/markdown"
+)
+
+var (
+ ErrBrokenHeader = errors.New("The article header is broken")
+ ErrMissingMandatoryHeaders = errors.New("The article header is missing some mandatory headers")
+)
+
+type Article struct {
+ Slug string
+ Published time.Time
+ Hidden bool
+ Title string
+ SummaryHtml string
+ FullHtml string
+ Tags map[string]struct{}
+}
+
+var reHtmlTag = regexp.MustCompile(`<([^>'"]+|'[^']*'|"[^"]*")*>`)
+var reHtmlEntity = regexp.MustCompile(`&[^;]*;`)
+
+func stripTags(s string) string {
+ s = reHtmlTag.ReplaceAllString(s, "")
+ s = reHtmlEntity.ReplaceAllStringFunc(s, html.UnescapeString)
+ return s
+}
+
+func (a Article) updateDbDetails(tx *sql.Tx, id int64) error {
+ _, err := tx.Exec(`
+ UPDATE article SET
+ published = ?,
+ hidden = ?,
+ title = ?,
+ summary_html = ?,
+ full_html = ?,
+ full_plain = ?
+ WHERE article_id = ?
+ `, a.Published.Format("2006-01-02 15:04:05"), a.Hidden, a.Title, a.SummaryHtml, a.FullHtml, stripTags(a.FullHtml), id)
+
+ return err
+}
+
+func (a Article) createInDb(tx *sql.Tx) (int64, error) {
+ res, err := tx.Exec(`
+ INSERT INTO article SET
+ slug = ?,
+ published = ?,
+ hidden = ?,
+ title = ?,
+ summary_html = ?,
+ full_html = ?,
+ full_plain = ?
+ `, a.Slug, a.Published.Format("2006-01-02 15:04:05"), a.Hidden, a.Title, a.SummaryHtml, a.FullHtml, stripTags(a.FullHtml))
+
+ if err != nil {
+ return 0, err
+ }
+
+ return res.LastInsertId()
+}
+
+func removeTags(tx *sql.Tx, id int64) error {
+ _, err := tx.Exec(`DELETE FROM article_tag WHERE article_id = ?`, id)
+ return err
+}
+
+func setTags(tx *sql.Tx, id int64, tags map[string]struct{}) error {
+ stmt, err := tx.Prepare(`REPLACE INTO article_tag SET article_id = ?, tag = ?`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ for tag := range tags {
+ if _, err = stmt.Exec(id, tag); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (a Article) saveToDb(tx *sql.Tx) (int64, error) {
+ var id int64
+ switch err := tx.QueryRow(`SELECT article_id FROM article WHERE slug = ?`, a.Slug).Scan(&id); err {
+ case nil:
+ if err = a.updateDbDetails(tx, id); err != nil {
+ return 0, err
+ }
+ case sql.ErrNoRows:
+ id, err = a.createInDb(tx)
+ if err != nil {
+ return 0, err
+ }
+ default:
+ return 0, err
+ }
+
+ if err := removeTags(tx, id); err != nil {
+ return 0, err
+ }
+
+ if err := setTags(tx, id, a.Tags); err != nil {
+ return 0, err
+ }
+
+ return id, nil
+}
+
+func (a Article) SaveToDb(db *sql.DB) (int64, error) {
+ tx, err := db.Begin()
+ if err != nil {
+ return 0, err
+ }
+
+ id, err := a.saveToDb(tx)
+ err = dbutils.TxCommitIfOk(tx, err)
+ return id, err
+}
+
+func DeleteArticlesFromDbExcept(db *sql.DB, slugs []string) error {
+ if len(slugs) == 0 {
+ return nil
+ }
+
+ query := new(strings.Builder)
+ query.WriteString("DELETE FROM article WHERE slug NOT IN (?")
+
+ for i := 1; i < len(slugs); i++ { // intentionally starting at 1, since the first '?' is already there
+ query.WriteString(",?")
+ }
+
+ query.WriteString(")")
+
+ slugsAsInterfaces := make([]interface{}, 0, len(slugs))
+ for _, slug := range slugs {
+ slugsAsInterfaces = append(slugsAsInterfaces, interface{}(slug))
+ }
+
+ _, err := db.Exec(query.String(), slugsAsInterfaces...)
+ return err
+}
+
+func splitTags(s string) map[string]struct{} {
+ tags := make(map[string]struct{})
+
+ parts := strings.Split(s, ",")
+ for _, part := range parts {
+ tag := strings.TrimSpace(part)
+ if tag != "" {
+ tags[tag] = struct{}{}
+ }
+ }
+
+ return tags
+}
+
+func parseDate(s string) (time.Time, error) {
+ return time.Parse("2006-01-02 15:04:05", s)
+}
+
+func parseHeader(scanner *bufio.Scanner) (Article, error) {
+ var article Article
+ var err error
+
+ seenTitle := false
+ seenPublished := false
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ line = strings.TrimSpace(line)
+
+ if line == "" {
+ break
+ }
+
+ parts := strings.SplitN(line, ":", 2)
+ if len(parts) != 2 {
+ return Article{}, ErrBrokenHeader
+ }
+
+ key := strings.ToLower(strings.TrimSpace(parts[0]))
+ value := strings.TrimSpace(parts[1])
+
+ switch key {
+ case "title":
+ article.Title = value
+ seenTitle = true
+ case "tags":
+ article.Tags = splitTags(value)
+ case "date":
+ if article.Published, err = parseDate(value); err != nil {
+ return Article{}, err
+ }
+ seenPublished = true
+ case "hidden":
+ article.Hidden = strings.ToLower(value) == "yes"
+ }
+ }
+
+ if err = scanner.Err(); err != nil {
+ return Article{}, err
+ }
+
+ if !seenTitle || !seenPublished {
+ return Article{}, ErrMissingMandatoryHeaders
+ }
+
+ return article, nil
+}
+
+var reMore = regexp.MustCompile(`^\s*~~+(?i:more)~~+\s*$`)
+
+func parseText(scanner *bufio.Scanner, article *Article) error {
+ var err error
+
+ builder := new(strings.Builder)
+
+ for scanner.Scan() {
+ line := scanner.Text()
+
+ if reMore.MatchString(line) {
+ if article.SummaryHtml, err = markdown.Parse(builder.String()); err != nil {
+ return err
+ }
+
+ continue
+ }
+
+ builder.WriteString(line)
+ builder.WriteRune('\n')
+ }
+
+ if err = scanner.Err(); err != nil {
+ return err
+ }
+
+ if article.FullHtml, err = markdown.Parse(builder.String()); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func ParseArticle(r io.Reader) (Article, error) {
+ var article Article
+
+ scanner := bufio.NewScanner(r)
+ article, err := parseHeader(scanner)
+ if err != nil {
+ return Article{}, err
+ }
+
+ if err = parseText(scanner, &article); err != nil {
+ return Article{}, err
+ }
+
+ return article, nil
+}
+
+func LoadArticle(filename string) (Article, error) {
+ parts := strings.Split(path.Base(filename), ".")
+ slug := strings.Join(parts[:len(parts)-1], ".")
+
+ f, err := os.Open(filename)
+ if err != nil {
+ return Article{}, err
+ }
+ defer f.Close()
+
+ article, err := ParseArticle(f)
+ if err != nil {
+ return Article{}, err
+ }
+
+ article.Slug = slug
+ return article, nil
+}
diff --git a/atom/atom.go b/atom/atom.go
new file mode 100644
index 0000000..a74a981
--- /dev/null
+++ b/atom/atom.go
@@ -0,0 +1,40 @@
+package atom
+
+import "time"
+
+type Link struct {
+ XMLName struct{} `xml:"link"`
+
+ Href string `xml:"href,attr"`
+ Rel string `xml:"rel,attr,omitempty"`
+}
+
+type Summary struct {
+ XMLName struct{} `xml:"summary"`
+
+ Type string `xml:"type,attr"`
+ Content string `xml:",chardata"`
+}
+
+type Entry struct {
+ XMLName struct{} `xml:"entry"`
+
+ Title string `xml:"title"`
+ Id string `xml:"id"`
+ Updated time.Time `xml:"updated"`
+ Summary Summary
+ Links []Link
+}
+
+type Feed struct {
+ XMLName struct{} `xml:"http://www.w3.org/2005/Atom feed"`
+
+ Title string `xml:"title"`
+ Links []Link
+ Id string `xml:"id"`
+ AuthorName string `xml:"author>name"`
+ AuthorEmail string `xml:"author>email"`
+ AuthorUri string `xml:"author>uri"`
+ Updated time.Time `xml:"updated"`
+ Entries []Entry
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..074c24c
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,49 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path"
+)
+
+type Config struct {
+ ContentRoot string
+ DbDsn string
+ TemplatePath string
+ StaticPath string `json:",omitempty"`
+ HttpLaddr string
+ Secret string
+ UpdateUrl string
+}
+
+func loadConfig(configPath string) (*Config, error) {
+ f, err := os.Open(configPath)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var conf Config
+
+ dec := json.NewDecoder(f)
+ if err = dec.Decode(&conf); err != nil {
+ return nil, err
+ }
+
+ return &conf, nil
+}
+
+func LoadConfig(configPath string) (*Config, error) {
+ if configPath == "" {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return nil, err
+ }
+
+ configPath = path.Join(configDir, "laria.me", "config.json")
+
+ return loadConfig(configPath)
+ }
+
+ return loadConfig(configPath)
+}
diff --git a/database.sql b/database.sql
new file mode 100644
index 0000000..30d2216
--- /dev/null
+++ b/database.sql
@@ -0,0 +1,24 @@
+CREATE TABLE article (
+ article_id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ slug VARCHAR(200) NOT NULL UNIQUE,
+ published DATETIME NOT NULL,
+ hidden TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ title TEXT NOT NULL,
+ summary_html LONGTEXT NOT NULL,
+ full_html LONGTEXT NOT NULL,
+ full_plain LONGTEXT NOT NULL,
+ FULLTEXT(full_plain),
+ FULLTEXT(title)
+);
+
+CREATE INDEX by_slug ON article (slug);
+CREATE INDEX by_publish_date ON article (published);
+
+CREATE TABLE article_tag (
+ article_id INT UNSIGNED NOT NULL REFERENCES article (article_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ tag VARCHAR(200) NOT NULL,
+ PRIMARY KEY(article_id, tag),
+ CONSTRAINT article_fk FOREIGN KEY (article_id) REFERENCES article (article_id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE INDEX by_tag ON article_tag (tag);
diff --git a/dbutils/dbutils.go b/dbutils/dbutils.go
new file mode 100644
index 0000000..4082efd
--- /dev/null
+++ b/dbutils/dbutils.go
@@ -0,0 +1,60 @@
+package dbutils
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+)
+
+func TxCommitIfOk(tx *sql.Tx, err error) error {
+ if err != nil {
+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
+ return fmt.Errorf("Failed rolling back transaction with error \"%s\" while handling error %w", rollbackErr, err)
+ }
+
+ return err
+ } else {
+ if err = tx.Commit(); err != nil {
+ return err
+ }
+
+ return nil
+ }
+}
+
+func BuildInIdsSqlAndArgs(field string, ids []int) (string, []interface{}) {
+ if len(ids) == 0 {
+ return "0", []interface{}{}
+ }
+
+ sb := new(strings.Builder)
+ sb.WriteString(field)
+ sb.WriteString(" IN (?")
+
+ for i := 1; i < len(ids); i++ {
+ sb.WriteString(",?")
+ }
+ sb.WriteRune(')')
+
+ args := make([]interface{}, 0, len(ids))
+ for _, id := range ids {
+ args = append(args, interface{}(id))
+ }
+
+ return sb.String(), args
+}
+
+// GetIndexedValues
+// func GetIndexedValues(db *sql.DB, m interface{}, query, args ...interface{}) error {
+// mv := reflect.ValueOf(m)
+// mt := mv.Type()
+// if mt.Kind() != reflect.Map {
+// panic("GetIndexValue needs a map as argument m")
+// }
+// if mt.
+
+// rows, err := db.Query(query, args...)
+// if err != nil {
+// return err
+// }
+// }
diff --git a/environment/environment.go b/environment/environment.go
new file mode 100644
index 0000000..a4ae719
--- /dev/null
+++ b/environment/environment.go
@@ -0,0 +1,58 @@
+// Package environment provides the Env type for commonly used data in the application.
+package environment
+
+import (
+ "database/sql"
+
+ _ "github.com/go-sql-driver/mysql"
+
+ "code.laria.me/laria.me/config"
+)
+
+// Env provides commonly used data in the application
+type Env struct {
+ configPath string
+
+ config *config.Config
+ db *sql.DB
+}
+
+func New(configPath string) *Env {
+ return &Env{
+ configPath: configPath,
+ }
+}
+
+func (e *Env) Config() (*config.Config, error) {
+ if e.config != nil {
+ return e.config, nil
+ }
+
+ conf, err := config.LoadConfig(e.configPath)
+ if err != nil {
+ return nil, err
+ }
+
+ e.config = conf
+ return conf, nil
+}
+
+func (e *Env) DB() (*sql.DB, error) {
+ if e.db != nil {
+ return e.db, nil
+ }
+
+ conf, err := e.Config()
+ if err != nil {
+ return nil, err
+ }
+
+ var db *sql.DB
+ db, err = sql.Open("mysql", conf.DbDsn)
+ if err != nil {
+ return nil, err
+ }
+
+ e.db = db
+ return db, nil
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..32473db
--- /dev/null
+++ b/main.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "code.laria.me/laria.me/environment"
+)
+
+type subcmd func(progname string, env *environment.Env, args []string)
+
+func main() {
+ subcmds := map[string]subcmd{
+ "serve": cmdServe,
+ "update": cmdUpdate,
+ }
+
+ progname := os.Args[0]
+
+ flagSet := flag.NewFlagSet(progname, flag.ExitOnError)
+ flagSet.Usage = func() {
+ fmt.Fprintf(flagSet.Output(), "Usage: %s [options] <command> [command specific options]\n", progname)
+ fmt.Fprintln(flagSet.Output(), "Where options can be any of:")
+ flagSet.PrintDefaults()
+ fmt.Fprintln(flagSet.Output(), "")
+ fmt.Fprintln(flagSet.Output(), "And command is one of:")
+ for n := range subcmds {
+ fmt.Fprintf(flagSet.Output(), " %s\n", n)
+ }
+ }
+
+ configPath := flagSet.String("config", "", "An optional config path")
+ flagSet.Parse(os.Args[1:])
+
+ args := flagSet.Args()
+ if len(args) == 0 {
+ flagSet.Usage()
+ os.Exit(1)
+ }
+
+ cmdName := args[0]
+ args = args[1:]
+
+ env := environment.New(*configPath)
+ cmd, ok := subcmds[cmdName]
+ if !ok {
+ fmt.Fprintf(os.Stderr, "%s: Unknown command %s\nSee %s -help for valid commands\n", progname, cmdName, progname)
+ os.Exit(1)
+ }
+
+ cmd(progname, env, args)
+}
diff --git a/markdown/markdown.go b/markdown/markdown.go
new file mode 100644
index 0000000..3a4beba
--- /dev/null
+++ b/markdown/markdown.go
@@ -0,0 +1,35 @@
+package markdown
+
+import (
+ "bytes"
+
+ "github.com/alecthomas/chroma/formatters/html"
+ "github.com/yuin/goldmark"
+ highlighting "github.com/yuin/goldmark-highlighting"
+ goldmarkHtml "github.com/yuin/goldmark/renderer/html"
+)
+
+func Parse(s string) (string, error) {
+ markdown := goldmark.New(
+ goldmark.WithExtensions(
+ highlighting.NewHighlighting(
+ highlighting.WithStyle("monokai"),
+ highlighting.WithFormatOptions(
+ // html.WithAllClasses(true),
+ html.WithClasses(true),
+ html.WithLineNumbers(false),
+ ),
+ ),
+ ),
+ goldmark.WithRendererOptions(
+ goldmarkHtml.WithUnsafe(),
+ ),
+ )
+
+ buf := new(bytes.Buffer)
+ if err := markdown.Convert([]byte(s), buf); err != nil {
+ return "", err
+ }
+
+ return buf.String(), nil
+}
diff --git a/menu/menu.go b/menu/menu.go
new file mode 100644
index 0000000..3916649
--- /dev/null
+++ b/menu/menu.go
@@ -0,0 +1,92 @@
+package menu
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+)
+
+type MenuItem struct {
+ Title string
+ Ident string
+ Url string
+ Children []*MenuItem
+ Parent *MenuItem
+}
+
+func (it MenuItem) IsRoot() bool {
+ return it.Parent == nil
+}
+
+type Menu struct {
+ byIdent map[string]*MenuItem
+ root *MenuItem
+}
+
+func (m Menu) Root() *MenuItem {
+ return m.root
+}
+
+func (m Menu) ByIdent(ident string) *MenuItem {
+ return m.byIdent[ident]
+}
+
+type JsonMenuItem struct {
+ Title string
+ Ident string
+ Url string
+ Children []JsonMenuItem `json:",omitempty"`
+}
+
+type JsonMenu []JsonMenuItem
+
+func (m *Menu) addJsonMenuItems(jsonItems []JsonMenuItem, parent *MenuItem) {
+ for _, jsonItem := range jsonItems {
+ item := MenuItem{
+ Title: jsonItem.Title,
+ Ident: jsonItem.Ident,
+ Url: jsonItem.Url,
+ Children: make([]*MenuItem, 0, len(jsonItem.Children)),
+ Parent: parent,
+ }
+
+ parent.Children = append(parent.Children, &item)
+ m.byIdent[jsonItem.Ident] = &item
+
+ m.addJsonMenuItems(jsonItem.Children, &item)
+ }
+}
+
+func (jm JsonMenu) toMenu() *Menu {
+ root := MenuItem{
+ Children: make([]*MenuItem, 0, len(jm)),
+ }
+
+ menu := &Menu{
+ byIdent: make(map[string]*MenuItem),
+ root: &root,
+ }
+
+ menu.addJsonMenuItems(jm, &root)
+
+ return menu
+}
+
+func Parse(r io.Reader) (*Menu, error) {
+ var jsonMenu JsonMenu
+ if err := json.NewDecoder(r).Decode(&jsonMenu); err != nil {
+ return nil, err
+ }
+ return jsonMenu.toMenu(), nil
+}
+
+func LoadFromFile(filename string) (*Menu, error) {
+ f, err := os.Open(filename)
+ if err != nil {
+ return nil, fmt.Errorf("Could not load menu from %s: %v", filename, err)
+ }
+ defer f.Close()
+
+ return Parse(f)
+}
diff --git a/serve.go b/serve.go
new file mode 100644
index 0000000..4f3aef8
--- /dev/null
+++ b/serve.go
@@ -0,0 +1,836 @@
+package main
+
+import (
+ "bytes"
+ "database/sql"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "log"
+ "math"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/go-sql-driver/mysql"
+ "github.com/gorilla/mux"
+
+ "code.laria.me/laria.me/atom"
+ "code.laria.me/laria.me/dbutils"
+ "code.laria.me/laria.me/environment"
+ "code.laria.me/laria.me/markdown"
+ "code.laria.me/laria.me/menu"
+)
+
+type serveContext struct {
+ env *environment.Env
+ rwMutex *sync.RWMutex
+ pages map[string]template.HTML
+ menu *menu.Menu
+ views Views
+}
+
+func newServeContext(env *environment.Env) (*serveContext, error) {
+ context := &serveContext{
+ env: env,
+ rwMutex: new(sync.RWMutex),
+ pages: make(map[string]template.HTML),
+ }
+ if err := context.update(); err != nil {
+ return nil, err
+ }
+ return context, nil
+}
+
+var rePageName = regexp.MustCompile(`^(?:[^/]*/)*([^\.]+).*$`)
+
+func pageName(filename string) string {
+ m := rePageName.FindStringSubmatch(filename)
+ if m == nil {
+ return ""
+ }
+
+ return m[1]
+}
+
+func loadPage(filename string) (template.HTML, error) {
+ f, err := os.Open(filename)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+
+ buf := new(bytes.Buffer)
+
+ if _, err := io.Copy(buf, f); err != nil {
+ return "", err
+ }
+
+ html, err := markdown.Parse(buf.String())
+ return template.HTML(html), err
+}
+
+func readPages(pagesPath string) (map[string]template.HTML, error) {
+ f, err := os.Open(pagesPath)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ infos, err := f.Readdir(-1)
+ if err != nil {
+ return nil, err
+ }
+
+ pages := make(map[string]template.HTML)
+ for _, info := range infos {
+ if info.IsDir() {
+ continue
+ }
+
+ name := pageName(info.Name())
+ if name == "" {
+ continue
+ }
+
+ html, err := loadPage(filepath.Join(pagesPath, info.Name()))
+ if err != nil {
+ return nil, err
+ }
+
+ pages[name] = html
+ }
+
+ return pages, nil
+}
+
+func (ctx *serveContext) update() error {
+ conf, err := ctx.env.Config()
+ if err != nil {
+ return err
+ }
+
+ menuPath := path.Join(conf.ContentRoot, "menu.json")
+ menu, err := menu.LoadFromFile(menuPath)
+ if err != nil {
+ return fmt.Errorf("Failed loading menu %s: %w", menuPath, err)
+ }
+
+ pagesPath := path.Join(conf.ContentRoot, "pages")
+ pages, err := readPages(pagesPath)
+ if err != nil {
+ return fmt.Errorf("Failed loading pages from %s: %w", pagesPath, err)
+ }
+
+ views, err := LoadViews(conf.TemplatePath)
+ if err != nil {
+ return fmt.Errorf("Failed loading templates: %w", err)
+ }
+
+ ctx.rwMutex.Lock()
+ defer ctx.rwMutex.Unlock()
+
+ ctx.menu = menu
+ ctx.pages = pages
+ ctx.views = views
+
+ return nil
+}
+
+func (ctx *serveContext) handleUpdate(w http.ResponseWriter, r *http.Request) {
+ conf, err := ctx.env.Config()
+ if err != nil {
+ panic(err)
+ }
+
+ if r.Method != "POST" {
+ w.WriteHeader(405)
+ return
+ }
+
+ if err := r.ParseForm(); err != nil {
+ log.Printf("Could not ParseForm: %s", err)
+ w.WriteHeader(500)
+ return
+ }
+
+ if r.PostForm.Get("secret") != conf.Secret {
+ w.WriteHeader(401)
+ }
+
+ if err := ctx.update(); err != nil {
+ log.Printf("Could not update: %s", err)
+ w.WriteHeader(500)
+ return
+ }
+
+ w.WriteHeader(200)
+}
+
+var reHtmlHeadline = regexp.MustCompile(`<\s*h\d\b`)
+
+func rewriteHeadlines(html template.HTML, sub int) template.HTML {
+ if sub == 0 {
+ return html
+ }
+
+ str := string(html)
+ str = reHtmlHeadline.ReplaceAllStringFunc(str, func(s string) string {
+ _n, _ := strconv.ParseInt(s[len(s)-1:], 10, 8)
+ n := int(_n)
+ n -= sub
+ if n < 1 {
+ n = 1
+ }
+ if n > 6 {
+ n = 6
+ }
+
+ return fmt.Sprintf("<h%d", n)
+ })
+ return template.HTML(str)
+}
+
+// viewArticlesFromDb creates ViewArticles from an SQL query.
+// The quey should select these values: ID, Published, Slug, Title, Content, ReadMore
+func viewArticlesFromDb(db *sql.DB, headlineSub int, query string, args ...interface{}) ([]ViewArticle, int, error) {
+ tx, err := db.Begin()
+ if err != nil {
+ return nil, 0, err
+ }
+
+ articles, count, err := viewArticlesFromDbTx(tx, headlineSub, query, args...)
+ err = dbutils.TxCommitIfOk(tx, err)
+ return articles, count, err
+}
+
+func viewArticlesFromDbTx(tx *sql.Tx, headlineSub int, query string, args ...interface{}) ([]ViewArticle, int, error) {
+ rows, err := tx.Query(query, args...)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer rows.Close()
+
+ ids := make([]int, 0)
+ articlesById := make(map[int]*ViewArticle)
+ for rows.Next() {
+ var va ViewArticle
+ var id int
+
+ var t mysql.NullTime
+
+ if err := rows.Scan(
+ &id,
+ &t,
+ &va.Slug,
+ &va.Title,
+ &va.Content,
+ &va.ReadMore,
+ ); err != nil {
+ return nil, 0, err
+ }
+
+ if t.Valid {
+ va.Published = t.Time
+ }
+
+ va.Content = rewriteHeadlines(va.Content, headlineSub)
+
+ ids = append(ids, id)
+ articlesById[id] = &va
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, 0, err
+ }
+
+ var total int
+ err = tx.QueryRow(`SELECT FOUND_ROWS()`).Scan(&total)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ if len(ids) > 0 {
+ inSql, inArgs := dbutils.BuildInIdsSqlAndArgs("article_id", ids)
+
+ rows, err := tx.Query(`
+ SELECT article_id, tag
+ FROM article_tag
+ WHERE `+inSql+`
+ ORDER BY article_id, tag
+ `, inArgs...)
+
+ if err != nil {
+ return nil, 0, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var id int
+ var tag string
+
+ if err := rows.Scan(&id, &tag); err != nil {
+ return nil, 0, err
+ }
+
+ article, ok := articlesById[id]
+ if ok {
+ article.Tags = append(article.Tags, tag)
+ }
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, 0, err
+ }
+ }
+
+ viewArticles := make([]ViewArticle, 0, len(ids))
+ for _, id := range ids {
+ viewArticles = append(viewArticles, *articlesById[id])
+ }
+
+ return viewArticles, total, nil
+}
+
+type responseWriterWithHeaderSentFlag struct {
+ http.ResponseWriter
+ headersSent bool
+}
+
+func (w *responseWriterWithHeaderSentFlag) Write(p []byte) (int, error) {
+ w.headersSent = true
+ return w.ResponseWriter.Write(p)
+}
+
+func (w *responseWriterWithHeaderSentFlag) WriteHeader(statusCode int) {
+ w.headersSent = true
+ w.ResponseWriter.WriteHeader(statusCode)
+}
+
+var errNotFound = errors.New("not found")
+
+func wrapHandleFunc(
+ name string,
+ f func(http.ResponseWriter, *http.Request) error,
+) func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ wWrap := &responseWriterWithHeaderSentFlag{w, false}
+
+ err := f(wWrap, r)
+ switch err {
+ case nil:
+ return
+ case errNotFound:
+ // TODO: A better 404 page
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(404)
+ if _, err = fmt.Fprintln(w, "404 Not Found"); err != nil {
+ log.Printf("%s: Failed sending 404: %s", name, err)
+ }
+ default:
+ log.Printf("%s: %s", name, err)
+ if !wWrap.headersSent {
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(500)
+ if _, err = fmt.Fprintln(w, "500 Internal Server Error"); err != nil {
+ log.Printf("%s: Failed sending 500: %s", name, err)
+ }
+ }
+ }
+ }
+}
+
+func (ctx *serveContext) handleArticle(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ vars := mux.Vars(r)
+ year, _ := strconv.Atoi(vars["year"])
+ month, _ := strconv.Atoi(vars["month"])
+ day, _ := strconv.Atoi(vars["day"])
+ slug := vars["slug"]
+
+ articles, _, err := viewArticlesFromDb(db, 0, `
+ SELECT article_id, published, slug, title, full_html, 0 AS ReadMore
+ FROM article
+ WHERE
+ slug = ?
+ AND YEAR(published) = ?
+ AND MONTH(published) = ?
+ AND DAY(published) = ?
+ AND NOT hidden
+ `, slug, year, month, day)
+
+ if err != nil {
+ return err
+ }
+
+ if len(articles) != 1 {
+ return errNotFound
+ }
+
+ return ctx.views.RenderArticle(w, ctx.menu, "blog", articles[0])
+}
+
+func (ctx *serveContext) handleArticleQuicklink(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ vars := mux.Vars(r)
+ slug := vars["slug"]
+
+ articles, _, err := viewArticlesFromDb(db, 0, `
+ SELECT article_id, published, slug, title, full_html, 0 AS ReadMore
+ FROM article
+ WHERE
+ slug = ?
+ AND NOT hidden
+ `, slug)
+
+ if err != nil {
+ return err
+ }
+
+ if len(articles) != 1 {
+ return errNotFound
+ }
+
+ article := articles[0]
+ y, m, d := article.Published.Date()
+
+ w.Header().Set("Location", fmt.Sprintf("/blog/%d/%d/%d/%s", y, m, d, slug))
+ w.WriteHeader(301)
+ return nil
+}
+
+func (ctx *serveContext) handleArchiveDay(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ vars := mux.Vars(r)
+ year, _ := strconv.Atoi(vars["year"])
+ month, _ := strconv.Atoi(vars["month"])
+ day, _ := strconv.Atoi(vars["day"])
+
+ articles, _, err := viewArticlesFromDb(db, 1, `
+ SELECT article_id, published, slug, title, full_html, 0 AS ReadMore
+ FROM article
+ WHERE
+ YEAR(published) = ?
+ AND MONTH(published) = ?
+ AND DAY(published) = ?
+ AND NOT hidden
+ ORDER BY published ASC
+ `, year, month, day)
+
+ if err != nil {
+ return err
+ }
+
+ return ctx.views.RenderArchiveDay(w, ctx.menu, "archive", year, month, day, articles)
+}
+
+func countArticlesBy(db *sql.DB, byExpr, whereExpr string, whereArgs ...interface{}) (map[int]int, error) {
+ rows, err := db.Query(`
+ SELECT `+byExpr+`, COUNT(*)
+ FROM article
+ WHERE `+whereExpr+` AND NOT hidden
+ GROUP BY `+byExpr+`
+ `, whereArgs...)
+
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ counts := make(map[int]int)
+ for rows.Next() {
+ var k, v int
+ if err := rows.Scan(&k, &v); err != nil {
+ return nil, err
+ }
+
+ counts[k] = v
+ }
+
+ return counts, nil
+}
+
+func (ctx *serveContext) handleArchiveMonth(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ vars := mux.Vars(r)
+ year, _ := strconv.Atoi(vars["year"])
+ month, _ := strconv.Atoi(vars["month"])
+
+ counts, err := countArticlesBy(db, "DAY(published)", "YEAR(published) = ? AND MONTH(published) = ?", year, month)
+ if err != nil {
+ return err
+ }
+
+ return ctx.views.RenderArchiveMonth(w, ctx.menu, "archive", year, month, counts)
+}
+
+func (ctx *serveContext) handleArchiveYear(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ vars := mux.Vars(r)
+ year, _ := strconv.Atoi(vars["year"])
+
+ counts, err := countArticlesBy(db, "MONTH(published)", "YEAR(published) = ?", year)
+ if err != nil {
+ return err
+ }
+
+ return ctx.views.RenderArchiveYear(w, ctx.menu, "archive", year, counts)
+}
+
+func (ctx *serveContext) handleArchive(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ counts, err := countArticlesBy(db, "YEAR(published)", "1")
+ if err != nil {
+ return err
+ }
+
+ return ctx.views.RenderArchive(w, ctx.menu, "archive", counts)
+}
+
+const articles_per_page = 30
+
+func getPageArgument(r *http.Request) int {
+ vals, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ return 1
+ }
+
+ if _, ok := vals["page"]; !ok {
+ return 1
+ }
+
+ rawPage := vals.Get("page")
+ page, err := strconv.ParseInt(rawPage, 10, 32)
+ if err != nil {
+ return 1
+ }
+
+ if page < 1 {
+ return 1
+ }
+
+ return int(page)
+}
+
+func calcPages(total int) int {
+ return int(math.Ceil(float64(total) / articles_per_page))
+}
+
+func (ctx *serveContext) handleTag(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ vars := mux.Vars(r)
+ tag := vars["tag"]
+
+ page := getPageArgument(r)
+
+ articles, total, err := viewArticlesFromDb(db, 1, `
+ SELECT SQL_CALC_FOUND_ROWS
+ a.article_id,
+ a.published,
+ a.slug,
+ a.title,
+ IF(a.summary_html = '', a.full_html, a.summary_html),
+ a.summary_html != '' AS ReadMore
+ FROM article_tag t
+ INNER JOIN article a
+ ON a.article_id = t.article_id
+ WHERE
+ t.tag = ?
+ AND NOT a.hidden
+ ORDER BY published DESC
+ LIMIT ? OFFSET ?
+ `, tag, articles_per_page, (page-1)*articles_per_page)
+
+ if err != nil {
+ return err
+ }
+
+ pages := calcPages(total)
+ return ctx.views.RenderTag(w, ctx.menu, "tags", tag, articles, pages, page)
+}
+
+func countTags(db *sql.DB) (map[string]int, error) {
+ rows, err := db.Query(`
+ SELECT
+ at.tag,
+ COUNT(*)
+ FROM article_tag at
+ INNER JOIN article a
+ ON a.article_id = at.article_id
+ WHERE NOT a.hidden
+ GROUP BY at.tag
+ `)
+
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ counts := make(map[string]int)
+ for rows.Next() {
+ var k string
+ var v int
+
+ if err := rows.Scan(&k, &v); err != nil {
+ return nil, err
+ }
+
+ counts[k] = v
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ return counts, nil
+}
+
+func (ctx *serveContext) handleTags(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ counts, err := countTags(db)
+ if err != nil {
+ return err
+ }
+
+ return ctx.views.RenderTags(w, ctx.menu, "tags", counts)
+}
+
+func getSearchQueryArgument(r *http.Request) string {
+ vals, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ return ""
+ }
+
+ if _, ok := vals["q"]; !ok {
+ return ""
+ }
+
+ q := vals.Get("q")
+
+ return strings.TrimSpace(q)
+}
+
+func (ctx *serveContext) handleSearch(w http.ResponseWriter, r *http.Request) error {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return err
+ }
+
+ q := getSearchQueryArgument(r)
+ page := getPageArgument(r)
+
+ articles := []ViewArticle{}
+ total := 0
+
+ if q != "" {
+ articles, total, err = viewArticlesFromDb(db, 1, `
+ SELECT SQL_CALC_FOUND_ROWS
+ article_id,
+ published,
+ slug,
+ title,
+ IF(summary_html = '', full_html, summary_html),
+ summary_html != '' AS ReadMore
+ FROM article
+ WHERE
+ (MATCH(full_plain) AGAINST(?) OR MATCH(title) AGAINST (?))
+ AND NOT hidden
+ ORDER BY published DESC
+ LIMIT ? OFFSET ?
+ `, q, q, articles_per_page, (page-1)*articles_per_page)
+
+ if err != nil {
+ return err
+ }
+ }
+
+ return ctx.views.RenderSearch(
+ w,
+ ctx.menu,
+ "search",
+ q,
+ total,
+ articles,
+ calcPages(total),
+ page,
+ )
+}
+
+func (ctx *serveContext) getBlogData(limit, offset int) ([]ViewArticle, int, error) {
+ db, err := ctx.env.DB()
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return viewArticlesFromDb(db, 1, `
+ SELECT SQL_CALC_FOUND_ROWS
+ article_id,
+ published,
+ slug,
+ title,
+ IF(summary_html = '', full_html, summary_html),
+ summary_html != '' AS ReadMore
+ FROM article
+ WHERE NOT hidden
+ ORDER BY published DESC
+ LIMIT ? OFFSET ?
+ `, limit, offset)
+}
+
+const numFeedEntries = 30
+
+func (ctx *serveContext) handleFeed(w http.ResponseWriter, r *http.Request) error {
+ articles, _, err := ctx.getBlogData(numFeedEntries, 0)
+
+ if err != nil {
+ return err
+ }
+
+ entries := make([]atom.Entry, 0, len(articles))
+ for _, article := range articles {
+ y, m, d := article.Published.Date()
+ url := fmt.Sprintf("http://laria.me/blog/%d/%d/%d/%s", y, m, d, article.Slug)
+ entries = append(entries, atom.Entry{
+ Title: article.Title,
+ Id: url,
+ Updated: article.Published, // TODO: Or should modification time be tracked?
+ Summary: atom.Summary{
+ Type: "html",
+ Content: string(article.Content),
+ },
+ Links: []atom.Link{
+ atom.Link{Rel: "alternate", Href: url},
+ },
+ })
+ }
+
+ feed := atom.Feed{
+ Title: "laria.me Blog",
+ Links: []atom.Link{
+ atom.Link{Href: "http://laria.me/"},
+ atom.Link{Href: "http://laria.me/blog/feed.xml", Rel: "self"},
+ },
+ Id: "http://laria.me/blog",
+ AuthorName: "Laria Carolin Chabowski",
+ AuthorEmail: "laria-blog@laria.me",
+ AuthorUri: "http://laria.me",
+ Updated: articles[0].Published, // TODO: This should be able to deal with articles being empty
+ Entries: entries,
+ }
+
+ return xml.NewEncoder(w).Encode(feed)
+}
+
+func (ctx *serveContext) handleBlog(w http.ResponseWriter, r *http.Request) error {
+ page := getPageArgument(r)
+
+ articles, total, err := ctx.getBlogData(articles_per_page, (page-1)*articles_per_page)
+
+ if err != nil {
+ return err
+ }
+
+ pages := calcPages(total)
+ return ctx.views.RenderBlog(w, ctx.menu, "blog", articles, pages, page)
+}
+
+func (ctx *serveContext) handlePage(w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+ pageName := vars["page"]
+
+ page, ok := ctx.pages[pageName]
+ if !ok {
+ return errNotFound
+ }
+
+ return ctx.views.RenderContent(w, ctx.menu, pageName, page)
+}
+
+const blogArticlesOnHomepage = 3
+
+func (ctx *serveContext) handleHome(w http.ResponseWriter, r *http.Request) error {
+ articles, _, err := ctx.getBlogData(blogArticlesOnHomepage, 0)
+
+ if err != nil {
+ return err
+ }
+
+ return ctx.views.RenderStart(w, ctx.menu, "", ctx.pages["hello"], articles)
+}
+
+func cmdServe(progname string, env *environment.Env, args []string) {
+ config, err := env.Config()
+ if err != nil {
+ log.Fatalf("Could not load config: %s", err)
+ }
+
+ ctx, err := newServeContext(env)
+ if err != nil {
+ log.Fatalf("Could not create serveContext: %s", err)
+ }
+
+ r := mux.NewRouter()
+
+ if config.StaticPath != "" {
+ r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
+ }
+
+ r.HandleFunc("/__update", ctx.handleUpdate)
+ r.HandleFunc("/blog/q/{slug}", wrapHandleFunc("article-quicklink", ctx.handleArticleQuicklink))
+ r.HandleFunc("/blog/{year:[0-9]+}/{month:[0-9]+}/{day:[0-9]+}/{slug}", wrapHandleFunc("article", ctx.handleArticle))
+ r.HandleFunc("/blog/{year:[0-9]+}/{month:[0-9]+}/{day:[0-9]+}", wrapHandleFunc("archiveDay", ctx.handleArchiveDay))
+ r.HandleFunc("/blog/{year:[0-9]+}/{month:[0-9]+}", wrapHandleFunc("archiveMonth", ctx.handleArchiveMonth))
+ r.HandleFunc("/blog/{year:[0-9]+}", wrapHandleFunc("archiveYear", ctx.handleArchiveYear))
+ r.HandleFunc("/blog/archive", wrapHandleFunc("archive", ctx.handleArchive))
+ r.HandleFunc("/blog/tags/{tag}", wrapHandleFunc("tag", ctx.handleTag))
+ r.HandleFunc("/blog/tags", wrapHandleFunc("tags", ctx.handleTags))
+ r.HandleFunc("/blog/search", wrapHandleFunc("search", ctx.handleSearch))
+ r.HandleFunc("/blog/feed.xml", wrapHandleFunc("feed", ctx.handleFeed))
+ r.HandleFunc("/blog", wrapHandleFunc("blog", ctx.handleBlog))
+ r.HandleFunc("/{page}", wrapHandleFunc("page", ctx.handlePage))
+ r.HandleFunc("/", wrapHandleFunc("home", ctx.handleHome))
+
+ if err := http.ListenAndServe(config.HttpLaddr, r); err != nil {
+ log.Fatalln(err)
+ }
+}
diff --git a/templates/archive-day.html b/templates/archive-day.html
new file mode 100644
index 0000000..16bd09a
--- /dev/null
+++ b/templates/archive-day.html
@@ -0,0 +1,13 @@
+{{define "main"}}
+{{- $year := .Year -}}
+{{- $month := .Month -}}
+{{- $day := .Day -}}
+<h1>{{nth .Day}} <a href="{{archive_link .Year .Month}}">{{.MonthText}}</a> <a href="{{archive_link .Year}}">{{.Year}}</a></h1>
+{{with .Articles}}
+{{template "article_list" .}}
+{{else}}
+<p>Nothing for this day.</p>
+{{end}}
+
+<p><a href="{{archive_link .Year .Month (add .Day -1)}}" rel="prev">&lt; Previous</a> | <a href="{{archive_link .Year .Month (add .Day 1)}}" rel="next">Next &gt;</a></p>
+{{end}}
diff --git a/templates/archive-month.html b/templates/archive-month.html
new file mode 100644
index 0000000..1d872a9
--- /dev/null
+++ b/templates/archive-month.html
@@ -0,0 +1,16 @@
+{{define "main"}}
+{{- $year := .Year -}}
+{{- $month := .Month -}}
+<h1>{{month_text .Month}} <a href="{{archive_link .Year}}">{{.Year}}</a></h1>
+{{with .Days}}
+<ul>
+ {{range .}}
+ <li><a href="{{archive_link $year $month .Num}}">{{day_text $year $month .Num}} [{{.Count}}]</a>
+ {{end}}
+</ul>
+{{else}}
+<p>Nothing for this month.</p>
+{{end}}
+
+<p><a href="{{archive_link .Year (add .Month -1)}}" rel="prev">&lt; Previous</a> | <a href="{{archive_link .Year (add .Month 1)}}" rel="next">Next &gt;</a></p>
+{{end}}
diff --git a/templates/archive-year.html b/templates/archive-year.html
new file mode 100644
index 0000000..c4aa7ee
--- /dev/null
+++ b/templates/archive-year.html
@@ -0,0 +1,15 @@
+{{define "main"}}
+{{- $year := .Year -}}
+<h1>{{.Year}}</h1>
+{{with .Months}}
+<ul>
+ {{range .}}
+ <li><a href="{{archive_link $year .Num}}">{{month_text .Num}} {{$year}} [{{.Count}}]</a>
+ {{end}}
+</ul>
+{{else}}
+<p>Nothing for this year.</p>
+{{end}}
+
+<p><a href="{{archive_link (add .Year -1)}}" rel="prev">&lt; Previous</a> | <a href="{{archive_link (add .Year 1)}}" rel="next">Next &gt;</a></p>
+{{end}}
diff --git a/templates/archive.html b/templates/archive.html
new file mode 100644
index 0000000..42eba08
--- /dev/null
+++ b/templates/archive.html
@@ -0,0 +1,12 @@
+{{define "main"}}
+<h1>Archive</h1>
+{{with .Years}}
+<ul>
+ {{range .}}
+ <li><a href="{{archive_link .Num}}">{{.Num}} [{{.Count}}]</a>
+ {{end}}
+</ul>
+{{else}}
+<p>Nothing yet :(</p>
+{{end}}
+{{end}}
diff --git a/templates/article.html b/templates/article.html
new file mode 100644
index 0000000..a208c75
--- /dev/null
+++ b/templates/article.html
@@ -0,0 +1,7 @@
+{{define "main"}}
+<article>
+ <h1>{{.Title}}</h1>
+ {{template "article_meta" .}}
+ <div class="content">{{.Content}}</div>
+</article>
+{{end}}
diff --git a/templates/blog.html b/templates/blog.html
new file mode 100644
index 0000000..117f5ab
--- /dev/null
+++ b/templates/blog.html
@@ -0,0 +1,12 @@
+{{define "main"}}
+{{with .Articles}}
+ {{template "article_list" .}}
+{{else}}
+ <p>Nothing found</p>
+{{end}}
+
+{{if gt .Pages 1}}
+ <p>{{pagination .Pages .Page "/blog"}}</p>
+{{end}}
+
+{{end}}
diff --git a/templates/content.html b/templates/content.html
new file mode 100644
index 0000000..ba58e3c
--- /dev/null
+++ b/templates/content.html
@@ -0,0 +1,3 @@
+{{define "main"}}
+<article>{{.}}</article>
+{{end}}
diff --git a/templates/root.html b/templates/root.html
new file mode 100644
index 0000000..abc66bf
--- /dev/null
+++ b/templates/root.html
@@ -0,0 +1,66 @@
+{{- define "article_meta"}}
+ <dl class="meta">
+ <div>
+ <dt>Published</dt>
+ <dd><time datetime="2006-01-02T15:04:05-0700">{{.Published.Format "Mon, Jan 2 2006, 15:04"}}</time></dd>
+ </div>
+ {{with .Tags}}
+ <div>
+ <dt>Tags</dt>
+ <dd><ul class="article-tags">{{range .}}
+ <li><a href="/blog/tags/{{.}}">{{.}}</a></li>
+ {{end}}</ul></dd>
+ </div>
+ {{end}}
+ </dl>
+{{end -}}
+{{- define "article_list"}}
+ {{range .}}
+ {{- $year := .Published.Format "2006" -}}
+ {{- $month := .Published.Format "01" -}}
+ {{- $day := .Published.Format "02" -}}
+ <article>
+ <h2><a href="/blog/{{$year}}/{{$month}}/{{$day}}/{{.Slug}}">{{.Title}}</a></h2>
+ {{template "article_meta" .}}
+ <div class="content">{{.Content}}</div>
+ {{if .ReadMore -}}
+ <p class="readmore-outer"><a href="/blog/{{$year}}/{{$month}}/{{$day}}/{{.Slug}}">Read more ...</a></p>
+ {{- end}}
+ </article>
+ {{end}}
+{{end -}}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{{with .Title}}{{.}} - {{end}}laria.me</title>
+ <link rel="stylesheet" type="text/css" href="/static/style.css">
+ <link rel="stylesheet" type="text/css" href="/static/syntax.css">
+ <link rel="alternate" type="application/atom+xml" href="/blog/feed.xml" title="Atom-Feed of the blog">
+ <link rel="author" href="/about-me" />
+ <meta name="author" content="Laria Carolin Chabowski" />
+ <meta name="description" content="Laria's website. They mainly write about adventures in programming but will also occasionally write about other things that interest them." />
+ <meta name="keywords" content="programming,blog,golang,php,music,links,gsrm,lgbt,lgbtq,genderqueer,trans,technology,web,opensource" />
+</head>
+<body>
+ <a href="#maincontent" class="skip-to-main-content">Skip to main content</a>
+ <header>
+ <a href="/" class="logolink">laria.me</a>
+ <nav>
+ {{ range .Menu }}
+ <!-- TODO: Label menus for screenreaders -->
+ <ul class="menu-level">{{ range . }}
+ <li {{ if .Active }}class="cur"{{ end }}><a href="{{ .Url }}">{{ .Title }}</a></li>
+ {{ end }}</ul>
+ {{ end }}
+ </nav>
+ </header>
+ <main id="maincontent">{{ template "main" .Main }}</main>
+ <footer>
+ <p>Contents of this page is copyrighted under the [WTFPL](http://www.wtfpl.net), unless noted otherwise. The content of the linked pages is © of their respective owners. You can contact me via email: <code>laria (minus) blog (at) laria (dot) me</code>.</p>
+ <p>If you really need more info, use the <a href="/impressum">Impressum</a>.</p>
+ </footer>
+</body>
+</html>
diff --git a/templates/search.html b/templates/search.html
new file mode 100644
index 0000000..9a6dbf7
--- /dev/null
+++ b/templates/search.html
@@ -0,0 +1,21 @@
+{{define "main"}}
+<form action="/blog/search" method="get">
+ <label>Search terms: <input type="text" name="q" value="{{.Q}}"></label>
+ <button type="submit">Go</button>
+ <!-- TODO: Form looks ugly, especially in dark mode -->
+</form>
+
+{{if .Q}}
+{{if gt .Total 0}}
+ <h1>Results ({{.Total}} total)</h1>
+ {{template "article_list" .Results}}
+
+ {{if gt .Pages 1}}
+ <p>{{pagination .Pages .Page "/blog/search" "q" .Q}}</p>
+ {{end}}
+{{else}}
+ <p>Nothing found</p>
+{{end}}
+{{end}}
+
+{{end}}
diff --git a/templates/start.html b/templates/start.html
new file mode 100644
index 0000000..cd97639
--- /dev/null
+++ b/templates/start.html
@@ -0,0 +1,8 @@
+{{block "main" .}}
+<article class="main-content">{{.Content}}</article>
+{{with .Blog}}
+ <p>Here are my latest blog entries:</p>
+ {{template "article_list" .}}
+ <p><a href="/blog">Read more blog entries</a>.</p>
+{{end}}
+{{end}}
diff --git a/templates/tag.html b/templates/tag.html
new file mode 100644
index 0000000..02fe5b3
--- /dev/null
+++ b/templates/tag.html
@@ -0,0 +1,13 @@
+{{define "main"}}
+<h1>Tag: {{.Tag}}</h1>
+{{with .Articles}}
+ {{template "article_list" .}}
+{{else}}
+ <p>Nothing found</p>
+{{end}}
+
+{{if gt .Pages 1}}
+ <p>{{pagination .Pages .Page (concat "/blog/tags/" .Tag)}}</p>
+{{end}}
+
+{{end}}
diff --git a/templates/tags.html b/templates/tags.html
new file mode 100644
index 0000000..7df7351
--- /dev/null
+++ b/templates/tags.html
@@ -0,0 +1,6 @@
+{{define "main"}}
+<h1>Tags</h1>
+{{with .Tags}}<ul class="tagcloud">{{range .}}
+ <li class="tc-{{.SizeClass}}"><a href="/blog/tags/{{.Tag}}">{{.Tag}}</a></li>
+{{end}}</ul>{{end}}
+{{end}}
diff --git a/update.go b/update.go
new file mode 100644
index 0000000..82024b7
--- /dev/null
+++ b/update.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+ "database/sql"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+
+ "code.laria.me/laria.me/article"
+ "code.laria.me/laria.me/config"
+ "code.laria.me/laria.me/environment"
+)
+
+func allArticlesFromDir(dir string) ([]article.Article, error) {
+ log.Printf("allArticlesFromDir(%s)", dir)
+ f, err := os.Open(dir)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ infos, err := f.Readdir(-1)
+ if err != nil {
+ return nil, err
+ }
+
+ articles := make([]article.Article, 0)
+
+ for _, info := range infos {
+ if info.IsDir() {
+ continue
+ }
+
+ fullname := filepath.Join(dir, info.Name())
+ a, err := article.LoadArticle(fullname)
+ if err != nil {
+ return nil, err
+ }
+
+ articles = append(articles, a)
+ }
+
+ return articles, nil
+}
+
+func updateArticles(conf *config.Config, db *sql.DB) {
+ articles, err := allArticlesFromDir(path.Join(conf.ContentRoot, "articles"))
+ if err != nil {
+ log.Fatalf("allArticlesFromDir(): %s", err)
+ }
+
+ slugs := make([]string, 0, len(articles))
+ for _, article := range articles {
+ if _, err = article.SaveToDb(db); err != nil {
+ log.Fatalf("SaveToDb: %s", err)
+ }
+
+ slugs = append(slugs, article.Slug)
+ }
+
+ if err = article.DeleteArticlesFromDbExcept(db, slugs); err != nil {
+ log.Fatalf("DeleteArticlesFromDbExcept: %s", err)
+ }
+}
+
+func cmdUpdate(progname string, env *environment.Env, args []string) {
+ conf, err := env.Config()
+ if err != nil {
+ log.Fatalf("env.Config() failed: %s", err)
+ }
+
+ db, err := env.DB()
+ if err != nil {
+ log.Fatalf("env.Config() failed: %s", err)
+ }
+
+ updateArticles(conf, db)
+
+ resp, err := http.PostForm(conf.UpdateUrl, url.Values{"secret": {conf.Secret}})
+ if err != nil {
+ log.Fatalf("triggering server update failed: %s", err)
+ }
+
+ if resp.StatusCode != 200 {
+ log.Fatalf("server update unexpectedly responded with %d %s", resp.StatusCode, resp.Status)
+ }
+}
diff --git a/views.go b/views.go
new file mode 100644
index 0000000..60c6d7e
--- /dev/null
+++ b/views.go
@@ -0,0 +1,526 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "io"
+ "math"
+ "path"
+ "sort"
+ "strings"
+ "time"
+
+ "code.laria.me/laria.me/menu"
+)
+
+type ViewMenuItem struct {
+ Active bool
+ Url string
+ Title string
+}
+
+type ViewMenu [][]ViewMenuItem
+
+func buildViewMenuLevel(
+ item *menu.MenuItem,
+ current string,
+ withNextLevel bool,
+) (level []ViewMenuItem, nextLevel []ViewMenuItem) {
+ level = make([]ViewMenuItem, 0, len(item.Children))
+
+ for _, child := range item.Children {
+ isCur := child.Ident == current
+
+ level = append(level, ViewMenuItem{
+ Active: isCur,
+ Url: child.Url,
+ Title: child.Title,
+ })
+
+ if isCur && withNextLevel {
+ nextLevel, _ = buildViewMenuLevel(child, "", false)
+ }
+ }
+
+ return
+}
+
+func buildViewMenuLevels(item *menu.MenuItem, current string, withNextLevel bool) ViewMenu {
+ if item == nil {
+ return nil
+ }
+
+ viewMenu := buildViewMenuLevels(item.Parent, item.Ident, false)
+
+ level, nextLevel := buildViewMenuLevel(item, current, withNextLevel)
+
+ if len(level) > 0 {
+ viewMenu = append(viewMenu, level)
+ }
+
+ if len(nextLevel) > 0 {
+ viewMenu = append(viewMenu, nextLevel)
+ }
+
+ return viewMenu
+}
+
+func BuildViewMenu(menu *menu.Menu, current string) ViewMenu {
+ curMenu := menu.Root()
+
+ curMenuItem := menu.ByIdent(current)
+ if curMenuItem != nil {
+ curMenu = curMenuItem.Parent
+ }
+
+ return buildViewMenuLevels(curMenu, current, true)
+}
+
+type RootData struct {
+ Menu ViewMenu
+ Title string
+ Main interface{}
+}
+
+type ViewArticle struct {
+ Published time.Time
+ Slug string
+ Title string
+ Content template.HTML
+ ReadMore bool
+ Tags []string
+}
+
+type Views struct {
+ archiveDay *template.Template
+ archive *template.Template
+ archiveMonth *template.Template
+ archiveYear *template.Template
+ article *template.Template
+ blog *template.Template
+ content *template.Template
+ search *template.Template
+ start *template.Template
+ tag *template.Template
+ tags *template.Template
+}
+
+func monthText(m int) string {
+ switch m {
+ case 1:
+ return "January"
+ case 2:
+ return "February"
+ case 3:
+ return "March"
+ case 4:
+ return "April"
+ case 5:
+ return "May"
+ case 6:
+ return "June"
+ case 7:
+ return "July"
+ case 8:
+ return "August"
+ case 9:
+ return "September"
+ case 10:
+ return "October"
+ case 11:
+ return "November"
+ case 12:
+ return "December"
+ default:
+ return fmt.Sprintf("<unknown month %d>", m)
+ }
+}
+
+func nth(n int) string {
+ switch n {
+ case 1:
+ return "1st"
+ case 2:
+ return "2nd"
+ case 3:
+ return "3rd"
+ default:
+ return fmt.Sprintf("%dth", n)
+ }
+}
+
+type paginationArg struct {
+ K, V string
+}
+
+type paginationTemplateData struct {
+ Action string
+ Args []paginationArg
+ Cur int
+ Pages int
+}
+
+var paginationTemplate = template.Must(template.New("").Funcs(template.FuncMap{"seq": func(max int) <-chan int {
+ ch := make(chan int)
+ go func() {
+ defer close(ch)
+ for i := 1; i <= max; i++ {
+ ch <- i
+ }
+ }()
+ return ch
+}}).Parse(`<form action="{{.Action}}" method="get" class="pagination">
+ {{- range .Args -}}
+ <input type="hidden" name="{{.K}}" value="{{.V}}">
+ {{- end -}}
+ {{- $cur := .Cur -}}
+ <label for="pagination-select">Page:</label>
+ <select name="page" id="pagination-select">{{- range (seq .Pages) -}}
+ <option {{if eq . $cur}}selected{{end}} value="{{.}}">{{.}}</option>
+ {{- end -}}</select>
+ <button type="submit">Go to</button>
+</form>`))
+
+func normalizeDate(y, m, d int) (int, int, int) {
+ t := time.Date(y, time.Month(m), d, 0, 0, 0, 0, time.UTC)
+ y, month, d := t.Date()
+
+ return y, int(month), d
+}
+
+func dayText(y, m, d int) string {
+ return fmt.Sprintf("%s %s %d", nth(d), monthText(m), y)
+}
+
+func LoadViews(templatesDir string) (Views, error) {
+ views := Views{}
+
+ root, err := template.New("root.html").ParseFiles(path.Join(templatesDir, "root.html"))
+
+ if err != nil {
+ return views, err
+ }
+
+ root.Funcs(template.FuncMap{
+ "add": func(nums ...int) int {
+ sum := 0
+ for _, i := range nums {
+ sum = sum + i
+ }
+ return sum
+ },
+ "concat": func(ss ...string) string {
+ sb := new(strings.Builder)
+ for _, s := range ss {
+ sb.WriteString(s)
+ }
+ return sb.String()
+ },
+ "nth": nth,
+ "day_text": dayText,
+ "month_text": monthText,
+ "pagination": func(pages, page int, path string, queryArgs ...string) (template.HTML, error) {
+ if len(queryArgs)%2 != 0 {
+ return "", fmt.Errorf("pagination: need even number of query args")
+ }
+
+ args := make([]paginationArg, 0, len(queryArgs)/2)
+ for i := 0; i < len(queryArgs); i += 2 {
+ args = append(args, paginationArg{K: queryArgs[i], V: queryArgs[i+1]})
+ }
+
+ buf := new(bytes.Buffer)
+
+ err := paginationTemplate.Execute(buf, paginationTemplateData{
+ Action: path,
+ Args: args,
+ Cur: page,
+ Pages: pages,
+ })
+
+ if err != nil {
+ return "", err
+ }
+
+ return template.HTML(buf.String()), nil
+ },
+ "archive_link": func(components ...int) (string, error) {
+ switch len(components) {
+ case 0:
+ return "/blog/archive", nil
+ case 1:
+ y := components[0]
+ return fmt.Sprintf("/blog/%d", y), nil
+ case 2:
+ y := components[0]
+ m := components[1]
+
+ y, m, _ = normalizeDate(y, m, 1)
+
+ return fmt.Sprintf("/blog/%d/%d", y, m), nil
+ case 3:
+ y := components[0]
+ m := components[1]
+ d := components[2]
+
+ y, m, d = normalizeDate(y, m, d)
+
+ return fmt.Sprintf("/blog/%d/%d/%d", y, m, d), nil
+ default:
+ return "", fmt.Errorf("archive_link accepts at most 3 arguments")
+ }
+ },
+ })
+
+ for name, t := range map[string]**template.Template{
+ "archive-day": &(views.archiveDay),
+ "archive": &(views.archive),
+ "archive-month": &(views.archiveMonth),
+ "archive-year": &(views.archiveYear),
+ "article": &(views.article),
+ "blog": &(views.blog),
+ "content": &(views.content),
+ "search": &(views.search),
+ "start": &(views.start),
+ "tag": &(views.tag),
+ "tags": &(views.tags),
+ } {
+ templateFile := path.Join(templatesDir, name+".html")
+
+ if *t, err = template.Must(root.Clone()).ParseFiles(templateFile); err != nil {
+ return views, fmt.Errorf("Failed loading template %s: %w", name, err)
+ }
+ }
+
+ return views, nil
+}
+
+func (v Views) RenderArchiveDay(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ y, m, d int,
+ articles []ViewArticle,
+) error {
+ return v.archiveDay.Execute(w, RootData{BuildViewMenu(menu, curMenu), dayText(y, m, d), struct {
+ Year, Month, Day int
+ MonthText string
+ Articles []ViewArticle
+ }{
+ Year: y,
+ Month: m,
+ Day: d,
+ MonthText: monthText(m),
+ Articles: articles,
+ }})
+}
+
+type archiveEntryWithCount struct {
+ Num int
+ Count int
+}
+
+type archiveEntriesWithCount []archiveEntryWithCount
+
+func (a archiveEntriesWithCount) Len() int { return len(a) }
+func (a archiveEntriesWithCount) Less(i, j int) bool { return a[i].Num < a[j].Num }
+func (a archiveEntriesWithCount) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
+
+func buildArchiveEntries(keyedCounts map[int]int) archiveEntriesWithCount {
+ entries := make(archiveEntriesWithCount, 0, len(keyedCounts))
+ for k, v := range keyedCounts {
+ entries = append(entries, archiveEntryWithCount{
+ Num: k,
+ Count: v,
+ })
+ }
+
+ sort.Sort(entries)
+
+ return entries
+}
+
+func (v Views) RenderArchive(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ countByYear map[int]int,
+) error {
+ return v.archive.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Archive", struct {
+ Years archiveEntriesWithCount
+ }{Years: buildArchiveEntries(countByYear)}})
+}
+
+func (v Views) RenderArchiveMonth(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ year, month int,
+ countByDay map[int]int,
+) error {
+ title := fmt.Sprintf("%s %d", monthText(month), year)
+
+ return v.archiveMonth.Execute(w, RootData{BuildViewMenu(menu, curMenu), title, struct {
+ Year, Month int
+ Days archiveEntriesWithCount
+ }{
+ Year: year,
+ Month: month,
+ Days: buildArchiveEntries(countByDay),
+ }})
+}
+
+func (v Views) RenderArchiveYear(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ year int,
+ countByMonth map[int]int,
+) error {
+ return v.archiveYear.Execute(w, RootData{BuildViewMenu(menu, curMenu), string(year), struct {
+ Year int
+ Months archiveEntriesWithCount
+ }{
+ Year: year,
+ Months: buildArchiveEntries(countByMonth),
+ }})
+}
+
+func (v Views) RenderArticle(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ article ViewArticle,
+) error {
+ return v.article.Execute(w, RootData{BuildViewMenu(menu, curMenu), article.Title, article})
+}
+
+func (v Views) RenderContent(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ html template.HTML,
+) error {
+ return v.content.Execute(w, RootData{BuildViewMenu(menu, curMenu), "", html})
+}
+
+func (v Views) RenderSearch(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ query string,
+ total int,
+ results []ViewArticle,
+ pages int,
+ page int,
+) error {
+ return v.search.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Search", struct {
+ Q string
+ Total int
+ Results []ViewArticle
+ Pages, Page int
+ }{
+ Q: query,
+ Total: total,
+ Results: results,
+ Pages: pages,
+ Page: page,
+ }})
+}
+
+func (v Views) RenderStart(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ content template.HTML,
+ blogArticles []ViewArticle,
+) error {
+ return v.start.Execute(w, RootData{BuildViewMenu(menu, curMenu), "", struct {
+ Content template.HTML
+ Blog []ViewArticle
+ }{
+ Content: content,
+ Blog: blogArticles,
+ }})
+}
+
+func (v Views) RenderTag(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ tag string,
+ articles []ViewArticle,
+ pages, page int,
+) error {
+ return v.tag.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Tag " + tag, struct {
+ Tag string
+ Articles []ViewArticle
+ Pages, Page int
+ }{
+ Tag: tag,
+ Articles: articles,
+ Pages: pages,
+ Page: page,
+ }})
+}
+
+func (v Views) RenderBlog(
+ w io.Writer,
+ menu *menu.Menu,
+ curMenu string,
+ articles []ViewArticle,
+ pages, page int,
+) error {
+ return v.blog.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Blog", struct {
+ Articles []ViewArticle
+ Pages, Page int
+ }{
+ Articles: articles,
+ Pages: pages,
+ Page: page,
+ }})
+}
+
+type tagcloudTag struct {
+ Tag string
+ SizeClass int
+}
+
+type tagcloudTags []tagcloudTag
+
+func (t tagcloudTags) Len() int { return len(t) }
+func (t tagcloudTags) Less(i, j int) bool {
+ return strings.ToLower(t[i].Tag) < strings.ToLower(t[j].Tag)
+}
+func (t tagcloudTags) Swap(i, j int) { t[i], t[j] = t[j], t[i] }
+
+const tagcloudCategories = 5
+
+func (v Views) RenderTags(w io.Writer, menu *menu.Menu, curMenu string, tagCounts map[string]int) error {
+ tags := make(tagcloudTags, 0, len(tagCounts))
+
+ maxCount := 0
+ for tag, count := range tagCounts {
+ tags = append(tags, tagcloudTag{
+ Tag: tag,
+ SizeClass: count,
+ })
+
+ if count > maxCount {
+ maxCount = count
+ }
+ }
+
+ for i, tag := range tags {
+ tags[i].SizeClass = int(math.Ceil((float64(tag.SizeClass) / float64(maxCount)) * tagcloudCategories))
+ }
+
+ sort.Sort(tags)
+
+ return v.tags.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Tags", struct {
+ Tags tagcloudTags
+ }{
+ Tags: tags,
+ }})
+}