diff --git a/.gitignore b/.gitignore
index 87fa001..57a1644 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
 # ignore binaries
-chameleon
 config.toml
-.storage
+/.storage/
 .mypy_cache/
 chameleon.tar.gz
+/.repo/
+/.database.bin
+/.task/
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..34d10b9
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,27 @@
+linters:
+  disable-all: true
+  # https://golangci-lint.run/usage/linters/
+  enable:
+    - gosimple
+    - deadcode
+    - ineffassign
+    - asciicheck
+    - dogsled
+    - exhaustive
+    - exportloopref
+    - gocritic
+    - gofmt
+    - golint
+    - prealloc
+    - lll
+    - sqlclosecheck
+    - tparallel
+    - unconvert
+
+linters-settings:
+  gocritic:
+    disabled-checks:
+      - exitAfterDefer
+      - ifElseChain
+  lll:
+    line-length: 100
diff --git a/README.md b/README.md
index 7e70996..8a96afa 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,37 @@
 # Chameleon
 
-Chameleon is web application that reflects content from markdown files from Github. Powers [articles.orsinium.dev](https://articles.orsinium.dev/).
+Chameleon is web application (blog engine) that reflects content from markdown files from a git repository. Powers [articles.orsinium.dev](https://articles.orsinium.dev/).
 
-## Run from release
+Features:
 
-1. [Download release](https://github.com/orsinium/chameleon/releases).
-2. Extract: `tar -xzf chameleon.tar.gz`
-3. Edit config: `nano config.toml`
-4. Run binary release for your platform: `./linux-amd64.bin`
++ Markdown
++ Minimalistic UI
++ Easy to use, no CI or a special repo structure required
++ Zero configuration
++ Single binary
++ Automatically pull the repo by schedule
++ Built-in prose linter ([Vale](https://github.com/errata-ai/vale))
++ Syntax highlighting ([Prism](https://prismjs.com/))
++ Formulas ([MathJax](https://www.mathjax.org/))
++ Emoji ([enescakir/emoji](https://github.com/enescakir/emoji))
++ Views count
++ Great performance and server-side caching
++ Optional password protection
++ Search
++ Minification ([minify](https://github.com/tdewolff/minify#examples))
 
-## Run from source
+## Usage
+
+Build:
 
 ```bash
-git clone https://github.com/orsinium/chameleon
+git clone https://github.com/life4/chameleon.git
 cd chameleon
-cp config{_example,}.toml
-go get .
-go run *.go
+go build -o chameleon.bin .
 ```
 
-## Run from build
+Run:
 
 ```bash
-go build .
-./chameleon
+./chameleon.bin --path ./path/to/repo/
 ```
diff --git a/Taskfile.yml b/Taskfile.yml
new file mode 100644
index 0000000..1c683b0
--- /dev/null
+++ b/Taskfile.yml
@@ -0,0 +1,10 @@
+# https://taskfile.dev/
+version: "3"
+
+tasks:
+  go:run:
+    sources:
+      - chameleon/
+      - main.go
+    cmds:
+      - go run .
diff --git a/article.go b/article.go
deleted file mode 100644
index a8e1cf9..0000000
--- a/article.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package main
-
-import (
-	"encoding/binary"
-	"fmt"
-	"io/ioutil"
-	"net/http"
-	"path"
-	"regexp"
-	"strings"
-	"time"
-
-	"github.com/errata-ai/vale/check"
-	"github.com/errata-ai/vale/core"
-	"github.com/errata-ai/vale/lint"
-	"github.com/recoilme/pudge"
-	"github.com/recoilme/slowpoke"
-	"github.com/tidwall/gjson"
-	blackfriday "gopkg.in/russross/blackfriday.v2"
-)
-
-// Author is a struct with contributor info
-type Author struct {
-	Login, Avatar string
-}
-
-// Article is a struct with article title and content
-type Article struct {
-	Category *Category
-	Config   *Config
-
-	File  string
-	Title string
-	Raw   string
-	HTML  string
-	Slug  string
-
-	Authors   []Author
-	CreatedAt time.Time
-	UpdatedAt time.Time
-	Alerts    []core.Alert
-
-	Views uint32
-}
-
-func (article *Article) updateRaw() error {
-	link, err := article.Category.makeLink(rawLinkT)
-	if err != nil {
-		return err
-	}
-	link += "/" + article.File
-	res, err := http.Get(link)
-	if err != nil {
-		return err
-	}
-	content, err := ioutil.ReadAll(res.Body)
-	res.Body.Close()
-	if err != nil {
-		return err
-	}
-	article.Raw = string(content)
-	return nil
-}
-
-func (article *Article) updateMetaInfo() error {
-	link, err := article.Category.makeLink(commitsLinkT)
-	if err != nil {
-		return err
-	}
-	link += "/" + article.File
-	res, err := http.Get(link)
-	if err != nil {
-		return err
-	}
-	if res.StatusCode != 200 {
-		return fmt.Errorf("invalid status code: %d (%s)", res.StatusCode, link)
-	}
-	content, err := ioutil.ReadAll(res.Body)
-	res.Body.Close()
-	if err != nil {
-		return err
-	}
-
-	authorsMap := make(map[string]Author)
-	var login string
-	for _, subtree := range gjson.Get(string(content), "#.author").Array() {
-		login = subtree.Get("login").String()
-		if login != "" {
-			authorsMap[login] = Author{Login: login, Avatar: subtree.Get("avatar_url").String()}
-		}
-	}
-	var authors []Author
-	for _, author := range authorsMap {
-		authors = append(authors, author)
-	}
-	article.Authors = authors
-
-	times := gjson.Get(string(content), "#.commit.author.date").Array()
-	t, err := time.Parse(time.RFC3339, times[0].String())
-	if err != nil {
-		return err
-	}
-	article.UpdatedAt = t
-
-	t, err = time.Parse(time.RFC3339, times[len(times)-1].String())
-	if err != nil {
-		return err
-	}
-	article.CreatedAt = t
-	return nil
-}
-
-func (article *Article) getTitle() string {
-	title := strings.Split(article.Raw, "\n")[0]
-	if strings.Index(title, "# ") != 0 {
-		return article.File
-	}
-	article.Raw = strings.TrimPrefix(article.Raw, title)
-	return title[2:]
-}
-
-var rexImg = regexp.MustCompile("(<img src=\"(.*?)\".*?/>)")
-
-func (article *Article) getHTML() (html string) {
-	// convert markdown to html
-	html = string(blackfriday.Run([]byte(article.Raw)))
-	// fix relative paths
-	html = strings.Replace(html, "src=\"./", "src=\"../", -1)
-	html = strings.Replace(html, "href=\"./", "href=\"../", -1)
-	// wrap images into link
-	html = rexImg.ReplaceAllString(html, "<a href=\"$2\" target=\"_blank\">$1</a>")
-	return
-}
-
-func (article *Article) updateAlerts() error {
-	config := core.NewConfig()
-	config.GBaseStyles = []string{"proselint", "write-good", "Joblint", "Spelling"}
-	config.MinAlertLevel = 1
-	config.InExt = ".html"
-	linter := lint.Linter{Config: config, CheckManager: check.NewManager(config)}
-	files, _ := linter.LintString(article.HTML)
-	article.Alerts = files[0].SortedAlerts()
-	return nil
-}
-
-func (article *Article) getFilename() string {
-	return path.Join(article.Config.Project, ".storage", article.Category.Slug+".db")
-}
-
-func (article *Article) updateViews() error {
-	if article.Views != 0 {
-		return nil
-	}
-	bs, err := slowpoke.Get(article.getFilename(), []byte(article.Slug))
-	if err == pudge.ErrKeyNotFound {
-		return nil
-	}
-	if err != nil {
-		return err
-	}
-	article.Views = binary.LittleEndian.Uint32(bs)
-	return nil
-}
-
-func (article *Article) incrementViews() error {
-	err := article.updateViews()
-	if err != pudge.ErrKeyNotFound && err != nil {
-		return err
-	}
-	article.Views++
-	bs := make([]byte, 4)
-	binary.LittleEndian.PutUint32(bs, article.Views)
-	return slowpoke.Set(article.getFilename(), []byte(article.Slug), bs)
-}
diff --git a/build.py b/build.py
deleted file mode 100644
index 3724145..0000000
--- a/build.py
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/usr/bin/env python3
-
-import os
-import sys
-
-builds = (
-    ('darwin', 'amd64'),
-    ('linux', '386'),
-    ('linux', 'amd64'),
-    ('linux', 'arm'),
-    ('windows', '386'),
-    ('windows', 'amd64'),
-)
-
-arches = ('arm', 'amd64', '386')
-platforms = ('darwin', 'linux', 'windows')
-
-cmd = "GOOS={platform} GOARCH={arch} go build -o builds/{platform}-{arch}.{ext} {package}"  # noQA
-
-for platform, arch in builds:
-    os.system(cmd.format(
-        platform=platform,
-        arch=arch,
-        ext='bin' if platform != 'windows' else 'exe',
-        package=sys.argv[1],
-    ))
diff --git a/category.go b/category.go
deleted file mode 100644
index 8e692b5..0000000
--- a/category.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"io/ioutil"
-	"log"
-	"net/http"
-	"sort"
-	"strings"
-	"sync"
-	"text/template"
-
-	"github.com/tidwall/gjson"
-)
-
-// Category is a struct with all information about content in given category
-type Category struct {
-	Repo, Branch, Dir, Name, Ext, Slug string
-}
-
-func (category *Category) makeLink(linkTemplate string) (string, error) {
-	t, err := template.New("linkTemplate").Parse(linkTemplate)
-	if err != nil {
-		return "", err
-	}
-	buffer := &bytes.Buffer{}
-	err = t.Execute(buffer, *category)
-	if err != nil {
-		return "", err
-	}
-	return buffer.String(), nil
-}
-
-func (category *Category) getArticles() (articles []Article, err error) {
-	link, err := category.makeLink(dirAPILinkT)
-	if err != nil {
-		return
-	}
-
-	// get filenames
-	res, err := http.Get(link)
-	if err != nil {
-		return
-	}
-	content, err := ioutil.ReadAll(res.Body)
-	res.Body.Close()
-	if err != nil {
-		return
-	}
-	names := gjson.Get(string(content), "#.name")
-
-	// get content
-	var wg sync.WaitGroup
-	var article Article
-	articlesChan := make(chan Article, len(names.Array()))
-	for _, name := range names.Array() {
-		if strings.HasSuffix(name.String(), category.Ext) {
-			wg.Add(1)
-			article = Article{
-				Category: category,
-				File:     name.String(),
-				Slug:     strings.TrimSuffix(name.String(), category.Ext),
-			}
-			go func(article Article) {
-				defer wg.Done()
-				err := article.updateRaw()
-				if err != nil {
-					log.Fatal(err)
-					return
-				}
-				article.Title = article.getTitle()
-				articlesChan <- article
-			}(article)
-		}
-	}
-	wg.Wait()
-
-	close(articlesChan)
-	for article := range articlesChan {
-		articles = append(articles, article)
-	}
-
-	sort.Slice(articles, func(i, j int) bool {
-		return articles[i].Title < articles[j].Title
-	})
-
-	return articles, nil
-}
diff --git a/chameleon.service b/chameleon.service
index fc1f335..91b34c1 100644
--- a/chameleon.service
+++ b/chameleon.service
@@ -7,7 +7,7 @@ Description=chameleon
 Type=simple
 Restart=always
 RestartSec=5s
-ExecStart=/path/to/bin/chameleon --config /path/to/config.toml  --project /path/to/project/root/
+ExecStart=/path/to/bin/chameleon --path /path/to/repo/
 
 [Install]
 WantedBy=multi-user.target
diff --git a/chameleon/article.go b/chameleon/article.go
new file mode 100644
index 0000000..ba30370
--- /dev/null
+++ b/chameleon/article.go
@@ -0,0 +1,147 @@
+package chameleon
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/enescakir/emoji"
+	"gopkg.in/russross/blackfriday.v2"
+)
+
+const ISO8601 = "2006-01-02T15:04:05-07:00"
+
+var rexImg = regexp.MustCompile("(<img src=\"(.*?)\".*?/>)")
+var rexLang = regexp.MustCompile("<code class=\"language-([a-zA-Z]+)\">")
+
+type Article struct {
+	Repository Repository
+	Path       Path
+
+	// cache
+	raw     []byte
+	title   string
+	commits Commits
+}
+
+func (a Article) Valid() (bool, error) {
+	if !strings.HasSuffix(a.Path.String(), Extension) {
+		return false, nil
+	}
+	return a.Path.IsFile()
+}
+
+func (a Article) IsReadme() bool {
+	return a.Path.Name() == ReadMe
+}
+
+func (a *Article) Linter() Linter {
+	return Linter{Article: a}
+}
+
+func (a *Article) Raw() ([]byte, error) {
+	var err error
+	if a.raw != nil {
+		return a.raw, nil
+	}
+	a.raw, err = os.ReadFile(a.Path.String())
+	if err != nil {
+		return nil, fmt.Errorf("cannot read file: %v", err)
+	}
+	a.trimTitle()
+	return a.raw, nil
+}
+
+func (a *Article) HTML() (string, error) {
+	raw, err := a.Raw()
+	if err != nil {
+		return "", err
+	}
+	html := string(blackfriday.Run(raw))
+	html = emoji.Parse(html)
+	// fix relative paths
+	html = strings.ReplaceAll(html, "src=\"./", "src=\"../")
+	html = strings.ReplaceAll(html, "href=\"./", "href=\"../")
+	// wrap images into link
+	html = rexImg.ReplaceAllString(html, "<a href=\"$2\" target=\"_blank\">$1</a>")
+	return html, nil
+}
+
+func (a *Article) Languages() ([]string, error) {
+	html, err := a.HTML()
+	if err != nil {
+		return nil, err
+	}
+	matches := rexLang.FindAllStringSubmatch(html, -1)
+	result := make([]string, 0)
+	set := make(map[string]struct{}, len(matches))
+	for _, m := range matches {
+		lang := m[1]
+		_, ok := set[lang]
+		if !ok {
+			set[lang] = struct{}{}
+			result = append(result, lang)
+		}
+	}
+	return result, nil
+}
+
+// trimTitle extracts title from raw content
+func (a *Article) trimTitle() {
+	title := bytes.SplitN(a.raw, []byte{'\n'}, 2)[0]
+	if bytes.Index(title, []byte{'#', ' '}) != 0 {
+		if a.IsReadme() {
+			a.title = a.Path.Parent().Name()
+			return
+		}
+		a.title = a.Path.Name()
+		return
+	}
+	a.raw = bytes.TrimPrefix(a.raw, title)
+	a.title = strings.TrimSuffix(string(title[2:]), "\n")
+}
+
+func (a *Article) Title() (string, error) {
+	if a.title == "" {
+		_, err := a.Raw()
+		if err != nil {
+			return "", err
+		}
+	}
+	return a.title, nil
+}
+
+func (a Article) Slug() string {
+	return strings.TrimSuffix(a.Path.Name(), Extension)
+}
+
+func (a *Article) Commits() (Commits, error) {
+	if a.commits != nil {
+		return a.commits, nil
+	}
+
+	p := a.Path.Relative(a.Repository.Path)
+	cmd := a.Repository.Command("log", "--pretty=%H|%cI|%an|%ae|%s", "--follow", p.String())
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("%v: %s", err, out)
+	}
+	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
+	a.commits = make(Commits, len(lines))
+	for i, line := range lines {
+		a.commits[i], err = ParseCommit(line)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return a.commits, nil
+}
+
+func (a Article) URLs() URLs {
+	return URLs{
+		Repository: a.Repository,
+		Path:       a.Path,
+	}
+}
diff --git a/chameleon/articles.go b/chameleon/articles.go
new file mode 100644
index 0000000..a59d9ed
--- /dev/null
+++ b/chameleon/articles.go
@@ -0,0 +1,23 @@
+package chameleon
+
+import "sort"
+
+type Articles []*Article
+
+func (a Articles) Len() int {
+	return len(a)
+}
+
+func (a Articles) Less(i, j int) bool {
+	c1, _ := a[i].Commits()
+	c2, _ := a[j].Commits()
+	return c1.Created().Time.Unix() < c2.Created().Time.Unix()
+}
+
+func (a Articles) Swap(i, j int) {
+	a[i], a[j] = a[j], a[i]
+}
+
+func (a *Articles) Sort() {
+	sort.Sort(sort.Reverse(a))
+}
diff --git a/assets/android-chrome-192x192.png b/chameleon/assets/android-chrome-192x192.png
similarity index 100%
rename from assets/android-chrome-192x192.png
rename to chameleon/assets/android-chrome-192x192.png
diff --git a/assets/android-chrome-512x512.png b/chameleon/assets/android-chrome-512x512.png
similarity index 100%
rename from assets/android-chrome-512x512.png
rename to chameleon/assets/android-chrome-512x512.png
diff --git a/assets/apple-touch-icon.png b/chameleon/assets/apple-touch-icon.png
similarity index 100%
rename from assets/apple-touch-icon.png
rename to chameleon/assets/apple-touch-icon.png
diff --git a/assets/browserconfig.xml b/chameleon/assets/browserconfig.xml
similarity index 100%
rename from assets/browserconfig.xml
rename to chameleon/assets/browserconfig.xml
diff --git a/assets/favicon-16x16.png b/chameleon/assets/favicon-16x16.png
similarity index 100%
rename from assets/favicon-16x16.png
rename to chameleon/assets/favicon-16x16.png
diff --git a/assets/favicon-32x32.png b/chameleon/assets/favicon-32x32.png
similarity index 100%
rename from assets/favicon-32x32.png
rename to chameleon/assets/favicon-32x32.png
diff --git a/assets/favicon.ico b/chameleon/assets/favicon.ico
similarity index 100%
rename from assets/favicon.ico
rename to chameleon/assets/favicon.ico
diff --git a/assets/icon.svg b/chameleon/assets/icon.svg
similarity index 100%
rename from assets/icon.svg
rename to chameleon/assets/icon.svg
diff --git a/assets/index.html b/chameleon/assets/index.html
similarity index 100%
rename from assets/index.html
rename to chameleon/assets/index.html
diff --git a/assets/mstile-144x144.png b/chameleon/assets/mstile-144x144.png
similarity index 100%
rename from assets/mstile-144x144.png
rename to chameleon/assets/mstile-144x144.png
diff --git a/assets/mstile-150x150.png b/chameleon/assets/mstile-150x150.png
similarity index 100%
rename from assets/mstile-150x150.png
rename to chameleon/assets/mstile-150x150.png
diff --git a/assets/mstile-310x150.png b/chameleon/assets/mstile-310x150.png
similarity index 100%
rename from assets/mstile-310x150.png
rename to chameleon/assets/mstile-310x150.png
diff --git a/assets/mstile-310x310.png b/chameleon/assets/mstile-310x310.png
similarity index 100%
rename from assets/mstile-310x310.png
rename to chameleon/assets/mstile-310x310.png
diff --git a/assets/mstile-70x70.png b/chameleon/assets/mstile-70x70.png
similarity index 100%
rename from assets/mstile-70x70.png
rename to chameleon/assets/mstile-70x70.png
diff --git a/assets/safari-pinned-tab.svg b/chameleon/assets/safari-pinned-tab.svg
similarity index 100%
rename from assets/safari-pinned-tab.svg
rename to chameleon/assets/safari-pinned-tab.svg
diff --git a/assets/site.webmanifest b/chameleon/assets/site.webmanifest
similarity index 100%
rename from assets/site.webmanifest
rename to chameleon/assets/site.webmanifest
diff --git a/assets/style.css b/chameleon/assets/style.css
similarity index 68%
rename from assets/style.css
rename to chameleon/assets/style.css
index 6706b75..63680ce 100644
--- a/assets/style.css
+++ b/chameleon/assets/style.css
@@ -19,37 +19,23 @@
 	text-align: center;
 }
 
-.statblock {
-	display: inline-block;
-	margin: 2px 10px;
-}
-
 .avatar {
-	opacity: .9;
-}
-
-.avatar:hover {
-	text-decoration: none;
-	opacity: 1;
-}
-
-.avatar img {
-	border-radius: 25px;
-	width: 50px;
-	height: 50px;
+	border-radius: .5em;
+	width: 1em;
+	height: 1em;
 	box-shadow: 0 0 1px;
 }
 
 .level-error {
-	color: red;
+	color: #EA2027;
 }
 
 .level-warning {
-	color: yellow;
+	color: #EE5A24;
 }
 
 .level-suggestion {
-	color: green;
+	color: #009432;
 }
 
 .content img {
@@ -66,4 +52,4 @@
 blockquote {
 	border-left: solid 6px #bdc3c7;
 	padding-left: 12px;
-}
\ No newline at end of file
+}
diff --git a/chameleon/auth.go b/chameleon/auth.go
new file mode 100644
index 0000000..2b01d5b
--- /dev/null
+++ b/chameleon/auth.go
@@ -0,0 +1,111 @@
+package chameleon
+
+import (
+	"crypto/sha512"
+	"crypto/subtle"
+	"encoding/base64"
+	"errors"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/julienschmidt/httprouter"
+	"go.uber.org/zap"
+)
+
+type Auth struct {
+	Password string
+	Logger   *zap.Logger
+	TTL      time.Duration
+}
+
+func (a Auth) generate(date string) string {
+	h := sha512.New().Sum([]byte(date + "|" + a.Password))
+	return base64.StdEncoding.EncodeToString(h)
+}
+
+func (a Auth) valid(r *http.Request) (bool, error) {
+	cookie, err := r.Cookie("auth")
+	if err == http.ErrNoCookie {
+		return false, nil
+	}
+	parts := strings.SplitN(cookie.Value, "|", 2)
+	if len(parts) != 2 {
+		return false, errors.New("auth date not found")
+	}
+
+	t, err := time.Parse(time.RFC3339, parts[1])
+	if err != nil {
+		return false, err
+	}
+	if t.Add(a.TTL).Unix() < time.Now().Unix() {
+		return false, nil
+	}
+
+	given := []byte(parts[0])
+	expected := []byte(a.generate(parts[1]))
+	eq := subtle.ConstantTimeCompare(given, expected) == 1
+	return eq, nil
+}
+
+func (a Auth) make(r *http.Request) *http.Cookie {
+	date := time.Now().Format(time.RFC3339)
+	token := a.generate(date) + "|" + date
+	return &http.Cookie{
+		Name:   "auth",
+		Value:  token,
+		Path:   "/",
+		MaxAge: 3600 * 24 * 7,
+	}
+}
+
+func (a Auth) render(w http.ResponseWriter, ok bool) {
+	w.WriteHeader(http.StatusForbidden)
+	_ = TemplateAuth.Execute(w, ok)
+}
+
+func (a Auth) Wrap(h httprouter.Handle) httprouter.Handle {
+	if a.Password == "" {
+		return h
+	}
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		valid, err := a.valid(r)
+		if err != nil {
+			a.Logger.Debug("auth error", zap.Error(err), zap.String("ip", r.RemoteAddr))
+			http.Redirect(w, r, AuthPrefix, http.StatusTemporaryRedirect)
+			return
+		}
+		if !valid {
+			http.Redirect(w, r, AuthPrefix, http.StatusTemporaryRedirect)
+			return
+		}
+		h(w, r, ps)
+	}
+}
+
+func (a Auth) HandleGET(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	valid, _ := a.valid(r)
+	if valid {
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+		return
+	}
+	a.render(w, true)
+}
+
+func (a Auth) HandlePOST(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	err := r.ParseForm()
+	if err != nil {
+		a.Logger.Debug("cannot parse form", zap.Error(err))
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	given := []byte(r.PostForm.Get("password"))
+	expected := []byte(a.Password)
+	ok := subtle.ConstantTimeCompare(given, expected) == 1
+	if !ok {
+		a.render(w, false)
+		return
+	}
+	http.SetCookie(w, a.make(r))
+	http.Redirect(w, r, MainPrefix, http.StatusSeeOther)
+}
diff --git a/chameleon/auth_test.go b/chameleon/auth_test.go
new file mode 100644
index 0000000..e356eea
--- /dev/null
+++ b/chameleon/auth_test.go
@@ -0,0 +1,99 @@
+package chameleon
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestAuthNoRedirect(t *testing.T) {
+	is := require.New(t)
+	request, err := http.NewRequest(http.MethodGet, "/p/", nil)
+	is.Nil(err)
+	response := httptest.NewRecorder()
+
+	s, err := NewServer(newTestConfig(), nil)
+	is.Nil(err)
+	s.ServeHTTP(response, request)
+
+	is.Equal(response.Code, 200)
+}
+
+func TestAuthRedirect(t *testing.T) {
+	is := require.New(t)
+	request, err := http.NewRequest(http.MethodGet, "/p/", nil)
+	is.Nil(err)
+	response := httptest.NewRecorder()
+
+	c := newTestConfig()
+	c.Password = "123"
+	s, err := NewServer(c, nil)
+	is.Nil(err)
+	s.ServeHTTP(response, request)
+
+	is.Equal(response.Code, 307)
+	is.Equal(response.Header()["Location"], []string{"/auth/"})
+}
+
+func TestAuthForm(t *testing.T) {
+	is := require.New(t)
+	request, err := http.NewRequest(http.MethodGet, "/auth/", nil)
+	is.Nil(err)
+	response := httptest.NewRecorder()
+
+	c := newTestConfig()
+	c.Password = "123"
+	s, err := NewServer(c, nil)
+	is.Nil(err)
+	s.ServeHTTP(response, request)
+
+	b := response.Body.String()
+	is.Equal(response.Code, 403, b)
+	is.Contains(b, "403: Password Required")
+}
+
+func TestAuthWrongPass(t *testing.T) {
+	is := require.New(t)
+	data := url.Values{}
+	data.Set("password", "1")
+	request, err := http.NewRequest(
+		http.MethodPost, "/auth/", strings.NewReader(data.Encode()),
+	)
+	is.Nil(err)
+	response := httptest.NewRecorder()
+
+	c := newTestConfig()
+	c.Password = "123"
+	s, err := NewServer(c, nil)
+	is.Nil(err)
+	s.ServeHTTP(response, request)
+
+	b := response.Body.String()
+	is.Equal(response.Code, 403, b)
+	is.Contains(b, "403: Password Required")
+}
+
+func TestAuthCorrectPass(t *testing.T) {
+	is := require.New(t)
+	data := url.Values{}
+	data.Set("password", "123")
+	request, err := http.NewRequest(
+		http.MethodPost, "/auth/", strings.NewReader(data.Encode()),
+	)
+	is.Nil(err)
+	request.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
+	response := httptest.NewRecorder()
+
+	c := newTestConfig()
+	c.Password = "123"
+	s, err := NewServer(c, nil)
+	is.Nil(err)
+	s.ServeHTTP(response, request)
+
+	is.Equal(response.Code, 303)
+	is.Equal(response.Header()["Location"], []string{"/p/"})
+}
diff --git a/chameleon/cache.go b/chameleon/cache.go
new file mode 100644
index 0000000..53b478e
--- /dev/null
+++ b/chameleon/cache.go
@@ -0,0 +1,49 @@
+package chameleon
+
+import (
+	"net/http"
+	"time"
+
+	"github.com/julienschmidt/httprouter"
+	cache "github.com/victorspringer/http-cache"
+	"github.com/victorspringer/http-cache/adapter/memory"
+)
+
+type Cache struct {
+	client *cache.Client
+}
+
+func NewCache(capacity int) (*Cache, error) {
+	if capacity == 0 {
+		return nil, nil
+	}
+	memcached, err := memory.NewAdapter(
+		memory.AdapterWithAlgorithm(memory.LRU),
+		memory.AdapterWithCapacity(capacity),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	client, err := cache.NewClient(
+		cache.ClientWithAdapter(memcached),
+		cache.ClientWithTTL(10*time.Minute),
+	)
+	if err != nil {
+		return nil, err
+	}
+	return &Cache{client: client}, nil
+}
+
+func (c *Cache) Wrap(f httprouter.Handle) httprouter.Handle {
+	if c == nil {
+		return f
+	}
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		h1 := func(w http.ResponseWriter, r *http.Request) {
+			f(w, r, ps)
+		}
+		h2 := c.client.Middleware(http.HandlerFunc(h1))
+		h2.ServeHTTP(w, r)
+	}
+}
diff --git a/chameleon/category.go b/chameleon/category.go
new file mode 100644
index 0000000..36638eb
--- /dev/null
+++ b/chameleon/category.go
@@ -0,0 +1,80 @@
+package chameleon
+
+import "fmt"
+
+type Category struct {
+	Repository Repository
+	Path       Path
+}
+
+func (c Category) Valid() (bool, error) {
+	isDir, err := c.Path.IsDir()
+	if err != nil {
+		return false, err
+	}
+	if !isDir {
+		return false, nil
+	}
+	return c.Path.Join(ReadMe).IsFile()
+}
+
+func (c Category) Article() *Article {
+	return &Article{
+		Repository: c.Repository,
+		Path:       c.Path.Join(ReadMe),
+	}
+}
+
+func (c Category) Categories() ([]Category, error) {
+	cats := make([]Category, 0)
+	paths, err := c.Path.SubPaths()
+	if err != nil {
+		return nil, fmt.Errorf("cannot get subpaths: %v", err)
+	}
+	for _, p := range paths {
+		cat := Category{
+			Repository: c.Repository,
+			Path:       p,
+		}
+		valid, err := cat.Valid()
+		if err != nil {
+			return nil, fmt.Errorf("cannot validate category: %v", err)
+		}
+		if !valid {
+			continue
+		}
+		cats = append(cats, cat)
+	}
+	return cats, nil
+}
+
+func (c Category) Articles() (Articles, error) {
+	arts := make(Articles, 0)
+	paths, err := c.Path.SubPaths()
+	if err != nil {
+		return nil, fmt.Errorf("cannot get subpaths: %v", err)
+	}
+	for _, p := range paths {
+		art := &Article{
+			Repository: c.Repository,
+			Path:       p,
+		}
+		valid, err := art.Valid()
+		if err != nil {
+			return nil, fmt.Errorf("cannot validate article: %v", err)
+		}
+		if !valid {
+			continue
+		}
+		if art.IsReadme() {
+			continue
+		}
+		arts = append(arts, art)
+	}
+	arts.Sort()
+	return arts, nil
+}
+
+func (c Category) URLs() URLs {
+	return URLs(c)
+}
diff --git a/chameleon/commit.go b/chameleon/commit.go
new file mode 100644
index 0000000..b23accb
--- /dev/null
+++ b/chameleon/commit.go
@@ -0,0 +1,46 @@
+package chameleon
+
+import (
+	"strings"
+	"time"
+)
+
+type Commits []Commit
+
+func (c Commits) Len() int {
+	return len(c)
+}
+
+func (c Commits) Edited() Commit {
+	return c[0]
+}
+
+func (c Commits) Created() Commit {
+	return c[len(c)-1]
+}
+
+type Commit struct {
+	Hash string
+	Time time.Time
+	Name string
+	Mail string
+	Msg  string
+	Diff string
+}
+
+func ParseCommit(line string) (Commit, error) {
+	line = strings.TrimSpace(line)
+	parts := strings.Split(line, "|")
+	t, err := time.Parse(ISO8601, parts[1])
+	if err != nil {
+		return Commit{}, err
+	}
+	c := Commit{
+		Hash: parts[0],
+		Time: t,
+		Name: parts[2],
+		Mail: parts[3],
+		Msg:  parts[4],
+	}
+	return c, err
+}
diff --git a/chameleon/config.go b/chameleon/config.go
new file mode 100644
index 0000000..01f7f68
--- /dev/null
+++ b/chameleon/config.go
@@ -0,0 +1,51 @@
+package chameleon
+
+import (
+	"time"
+
+	"github.com/spf13/pflag"
+)
+
+type Config struct {
+	// repo
+	RepoPath string
+	RepoURL  string
+	Pull     time.Duration
+	Cache    int
+
+	// auth
+	Password string
+	AuthTTL  time.Duration
+
+	// other
+	Address string
+	DBPath  string
+	PProf   bool
+}
+
+func NewConfig() Config {
+	return Config{
+		Address:  "127.0.0.1:1337",
+		RepoPath: ".repo",
+		RepoURL:  "https://github.com/orsinium/notes.git",
+		Pull:     5 * time.Minute,
+		Cache:    1000,
+		Password: "",
+		AuthTTL:  time.Hour * 24 * 7,
+		DBPath:   ".database.bin",
+	}
+}
+
+func (c Config) Parse() Config {
+	pflag.StringVar(&c.Address, "addr", c.Address, "address to serve")
+	pflag.StringVar(&c.RepoPath, "path", c.RepoPath, "path to repository")
+	pflag.StringVar(&c.RepoURL, "url", c.RepoURL, "clone URL for repo if not exist")
+	pflag.DurationVar(&c.Pull, "pull", c.Pull, "how often pull repository, 0 to disable")
+	pflag.IntVar(&c.Cache, "cache", c.Cache, "how many records to cache, 0 to disable")
+	pflag.StringVar(&c.Password, "pass", c.Password, "require password")
+	pflag.DurationVar(&c.AuthTTL, "auth-ttl", c.AuthTTL, "time to keep users logged in")
+	pflag.StringVar(&c.DBPath, "db", c.DBPath, "path to database file")
+	pflag.BoolVar(&c.PProf, "pprof", c.PProf, "serve pprof endpoints")
+	pflag.Parse()
+	return c
+}
diff --git a/chameleon/const.go b/chameleon/const.go
new file mode 100644
index 0000000..87b8245
--- /dev/null
+++ b/chameleon/const.go
@@ -0,0 +1,14 @@
+package chameleon
+
+const (
+	Extension = ".md"
+	ReadMe    = "README.md"
+
+	MainPrefix    = "/p/"
+	LinterPrefix  = "/linter/"
+	CommitsPrefix = "/commits/"
+	DiffPrefix    = "/diff/"
+	AuthPrefix    = "/auth/"
+	StatPrefix    = "/stat/"
+	SearchPrefix  = "/search/"
+)
diff --git a/chameleon/database.go b/chameleon/database.go
new file mode 100644
index 0000000..af4a253
--- /dev/null
+++ b/chameleon/database.go
@@ -0,0 +1,51 @@
+package chameleon
+
+import (
+	"fmt"
+	"time"
+
+	"go.etcd.io/bbolt"
+)
+
+type Database struct {
+	db *bbolt.DB
+}
+
+func (db *Database) Open(path string) error {
+	var err error
+	opts := &bbolt.Options{Timeout: 5 * time.Second}
+	db.db, err = bbolt.Open(path, 0600, opts)
+	if err != nil {
+		return fmt.Errorf("cannot open db: %v", err)
+	}
+
+	err = db.db.Update(func(tx *bbolt.Tx) error {
+		_, err = tx.CreateBucketIfNotExists([]byte("views"))
+		if err != nil {
+			return fmt.Errorf("cannot create bucket: %v", err)
+		}
+		return nil
+	})
+	if err != nil {
+		return fmt.Errorf("cannot execute transaction: %v", err)
+	}
+
+	return nil
+}
+
+func (db Database) Close() error {
+	if db.db == nil {
+		return nil
+	}
+	return db.db.Close()
+}
+
+func (db Database) Views(path Path) *Views {
+	if db.db == nil {
+		return nil
+	}
+	return &Views{
+		db:   db.db,
+		path: path,
+	}
+}
diff --git a/chameleon/handler_diff.go b/chameleon/handler_diff.go
new file mode 100644
index 0000000..db6a350
--- /dev/null
+++ b/chameleon/handler_diff.go
@@ -0,0 +1,45 @@
+package chameleon
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"regexp"
+	"strings"
+
+	"github.com/julienschmidt/httprouter"
+)
+
+var rexHash = regexp.MustCompile(`^[a-f0-9]{40}$`)
+
+type HandlerDiff struct {
+	Server *Server
+}
+
+func (h HandlerDiff) Handle(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	err := h.Render(w, ps.ByName("hash"))
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+}
+
+func (h HandlerDiff) Render(w http.ResponseWriter, hash string) error {
+	if !rexHash.MatchString(hash) {
+		return errors.New("invalid commit hash")
+	}
+	cmd := h.Server.Repository.Command("show", "--pretty=%H|%cI|%an|%ae|%s", hash)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("%v: %s", err, out)
+	}
+
+	lines := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)
+	commit, err := ParseCommit(lines[0])
+	if err != nil {
+		return err
+	}
+	commit.Diff = lines[1]
+	return TemplateDiff.Execute(w, &commit)
+}
diff --git a/chameleon/handler_main.go b/chameleon/handler_main.go
new file mode 100644
index 0000000..9b368e5
--- /dev/null
+++ b/chameleon/handler_main.go
@@ -0,0 +1,137 @@
+package chameleon
+
+import (
+	"fmt"
+	"net/http"
+	"regexp"
+	"strings"
+	"text/template"
+
+	"github.com/julienschmidt/httprouter"
+	"go.uber.org/zap"
+)
+
+var rexNotLetter = regexp.MustCompile(`[^a-zA-Z0-9]`)
+
+type HandlerMain struct {
+	Template *template.Template
+	Server   *Server
+}
+
+func (h HandlerMain) Handle(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	path := safeText(ps.ByName("filepath"))
+
+	// get page
+	page, err := h.Page(path)
+	if err != nil {
+		h.Server.Logger.Error("cannot get page", zap.Error(err), zap.String("path", path))
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// increment views
+	if path != "" {
+		key := "viewed_" + rexNotLetter.ReplaceAllString(path, "_")
+		_, err = r.Cookie(key)
+		if err != nil {
+			cookie := http.Cookie{
+				Name:   key,
+				Value:  "1",
+				Path:   r.URL.Path,
+				MaxAge: 3600 * 24,
+			}
+			http.SetCookie(w, &cookie)
+			page.Inc()
+		}
+	}
+
+	// render the page
+	w.WriteHeader(page.Status())
+	err = page.Render(w)
+	if err != nil {
+		h.Server.Logger.Error("cannot render page", zap.Error(err), zap.String("path", path))
+		_, err = fmt.Fprint(w, err.Error())
+		if err != nil {
+			h.Server.Logger.Warn("cannot write response", zap.Error(err), zap.String("path", path))
+		}
+		return
+	}
+}
+
+func (h HandlerMain) Page(urlPath string) (Page, error) {
+	if strings.Contains(urlPath, "/.") {
+		return Page403{}, nil
+	}
+
+	p := h.Server.Repository.Path.Join(urlPath)
+
+	// category page
+	isdir, err := p.IsDir()
+	if err != nil {
+		return nil, err
+	}
+	if isdir {
+		isfile, err := p.Join(ReadMe).IsFile()
+		if err != nil {
+			return nil, err
+		}
+		if !isfile {
+			return nil, fmt.Errorf("README.md not found")
+		}
+		page := PageArticle{
+			Category: &Category{
+				Repository: h.Server.Repository,
+				Path:       p,
+			},
+			Article: Article{
+				Repository: h.Server.Repository,
+				Path:       p.Join(ReadMe),
+			},
+			Views:    h.Server.Database.Views(p),
+			Template: h.Template,
+			Logger:   h.Server.Logger,
+		}
+		if urlPath != "" && urlPath != "/" {
+			page.Parent = &Category{
+				Repository: h.Server.Repository,
+				Path:       p.Parent(),
+			}
+		}
+		return page, nil
+	}
+
+	// raw file
+	isfile, err := p.IsFile()
+	if err != nil {
+		return nil, err
+	}
+	if isfile {
+		page := PageAsset{Path: p}
+		return page, nil
+	}
+
+	// article page
+	p = h.Server.Repository.Path.Join(urlPath + Extension)
+	isfile, err = p.IsFile()
+	if err != nil {
+		return nil, err
+	}
+	if isfile {
+		page := PageArticle{
+			Parent: &Category{
+				Repository: h.Server.Repository,
+				Path:       p.Parent(),
+			},
+			Article: Article{
+				Repository: h.Server.Repository,
+				Path:       p,
+			},
+			Views:    h.Server.Database.Views(p),
+			Template: h.Template,
+			Logger:   h.Server.Logger,
+		}
+		return page, nil
+	}
+
+	return Page404{}, nil
+}
diff --git a/chameleon/handler_search.go b/chameleon/handler_search.go
new file mode 100644
index 0000000..5ed03b3
--- /dev/null
+++ b/chameleon/handler_search.go
@@ -0,0 +1,87 @@
+package chameleon
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/julienschmidt/httprouter"
+	"go.uber.org/zap"
+)
+
+type HandlerSearch struct {
+	Server *Server
+}
+
+func (h HandlerSearch) Handle(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	page, err := h.Page(r)
+	if err != nil {
+		h.Server.Logger.Error("cannot get page", zap.Error(err), zap.String("url", r.URL.RawPath))
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(page.Status())
+	err = page.Render(w)
+	if err != nil {
+		h.Server.Logger.Error("cannot render page", zap.Error(err), zap.String("url", r.URL.RawPath))
+		_, err = fmt.Fprint(w, err.Error())
+		if err != nil {
+			h.Server.Logger.Warn("cannot write response", zap.Error(err), zap.String("url", r.URL.RawPath))
+		}
+		return
+	}
+}
+
+func (h HandlerSearch) Page(r *http.Request) (Page, error) {
+	queries, ok := r.URL.Query()["q"]
+	if !ok || len(queries) == 0 {
+		return PageSearch{}, nil
+	}
+
+	query := safeText(queries[0])
+	if query == "" {
+		return PageSearch{}, nil
+	}
+
+	cmd := h.Server.Repository.Command(
+		"grep",
+		"--full-name",
+		"--ignore-case",
+		"--recursive",
+		"--word-regexp",
+		"--name-only",
+		"--fixed-strings",
+		query,
+	)
+	out, err := cmd.CombinedOutput()
+	if cmd.ProcessState.ExitCode() == 1 {
+		return PageSearch{Query: query}, nil
+	}
+	if err != nil {
+		return nil, fmt.Errorf("%v: %s", err, out)
+	}
+
+	paths := strings.Split(strings.TrimSpace(string(out)), "\n")
+	page := PageSearch{
+		Query:   query,
+		Results: make([]*Article, 0),
+	}
+	for _, path := range paths {
+		if path == "" {
+			continue
+		}
+		art := &Article{
+			Repository: h.Server.Repository,
+			Path:       h.Server.Repository.Join(path),
+		}
+		valid, err := art.Valid()
+		if err != nil {
+			return nil, err
+		}
+		if !valid {
+			continue
+		}
+		page.Results = append(page.Results, art)
+	}
+	return page, nil
+}
diff --git a/chameleon/handler_stat.go b/chameleon/handler_stat.go
new file mode 100644
index 0000000..c68a669
--- /dev/null
+++ b/chameleon/handler_stat.go
@@ -0,0 +1,30 @@
+package chameleon
+
+import (
+	"net/http"
+
+	"github.com/julienschmidt/httprouter"
+)
+
+type HandlerStat struct {
+	Server *Server
+}
+
+func (h HandlerStat) Handle(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	err := h.Render(w)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+}
+
+func (h HandlerStat) Render(w http.ResponseWriter) error {
+	stat, err := h.Server.Database.Views(h.Server.Repository.Path).All()
+	if err != nil {
+		return err
+	}
+	stat.Sort()
+	stat.SetRepo(h.Server.Repository)
+	return TemplateStat.Execute(w, stat)
+}
diff --git a/chameleon/linter.go b/chameleon/linter.go
new file mode 100644
index 0000000..acdc3b8
--- /dev/null
+++ b/chameleon/linter.go
@@ -0,0 +1,30 @@
+package chameleon
+
+import (
+	"fmt"
+
+	"github.com/errata-ai/vale/check"
+	"github.com/errata-ai/vale/core"
+	"github.com/errata-ai/vale/lint"
+)
+
+type Linter struct {
+	Article *Article
+}
+
+func (linter Linter) Alerts() ([]core.Alert, error) {
+	config := core.NewConfig()
+	config.GBaseStyles = []string{"proselint", "write-good", "Joblint", "Spelling"}
+	config.MinAlertLevel = 1
+	config.InExt = ".html"
+	vale := lint.Linter{Config: config, CheckManager: check.NewManager(config)}
+	html, err := linter.Article.HTML()
+	if err != nil {
+		return nil, fmt.Errorf("cannot read html: %v", err)
+	}
+	files, err := vale.LintString(html)
+	if err != nil {
+		return nil, fmt.Errorf("cannot lint html: %v", err)
+	}
+	return files[0].SortedAlerts(), nil
+}
diff --git a/chameleon/page.go b/chameleon/page.go
new file mode 100644
index 0000000..1470814
--- /dev/null
+++ b/chameleon/page.go
@@ -0,0 +1,11 @@
+package chameleon
+
+import (
+	"io"
+)
+
+type Page interface {
+	Render(io.Writer) error
+	Inc()
+	Status() int
+}
diff --git a/chameleon/page_403.go b/chameleon/page_403.go
new file mode 100644
index 0000000..08210fa
--- /dev/null
+++ b/chameleon/page_403.go
@@ -0,0 +1,19 @@
+package chameleon
+
+import (
+	"io"
+	"net/http"
+)
+
+type Page403 struct {
+}
+
+func (p Page403) Render(w io.Writer) error {
+	return Template403.Execute(w, &p)
+}
+
+func (p Page403) Inc() {}
+
+func (p Page403) Status() int {
+	return http.StatusForbidden
+}
diff --git a/chameleon/page_404.go b/chameleon/page_404.go
new file mode 100644
index 0000000..016c2ea
--- /dev/null
+++ b/chameleon/page_404.go
@@ -0,0 +1,19 @@
+package chameleon
+
+import (
+	"io"
+	"net/http"
+)
+
+type Page404 struct {
+}
+
+func (p Page404) Render(w io.Writer) error {
+	return Template404.Execute(w, &p)
+}
+
+func (p Page404) Inc() {}
+
+func (p Page404) Status() int {
+	return http.StatusNotFound
+}
diff --git a/chameleon/page_article.go b/chameleon/page_article.go
new file mode 100644
index 0000000..907f52a
--- /dev/null
+++ b/chameleon/page_article.go
@@ -0,0 +1,53 @@
+package chameleon
+
+import (
+	"io"
+	"net/http"
+	"text/template"
+
+	"github.com/tdewolff/minify"
+	"github.com/tdewolff/minify/html"
+	"go.uber.org/zap"
+)
+
+type PageArticle struct {
+	Article  Article
+	Parent   *Category
+	Category *Category
+	Views    *Views
+	Template *template.Template
+	Logger   *zap.Logger
+}
+
+func (p PageArticle) Render(w io.Writer) error {
+	m := minify.New()
+	m.AddFunc("text/html", html.Minify)
+
+	r, interw := io.Pipe()
+	var errt error
+	go func() {
+		errt = p.Template.Execute(interw, &p)
+		interw.Close()
+	}()
+	errm := m.Minify("text/html", w, r)
+	if errt != nil {
+		return errt
+	}
+	return errm
+}
+
+func (p PageArticle) Inc() {
+	if p.Views == nil {
+		return
+	}
+	go func() {
+		err := p.Views.Inc()
+		if err != nil {
+			p.Logger.Error("cannot increment views", zap.Error(err))
+		}
+	}()
+}
+
+func (p PageArticle) Status() int {
+	return http.StatusOK
+}
diff --git a/chameleon/page_asset.go b/chameleon/page_asset.go
new file mode 100644
index 0000000..82f81fb
--- /dev/null
+++ b/chameleon/page_asset.go
@@ -0,0 +1,29 @@
+package chameleon
+
+import (
+	"io"
+	"net/http"
+)
+
+type PageAsset struct {
+	Path Path
+}
+
+func (page PageAsset) Render(w io.Writer) error {
+	f, err := page.Path.Open()
+	if err != nil {
+		return err
+	}
+	_, err = io.Copy(w, f)
+	err2 := f.Close()
+	if err2 != nil {
+		return err2
+	}
+	return err
+}
+
+func (page PageAsset) Inc() {}
+
+func (page PageAsset) Status() int {
+	return http.StatusOK
+}
diff --git a/chameleon/page_search.go b/chameleon/page_search.go
new file mode 100644
index 0000000..5ec60de
--- /dev/null
+++ b/chameleon/page_search.go
@@ -0,0 +1,21 @@
+package chameleon
+
+import (
+	"io"
+	"net/http"
+)
+
+type PageSearch struct {
+	Query   string
+	Results []*Article
+}
+
+func (p PageSearch) Render(w io.Writer) error {
+	return TemplateSearch.Execute(w, &p)
+}
+
+func (p PageSearch) Inc() {}
+
+func (p PageSearch) Status() int {
+	return http.StatusOK
+}
diff --git a/chameleon/path.go b/chameleon/path.go
new file mode 100644
index 0000000..51cc094
--- /dev/null
+++ b/chameleon/path.go
@@ -0,0 +1,91 @@
+package chameleon
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"strings"
+)
+
+type Path string
+
+func (p Path) String() string {
+	return string(p)
+}
+
+func (p Path) Name() string {
+	return path.Base(p.String())
+}
+
+func (p Path) Relative(to Path) Path {
+	if p == to {
+		return ""
+	}
+	return Path(strings.TrimPrefix(p.String(), to.String()+"/"))
+}
+
+func (p Path) Parent() Path {
+	return Path(path.Dir(p.String()))
+}
+
+func (p Path) Join(fname string) Path {
+	if fname == "" {
+		return p
+	}
+	return Path(path.Join(p.String(), fname))
+}
+
+func (p Path) IsDir() (bool, error) {
+	stat, err := os.Stat(p.String())
+	if err != nil {
+		if os.IsNotExist(err) {
+			return false, nil
+		}
+		return false, err
+	}
+	return stat.IsDir(), nil
+}
+
+func (p Path) IsFile() (bool, error) {
+	stat, err := os.Stat(p.String())
+	if err != nil {
+		if os.IsNotExist(err) {
+			return false, nil
+		}
+		return false, err
+	}
+	return !stat.IsDir(), nil
+}
+
+func (p Path) EnsureDir() error {
+	exists, err := p.IsDir()
+	if err != nil {
+		return fmt.Errorf("cannot get dir stat: %v", err)
+	}
+	if exists {
+		return nil
+	}
+	err = os.MkdirAll(p.String(), 0777)
+	if err != nil {
+		return fmt.Errorf("cannot create dir: %v", err)
+	}
+	return nil
+}
+
+func (p Path) SubPaths() ([]Path, error) {
+	infos, err := ioutil.ReadDir(p.String())
+	if err != nil {
+		return nil, err
+	}
+
+	result := make([]Path, len(infos))
+	for i, info := range infos {
+		result[i] = p.Join(info.Name())
+	}
+	return result, nil
+}
+
+func (p Path) Open() (*os.File, error) {
+	return os.Open(p.String())
+}
diff --git a/chameleon/repository.go b/chameleon/repository.go
new file mode 100644
index 0000000..41c0278
--- /dev/null
+++ b/chameleon/repository.go
@@ -0,0 +1,71 @@
+package chameleon
+
+import (
+	"fmt"
+	"net/url"
+	"os/exec"
+	"strings"
+
+	giturls "github.com/whilp/git-urls"
+)
+
+const CloneURL = "https://github.com/%s.git"
+
+type Repository struct {
+	Path
+}
+
+func (r Repository) Command(args ...string) *exec.Cmd {
+	c := exec.Command("git", args...)
+	c.Dir = r.Path.String()
+	return c
+}
+
+func (r Repository) Pull() error {
+	c := r.Command("pull")
+	err := c.Run()
+	if err != nil {
+		return fmt.Errorf("cannot pull repo: %v", err)
+	}
+	return nil
+}
+
+func (r Repository) Remote() (*url.URL, error) {
+	c := r.Command("remote", "get-url", "origin")
+	out, err := c.CombinedOutput()
+	if err != nil {
+		return nil, err
+	}
+	rawURL := strings.TrimSpace(string(out))
+	return giturls.Parse(rawURL)
+}
+
+func (r Repository) Branch() (string, error) {
+	c := r.Command("rev-parse", "--abbrev-ref", "HEAD")
+	out, err := c.CombinedOutput()
+	if err != nil {
+		return "", err
+	}
+	branch := strings.TrimSpace(string(out))
+	return branch, nil
+}
+
+func (r Repository) Clone(url string) error {
+	isdir, err := r.Path.IsDir()
+	if err != nil {
+		return fmt.Errorf("cannot access local repo: %v", err)
+	}
+	if isdir {
+		return nil
+	}
+	if url == "" {
+		return fmt.Errorf("repo not found and no clone URL specified")
+	}
+
+	c := exec.Command("git", "clone", url, r.Path.String())
+	out, err := c.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("cannot clone repo: %v: %s", err, out)
+	}
+	return nil
+}
diff --git a/chameleon/server.go b/chameleon/server.go
new file mode 100644
index 0000000..d7b67e0
--- /dev/null
+++ b/chameleon/server.go
@@ -0,0 +1,154 @@
+package chameleon
+
+import (
+	"embed"
+	"fmt"
+	"net/http"
+	"net/http/pprof"
+	"time"
+
+	"github.com/go-co-op/gocron"
+	"github.com/julienschmidt/httprouter"
+	"go.uber.org/zap"
+)
+
+//go:embed assets/*
+var assets embed.FS
+
+type Server struct {
+	Repository Repository
+	Database   *Database
+	Logger     *zap.Logger
+	Config     Config
+
+	cache  *Cache
+	auth   Auth
+	router *httprouter.Router
+}
+
+func NewServer(config Config, logger *zap.Logger) (*Server, error) {
+	if logger == nil {
+		logger = zap.NewNop()
+	}
+	repo := Repository{Path: Path(config.RepoPath)}
+
+	cache, err := NewCache(config.Cache)
+	if err != nil {
+		return nil, fmt.Errorf("cannot create cache: %v", err)
+	}
+
+	server := Server{
+		Repository: repo,
+		Database:   &Database{},
+		Logger:     logger,
+		cache:      cache,
+		auth: Auth{
+			Password: config.Password,
+			TTL:      config.AuthTTL,
+			Logger:   logger,
+		},
+		Config: config,
+	}
+
+	if config.DBPath != "" {
+		err = server.Database.Open(config.DBPath)
+		if err != nil {
+			return nil, fmt.Errorf("cannot open database: %v", err)
+		}
+	}
+
+	err = repo.Clone(config.RepoURL)
+	if err != nil {
+		return nil, fmt.Errorf("cannot clone repo: %v", err)
+	}
+
+	server.Init()
+
+	if config.Pull != 0 {
+		sch := gocron.NewScheduler(time.UTC)
+		job, err := sch.Every(config.Pull).Do(func() {
+			logger.Debug("pulling the repo")
+			err := repo.Pull()
+			if err != nil {
+				logger.Error("cannot pull the repo", zap.Error(err))
+			}
+		})
+		if err != nil {
+			return nil, fmt.Errorf("cannot schedule the task: %v", err)
+		}
+		job.SingletonMode()
+		sch.StartAsync()
+	}
+
+	return &server, nil
+}
+
+func (s *Server) Init() {
+	s.router = httprouter.New()
+	s.router.Handler(
+		http.MethodGet,
+		"/",
+		http.RedirectHandler(MainPrefix, http.StatusTemporaryRedirect),
+	)
+	s.router.Handler(
+		http.MethodGet,
+		"/assets/*filepath",
+		http.FileServer(http.FS(assets)),
+	)
+	s.router.GET(
+		MainPrefix+"*filepath",
+		s.auth.Wrap(
+			s.cache.Wrap(
+				HandlerMain{Server: s, Template: TemplateArticle}.Handle,
+			),
+		),
+	)
+	s.router.GET(
+		LinterPrefix+"*filepath",
+		s.auth.Wrap(
+			s.cache.Wrap(
+				HandlerMain{Server: s, Template: TemplateLinter}.Handle,
+			),
+		),
+	)
+	s.router.GET(
+		CommitsPrefix+"*filepath",
+		s.auth.Wrap(
+			HandlerMain{Server: s, Template: TemplateCommits}.Handle,
+		),
+	)
+	s.router.GET(
+		DiffPrefix+":hash",
+		s.auth.Wrap(
+			HandlerDiff{Server: s}.Handle,
+		),
+	)
+	s.router.GET(
+		StatPrefix,
+		s.auth.Wrap(
+			HandlerStat{Server: s}.Handle,
+		),
+	)
+	s.router.GET(
+		SearchPrefix,
+		s.auth.Wrap(
+			HandlerSearch{Server: s}.Handle,
+		),
+	)
+
+	s.router.GET(AuthPrefix, s.auth.HandleGET)
+	s.router.POST(AuthPrefix, s.auth.HandlePOST)
+
+	if s.Config.PProf {
+		s.Logger.Debug("debugging enabled", zap.String("endpoint", "/debug/pprof/"))
+		s.router.HandlerFunc("GET", "/debug/pprof/", pprof.Index)
+		s.router.HandlerFunc("GET", "/debug/pprof/cmdline", pprof.Cmdline)
+		s.router.HandlerFunc("GET", "/debug/pprof/profile", pprof.Profile)
+		s.router.HandlerFunc("GET", "/debug/pprof/symbol", pprof.Symbol)
+		s.router.HandlerFunc("GET", "/debug/pprof/trace", pprof.Trace)
+	}
+}
+
+func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	s.router.ServeHTTP(w, r)
+}
diff --git a/chameleon/server_test.go b/chameleon/server_test.go
new file mode 100644
index 0000000..dec76a2
--- /dev/null
+++ b/chameleon/server_test.go
@@ -0,0 +1,31 @@
+package chameleon
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func newTestConfig() Config {
+	config := NewConfig()
+	config.Pull = 0
+	config.DBPath = ""
+	config.RepoPath = "../.repo"
+	return config
+}
+
+func TestRootRedirect(t *testing.T) {
+	is := require.New(t)
+	request, err := http.NewRequest(http.MethodGet, "/", nil)
+	is.Nil(err)
+	response := httptest.NewRecorder()
+
+	s, err := NewServer(newTestConfig(), nil)
+	is.Nil(err)
+	s.ServeHTTP(response, request)
+
+	is.Equal(response.Code, 307)
+	is.Equal(response.Header()["Location"], []string{"/p/"})
+}
diff --git a/chameleon/stat.go b/chameleon/stat.go
new file mode 100644
index 0000000..a2d6c4b
--- /dev/null
+++ b/chameleon/stat.go
@@ -0,0 +1,87 @@
+package chameleon
+
+import "sort"
+
+type Stat struct {
+	Path  string
+	Count uint32
+	Repo  Repository
+
+	article  *Article
+	category *Category
+}
+
+func (s Stat) URLs() URLs {
+	return URLs{
+		Repository: s.Repo,
+		Path:       Path(s.Path),
+	}
+}
+
+func (s Stat) Article() *Article {
+	if s.article == nil {
+		s.article = &Article{
+			Repository: s.Repo,
+			Path:       Path(s.Path),
+		}
+		ok, _ := s.article.Valid()
+		if !ok {
+			s.article = s.Category().Article()
+		}
+	}
+	return s.article
+}
+
+func (s Stat) Category() *Category {
+	if s.category == nil {
+		s.category = &Category{
+			Repository: s.Repo,
+			Path:       Path(s.Path),
+		}
+	}
+	return s.category
+}
+
+func (s Stat) Title() string {
+	art := s.Article()
+	title, err := art.Title()
+	if err != nil {
+		return s.Path
+	}
+	return title
+}
+
+type ViewStat struct {
+	Stats []*Stat
+	Max   uint32
+}
+
+func (s ViewStat) Len() int {
+	return len(s.Stats)
+}
+
+func (s ViewStat) Less(i, j int) bool {
+	return s.Stats[i].Count < s.Stats[j].Count
+}
+
+func (s ViewStat) Swap(i, j int) {
+	s.Stats[i], s.Stats[j] = s.Stats[j], s.Stats[i]
+}
+
+func (s *ViewStat) Sort() {
+	sort.Sort(sort.Reverse(s))
+}
+
+func (s *ViewStat) Add(path string, count uint32) {
+	s.Stats = append(s.Stats, &Stat{Path: path, Count: count})
+	if count > s.Max {
+		s.Max = count
+	}
+}
+
+func (s ViewStat) SetRepo(repo Repository) {
+	for i, stat := range s.Stats {
+		stat.Repo = repo
+		s.Stats[i] = stat
+	}
+}
diff --git a/chameleon/templates.go b/chameleon/templates.go
new file mode 100644
index 0000000..2b8bbc6
--- /dev/null
+++ b/chameleon/templates.go
@@ -0,0 +1,46 @@
+package chameleon
+
+import (
+	"crypto/md5"
+	"embed"
+	"fmt"
+	"text/template"
+	"time"
+)
+
+//go:embed templates/*.html.j2
+var templates embed.FS
+
+var (
+	TemplateArticle = parseTemplate("templates/category.html.j2")
+	TemplateLinter  = parseTemplate("templates/linter.html.j2")
+	TemplateCommits = parseTemplate("templates/commits.html.j2")
+	TemplateAuth    = parseTemplate("templates/auth.html.j2")
+	TemplateDiff    = parseTemplate("templates/diff.html.j2")
+	TemplateStat    = parseTemplate("templates/stat.html.j2")
+	TemplateSearch  = parseTemplate("templates/search.html.j2")
+
+	Template403 = parseTemplate("templates/403.html.j2")
+	Template404 = parseTemplate("templates/404.html.j2")
+)
+
+func parseTemplate(tname string) *template.Template {
+	return template.Must(template.New("base.html.j2").Funcs(funcs).ParseFS(
+		templates,
+		"templates/base.html.j2",
+		tname,
+	))
+}
+
+var funcs = template.FuncMap{
+	"date": func(item time.Time) string {
+		return item.Format("2006-01-02")
+	},
+	"gravatar": func(mail string) string {
+		hash := md5.Sum([]byte(mail))
+		return fmt.Sprintf("https://www.gravatar.com/avatar/%x", hash)
+	},
+	"percent": func(a, b uint32) uint32 {
+		return a * 100 / b
+	},
+}
diff --git a/chameleon/templates/403.html.j2 b/chameleon/templates/403.html.j2
new file mode 100644
index 0000000..0ae0c93
--- /dev/null
+++ b/chameleon/templates/403.html.j2
@@ -0,0 +1,15 @@
+{{define "title"}}
+  403: Access Denied
+{{end}}
+
+{{define "header"}}
+  <h1>
+    403: Access Denied
+  </h1>
+{{end}}
+
+{{define "content"}}
+  <h3 class="text-center">
+    <a href="/">Restart</a>
+  </h3>
+{{end}}
diff --git a/chameleon/templates/404.html.j2 b/chameleon/templates/404.html.j2
new file mode 100644
index 0000000..e16ee5b
--- /dev/null
+++ b/chameleon/templates/404.html.j2
@@ -0,0 +1,15 @@
+{{define "title"}}
+  404: Game Over
+{{end}}
+
+{{define "header"}}
+  <h1>
+    404: Game Over
+  </h1>
+{{end}}
+
+{{define "content"}}
+  <h3 class="text-center">
+    <a href="/">Restart</a>
+  </h3>
+{{end}}
diff --git a/chameleon/templates/auth.html.j2 b/chameleon/templates/auth.html.j2
new file mode 100644
index 0000000..ff85601
--- /dev/null
+++ b/chameleon/templates/auth.html.j2
@@ -0,0 +1,27 @@
+{{define "title"}}
+  403: Password Required
+{{end}}
+
+{{define "header"}}
+  <h1>
+    403: Password Required
+  </h1>
+{{end}}
+
+{{define "content"}}
+  <div class="card container-xs" style="width: 18rem; margin: auto">
+    <form method="post" action="/auth/" class="card-body row">
+      {{ if not . }}
+        <div class="alert alert-danger" role="alert">
+          Invalid password
+        </div>
+      {{ end }}
+      <div class="col-8">
+        <input name="password" type="password" class="form-control" placeholder="password">
+      </div>
+      <div class="col-4">
+        <button type="submit" class="btn btn-primary">enter</button>
+      </div>
+    </form>
+  </div>
+{{end}}
diff --git a/chameleon/templates/base.html.j2 b/chameleon/templates/base.html.j2
new file mode 100644
index 0000000..0fc51e6
--- /dev/null
+++ b/chameleon/templates/base.html.j2
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta http-equiv="x-ua-compatible" content="ie=edge">
+    <title>{{block "title" .}}Home{{end}} - Chameleon</title>
+    <link rel="shortcut icon" href="favicon.ico">
+
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous">
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" integrity="sha512-iBBXm8fW90+nuLcSKlbmrPcLa0OT92xO1BIsZ+ywDWZCvqsWgccV3gFoRBv0z+8dLJgyAHIhR35VZc2oM/gI1w==" crossorigin="anonymous" />
+
+    <link href="/assets/style.css" rel="stylesheet" />
+
+    <!-- https://realfavicongenerator.net/ -->
+    <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png">
+    <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png">
+    <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png">
+    <link rel="manifest" href="/assets/site.webmanifest">
+    <link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5">
+    <link rel="shortcut icon" href="/assets/favicon.ico">
+    <meta name="msapplication-TileColor" content="#2c3e50">
+    <meta name="msapplication-config" content="/assets/browserconfig.xml">
+    <meta name="theme-color" content="#ffffff">
+  </head>
+
+  <body class="bg-white text-body">
+
+    <div class="container-lg content">
+      <div class="row header">
+        {{block "header" .}}
+        {{end}}
+      </div>
+      <div class="row">
+        <div class="col-md-2 sidebar">
+          {{ block "sidebar" . }}{{ end }}
+          <div class="d-grid gap-2 py-1">
+            <a class="btn btn-outline-dark btn-sm text-start" href="/">
+              <i class="fas fa-home fa-fw"></i>
+              home
+            </a>
+            <a class="btn btn-outline-dark btn-sm text-start" href="/search/">
+              <i class="fas fa-search fa-fw"></i>
+              search
+            </a>
+            <a class="btn btn-outline-dark btn-sm text-start" href="/stat/">
+              <i class="fas fa-chart-bar fa-fw"></i>
+              stat
+            </a>
+          </div>
+        </div>
+        <div class="col">
+          {{ block "content" . }}{{ end }}
+        </div>
+      </div>
+    </div>
+    {{block "toc" .}}{{end}}
+
+    <div class="footer container-fluid" style="margin-top: 10px">
+      <h1 class="text-center">
+        <a href="/" class="link-dark">
+          <i class="far fa-heart"></i>
+        </a>
+      </h1>
+    </div>
+
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.bundle.min.js" integrity="sha384-b5kHyXgcpbZJO/tY9Ul7kGkf1S0CWuKcCD38l8YkeH8z8QjE0GmW1gYU5S9FOnJ0" crossorigin="anonymous"></script>
+    <script>
+      var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
+      var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
+        return new bootstrap.Tooltip(tooltipTriggerEl)
+      })
+    </script>
+
+
+    <!-- https://www.mathjax.org/ -->
+    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-MML-AM_CHTML"></script>
+
+    <!-- https://www.bryanbraun.com/anchorjs/ -->
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/anchor-js/4.3.0/anchor.min.js"></script>
+    <script>
+      anchors.options = {
+        placement: 'left',
+        icon: '#',
+      };
+      anchors.add('h2');
+    </script>
+
+  </body>
+
+</html>
diff --git a/chameleon/templates/category.html.j2 b/chameleon/templates/category.html.j2
new file mode 100644
index 0000000..1f00dec
--- /dev/null
+++ b/chameleon/templates/category.html.j2
@@ -0,0 +1,95 @@
+{{define "title"}}
+  {{ .Article.Title }}
+{{end}}
+
+{{define "header"}}
+  <h1>
+    {{if .Parent}}
+      <a href="{{ .Parent.URLs.Main }}">{{ .Parent.Article.Title }}</a> /
+    {{end}}
+    {{ .Article.Title }}
+  </h1>
+{{end}}
+
+{{define "toc"}}
+  {{if .Category}}
+    <div class="container-fluid text-light bg-dark text-center lead">
+      <ul class="text-start list-unstyled" style="display: inline-block">
+        {{range .Category.Categories}}
+          <li>
+            <i class="far fa-folder fa-fw"></i>
+            <a href="{{ .URLs.Main }}" class="link-light">{{ .Article.Title }}</a>
+          </li>
+        {{end}}
+        {{range .Category.Articles}}
+          <li>
+            <i class="far fa-file fa-fw"></i>
+            <span class="text-secondary">
+              {{ .Commits.Created.Time | date }}
+            </span>
+            <a href="{{ .URLs.Main }}" class="link-light">{{ .Title }}</a>
+          </li>
+        {{end}}
+      </ul>
+    </div>
+  {{end}}
+{{end}}
+
+{{define "content"}}
+  {{ .Article.HTML }}
+
+  <!-- https://prismjs.com/ -->
+  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/themes/prism-tomorrow.min.css" />
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/prism.min.js"></script>
+
+  <!-- https://cdnjs.com/libraries/prism/ -->
+  {{ range .Article.Languages }}
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/components/prism-{{ . }}.min.js"></script>
+  {{ end }}
+{{end}}
+
+{{define "sidebar"}}
+  {{if not .Category}}
+    <div>
+      <i class="far fa-eye fa-fw" title="views" data-bs-toggle="tooltip" data-bs-placement="right"></i>
+      {{ .Views.Get }}
+    </div>
+
+    <div>
+      <i class="far fa-save fa-fw" title="created" data-bs-toggle="tooltip" data-bs-placement="right"></i>
+      {{ .Article.Commits.Created.Time | date }}
+    </div>
+
+    <div>
+      <i class="far fa-edit fa-fw" title="edited" data-bs-toggle="tooltip" data-bs-placement="right"></i>
+      {{ .Article.Commits.Edited.Time | date }}
+    </div>
+
+    <div>
+      <i class="far fa-user fa-fw" title="author" data-bs-toggle="tooltip" data-bs-placement="right"></i>
+      {{ .Article.Commits.Created.Name }}
+    </div>
+
+    <div class="d-grid gap-2">
+      <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Linter }}">
+        <i class="fas fa-bug fa-fw"></i>
+        linter
+      </a>
+      <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Commits }}">
+        <i class="fas fa-code-branch fa-fw"></i>
+        commits
+        ({{ .Article.Commits.Len }})
+      </a>
+      <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Raw }}">
+        <i class="fas fa-code fa-fw"></i>
+        raw
+      </a>
+      {{ if .Article.URLs.Edit }}
+        <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Edit }}" target="_blank">
+          <i class="far fa-edit fa-fw"></i>
+          edit
+        </a>
+      {{ end }}
+    </div>
+  {{end}}
+{{end}}
diff --git a/chameleon/templates/commits.html.j2 b/chameleon/templates/commits.html.j2
new file mode 100644
index 0000000..420189f
--- /dev/null
+++ b/chameleon/templates/commits.html.j2
@@ -0,0 +1,28 @@
+{{define "title"}}
+  {{ .Article.Title }}
+{{end}}
+
+{{define "header"}}
+  <h1>
+    {{if .Parent}}
+      <a href="../">{{ .Parent.Article.Title }}</a> /
+    {{end}}
+    {{ .Article.Title }}
+  </h1>
+{{end}}
+
+{{define "content"}}
+  <ul>
+    {{ range .Article.Commits }}
+      <li>
+        <a href="/diff/{{ .Hash }}">
+          {{ .Time | date }}
+        </a>
+        by
+        <img src="{{ .Mail | gravatar }}" class="avatar" />
+        <a href="mailto:{{.Mail}}">{{ .Name }}</a>:
+        {{ .Msg }}
+      </li>
+    {{end}}
+  <ul>
+{{end}}
diff --git a/chameleon/templates/diff.html.j2 b/chameleon/templates/diff.html.j2
new file mode 100644
index 0000000..4555b9e
--- /dev/null
+++ b/chameleon/templates/diff.html.j2
@@ -0,0 +1,21 @@
+{{define "title"}}
+  {{ .Msg }}
+{{end}}
+{{define "header"}}
+  <h1>
+    {{ .Msg }}
+  </h1>
+{{end}}
+
+{{define "content"}}
+  <pre>
+    <code class="language-diff" style="white-space: pre-wrap">
+      {{ .Diff }}
+    </code>
+  </pre>
+
+  <!-- https://prismjs.com/ -->
+  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/themes/prism-tomorrow.min.css" />
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/prism.min.js"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/components/prism-diff.min.js"></script>
+{{end}}
diff --git a/chameleon/templates/linter.html.j2 b/chameleon/templates/linter.html.j2
new file mode 100644
index 0000000..597770f
--- /dev/null
+++ b/chameleon/templates/linter.html.j2
@@ -0,0 +1,40 @@
+{{define "title"}}
+  {{ .Article.Title }}
+{{end}}
+
+{{define "header"}}
+  <h1>
+    {{if .Parent}}
+      {{ .Parent.Article.Title }} /
+    {{end}}
+    {{ .Article.Title }}
+  </h1>
+{{end}}
+
+{{define "sidebar"}}
+  <div class="d-grid gap-2">
+    <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Main }}">
+      <i class="fas fa-file fa-fw"></i>
+      article
+    </a>
+  </div>
+{{end}}
+
+{{define "content"}}
+  <table class="table table-sm" style="text-align: left">
+    <tr>
+      <th>line</th>
+      <th>level</th>
+      <th>message</th>
+      <th>check</th>
+    </tr>
+    {{ range .Article.Linter.Alerts }}
+      <tr>
+        <td>{{ .Line }}:{{ .Span }}</td>
+        <td class="level-{{ .Severity }}">{{ .Severity }}</td>
+        <td>{{ .Message }}</td>
+        <td>{{ .Check }}</td>
+      </tr>
+    {{end}}
+  </table>
+{{end}}
diff --git a/chameleon/templates/search.html.j2 b/chameleon/templates/search.html.j2
new file mode 100644
index 0000000..2fcd6b9
--- /dev/null
+++ b/chameleon/templates/search.html.j2
@@ -0,0 +1,39 @@
+{{define "title"}}
+  {{ if .Query }}
+    Search: {{ .Query }}
+  {{ else }}
+    Search
+  {{ end }}
+{{end}}
+
+{{define "header"}}
+  <h1>
+    Search
+  </h1>
+{{end}}
+
+{{define "content"}}
+  <form class="text-center g-1">
+    <input name="q" class="form-control" placeholder="search" value="{{.Query}}">
+  </form>
+{{end}}
+
+{{define "toc"}}
+  {{ if .Query }}
+    <div class="container-fluid text-light bg-dark text-center lead">
+      {{ if not .Results }}
+        No results
+      {{ else }}
+        <ul class="text-start list-unstyled" style="display: inline-block">
+          {{ range .Results }}
+            <li>
+              <a href="{{ .URLs.Main }}" class="link-light">
+                {{ .Title }}
+              </a>
+            </li>
+          {{ end }}
+        </ul>
+      {{ end }}
+    </div>
+  {{ end }}
+{{ end }}
diff --git a/chameleon/templates/stat.html.j2 b/chameleon/templates/stat.html.j2
new file mode 100644
index 0000000..1034162
--- /dev/null
+++ b/chameleon/templates/stat.html.j2
@@ -0,0 +1,37 @@
+{{define "title"}}
+  Statistics
+{{end}}
+
+{{define "header"}}
+  <h1>
+    Statistics
+  </h1>
+{{end}}
+
+{{define "content"}}
+  <h2>Views per page</h2>
+  <div class="container">
+    {{ $max := .Max }}
+    {{ range .Stats }}
+      <div class="row">
+        <div class="col-4">
+          {{ if .Category.Valid }}
+            <i class="far fa-folder fa-fw"></i>
+          {{ else }}
+            <i class="far fa-file fa-fw"></i>
+          {{ end }}
+          <a href="{{ .URLs.Main }}">
+            {{ .Article.Title }}
+          </a>
+        </div>
+        <div class="col-1">
+          {{ .Count }}
+        </div>
+        <div class="col">
+          <div style="width: {{ percent .Count $max }}%; height: 1em" class="bg-dark">
+          </div>
+        </div>
+      </div>
+    {{ end }}
+  </table>
+{{end}}
diff --git a/chameleon/text.go b/chameleon/text.go
new file mode 100644
index 0000000..acae133
--- /dev/null
+++ b/chameleon/text.go
@@ -0,0 +1,22 @@
+package chameleon
+
+import (
+	"regexp"
+	"strings"
+)
+
+var rexSafeChars = regexp.MustCompile(`[^a-zA-Z\-\.\_\/ ]`)
+
+// safeText sanitizes the input to be safe to pass into system calls
+func safeText(s string) string {
+	s = rexSafeChars.ReplaceAllString(s, " ")
+	for strings.Contains(s, "  ") {
+		s = strings.ReplaceAll(s, "  ", " ")
+	}
+	s = strings.Trim(s, " ")
+	s = strings.Trim(s, "/")
+	for strings.Contains(s, "..") {
+		s = strings.ReplaceAll(s, "..", ".")
+	}
+	return s
+}
diff --git a/chameleon/urls.go b/chameleon/urls.go
new file mode 100644
index 0000000..f8b5277
--- /dev/null
+++ b/chameleon/urls.go
@@ -0,0 +1,59 @@
+package chameleon
+
+import (
+	"fmt"
+	"strings"
+)
+
+const (
+	githubEditURL = "https://github.com/%s/edit/%s/%s"
+)
+
+type URLs struct {
+	Repository Repository
+	Path       Path
+}
+
+func (urls URLs) suffix() string {
+	s := urls.Path.Relative(urls.Repository.Path).String()
+	s = strings.TrimSuffix(s, Extension)
+	if s == "" {
+		return s
+	}
+	return s + "/"
+}
+
+func (urls URLs) Main() string {
+	return MainPrefix + urls.suffix()
+}
+
+func (urls URLs) Linter() string {
+	return LinterPrefix + urls.suffix()
+}
+
+func (urls URLs) Commits() string {
+	return CommitsPrefix + urls.suffix()
+}
+
+func (urls URLs) Raw() string {
+	return MainPrefix + strings.TrimSuffix(urls.suffix(), "/") + Extension
+}
+
+func (urls URLs) Edit() (string, error) {
+	remote, err := urls.Repository.Remote()
+	if err != nil {
+		return "", nil
+	}
+
+	if remote.Hostname() == "github.com" {
+		branch, err := urls.Repository.Branch()
+		if err != nil {
+			return "", err
+		}
+		repo := strings.TrimSuffix(remote.Path, ".git")
+		url := fmt.Sprintf(githubEditURL, repo, branch, urls.suffix())
+		return url, nil
+	}
+
+	return "", nil
+}
diff --git a/chameleon/views.go b/chameleon/views.go
new file mode 100644
index 0000000..a8668c1
--- /dev/null
+++ b/chameleon/views.go
@@ -0,0 +1,52 @@
+package chameleon
+
+import (
+	"encoding/binary"
+
+	"go.etcd.io/bbolt"
+)
+
+type Views struct {
+	db   *bbolt.DB
+	path Path
+}
+
+func (views Views) Inc() error {
+	err := views.db.Update(func(tx *bbolt.Tx) error {
+		bucket := tx.Bucket([]byte("views"))
+		b := bucket.Get([]byte(views.path))
+		var count uint32
+		if len(b) > 1 {
+			count = binary.BigEndian.Uint32(b)
+		}
+		b = make([]byte, 4)
+		binary.BigEndian.PutUint32(b, count+1)
+		return bucket.Put([]byte(views.path), b)
+	})
+	return err
+}
+
+func (views Views) Get() (uint32, error) {
+	var count uint32
+	err := views.db.View(func(tx *bbolt.Tx) error {
+		bucket := tx.Bucket([]byte("views"))
+		b := bucket.Get([]byte(views.path))
+		if len(b) > 1 {
+			count = binary.BigEndian.Uint32(b)
+		}
+		return nil
+	})
+	return count, err
+}
+
+func (views Views) All() (ViewStat, error) {
+	result := ViewStat{Stats: make([]*Stat, 0)}
+	err := views.db.View(func(tx *bbolt.Tx) error {
+		bucket := tx.Bucket([]byte("views"))
+		return bucket.ForEach(func(k, v []byte) error {
+			result.Add(string(k), binary.BigEndian.Uint32(v))
+			return nil
+		})
+	})
+	return result, err
+}
diff --git a/config_example.toml b/config_example.toml
deleted file mode 100644
index 72ca508..0000000
--- a/config_example.toml
+++ /dev/null
@@ -1,21 +0,0 @@
-listen = "127.0.0.1:1337"
-root = "/"
-
-cache = true
-contributors = true
-lint = true
-views = true
-
-[categories.rus]
-repo = "orsinium/notes"
-branch = "master"
-dir = "notes-ru"
-name = "Russian Articles"
-ext = ".md"
-
-[categories.eng]
-repo = "orsinium/notes"
-branch = "master"
-dir = "notes-en"
-name = "English Articles"
-ext = ".md"
diff --git a/go.mod b/go.mod
index d9b6d1a..31441be 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,26 @@
 module github.com/orsinium/chameleon
 
