From: Sergey Matveev <stargrave@stargrave.org>
Date: Sun, 26 Jul 2020 22:48:17 +0000 (+0300)
Subject: Use templates for code simplicity
X-Git-Tag: v0.7.0^0
X-Git-Url: http://www.git.stargrave.org/?a=commitdiff_plain;h=236718b29541b104d68cb2a95693a99529ffa7dc;p=sgblog.git

Use templates for code simplicity
---

diff --git a/cmd/sgblog/gopher.go b/cmd/sgblog/gopher.go
index 1e3eb72..d8e4fe4 100644
--- a/cmd/sgblog/gopher.go
+++ b/cmd/sgblog/gopher.go
@@ -19,7 +19,6 @@ package main
 
 import (
 	"bufio"
-	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -29,20 +28,54 @@ import (
 	"os"
 	"strconv"
 	"strings"
-	"time"
+	"text/template"
 
 	"github.com/hjson/hjson-go"
 	"go.stargrave.org/sgblog"
 	"gopkg.in/src-d/go-git.v4"
 	"gopkg.in/src-d/go-git.v4/plumbing"
+	"gopkg.in/src-d/go-git.v4/plumbing/object"
 )
 
-const CRLF = "\r\n"
-
-var DashLine = strings.Repeat("-", 72)
+const (
+	TmplGopherMenu = `{{$Cfg := .Cfg}}{{$CR := .CR}}i{{.Cfg.Title}} ({{.Offset}}-{{.OffsetNext}})	err	{{.Cfg.GopherDomain}}	70{{.CR}}
+{{if .Cfg.AboutURL}}hAbout	URL:{{.Cfg.AboutURL}}	{{.Cfg.GopherDomain}}	70{{.CR}}{{end}}
+{{if .Offset}}1Prev	offset/{{.OffsetPrev}}	{{.Cfg.GopherDomain}}	70{{.CR}}{{end}}
+{{if not .LogEnded}}1Next	offset/{{.OffsetNext}}	{{.Cfg.GopherDomain}}	70{{.CR}}{{end -}}
+{{- $yearPrev := 0}}{{$monthPrev := 0}}{{$dayPrev := 0 -}}
+{{- range .Entries }}{{$yearCur := .Commit.Author.When.Year}}{{$monthCur := .Commit.Author.When.Month}}{{$dayCur := .Commit.Author.When.Day -}}
+{{- if or (ne $dayCur $dayPrev) (ne $monthCur $monthPrev) (ne $yearCur $yearPrev)}}{{$dayPrev = $dayCur}}{{$monthPrev = $monthCur}}{{$yearPrev = $yearCur}}
+i{{$yearCur | printf "%04d"}}-{{$monthCur | printf "%02d"}}-{{$dayCur | printf "%02d"}}	err	{{$Cfg.GopherDomain}}	70{{$CR}}{{end}}
+0[{{.Commit.Author.When.Hour | printf "%02d"}}:{{.Commit.Author.When.Minute | printf "%02d"}}] {{.Title}} ({{.LinesNum}}L){{if .CommentsNum}} ({{.CommentsNum}}C){{end}}	/{{.Commit.Hash.String}}	{{$Cfg.GopherDomain}}	70{{$CR}}{{end}}
+iGenerated by: SGBlog {{.Version}}	err	{{.Cfg.GopherDomain}}	70{{.CR}}
+.{{.CR}}
+`
+	TmplGopherEntry = `What: {{.Commit.Hash.String}}
+When: {{.When}}
+------------------------------------------------------------------------
+{{.Commit.Message -}}
+{{- if .Note}}
+------------------------------------------------------------------------
+Note:
+{{.Note}}{{end -}}
+{{- if .Cfg.CommentsEmail}}
+------------------------------------------------------------------------
+leave comment: mailto:{{.Cfg.CommentsEmail}}?subject={{.Commit.Hash.String}}
+{{end}}{{range $idx, $comment := .Comments}}
+------------------------------------------------------------------------
+comment {{$idx}}:
+{{$comment}}
+{{end}}
+------------------------------------------------------------------------
+Generated by: SGBlog {{.Version}}
+`
+)
 
-func makeI(cfg *Cfg, value string) string {
-	return strings.Join([]string{"i" + value, "err", cfg.GopherDomain, "70", CRLF}, "\t")
+type TableMenuEntry struct {
+	Commit      *object.Commit
+	Title       string
+	LinesNum    int
+	CommentsNum int
 }
 
 func serveGopher() {
@@ -89,79 +122,50 @@ func serveGopher() {
 		if err != nil {
 			log.Fatalln(err)
 		}
-		commitN := 0
 		for i := 0; i < offset; i++ {
 			if _, err = repoLog.Next(); err != nil {
 				break
 			}
-			commitN++
 		}
-
 		logEnded := false
-		var menu bytes.Buffer
-		var yearPrev int
-		var monthPrev time.Month
-		var dayPrev int
+		entries := make([]TableMenuEntry, 0, PageEntries)
 		for i := 0; i < PageEntries; i++ {
 			commit, err := repoLog.Next()
 			if err != nil {
 				logEnded = true
 				break
 			}
-			yearCur, monthCur, dayCur := commit.Author.When.Date()
-			if dayCur != dayPrev || monthCur != monthPrev || yearCur != yearPrev {
-				menu.WriteString(makeI(cfg, fmt.Sprintf(
-					"%04d-%02d-%02d", yearCur, monthCur, dayCur,
-				)))
-				yearPrev, monthPrev, dayPrev = yearCur, monthCur, dayCur
-			}
-			commitN++
 			lines := msgSplit(commit.Message)
-			var commentsValue string
-			if l := len(parseComments(getNote(commentsTree, commit.Hash))); l > 0 {
-				commentsValue = fmt.Sprintf(" (%dC)", l)
-			}
-			menu.WriteString(fmt.Sprintf(
-				"0[%02d:%02d] %s (%dL)%s\t/%s\t%s\t%d%s",
-				commit.Author.When.Hour(),
-				commit.Author.When.Minute(),
-				lines[0],
-				len(lines)-2,
-				commentsValue,
-				commit.Hash.String(),
-				cfg.GopherDomain, 70, CRLF,
-			))
-		}
-
-		fmt.Print(makeI(cfg, fmt.Sprintf("%s (%d-%d)", cfg.Title, offset, offset+PageEntries)))
-		if cfg.AboutURL != "" {
-			fmt.Printf(
-				"hAbout\tURL:%s\t%s\t%d%s",
-				cfg.AboutURL,
-				cfg.GopherDomain, 70, CRLF,
-			)
+			entries = append(entries, TableMenuEntry{
+				Commit:      commit,
+				Title:       lines[0],
+				LinesNum:    len(lines) - 2,
+				CommentsNum: len(parseComments(getNote(commentsTree, commit.Hash))),
+			})
 		}
-		if offset > 0 {
-			offsetPrev := offset - PageEntries
-			if offsetPrev < 0 {
-				offsetPrev = 0
-			}
-			fmt.Printf(
-				"1Prev\toffset/%d\t%s\t%d%s",
-				offsetPrev,
-				cfg.GopherDomain, 70, CRLF,
-			)
-		}
-		if !logEnded {
-			fmt.Printf(
-				"1Next\toffset/%d\t%s\t%d%s",
-				offset+PageEntries,
-				cfg.GopherDomain, 70, CRLF,
-			)
+		tmpl := template.Must(template.New("menu").Parse(TmplGopherMenu))
+		err = tmpl.Execute(os.Stdout, struct {
+			Cfg        *Cfg
+			Offset     int
+			OffsetPrev int
+			OffsetNext int
+			LogEnded   bool
+			Entries    []TableMenuEntry
+			Version    string
+			CR         string
+		}{
+			Cfg:        cfg,
+			Offset:     offset,
+			OffsetPrev: offset - PageEntries,
+			OffsetNext: offset + PageEntries,
+			LogEnded:   logEnded,
+			Entries:    entries,
+			Version:    sgblog.Version,
+			CR:         "\r",
+		})
+		if err != nil {
+			log.Fatalln(err)
 		}
-		fmt.Print(menu.String())
-		fmt.Printf(makeI(cfg, "Generated by: SGBlog "+sgblog.Version))
-		fmt.Print("." + CRLF)
 	} else if strings.HasPrefix(selector, "URL:") {
 		selector = selector[len("URL:"):]
 		fmt.Printf(`<html>
@@ -179,29 +183,25 @@ Redirecting to <a href="%s">%s</a>...
 		if err != nil {
 			log.Fatalln(err)
 		}
-		fmt.Printf(
-			"What: %s\nWhen: %s\n%s\n%s",
-			commit.Hash.String(),
-			commit.Author.When.Format(sgblog.WhenFmt),
-			DashLine,
-			commit.Message,
-		)
-		notesRaw := getNote(notesTree, commit.Hash)
-		if len(notesRaw) > 0 {
-			fmt.Printf("%s\nNote:\n%s\n", DashLine, string(notesRaw))
-		}
-		if cfg.CommentsEmail != "" {
-			fmt.Printf(
-				"%s\nleave comment: mailto:%s?subject=%s\n",
-				DashLine,
-				cfg.CommentsEmail,
-				commit.Hash.String(),
-			)
-		}
-		for i, comment := range parseComments(getNote(commentsTree, commit.Hash)) {
-			fmt.Printf("%s\ncomment %d:\n%s\n", DashLine, i, comment)
+		tmpl := template.Must(template.New("entry").Parse(TmplGopherEntry))
+		err = tmpl.Execute(os.Stdout, struct {
+			Commit   *object.Commit
+			When     string
+			Cfg      *Cfg
+			Note     string
+			Comments []string
+			Version  string
+		}{
+			Commit:   commit,
+			When:     commit.Author.When.Format(sgblog.WhenFmt),
+			Cfg:      cfg,
+			Note:     string(getNote(notesTree, commit.Hash)),
+			Comments: parseComments(getNote(commentsTree, commit.Hash)),
+			Version:  sgblog.Version,
+		})
+		if err != nil {
+			log.Fatalln(err)
 		}
-		fmt.Printf("%s\nGenerated by: SGBlog %s\n", DashLine, sgblog.Version)
 	} else {
 		log.Fatalln(errors.New("unknown selector"))
 	}
diff --git a/cmd/sgblog/http.go b/cmd/sgblog/http.go
index a4ad671..0c09f2b 100644
--- a/cmd/sgblog/http.go
+++ b/cmd/sgblog/http.go
@@ -35,7 +35,7 @@ import (
 	"os"
 	"strconv"
 	"strings"
-	"time"
+	"text/template"
 
 	"github.com/hjson/hjson-go"
 	"go.stargrave.org/sgblog"
@@ -49,11 +49,106 @@ import (
 const (
 	AtomPostsFeed    = "feed.atom"
 	AtomCommentsFeed = "comments.atom"
+	TmplHTMLIndex    = `<html>
+<head>
+	<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+	<meta name="generator" content="SGBlog {{.Version}}">
+	<title>{{.Cfg.Title}} ({{.Offset}}-{{.OffsetNext}})</title>
+	{{if .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.Cfg.CSS}}">{{end}}
+	{{if .Cfg.Webmaster}}<link rev="made" href="mailto:{{.Cfg.Webmaster}}">{{end -}}
+	{{- range .Cfg.GitURLs}}
+	<link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
+	<link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
+	<link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}" type="application/atom+xml">
+	{{if .CommentsEnabled}}<link rel="alternate" title="Comments feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomCommentsFeed}}" type="application/atom+xml">{{end}}
+	{{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{if .OffsetPrev}}?offset={{.OffsetPrev}}{{end}}" title="prev">{{end}}
+	{{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}" title="next">{{end}}
+</head>
+<body>
+{{if .Cfg.AboutURL}}[<a href="{{.Cfg.AboutURL}}">about</a>]{{end}}
+{{block "links" .}}
+{{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/{{if .OffsetPrev}}?offset={{.OffsetPrev}}{{end}}">prev</a>]{{end}}
+{{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}">next</a>]{{end}}
+{{end}}
+{{- $Cfg := .Cfg -}}
+{{- $yearPrev := 0 -}}
+{{- $monthPrev := 0 -}}
+{{- $dayPrev := 0}}
+<table border=1>
+<tr>
+	<th>N</th><th>When</th><th>Title</th>
+	<th size="5%"><a title="Lines">L</a></th>
+	<th size="5%"><a title="Comments">C</a></th>
+	<th>Linked to</th>
+</tr>{{range .Entries -}}
+{{- $yearCur := .Commit.Author.When.Year -}}
+{{- $monthCur := .Commit.Author.When.Month -}}
+{{- $dayCur := .Commit.Author.When.Day -}}
+{{- if or (ne $dayCur $dayPrev) (ne $monthCur $monthPrev) (ne $yearCur $yearPrev) -}}
+<tr><td colspan=6><center><tt>{{$yearCur | printf "%04d"}}-{{$monthCur | printf "%02d"}}-{{$dayCur | printf "%02d"}}</tt></center></td></tr>
+{{- $dayPrev = $dayCur}}{{$monthPrev = $monthCur}}{{$yearPrev = $yearCur -}}
+{{- end -}}
+<tr>
+	<td>{{.Num}}</td>
+	<td><tt>{{.Commit.Author.When.Hour | printf "%02d" -}}:{{- .Commit.Author.When.Minute | printf "%02d"}}</tt></td>
+	<td><a href="{{$Cfg.URLPrefix}}/{{.Commit.Hash.String}}">{{.Title}}</a></td>
+	<td>{{.LinesNum}}</td>
+	<td>{{if .CommentsNum}}{{.CommentsNum}}{{else}}&nbsp;{{end}}</td>
+	<td>{{range .DomainURLs}} {{.}} {{end}}</td>
+</tr>
+{{- end}}</table>
+{{template "links" .}}
+</body>
+</html>
+`
+	TmplHTMLEntry = `{{$Cfg := .Cfg}}<html>
+<head>
+	<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+	<meta name="generator" content="SGBlog {{.Version}}">
+	<title>{{.Title}} ({{.When}})</title>
+	{{if .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.Cfg.CSS}}">{{end}}
+	{{if .Cfg.Webmaster}}<link rev="made" href="mailto:{{.Cfg.Webmaster}}">{{end -}}
+	{{- range .Cfg.GitURLs}}
+	<link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
+	<link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
+	<link rel="alternate" title="Comments feed" href="{{.AtomCommentsURL}}" type="application/atom+xml">
+	{{if .Parent}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{.Parent}}" title="prev">{{end}}
+</head>
+<body>
+{{if .Cfg.AboutURL}}[<a href="{{.Cfg.AboutURL}}">about</a>]{{end}}
+[<a href="{{.Cfg.URLPrefix}}/">index</a>]
+{{if .Parent}}[<a href="{{.Cfg.URLPrefix}}/{{.Parent}}">prev</a>]{{end}}
+[<tt><a title="When">{{.When}}</a></tt>]
+[<tt><a title="What">{{.Commit.Hash.String}}</a></tt>]
+
+<hr/>
+<h2>{{.Title}}</h2>
+<pre>
+{{range .Lines}}{{. | lineURLize $Cfg.URLPrefix}}
+{{end}}</pre>
+<hr/>
+
+{{if .NoteLines}}Note:<pre>
+{{range .NoteLines}}{{. | lineURLize $Cfg.URLPrefix}}
+{{end}}</pre>
+<hr/>{{end}}
+
+{{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject={{.Commit.Hash.String}}">leave comment</a>]{{end}}
+
+<dl>{{range $idx, $comment := .Comments}}
+<dt><a name="comment{{$idx}}"><a href="#comment{{$idx}}">comment {{$idx}}</a>:</dt>
+<dd><pre>
+{{range $comment.HeaderLines}}{{.}}
+{{end}}{{range $comment.BodyLines}}{{. | lineURLize $Cfg.URLPrefix}}
+{{end}}</pre></dd>
+{{end}}</dl>
+
+</body>
+</html>
+`
 )
 
 var (
-	defaultLinks = []string{}
-
 	renderableSchemes = map[string]struct{}{
 		"ftp":    struct{}{},
 		"gopher": struct{}{},
@@ -64,8 +159,18 @@ var (
 )
 
 type TableEntry struct {
-	commit      *object.Commit
-	commentsRaw []byte
+	Commit      *object.Commit
+	CommentsRaw []byte
+	Num         int
+	Title       string
+	LinesNum    int
+	CommentsNum int
+	DomainURLs  []string
+}
+
+type CommentEntry struct {
+	HeaderLines []string
+	BodyLines   []string
 }
 
 func makeA(href, text string) string {
@@ -92,13 +197,15 @@ func lineURLize(urlPrefix, line string) string {
 			cols[i] = makeA(col, col)
 			continue
 		}
-		cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(
-			urlPrefix+"/$1", "$1",
-		))
+		cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
 	}
 	return strings.Join(cols, " ")
 }
 
+func lineURLizeInTemplate(urlPrefix, line interface{}) string {
+	return lineURLize(urlPrefix.(string), line.(string))
+}
+
 func startHeader(etag hash.Hash, gziped bool) string {
 	lines := []string{
 		"Content-Type: text/html; charset=UTF-8",
@@ -112,21 +219,6 @@ func startHeader(etag hash.Hash, gziped bool) string {
 	return strings.Join(lines, "\n")
 }
 
-func startHTML(title string, additional []string) string {
-	return fmt.Sprintf(`<html>
-<head>
-	<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
-	<meta name="generator" content="SGBlog %s">
-	<title>%s</title>
-	%s
-</head>
-<body>
-`,
-		sgblog.Version, title,
-		strings.Join(append(defaultLinks, additional...), "\n	"),
-	)
-}
-
 func makeErr(err error) {
 	fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
 	fmt.Println(err)
@@ -182,43 +274,32 @@ func serveHTTP() {
 	if err != nil {
 		panic(err)
 	}
-	etagHash.Write([]byte("SGBLOG"))
-	etagHash.Write([]byte(sgblog.Version))
-	etagHash.Write([]byte(cfg.GitPath))
-	etagHash.Write([]byte(cfg.Branch))
-	etagHash.Write([]byte(cfg.Title))
-	etagHash.Write([]byte(cfg.URLPrefix))
-	etagHash.Write([]byte(cfg.AtomBaseURL))
-	etagHash.Write([]byte(cfg.AtomId))
-	etagHash.Write([]byte(cfg.AtomAuthor))
-
-	etagHashForWeb := [][]byte{}
-	if cfg.CSS != "" {
-		defaultLinks = append(defaultLinks, `<link rel="stylesheet" type="text/css" href="`+cfg.CSS+`">`)
-		etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS))
-	}
-	if cfg.Webmaster != "" {
-		defaultLinks = append(defaultLinks, `<link rev="made" href="mailto:`+cfg.Webmaster+`">`)
-		etagHashForWeb = append(etagHashForWeb, []byte(cfg.Webmaster))
+	for _, s := range []string{
+		"SGBLOG",
+		sgblog.Version,
+		cfg.GitPath,
+		cfg.Branch,
+		cfg.Title,
+		cfg.URLPrefix,
+		cfg.AtomBaseURL,
+		cfg.AtomId,
+		cfg.AtomAuthor,
+	} {
+		if _, err = etagHash.Write([]byte(s)); err != nil {
+			panic(err)
+		}
 	}
-	if cfg.AboutURL != "" {
-		etagHashForWeb = append(etagHashForWeb, []byte(cfg.AboutURL))
+	etagHashForWeb := []string{
+		cfg.CSS,
+		cfg.Webmaster,
+		cfg.AboutURL,
+		cfg.CommentsNotesRef,
+		cfg.CommentsEmail,
 	}
 	for _, gitURL := range cfg.GitURLs {
-		defaultLinks = append(defaultLinks, `<link rel="vcs-git" href="`+gitURL+`" title="Git repository">`)
-		etagHashForWeb = append(etagHashForWeb, []byte(gitURL))
-	}
-	if cfg.CommentsNotesRef != "" {
-		etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsNotesRef))
-	}
-	if cfg.CommentsEmail != "" {
-		etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsEmail))
+		etagHashForWeb = append(etagHashForWeb, gitURL)
 	}
 
-	defaultLinks = append(defaultLinks, `<link rel="top" href="`+cfg.URLPrefix+`/" title="top">`)
-	atomPostsURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed
-	atomCommentsURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed
-
 	headHash, err := initRepo(cfg)
 	if err != nil {
 		makeErr(err)
@@ -283,7 +364,7 @@ func serveHTTP() {
 		entries := make([]TableEntry, 0, PageEntries)
 		logEnded := false
 		for _, data := range etagHashForWeb {
-			etagHash.Write(data)
+			etagHash.Write([]byte(data))
 		}
 		etagHash.Write([]byte("INDEX"))
 		for i := 0; i < PageEntries; i++ {
@@ -295,96 +376,57 @@ func serveHTTP() {
 			etagHash.Write(commit.Hash[:])
 			commentsRaw := getNote(commentsTree, commit.Hash)
 			etagHash.Write(commentsRaw)
-			entries = append(entries, TableEntry{commit, commentsRaw})
+			entries = append(entries, TableEntry{
+				Commit:      commit,
+				CommentsRaw: commentsRaw,
+			})
 		}
 		checkETag(etagHash)
 
-		var table bytes.Buffer
-		table.WriteString(
-			"<table border=1>\n" +
-				"<th>N</th>" +
-				"<th>When</th>" +
-				"<th>Title</th>" +
-				`<th size="5%"><a title="Lines">L</a></th>` +
-				`<th size="5%"><a title="Comments">C</a></th>` +
-				"<th>Linked to</th></tr>\n")
-		var yearPrev int
-		var monthPrev time.Month
-		var dayPrev int
-		for _, entry := range entries {
-			yearCur, monthCur, dayCur := entry.commit.Author.When.Date()
-			if dayCur != dayPrev || monthCur != monthPrev || yearCur != yearPrev {
-				table.WriteString(fmt.Sprintf(
-					"<tr><td colspan=6><center><tt>%04d-%02d-%02d</tt></center></td></tr>\n",
-					yearCur, monthCur, dayCur,
-				))
-				yearPrev, monthPrev, dayPrev = yearCur, monthCur, dayCur
-			}
+		for i, entry := range entries {
 			commitN++
-			lines := msgSplit(entry.commit.Message)
-			domains := []string{}
+			entry.Num = commitN
+			lines := msgSplit(entry.Commit.Message)
+			entry.Title = lines[0]
+			entry.LinesNum = len(lines) - 2
 			for _, line := range lines[2:] {
-				if u := urlParse(line); u == nil {
+				u := urlParse(line)
+				if u == nil {
 					break
-				} else {
-					domains = append(domains, makeA(line, u.Host))
 				}
+				entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
 			}
-			var commentsValue string
-			if l := len(parseComments(entry.commentsRaw)); l > 0 {
-				commentsValue = strconv.Itoa(l)
-			} else {
-				commentsValue = "&nbsp;"
-			}
-			table.WriteString(fmt.Sprintf(
-				"<tr><td>%d</td><td><tt>%02d:%02d</tt></td>"+
-					"<td>%s</td>"+
-					"<td>%d</td><td>%s</td>"+
-					"<td>%s</td></tr>\n",
-				commitN,
-				entry.commit.Author.When.Hour(),
-				entry.commit.Author.When.Minute(),
-				makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]),
-				len(lines)-2,
-				commentsValue,
-				strings.Join(domains, " "),
-			))
+			entry.CommentsNum = len(parseComments(entry.CommentsRaw))
+			entries[i] = entry
 		}
-		table.WriteString("</table>")
-
-		var href string
-		links := []string{`<link rel="alternate" title="Posts feed" href="` + atomPostsURL + `" type="application/atom+xml">`}
-		var refs bytes.Buffer
-		if commentsTree != nil {
-			links = append(links, `<link rel="alternate" title="Comments feed" href="`+atomCommentsURL+`" type="application/atom+xml">`)
-		}
-		if offset > 0 {
-			if offsetPrev := offset - PageEntries; offsetPrev > 0 {
-				href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
-			} else {
-				href = cfg.URLPrefix + "/"
-			}
-			links = append(links, `<link rel="prev" href="`+href+`" title="prev">`)
-			refs.WriteString("\n" + makeA(href, "[prev]"))
-		}
-		if !logEnded {
-			href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
-			links = append(links, `<link rel="next" href="`+href+`" title="next">`)
-			refs.WriteString("\n" + makeA(href, "[next]"))
-		}
-
+		tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
 		os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
-		out.Write([]byte(startHTML(
-			fmt.Sprintf("%s (%d-%d)", cfg.Title, offset, offset+PageEntries),
-			links,
-		)))
-		if cfg.AboutURL != "" {
-			out.Write([]byte(fmt.Sprintf("[%s]", makeA(cfg.AboutURL, "about"))))
+		err = tmpl.Execute(out, struct {
+			Version          string
+			Cfg              *Cfg
+			CommentsEnabled  bool
+			AtomPostsFeed    string
+			AtomCommentsFeed string
+			Offset           int
+			OffsetPrev       int
+			OffsetNext       int
+			LogEnded         bool
+			Entries          []TableEntry
+		}{
+			Version:          sgblog.Version,
+			Cfg:              cfg,
+			CommentsEnabled:  commentsTree != nil,
+			AtomPostsFeed:    AtomPostsFeed,
+			AtomCommentsFeed: AtomCommentsFeed,
+			Offset:           offset,
+			OffsetPrev:       offset - PageEntries,
+			OffsetNext:       offset + PageEntries,
+			LogEnded:         logEnded,
+			Entries:          entries,
+		})
+		if err != nil {
+			makeErr(err)
 		}
-		out.Write(refs.Bytes())
-		out.Write(table.Bytes())
-		out.Write(refs.Bytes())
-		out.Write([]byte("\n"))
 	} else if pathInfo == "/"+AtomPostsFeed {
 		commit, err := repo.CommitObject(*headHash)
 		if err != nil {
@@ -397,8 +439,11 @@ func serveHTTP() {
 			Title:   cfg.Title,
 			ID:      cfg.AtomId,
 			Updated: atom.Time(commit.Author.When),
-			Link:    []atom.Link{{Rel: "self", Href: atomPostsURL}},
-			Author:  &atom.Person{Name: cfg.AtomAuthor},
+			Link: []atom.Link{{
+				Rel:  "self",
+				Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed,
+			}},
+			Author: &atom.Person{Name: cfg.AtomAuthor},
 		}
 		repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
 		if err != nil {
@@ -450,8 +495,11 @@ func serveHTTP() {
 			Title:   cfg.Title + " comments",
 			ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
 			Updated: atom.Time(commit.Author.When),
-			Link:    []atom.Link{{Rel: "self", Href: atomCommentsURL}},
-			Author:  &atom.Person{Name: cfg.AtomAuthor},
+			Link: []atom.Link{{
+				Rel:  "self",
+				Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed,
+			}},
+			Author: &atom.Person{Name: cfg.AtomAuthor},
 		}
 		repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
 		if err != nil {
@@ -490,11 +538,10 @@ func serveHTTP() {
 			idHasher.Write(commit.Hash[:])
 			idHasher.Write([]byte(commentN))
 			feed.Entry = append(feed.Entry, &atom.Entry{
-				Title: strings.Join([]string{
-					"Comment ", commentN,
-					" for \"", msgSplit(commit.Message)[0],
-					"\" by ", from,
-				}, ""),
+				Title: fmt.Sprintf(
+					"Comment %s for \"%s\" by %s",
+					commentN, msgSplit(commit.Message)[0], from,
+				),
 				Author: &atom.Person{Name: from},
 				ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
 				Link: []atom.Link{{
@@ -524,11 +571,11 @@ func serveHTTP() {
 			makeErr(err)
 		}
 		for _, data := range etagHashForWeb {
-			etagHash.Write(data)
+			etagHash.Write([]byte(data))
 		}
 		etagHash.Write([]byte("ENTRY"))
 		etagHash.Write(commit.Hash[:])
-		atomCommentsURL = strings.Join([]string{
+		atomCommentsURL := strings.Join([]string{
 			cfg.AtomBaseURL, cfg.URLPrefix, "/",
 			commit.Hash.String(), "/", AtomCommentsFeed,
 		}, "")
@@ -583,10 +630,7 @@ func serveHTTP() {
 				idHasher.Write(commit.Hash[:])
 				idHasher.Write([]byte(comment.n))
 				feed.Entry = append(feed.Entry, &atom.Entry{
-					Title: strings.Join([]string{
-						"Comment", comment.n,
-						"by", comment.from,
-					}, " "),
+					Title:  fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
 					Author: &atom.Person{Name: comment.from},
 					ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
 					Link: []atom.Link{{
@@ -616,64 +660,55 @@ func serveHTTP() {
 		notesRaw := getNote(notesTree, commit.Hash)
 		etagHash.Write(notesRaw)
 		checkETag(etagHash)
+
 		lines := msgSplit(commit.Message)
 		title := lines[0]
 		when := commit.Author.When.Format(sgblog.WhenFmt)
-		os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
-		links := []string{`<link rel="alternate" title="Comments feed" href="` + atomCommentsURL + `" type="application/atom+xml">`}
 		var parent string
 		if len(commit.ParentHashes) > 0 {
 			parent = commit.ParentHashes[0].String()
-			links = append(links, `<link rel="prev" href="`+cfg.URLPrefix+"/"+parent+`" title="prev">`)
-		}
-		out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links)))
-		if cfg.AboutURL != "" {
-			out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.AboutURL, "about"))))
-		}
-		out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index"))))
-		if parent != "" {
-			out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/"+parent, "prev"))))
 		}
-		out.Write([]byte(fmt.Sprintf(
-			"[<tt><a title=\"When\">%s</a></tt>]\n"+
-				"[<tt><a title=\"What\">%s</a></tt>]\n"+
-				"<hr/>\n<h2>%s</h2>\n<pre>\n",
-			when, commit.Hash.String(), title,
-		)))
-		for _, line := range lines[2:] {
-			out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
+		commentsParsed := parseComments(commentsRaw)
+		comments := make([]CommentEntry, 0, len(commentsParsed))
+		for _, comment := range commentsParsed {
+			lines := strings.Split(comment, "\n")
+			comments = append(comments, CommentEntry{lines[:3], lines[3:]})
 		}
-		out.Write([]byte("</pre>\n<hr/>\n"))
+		var notesLines []string
 		if len(notesRaw) > 0 {
-			out.Write([]byte("Note:<pre>\n"))
-			for _, line := range strings.Split(string(notesRaw), "\n") {
-				out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
-			}
-			out.Write([]byte("</pre>\n<hr/>\n"))
-		}
-		if cfg.CommentsEmail != "" {
-			out.Write([]byte("[" + makeA(
-				"mailto:"+cfg.CommentsEmail+"?subject="+commit.Hash.String(),
-				"leave comment",
-			) + "]\n"))
+			notesLines = strings.Split(string(notesRaw), "\n")
 		}
-		out.Write([]byte("<dl>\n"))
-		for i, comment := range parseComments(commentsRaw) {
-			out.Write([]byte(fmt.Sprintf(
-				"<dt><a name=\"comment%d\"><a href=\"#comment%d\">comment %d</a>:"+
-					"</dt>\n<dd><pre>\n",
-				i, i, i,
-			)))
-			lines = strings.Split(comment, "\n")
-			for _, line := range lines[:3] {
-				out.Write([]byte(line + "\n"))
-			}
-			for _, line := range lines[3:] {
-				out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
-			}
-			out.Write([]byte("</pre></dd>\n"))
+
+		tmpl := template.New("entry")
+		tmpl = tmpl.Funcs(template.FuncMap{"lineURLize": lineURLizeInTemplate})
+		tmpl = template.Must(tmpl.Parse(TmplHTMLEntry))
+		os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
+		err = tmpl.Execute(out, struct {
+			Version         string
+			Cfg             *Cfg
+			Title           string
+			When            string
+			AtomCommentsURL string
+			Parent          string
+			Commit          *object.Commit
+			Lines           []string
+			NoteLines       []string
+			Comments        []CommentEntry
+		}{
+			Version:         sgblog.Version,
+			Cfg:             cfg,
+			Title:           title,
+			When:            when,
+			AtomCommentsURL: atomCommentsURL,
+			Parent:          parent,
+			Commit:          commit,
+			Lines:           lines[2:],
+			NoteLines:       notesLines,
+			Comments:        comments,
+		})
+		if err != nil {
+			makeErr(err)
 		}
-		out.Write([]byte("</dl>\n"))
 	} else {
 		makeErr(errors.New("unknown URL action"))
 	}
diff --git a/common.go b/common.go
index 64133be..f0558f1 100644
--- a/common.go
+++ b/common.go
@@ -2,6 +2,6 @@
 package sgblog
 
 const (
-	Version = "0.6.5"
+	Version = "0.7.0"
 	WhenFmt = "2006-01-02 15:04:05Z07:00"
 )