]> Sergey Matveev's repositories - sgblog.git/blobdiff - cmd/sgblog/http.go
ISO compatible, Atom-required, dates in comments feed
[sgblog.git] / cmd / sgblog / http.go
index 0c09f2b440af44e7bc7be2a5772005d949636e04..02237353366459d2fc20550fa27c22721c54814b 100644 (file)
@@ -1,6 +1,6 @@
 /*
 SGBlog -- Git-backed CGI/inetd blogging/phlogging engine
-Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
+Copyright (C) 2020-2021 Sergey Matveev <stargrave@stargrave.org>
 
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
@@ -22,14 +22,12 @@ import (
        "compress/gzip"
        "crypto/sha1"
        "encoding/hex"
-       "encoding/json"
        "encoding/xml"
        "errors"
        "fmt"
        "hash"
        "html"
        "io"
-       "io/ioutil"
        "log"
        "net/url"
        "os"
@@ -37,13 +35,12 @@ import (
        "strings"
        "text/template"
 
-       "github.com/hjson/hjson-go"
+       "github.com/go-git/go-git/v5"
+       "github.com/go-git/go-git/v5/plumbing"
+       "github.com/go-git/go-git/v5/plumbing/object"
        "go.stargrave.org/sgblog"
+       "go.stargrave.org/sgblog/cmd/sgblog/atom"
        "golang.org/x/crypto/blake2b"
-       "golang.org/x/tools/blog/atom"
-       "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 (
@@ -53,50 +50,54 @@ const (
 <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}}
+       <title>{{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})</title>
+       {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
+       {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{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">
+       <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}{{if .Topic}}?topic={{.Topic}}{{end}}" 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}}
+       {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="prev">{{end}}
+       {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="next">{{end}}
 </head>
 <body>
-{{if .Cfg.AboutURL}}[<a href="{{.Cfg.AboutURL}}">about</a>]{{end}}
+{{with .Cfg.AboutURL}}[<a href="{{.}}">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}}
+{{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}">prev</a>]{{end}}
+{{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}">next</a>]{{end}}
 {{end}}
 {{- $Cfg := .Cfg -}}
-{{- $yearPrev := 0 -}}
-{{- $monthPrev := 0 -}}
-{{- $dayPrev := 0}}
+{{if .Topics}}<hr/>
+Topics: [<tt><a href="{{$Cfg.URLPrefix}}/">ALL</a></tt>]
+{{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]
+{{end}}
+{{end}}
+{{- $TopicsEnabled := .TopicsEnabled -}}
+{{- $datePrev := "0001-01-01" -}}
 <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 -}}
+       {{if .TopicsEnabled}}<th>Topics</th>{{end}}
+</tr>
+{{range .Entries -}}
+{{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
+{{- if ne $dateCur $datePrev -}}
+       <tr><td colspan={{if $TopicsEnabled}}7{{else}}7{{end}}><center><tt>{{$dateCur}}</tt></center></td></tr>
+       {{- $datePrev = $dateCur -}}
 {{- end -}}
 <tr>
        <td>{{.Num}}</td>
-       <td><tt>{{.Commit.Author.When.Hour | printf "%02d" -}}:{{- .Commit.Author.When.Minute | printf "%02d"}}</tt></td>
+       <td><tt>{{.Commit.Author.When.Format "15:04"}}</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>
+       <td>{{if .DomainURLs}}{{range .DomainURLs}} {{.}} {{end}}{{else}}&nbsp;{{end}}</td>
+       {{if $TopicsEnabled}}<td>{{if .Topics}}{{range .Topics}} <a href="{{$Cfg.URLPrefix}}/?topic={{.}}">{{.}}</a> {{end}}{{else}}&nbsp;{{end}}</td>{{end}}
 </tr>
-{{end}}</table>
+{{end}}</table>
 {{template "links" .}}
 </body>
 </html>
@@ -106,8 +107,8 @@ const (
        <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 -}}
+       {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
+       {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end -}}
        {{- range .Cfg.GitURLs}}
        <link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
        <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
@@ -115,12 +116,17 @@ const (
        {{if .Parent}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{.Parent}}" title="prev">{{end}}
 </head>
 <body>
-{{if .Cfg.AboutURL}}[<a href="{{.Cfg.AboutURL}}">about</a>]{{end}}
+{{with .Cfg.AboutURL}}[<a href="{{.}}">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>]
 
+{{if .Topics}}
+<hr/>
+Topics: {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]{{end}}
+{{end}}
+
 <hr/>
 <h2>{{.Title}}</h2>
 <pre>
@@ -133,7 +139,7 @@ const (
 {{end}}</pre>
 <hr/>{{end}}
 
-{{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject={{.Commit.Hash.String}}">leave comment</a>]{{end}}
+{{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject={{.TitleEscaped}}">leave comment</a>]{{end}}
 
 <dl>{{range $idx, $comment := .Comments}}
 <dt><a name="comment{{$idx}}"><a href="#comment{{$idx}}">comment {{$idx}}</a>:</dt>
@@ -150,22 +156,24 @@ const (
 
 var (
        renderableSchemes = map[string]struct{}{
-               "ftp":    struct{}{},
-               "gopher": struct{}{},
-               "http":   struct{}{},
-               "https":  struct{}{},
-               "telnet": struct{}{},
+               "ftp":    {},
+               "gopher": {},
+               "http":   {},
+               "https":  {},
+               "telnet": {},
        }
 )
 
 type TableEntry struct {
        Commit      *object.Commit
        CommentsRaw []byte
+       TopicsRaw   []byte
        Num         int
        Title       string
        LinesNum    int
        CommentsNum int
        DomainURLs  []string
+       Topics      []string
 }
 
 type CommentEntry struct {
@@ -240,27 +248,20 @@ func bytes2uuid(b []byte) string {
        return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:])
 }
 
+type CommitIterNext interface {
+       Next() (*object.Commit, error)
+}
+
 func serveHTTP() {
        cfgPath := os.Getenv("SGBLOG_CFG")
        if cfgPath == "" {
                log.Fatalln("SGBLOG_CFG is not set")
        }
-       cfgRaw, err := ioutil.ReadFile(cfgPath)
+       cfg, err := readCfg(cfgPath)
        if err != nil {
-               makeErr(err)
-       }
-       var cfgGeneral map[string]interface{}
-       if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
-               makeErr(err)
-       }
-       cfgRaw, err = json.Marshal(cfgGeneral)
-       if err != nil {
-               makeErr(err)
-       }
-       var cfg *Cfg
-       if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
-               makeErr(err)
+               log.Fatalln(err)
        }
+
        pathInfo, exists := os.LookupEnv("PATH_INFO")
        if !exists {
                pathInfo = "/"
@@ -353,12 +354,37 @@ func serveHTTP() {
                if err != nil {
                        makeErr(err)
                }
+               topicsCache, err := getTopicsCache(cfg, repoLog)
+               if err != nil {
+                       makeErr(err)
+               }
+               repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
+               if err != nil {
+                       makeErr(err)
+               }
+
                commitN := 0
-               for i := 0; i < offset; i++ {
-                       if _, err = repoLog.Next(); err != nil {
-                               break
+               var commits CommitIterNext
+               var topic string
+               if t, exists := queryValues["topic"]; exists {
+                       topic = t[0]
+                       hashes := topicsCache[topic]
+                       if hashes == nil {
+                               makeErr(errors.New("no posts with that topic"))
                        }
-                       commitN++
+                       if len(hashes) > offset {
+                               hashes = hashes[offset:]
+                               commitN += offset
+                       }
+                       commits = &HashesIter{hashes}
+               } else {
+                       for i := 0; i < offset; i++ {
+                               if _, err = repoLog.Next(); err != nil {
+                                       break
+                               }
+                               commitN++
+                       }
+                       commits = repoLog
                }
 
                entries := make([]TableEntry, 0, PageEntries)
@@ -367,18 +393,22 @@ func serveHTTP() {
                        etagHash.Write([]byte(data))
                }
                etagHash.Write([]byte("INDEX"))
+               etagHash.Write([]byte(topic))
                for i := 0; i < PageEntries; i++ {
-                       commit, err := repoLog.Next()
+                       commit, err := commits.Next()
                        if err != nil {
                                logEnded = true
                                break
                        }
                        etagHash.Write(commit.Hash[:])
-                       commentsRaw := getNote(commentsTree, commit.Hash)
+                       commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
                        etagHash.Write(commentsRaw)
+                       topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
+                       etagHash.Write(topicsRaw)
                        entries = append(entries, TableEntry{
                                Commit:      commit,
                                CommentsRaw: commentsRaw,
+                               TopicsRaw:   topicsRaw,
                        })
                }
                checkETag(etagHash)
@@ -396,14 +426,22 @@ func serveHTTP() {
                                }
                                entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
                        }
-                       entry.CommentsNum = len(parseComments(entry.CommentsRaw))
+                       entry.CommentsNum = len(sgblog.ParseComments(entry.CommentsRaw))
+                       entry.Topics = sgblog.ParseTopics(entry.TopicsRaw)
                        entries[i] = entry
                }
+               offsetPrev := offset - PageEntries
+               if offsetPrev < 0 {
+                       offsetPrev = 0
+               }
                tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
                os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
                err = tmpl.Execute(out, struct {
                        Version          string
                        Cfg              *Cfg
+                       Topic            string
+                       TopicsEnabled    bool
+                       Topics           []string
                        CommentsEnabled  bool
                        AtomPostsFeed    string
                        AtomCommentsFeed string
@@ -415,11 +453,14 @@ func serveHTTP() {
                }{
                        Version:          sgblog.Version,
                        Cfg:              cfg,
+                       Topic:            topic,
+                       TopicsEnabled:    topicsTree != nil,
+                       Topics:           topicsCache.Topics(),
                        CommentsEnabled:  commentsTree != nil,
                        AtomPostsFeed:    AtomPostsFeed,
                        AtomCommentsFeed: AtomCommentsFeed,
                        Offset:           offset,
-                       OffsetPrev:       offset - PageEntries,
+                       OffsetPrev:       offsetPrev,
                        OffsetNext:       offset + PageEntries,
                        LogEnded:         logEnded,
                        Entries:          entries,
@@ -432,12 +473,32 @@ func serveHTTP() {
                if err != nil {
                        makeErr(err)
                }
+
+               var topic string
+               if t, exists := queryValues["topic"]; exists {
+                       topic = t[0]
+               }
+
                etagHash.Write([]byte("ATOM POSTS"))
+               etagHash.Write([]byte(topic))
                etagHash.Write(commit.Hash[:])
                checkETag(etagHash)
+               var title string
+               if topic == "" {
+                       title = cfg.Title
+               } else {
+                       title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
+               }
+               idHasher, err := blake2b.New256(nil)
+               if err != nil {
+                       panic(err)
+               }
+               idHasher.Write([]byte("ATOM POSTS"))
+               idHasher.Write([]byte(cfg.AtomId))
+               idHasher.Write([]byte(topic))
                feed := atom.Feed{
-                       Title:   cfg.Title,
-                       ID:      cfg.AtomId,
+                       Title:   title,
+                       ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
                        Updated: atom.Time(commit.Author.When),
                        Link: []atom.Link{{
                                Rel:  "self",
@@ -445,16 +506,42 @@ func serveHTTP() {
                        }},
                        Author: &atom.Person{Name: cfg.AtomAuthor},
                }
+
                repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
                if err != nil {
                        makeErr(err)
                }
+               var commits CommitIterNext
+               if topic == "" {
+                       commits = repoLog
+               } else {
+                       topicsCache, err := getTopicsCache(cfg, repoLog)
+                       if err != nil {
+                               makeErr(err)
+                       }
+                       hashes := topicsCache[topic]
+                       if hashes == nil {
+                               makeErr(errors.New("no posts with that topic"))
+                       }
+                       commits = &HashesIter{hashes}
+               }
+
                for i := 0; i < PageEntries; i++ {
-                       commit, err = repoLog.Next()
+                       commit, err = commits.Next()
                        if err != nil {
                                break
                        }
                        lines := msgSplit(commit.Message)
+                       var categories []atom.Category
+                       for _, topic := range sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)) {
+                               categories = append(categories, atom.Category{Term: topic})
+                       }
+                       htmlized := make([]string, 0, len(lines))
+                       htmlized = append(htmlized, "<pre>")
+                       for _, l := range lines[2:] {
+                               htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
+                       }
+                       htmlized = append(htmlized, "</pre>")
                        feed.Entry = append(feed.Entry, &atom.Entry{
                                Title: lines[0],
                                ID:    "urn:uuid:" + bytes2uuid(commit.Hash[:]),
@@ -466,9 +553,10 @@ func serveHTTP() {
                                Updated:   atom.Time(commit.Author.When),
                                Summary:   &atom.Text{Type: "text", Body: lines[0]},
                                Content: &atom.Text{
-                                       Type: "text",
-                                       Body: strings.Join(lines[2:], "\n"),
+                                       Type: "html",
+                                       Body: strings.Join(htmlized, "\n"),
                                },
+                               Category: categories,
                        })
                }
                data, err := xml.MarshalIndent(&feed, "", "  ")
@@ -523,9 +611,9 @@ func serveHTTP() {
                        ))
                        commit, err = repo.CommitObject(commentedHash)
                        if err != nil {
-                               makeErr(err)
+                               continue
                        }
-                       comments := parseComments(getNote(t, commentedHash))
+                       comments := sgblog.ParseComments(sgblog.GetNote(repo, t, commentedHash))
                        if len(comments) == 0 {
                                continue
                        }
@@ -533,6 +621,12 @@ func serveHTTP() {
                        lines := strings.Split(comments[len(comments)-1], "\n")
                        from := strings.TrimPrefix(lines[0], "From: ")
                        date := strings.TrimPrefix(lines[1], "Date: ")
+                       htmlized := make([]string, 0, len(lines))
+                       htmlized = append(htmlized, "<pre>")
+                       for _, l := range lines[2:] {
+                               htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
+                       }
+                       htmlized = append(htmlized, "</pre>")
                        idHasher.Reset()
                        idHasher.Write([]byte("COMMENT"))
                        idHasher.Write(commit.Hash[:])
@@ -551,11 +645,11 @@ func serveHTTP() {
                                                commit.Hash.String(), "#comment", commentN,
                                        }, ""),
                                }},
-                               Published: atom.TimeStr(date),
-                               Updated:   atom.TimeStr(date),
+                               Published: atom.TimeStr(strings.Replace(date, " ", "T", -1)),
+                               Updated:   atom.TimeStr(strings.Replace(date, " ", "T", -1)),
                                Content: &atom.Text{
-                                       Type: "text",
-                                       Body: strings.Join(lines[2:], "\n"),
+                                       Type: "html",
+                                       Body: strings.Join(htmlized, "\n"),
                                },
                        })
                }
@@ -579,8 +673,10 @@ func serveHTTP() {
                        cfg.AtomBaseURL, cfg.URLPrefix, "/",
                        commit.Hash.String(), "/", AtomCommentsFeed,
                }, "")
-               commentsRaw := getNote(commentsTree, commit.Hash)
+               commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
                etagHash.Write(commentsRaw)
+               topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
+               etagHash.Write(topicsRaw)
                if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
                        etagHash.Write([]byte("ATOM COMMENTS"))
                        checkETag(etagHash)
@@ -590,7 +686,7 @@ func serveHTTP() {
                                date string
                                body []string
                        }
-                       commentsRaw := parseComments(commentsRaw)
+                       commentsRaw := sgblog.ParseComments(commentsRaw)
                        var toSkip int
                        if len(commentsRaw) > PageEntries {
                                toSkip = len(commentsRaw) - PageEntries
@@ -629,6 +725,15 @@ func serveHTTP() {
                                idHasher.Write([]byte("COMMENT"))
                                idHasher.Write(commit.Hash[:])
                                idHasher.Write([]byte(comment.n))
+                               htmlized := make([]string, 0, len(comment.body))
+                               htmlized = append(htmlized, "<pre>")
+                               for _, l := range comment.body {
+                                       htmlized = append(
+                                               htmlized,
+                                               lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l),
+                                       )
+                               }
+                               htmlized = append(htmlized, "</pre>")
                                feed.Entry = append(feed.Entry, &atom.Entry{
                                        Title:  fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
                                        Author: &atom.Person{Name: comment.from},
@@ -642,11 +747,15 @@ func serveHTTP() {
                                                        "#comment", comment.n,
                                                }, ""),
                                        }},
-                                       Published: atom.TimeStr(comment.date),
-                                       Updated:   atom.TimeStr(comment.date),
+                                       Published: atom.TimeStr(
+                                               strings.Replace(comment.date, " ", "T", -1),
+                                       ),
+                                       Updated: atom.TimeStr(
+                                               strings.Replace(comment.date, " ", "T", -1),
+                                       ),
                                        Content: &atom.Text{
-                                               Type: "text",
-                                               Body: strings.Join(comment.body, "\n"),
+                                               Type: "html",
+                                               Body: strings.Join(htmlized, "\n"),
                                        },
                                })
                        }
@@ -657,7 +766,7 @@ func serveHTTP() {
                        out.Write(data)
                        goto AtomFinish
                }
-               notesRaw := getNote(notesTree, commit.Hash)
+               notesRaw := sgblog.GetNote(repo, notesTree, commit.Hash)
                etagHash.Write(notesRaw)
                checkETag(etagHash)
 
@@ -668,7 +777,7 @@ func serveHTTP() {
                if len(commit.ParentHashes) > 0 {
                        parent = commit.ParentHashes[0].String()
                }
-               commentsParsed := parseComments(commentsRaw)
+               commentsParsed := sgblog.ParseComments(commentsRaw)
                comments := make([]CommentEntry, 0, len(commentsParsed))
                for _, comment := range commentsParsed {
                        lines := strings.Split(comment, "\n")
@@ -687,6 +796,7 @@ func serveHTTP() {
                        Version         string
                        Cfg             *Cfg
                        Title           string
+                       TitleEscaped    string
                        When            string
                        AtomCommentsURL string
                        Parent          string
@@ -694,10 +804,12 @@ func serveHTTP() {
                        Lines           []string
                        NoteLines       []string
                        Comments        []CommentEntry
+                       Topics          []string
                }{
                        Version:         sgblog.Version,
                        Cfg:             cfg,
                        Title:           title,
+                       TitleEscaped:    url.PathEscape(fmt.Sprintf("Re: %s (%s)", title, commit.Hash)),
                        When:            when,
                        AtomCommentsURL: atomCommentsURL,
                        Parent:          parent,
@@ -705,6 +817,7 @@ func serveHTTP() {
                        Lines:           lines[2:],
                        NoteLines:       notesLines,
                        Comments:        comments,
+                       Topics:          sgblog.ParseTopics(topicsRaw),
                })
                if err != nil {
                        makeErr(err)
@@ -720,7 +833,7 @@ func serveHTTP() {
        return
 
 AtomFinish:
-       os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
+       os.Stdout.WriteString("Content-Type: application/atom+xml; charset=UTF-8\n")
        os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
        if gzipWriter != nil {
                os.Stdout.WriteString("Content-Encoding: gzip\n")