-go 1.13
+go 1.16
 
 require (
 	github.com/BurntSushi/toml v0.3.1
+	github.com/enescakir/emoji v1.0.0 // indirect
 	github.com/errata-ai/vale v1.7.1
 	github.com/go-chi/chi v4.0.2+incompatible
+	github.com/go-co-op/gocron v0.7.1 // indirect
+	github.com/julienschmidt/httprouter v1.3.0 // indirect
 	github.com/recoilme/pudge v1.0.3
 	github.com/recoilme/slowpoke v2.0.1+incompatible
 	github.com/spf13/pflag v1.0.5
+	github.com/stretchr/testify v1.7.0 // indirect
+	github.com/tdewolff/minify v2.3.6+incompatible
+	github.com/tdewolff/minify/v2 v2.9.15 // indirect
+	github.com/tdewolff/parse v2.3.4+incompatible // indirect
 	github.com/tidwall/gjson v1.3.5
 	github.com/victorspringer/http-cache v0.0.0-20190721184638-fe78e97af707
+	github.com/whilp/git-urls v1.0.0 // indirect
+	go.etcd.io/bbolt v1.3.5 // indirect
+	go.uber.org/multierr v1.6.0 // indirect
+	go.uber.org/zap v1.16.0 // indirect
 	gopkg.in/russross/blackfriday.v2 v2.0.0
 )
diff --git a/go.sum b/go.sum
index 243a53f..02aac52 100644
--- a/go.sum
+++ b/go.sum
@@ -4,22 +4,32 @@ github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8
 github.com/ValeLint/gospell v0.0.0-20160306015952-90dfc71015df h1:eaw3Zd3eAS3vnCKYwCk9o4ZcDJbI84tOL44vC9swz9o=
 github.com/ValeLint/gospell v0.0.0-20160306015952-90dfc71015df/go.mod h1:YyyUOeo+JX2NINtuB09ft2lvoMr4jbi07frpAv/jOaQ=
 github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
+github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U=
 github.com/client9/gospell v0.0.0-20160306015952-90dfc71015df h1:XXCjxndsxMyNjoZtyuyDnzSck+h681QN7vKkK0EIVq0=
 github.com/client9/gospell v0.0.0-20160306015952-90dfc71015df/go.mod h1:X4IDm8zK6KavjWkfKQCet43DKeLii9nJhUK/seHoSbA=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
 github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog=
+github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
 github.com/errata-ai/vale v1.7.1 h1:ZEKnF7dc1nnOCFM670lFsAfmZ/FThQeF4ybTpB6SsmU=
 github.com/errata-ai/vale v1.7.1/go.mod h1:kKk8YYvErOC9wToOqKy/XcmQjLMrcU26/l+6wEp7NxY=
 github.com/fatih/color v1.5.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/frankban/quicktest v1.4.1/go.mod h1:36zfPVQyHxymz4cH7wlDmVwDrJuljRB60qkgn7rorfQ=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs=
 github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
+github.com/go-co-op/gocron v0.7.1 h1:olyF7+ZKMM7bVk8oWcrAQ75Ewm1O+1U8WWvagrIQ89U=
+github.com/go-co-op/gocron v0.7.1/go.mod h1:Hyge6OdrinfqhNgi1kNLnA/O7GtFsr004+Rbrgx5Ylc=
 github.com/gobwas/glob v0.2.2 h1:czsC5u90AkrSujyGY0l7ST7QVLEPrdoMoXxRx/hXgq0=
 github.com/gobwas/glob v0.2.2/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/jdkato/prose v0.0.0-20170725235243-4d68d1b77f66 h1:uJCQ0UmypG5jU5iZqojaxTy8N+PNdDkUS/Qy5dUwNjQ=
 github.com/jdkato/prose v0.0.0-20170725235243-4d68d1b77f66/go.mod h1:jkF0lkxaX5PFSlk9l4Gh9Y+T57TqUZziWT7uZbW5ADg=
@@ -27,12 +37,16 @@ github.com/jdkato/regexp v0.0.0-20170725234532-38ab2f7842bf h1:0dDP+WVB6SiB8MUwA
 github.com/jdkato/regexp v0.0.0-20170725234532-38ab2f7842bf/go.mod h1:o9fY0c1vIooBxHLENPUeocgjhdBFuERi5VMDH+two4s=
 github.com/jdkato/syllables v0.1.0/go.mod h1:f1KhTKBE2PJvmbDxNAoXYDQABKMoPbtxszWqsf6opjs=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
 github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/levigross/grequests v0.0.0-20190130132859-37c80f76a0da/go.mod h1:uCZIhROSrVmuF/BPYFPwDeiiQ6juSLp0kikFoEcNcEs=
+github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs=
 github.com/mattn/go-colorable v0.0.8/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-isatty v0.0.2/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
@@ -49,6 +63,8 @@ github.com/neurosnap/sentences v1.0.6/go.mod h1:pg1IapvYpWCJJm/Etxeh0+gtMf1rI1ST
 github.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
 github.com/olekukonko/tablewriter v0.0.0-20170719101040-be5337e7b39e/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
 github.com/pierrec/lz4 v2.2.6+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/recoilme/pudge v1.0.3 h1:h/9dEv5fRqtzM4lnO69kUoN+k7ukxxrW9NGb9ug0grM=
 github.com/recoilme/pudge v1.0.3/go.mod h1:VMvxBLVkrSStldckzCsETBXox3pfovfrnEchafXk8qA=
@@ -56,6 +72,7 @@ github.com/recoilme/slowpoke v2.0.1+incompatible h1:55UUAkmU9zOIG2Ra7I/6PJJcg11v
 github.com/recoilme/slowpoke v2.0.1+incompatible/go.mod h1:bAQc5fISCFURi0Gp488wpYKbsu5iLLYwofS0mg9l6xI=
 github.com/remeh/sizedwaitgroup v0.0.0-20161123101944-4b44541c9359 h1:Qevki/5jkGrihNHC/G/x515V0DzNqIreUS2QKTcu5lM=
 github.com/remeh/sizedwaitgroup v0.0.0-20161123101944-4b44541c9359/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/russross/blackfriday v0.0.0-20151110051855-0b647d0506a6 h1:NCdCpzq6sADll3AUyACrweBbe2AqzpU3dVepB3mEaRE=
 github.com/russross/blackfriday v0.0.0-20151110051855-0b647d0506a6/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/shogo82148/go-shuffle v0.0.0-20170226075450-4789c7c401f2 h1:8+n3ByPah5vdCpEZHjOQ/ZPB0d4fCQOo8c57hJSbboo=
@@ -68,7 +85,21 @@ github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/tdewolff/minify v2.3.6+incompatible h1:2hw5/9ZvxhWLvBUnHE06gElGYz+Jv9R4Eys0XUzItYo=
+github.com/tdewolff/minify v2.3.6+incompatible/go.mod h1:9Ov578KJUmAWpS6NeZwRZyT56Uf6o3Mcz9CEsg8USYs=
+github.com/tdewolff/minify/v2 v2.9.15 h1:gZzGuFHvmxDjsAM6Eu53xo8A5NiaVu3gzamvHAxDpAI=
+github.com/tdewolff/minify/v2 v2.9.15/go.mod h1:tK4qPnHUZgANtEGVMwTBxrF1eNIBkigHFYo7F3Y98GQ=
+github.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxpP46ghf0Qzh38=
+github.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtpzXOT0WXX9cWhz+bIzJQ=
+github.com/tdewolff/parse/v2 v2.5.14 h1:ftdD54vkOeLZ7VkEZxp+wZrYZyyPi43GGon5GwBTRUI=
+github.com/tdewolff/parse/v2 v2.5.14/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
+github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
 github.com/tidwall/gjson v1.3.5 h1:2oW9FBNu8qt9jy5URgrzsVx/T/KSn3qn/smJQ0crlDQ=
 github.com/tidwall/gjson v1.3.5/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
 github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc=
@@ -79,19 +110,53 @@ github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4A
 github.com/urfave/cli v1.19.1/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
 github.com/victorspringer/http-cache v0.0.0-20190721184638-fe78e97af707 h1:Pg/LJmFZnr+hlP9sohJKDaxi1nTSOPvGNo8dBBgRIkM=
 github.com/victorspringer/http-cache v0.0.0-20190721184638-fe78e97af707/go.mod h1:V7CEaXWuLs0tH3DNWqJO+GVr8YgiAwRgBh76T4LNSPU=
+github.com/whilp/git-urls v1.0.0 h1:95f6UMWN5FKW71ECsXRUd3FVYiXdrE7aX4NZKcPmIjU=
+github.com/whilp/git-urls v1.0.0/go.mod h1:J16SAmobsqc3Qcy98brfl5f5+e0clUvg1krgwk/qCfE=
 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
 github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9 h1:w8V9v0qVympSF6GjdjIyeqR7+EVhAF9CBQmkmW7Zw0w=
 github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
+go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
+go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
+go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
+go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM=
+go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
 golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/ini.v1 v1.28.0 h1:Bp9VlwLuKFwZwag5eRqc0KdIfk3I/iJfkxh4qb/rsS4=
 gopkg.in/ini.v1 v1.28.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/neurosnap/sentences.v1 v1.0.6 h1:v7ElyP020iEZQONyLld3fHILHWOPs+ntzuQTNPkul8E=
@@ -100,3 +165,9 @@ gopkg.in/russross/blackfriday.v2 v2.0.0 h1:+FlnIV8DSQnT7NZ43hcVKcdJdzZoeCmJj4Ql8
 gopkg.in/russross/blackfriday.v2 v2.0.0/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI=
 gopkg.in/yaml.v2 v2.0.0-20170721122051-25c4ec802a7d h1:2DX7x6HUDGZUyuEDAhUsQQNqkb1zvDyKTjVoTdzaEzo=
 gopkg.in/yaml.v2 v2.0.0-20170721122051-25c4ec802a7d/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
+gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
diff --git a/main.go b/main.go
index 8228372..f35992c 100644
--- a/main.go
+++ b/main.go
@@ -2,289 +2,43 @@ package main
 
 import (
 	"fmt"
-	"log"
 	"net/http"
-	"path"
-	"sort"
-	"strings"
-	"text/template"
-	"time"
+	"os"
 
-	"github.com/BurntSushi/toml"
-	"github.com/go-chi/chi"
-	"github.com/spf13/pflag"
-	cache "github.com/victorspringer/http-cache"
-	"github.com/victorspringer/http-cache/adapter/memory"
+	"github.com/orsinium/chameleon/chameleon"
+	"go.uber.org/zap"
 )
 
-const (
-	commitsLinkT = "https://api.github.com/repos/{{.Repo}}/commits?sha={{.Branch}}&path={{.Dir}}"
-	rawLinkT     = "https://raw.githubusercontent.com/{{.Repo}}/{{.Branch}}/{{.Dir}}"
-	dirAPILinkT  = "https://api.github.com/repos/{{.Repo}}/contents/{{.Dir}}?ref={{.Branch}}"
-)
-
-// Config is the TOML config with attached handlers
-type Config struct {
-	Listen    string // hostname and port to listen
-	Root      string // prefix for all URLs
-	Project   string // path to the project dir
-	Templates string // path to the templates dir
-
-	Cache        bool
-	Contributors bool
-	Lint         bool
-	Views        bool
-
-	Categories map[string]Category
-}
-
-func (config *Config) handleCategories(w http.ResponseWriter, r *http.Request) {
-	keys := make([]string, 0, len(config.Categories))
-	for k := range config.Categories {
-		keys = append(keys, k)
-	}
-	sort.Strings(keys)
-	var categories []Category
-	var category Category
-	for _, key := range keys {
-		category = config.Categories[key]
-		category.Slug = key
-		categories = append(categories, category)
-	}
-
-	t, err := template.ParseFiles(
-		path.Join(config.Templates, "base.html"),
-		path.Join(config.Templates, "categories.html"),
-	)
-	if err != nil {
-		log.Fatal(err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-	err = t.Execute(w, categories)
-	if err != nil {
-		log.Fatal(err)
-		return
-	}
-}
-
-func (config *Config) handleCategory(w http.ResponseWriter, r *http.Request) {
-	categorySlug := chi.URLParam(r, "category")
-	category, ok := config.Categories[categorySlug]
-	if !ok {
-		w.WriteHeader(http.StatusNotFound)
-		return
-	}
-
-	articles, err := category.getArticles()
-	if err != nil {
-		log.Fatal(err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-
-	t, err := template.ParseFiles(
-		path.Join(config.Templates, "base.html"),
-		path.Join(config.Templates, "category.html"),
-	)
-	if err != nil {
-		log.Fatal(err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-	err = t.Execute(w, struct {
-		Articles []Article
-		Category Category
-	}{
-		Articles: articles,
-		Category: category,
-	})
-	if err != nil {
-		log.Fatal(err)
-		return
-	}
-}
-
-func (config *Config) handleArticleRedirect(w http.ResponseWriter, r *http.Request) {
-	categorySlug := chi.URLParam(r, "category")
-	articleSlug := strings.TrimPrefix(chi.URLParam(r, "*"), "/"+categorySlug+"/")
-
-	category, ok := config.Categories[categorySlug]
-	if !ok {
-		w.WriteHeader(http.StatusNotFound)
-		return
-	}
-
-	// if there is no extension then it is probably article without trailing slash (/lol -> /lol/)
-	if !strings.ContainsRune(articleSlug, '.') {
-		http.Redirect(w, r, articleSlug+"/", http.StatusTemporaryRedirect)
-		return
-	}
-
-	// if it has extension of article then it is definetly article (/lol.md -> /lol/)
-	if strings.HasSuffix(articleSlug, category.Ext) {
-		http.Redirect(w, r, strings.TrimSuffix(articleSlug, category.Ext)+"/", http.StatusTemporaryRedirect)
-		return
-	}
-
-	// if it has different extension then it is file (/lol.jpg -> githubusercontent.com/.../lol.jpg)
-	link, err := category.makeLink(rawLinkT)
+func run(logger *zap.Logger) error {
+	config := chameleon.NewConfig().Parse()
+	server, err := chameleon.NewServer(config, logger)
 	if err != nil {
-		w.WriteHeader(http.StatusInternalServerError)
-		log.Fatal(err)
-		return
+		return err
 	}
-	link += "/" + articleSlug
-	http.Redirect(w, r, link, http.StatusPermanentRedirect)
-
-}
 
-func (config *Config) handleArticle(w http.ResponseWriter, r *http.Request) {
-	categorySlug := chi.URLParam(r, "category")
-	articleSlug := chi.URLParam(r, "article")
-
-	category, ok := config.Categories[categorySlug]
-	if !ok {
-		w.WriteHeader(http.StatusNotFound)
-		return
-	}
-
-	if strings.HasSuffix(articleSlug, category.Ext) {
-		newURL := fmt.Sprintf("/%s/%s/", categorySlug, strings.TrimSuffix(articleSlug, category.Ext))
-		http.Redirect(w, r, newURL, http.StatusPermanentRedirect)
-		return
-	}
-
-	category.Slug = categorySlug
-	file := articleSlug + category.Ext
-	article := Article{
-		Config:   config,
-		Category: &category,
-		File:     file,
-		Slug:     articleSlug,
-	}
-	err := article.updateRaw()
-	if err != nil {
-		log.Fatal(err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-	article.Title = article.getTitle()
-	article.HTML = article.getHTML()
-
-	if config.Contributors {
-		err := article.updateMetaInfo()
-		if err != nil {
-			fmt.Println(err)
-		}
-	}
-
-	if config.Lint {
-		err := article.updateAlerts()
+	defer func() {
+		err := server.Database.Close()
 		if err != nil {
-			log.Fatal(err)
-			w.WriteHeader(http.StatusInternalServerError)
-			return
-		}
-	}
-
-	if config.Views {
-		_, err := r.Cookie("viewed")
-		if err != http.ErrNoCookie {
-			// article already viewed
-			err := article.updateViews()
-			if err != nil {
-				log.Fatal(err)
-				w.WriteHeader(http.StatusInternalServerError)
-				return
-			}
-		} else {
-			// article hasn't been viewed yet
-			err := article.incrementViews()
-			if err != nil {
-				log.Fatal(err)
-				w.WriteHeader(http.StatusInternalServerError)
-				return
-			}
-			cookie := http.Cookie{
-				Name:   "viewed",
-				Value:  "1",
-				Domain: r.Host,
-				Path:   fmt.Sprintf("/%s/%s/", category.Slug, article.Slug),
-				MaxAge: 3600 * 24,
-			}
-			http.SetCookie(w, &cookie)
+			logger.Error("cannot close connection", zap.Error(err))
 		}
-	}
+	}()
 
-	t, err := template.ParseFiles(
-		path.Join(config.Templates, "base.html"),
-		path.Join(config.Templates, "article.html"),
-	)
-	if err != nil {
-		log.Fatal(err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-	err = t.Execute(w, article)
-	if err != nil {
-		log.Fatal(err)
-		return
-	}
+	logger.Info("listening", zap.String("addr", config.Address))
+	return http.ListenAndServe(config.Address, server)
 }
 
 func main() {
-	configPath := pflag.StringP("config", "c", "config.toml", "path to config file")
-	projectPath := pflag.StringP("project", "p", "./", "path to the project directory")
-	listen := pflag.StringP("listen", "l", "", "server and port to listen (value from config by default)")
-	pflag.Parse()
-
-	var conf Config
-	if _, err := toml.DecodeFile(*configPath, &conf); err != nil {
-		log.Fatal(err)
+	logger, err := zap.NewDevelopment()
+	if err != nil {
+		fmt.Println(err)
 		return
 	}
-	if *listen != "" {
-		conf.Listen = *listen
-	}
-	conf.Project = *projectPath
-	conf.Templates = path.Join(conf.Project, "templates")
+	defer logger.Sync() // nolint
 
-	// serve static files
-	fileServer := http.FileServer(http.Dir(path.Join(conf.Project, "assets")))
-	http.Handle(conf.Root+"assets/", http.StripPrefix(conf.Root+"assets/", fileServer))
-
-	// serve dynamic pages
-	r := chi.NewRouter()
-	r.Get("/", conf.handleCategories)
-	r.Get("/{category}/", conf.handleCategory)
-	r.Get("/{category}/{article}/", conf.handleArticle)
-	r.Get("/{category}/*", conf.handleArticleRedirect)
-
-	if conf.Cache {
-		memcached, err := memory.NewAdapter(
-			memory.AdapterWithAlgorithm(memory.LRU),
-			memory.AdapterWithCapacity(20),
-		)
-		if err != nil {
-			log.Fatal(err)
-			return
-		}
-
-		cacheClient, err := cache.NewClient(
-			cache.ClientWithAdapter(memcached),
-			cache.ClientWithTTL(10*time.Minute),
-		)
-		if err != nil {
-			log.Fatal(err)
-			return
-		}
-
-		http.Handle(conf.Root, cacheClient.Middleware(r))
-	} else {
-		http.Handle(conf.Root, r)
+	logger.Info("starting...")
+	err = run(logger)
+	if err != nil {
+		logger.Error("fatal error", zap.Error(err))
+		os.Exit(1)
 	}
-
-	fmt.Printf("Ready to get connections on %s%s\n", conf.Listen, conf.Root)
-	log.Fatal(http.ListenAndServe(conf.Listen, nil))
 }
diff --git a/release.sh b/release.sh
deleted file mode 100755
index 7f488dc..0000000
--- a/release.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env bash
-
-echo "make binary releases"
-python3 build.py .
-
-echo "make archive"
-tar --create --file chameleon.tar \
-    --transform='flags=r;s|config_example|config|' \
-    --transform='flags=r;s|builds/||' \
-    builds templates config_example.toml chameleon.service
-
-echo "delete empty directory from archive"
-tar --delete --file chameleon.tar builds
-
-echo "compress"
-gzip chameleon.tar
-
-echo "remove binary releases"
-rm -r builds
diff --git a/templates/article.html b/templates/article.html
deleted file mode 100644
index c738328..0000000
--- a/templates/article.html
+++ /dev/null
@@ -1,116 +0,0 @@
-{{define "title"}}
-  {{ .Title }}
-{{end}}
-
-{{define "header"}}
-  <a href="/{{ .Category.Slug }}/" class="btn btn-dark">
-    <i class="fas fa-chevron-left"></i>
-    {{ .Category.Name }}
-  <a>
-  <h1>
-    {{ .Title }}
-  </h1>
-{{end}}
-
-{{define "content"}}
-  {{ .HTML }}
-{{end}}
-
-{{define "footer"}}
-  <a href="https://github.com/{{.Category.Repo}}/blob/{{.Category.Branch}}/{{.Category.Dir}}/{{.File}}" class="btn btn-dark" target="_blank">
-    <i class="fab fa-github"></i>
-    Read on Github
-  <a>
-
-  <a href="https://github.com/{{.Category.Repo}}/edit/{{.Category.Branch}}/{{.Category.Dir}}/{{.File}}" class="btn btn-dark" target="_blank">
-    <i class="far fa-edit"></i>
-    Edit
-  <a>
-
-  <a href="https://github.com/{{.Category.Repo}}/issues/new" class="btn btn-dark" target="_blank">
-    <i class="far fa-comment"></i>
-    Comment
-  <a>
-
-  <a href="https://raw.githubusercontent.com/{{.Category.Repo}}/{{.Category.Branch}}/{{.Category.Dir}}/{{.File}}" class="btn btn-dark"
-  target="_blank">
-    <i class="far fa-file-code"></i>
-    View source
-  <a>
-
-  <div>
-    <a href="/{{ .Category.Slug }}/" class="btn btn-dark">
-      <i class="fas fa-chevron-left"></i>
-      {{ .Category.Name }}
-    <a>
-  </div>
-{{end}}
-
-
-{{block "underground" .}}
-  {{ if .Authors }}
-    <div class="bg-white text-body floor">
-      <div class="text-muted" style="padding-bottom: 20px">
-        <span class="statblock">
-          <i class="fas fa-plus"></i>
-          created:
-          {{ .CreatedAt.Format "2006-01-02 (Mon)" }}
-        </span>
-        <span class="statblock">
-          <i class="far fa-edit"></i>
-          updated:
-          {{ .UpdatedAt.Format "2006-01-02 (Mon)" }}
-        </span>
-        <span class="statblock">
-          <i class="far fa-eye"></i>
-          views:
-          {{ .Views }}
-        </span>
-      </div>
-
-      <h2>Contributors</h2>
-      {{ range .Authors }}
-        <a href="https://github.com/{{ .Login }}" target="_blank" class="avatar">
-          <img src="{{ .Avatar }}" alt="{{ .Login }}">
-        </a>
-      {{end}}
-    </div>
-  {{end}}
-
-  {{ if .Alerts }}
-    <div class="container-fluid bg-dark text-white footer">
-      <h2>How can I contribute?</h2>
-      <p>
-        There is an output of
-        <a href="https://github.com/errata-ai/vale" target="_blank">vale linter</a>.
-        Please, help me to fix these issues to make the article better.
-        You can change anything, not only issues from the table.
-      </p>
-
-      <div style="margin-bottom: 20px">
-        <a href="https://github.com/{{.Category.Repo}}/edit/{{.Category.Branch}}/{{.Category.Dir}}/{{.File}}" class="btn btn-success" target="_blank">
-          <i class="far fa-heart"></i>
-          Help
-        <a>
-      </div>
-
-      <table class="table table-sm" style="text-align: left">
-        <tr>
-          <th>line</th>
-          <th>level</th>
-          <th>message</th>
-          <th>check</th>
-        </tr>
-        {{ range .Alerts }}
-          <tr>
-            <td>{{ .Line }}:{{ .Span }}</td>
-            <td class="level-{{ .Severity }}">{{ .Severity }}</td>
-            <td>{{ .Message }}</td>
-            <td>{{ .Check }}</td>
-          </tr>
-        {{end}}
-      </table>
-    </div>
-  {{end}}
-
-{{end}}
diff --git a/templates/base.html b/templates/base.html
deleted file mode 100644
index ea00093..0000000
--- a/templates/base.html
+++ /dev/null
@@ -1,84 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-    <meta http-equiv="x-ua-compatible" content="ie=edge">
-    <meta name="description" content="Articles about Python and Go">
-    <meta name="keywords" content="articles, python, go, golang, orsinium">
-    <meta name="author" content="Gram (@orsinium)">
-    <title>{{block "title" .}}Gram's articles{{end}} - Chameleon</title>
-    <link rel="shortcut icon" href="favicon.ico">
-
-    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Smlep5jCw/wG7hdkwQ/Z5nLIefveQRIY9nfy6xoR1uRYBtpZgI6339F5dgvm/e9B" crossorigin="anonymous" />
-    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/js/bootstrap.min.js" integrity="sha384-o+RDsa0aLu++PJvFqy8fFScvbHFLtbvScb8AjopnFD+iEQ7wo/CG0xlczd+2O/em" crossorigin="anonymous"></script>
-    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.1.1/css/all.css" integrity="sha384-O8whS3fhG2OnA5Kas0Y9l3cfpmYjapjI0E4theH4iuMD+pLhbf6JI0jIMfYcK3yZ" crossorigin="anonymous" />
-
-    <link href="/assets/style.css" rel="stylesheet" />
-
-    <!-- https://realfavicongenerator.net/ -->
-    <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png">
-    <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png">
-    <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png">
-    <link rel="manifest" href="/assets/site.webmanifest">
-    <link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5">
-    <link rel="shortcut icon" href="/assets/favicon.ico">
-    <meta name="msapplication-TileColor" content="#2c3e50">
-    <meta name="msapplication-config" content="/assets/browserconfig.xml">
-    <meta name="theme-color" content="#ffffff">
-  </head>
-
-  <body class="bg-dark text-white">
-
-    <div class="container-fluid bg-dark text-white header">
-      <div class="container">
-        {{block "header" .}}
-        {{end}}
-      </div>
-    </div>
-
-    <div class="container-fluid bg-white text-body">
-      <div class="container content">
-        {{block "content" .}}
-          No content
-        {{end}}
-      </div>
-    </div>
-
-    <div class="container-fluid bg-dark text-white footer">
-      <div class="container">
-        {{block "footer" .}}
-        {{end}}
-      </div>
-    </div>
-
-    {{block "underground" .}}
-    {{end}}
-
-    <!-- https://www.mathjax.org/ -->
-    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=TeX-MML-AM_CHTML"></script>
-
-    <!-- https://www.bryanbraun.com/anchorjs/ -->
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/anchor-js/4.1.1/anchor.min.js"></script>
-    <script>
-      anchors.options = {
-        placement: 'left',
-        icon: '#',
-      };
-      anchors.add('h2');
-    </script>
-
-    <!-- https://prismjs.com/ -->
-    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/themes/prism-tomorrow.min.css" />
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/prism.min.js"></script>
-
-    <!-- https://cdnjs.com/libraries/prism/ -->
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-bash.min.js"></script>
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-go.min.js"></script>
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-json.min.js"></script>
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-python.min.js"></script>
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-r.min.js"></script>
-
-  </body>
-
-</html>
diff --git a/templates/categories.html b/templates/categories.html
deleted file mode 100644
index 8d7de83..0000000
--- a/templates/categories.html
+++ /dev/null
@@ -1,23 +0,0 @@
-{{define "title"}}
-  Categories
-{{end}}
-
-{{define "header"}}
-  <h1>
-    Categories
-  </h1>
-{{end}}
-
-{{define "content"}}
-  <div class="row">
-    {{range .}}
-      <div class="col-sm">
-        <a href="/{{ .Slug }}/" class="card btn btn-secondary bg-secondary text-white">
-          <div class="card-body">
-            {{ .Name }}
-          </div>
-        </a>
-      </div>
-    {{end}}
-  </div>
-{{end}}
diff --git a/templates/category.html b/templates/category.html
deleted file mode 100644
index 65d2836..0000000
--- a/templates/category.html
+++ /dev/null
@@ -1,23 +0,0 @@
-{{define "title"}}
-  {{ .Category.Name }}
-{{end}}
-
-{{define "header"}}
-  <a href="/" class="btn btn-dark">
-    <i class="fas fa-chevron-left"></i>
-    Categories
-  <a>
-  <h1>
-    {{ .Category.Name }}
-  </h1>
-{{end}}
-
-{{define "content"}}
-  <ul>
-    {{range .Articles}}
-      <li>
-        <a href="{{ .Slug }}/">{{ .Title }}</a>
-      </li>
-    {{end}}
-  </ul>
-{{end}}
diff --git a/tmp.py b/tmp.py
new file mode 100644
index 0000000..b2d0e5a
--- /dev/null
+++ b/tmp.py
@@ -0,0 +1,66 @@
+import re
+import sys
+import tokenize
+import typing
+from pathlib import Path
+
+REX_WORD = re.compile(r'[A-Za-z]+')
+# https://stackoverflow.com/a/1176023/8704691
+REX1 = re.compile(r'(.)([A-Z][a-z]+)')
+REX2 = re.compile(r'([a-z0-9])([A-Z])')
+
+
+def get_words(text: str) -> typing.Set[str]:
+    text = REX1.sub(r'\1 \2', text)
+    text = REX2.sub(r'\1 \2', text).lower()
+    text = text.replace('_', ' ')
+    return set(text.split())
+
+
+def get_redundant_comments(tokens: typing.Iterable[tokenize.TokenInfo]):
+    comment = None
+    code_words: typing.Set[str] = set()
+    for token in tokens:
+        if token.type == tokenize.NL:
+            continue
+
+        if token.type == tokenize.COMMENT:
+            if comment is None:
+                comment = token
+            else:
+                comment = None
+            continue
+        if comment is None:
+            continue
+
+        if token.start[0] == comment.start[0] + 1:
+            code_words.update(get_words(token.string))
+            continue
+
+        comment_words = set(REX_WORD.findall(comment.string))
+        if comment_words and not comment_words - code_words:
+            yield comment
+        code_words = set()
+        comment = None
+
+
+def process_file(path: Path):
+    with path.open('rb') as stream:
+        tokens = tokenize.tokenize(stream.readline)
+        for comment in get_redundant_comments(tokens):
+            print(f'{path}:{comment.start[0]} {comment.string.strip()}')
+
+
+def get_paths(path: Path):
+    if path.is_dir():
+        for p in path.iterdir():
+            yield from get_paths(p)
+    elif path.suffix == '.py':
+        yield path
+
+
+if __name__ == '__main__':
+    for path in sys.argv[1:]:
+        for p in get_paths(Path(path)):
+            # process file
+            process_file(p)