mirror of
https://github.com/golang/go
synced 2024-11-19 00:34:40 -07:00
4a813e4058
When computing the time for the "updated" tag of the atom feed, the current code checks if there is more than one article and if that is not true, it sets the time to the zero time.Time. This means that the feed also gets the zero time in this tag when there is exactly one article. This trivial patch fixes this so that when there is exactly one article, the time is set to that article's time. R=golang-dev, adg CC=golang-dev https://golang.org/cl/18420044
425 lines
10 KiB
Go
425 lines
10 KiB
Go
// Copyright 2013 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
// Package blog implements a web server for articles written in present format.
|
|
package blog
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.google.com/p/go.tools/blog/atom"
|
|
"code.google.com/p/go.tools/present"
|
|
)
|
|
|
|
var validJSONPFunc = regexp.MustCompile(`(?i)^[a-z_][a-z0-9_.]*$`)
|
|
|
|
// Config specifies Server configuration values.
|
|
type Config struct {
|
|
ContentPath string // Relative or absolute location of article files and related content.
|
|
TemplatePath string // Relative or absolute location of template files.
|
|
|
|
BaseURL string // Absolute base URL (for permalinks; no trailing slash).
|
|
BasePath string // Base URL path relative to server root (no trailing slash).
|
|
GodocURL string // The base URL of godoc (for menu bar; no trailing slash).
|
|
Hostname string // Server host name, used for rendering ATOM feeds.
|
|
|
|
HomeArticles int // Articles to display on the home page.
|
|
FeedArticles int // Articles to include in Atom and JSON feeds.
|
|
FeedTitle string // The title of the Atom XML feed
|
|
|
|
PlayEnabled bool
|
|
}
|
|
|
|
// Doc represents an article adorned with presentation data.
|
|
type Doc struct {
|
|
*present.Doc
|
|
Permalink string // Canonical URL for this document.
|
|
Path string // Path relative to server root (including base).
|
|
HTML template.HTML // rendered article
|
|
|
|
Related []*Doc
|
|
Newer, Older *Doc
|
|
}
|
|
|
|
// Server implements an http.Handler that serves blog articles.
|
|
type Server struct {
|
|
cfg Config
|
|
docs []*Doc
|
|
tags []string
|
|
docPaths map[string]*Doc // key is path without BasePath.
|
|
docTags map[string][]*Doc
|
|
template struct {
|
|
home, index, article, doc *template.Template
|
|
}
|
|
atomFeed []byte // pre-rendered Atom feed
|
|
jsonFeed []byte // pre-rendered JSON feed
|
|
content http.Handler
|
|
}
|
|
|
|
// NewServer constructs a new Server using the specified config.
|
|
func NewServer(cfg Config) (*Server, error) {
|
|
present.PlayEnabled = cfg.PlayEnabled
|
|
|
|
root := filepath.Join(cfg.TemplatePath, "root.tmpl")
|
|
parse := func(name string) (*template.Template, error) {
|
|
t := template.New("").Funcs(funcMap)
|
|
return t.ParseFiles(root, filepath.Join(cfg.TemplatePath, name))
|
|
}
|
|
|
|
s := &Server{cfg: cfg}
|
|
|
|
// Parse templates.
|
|
var err error
|
|
s.template.home, err = parse("home.tmpl")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.template.index, err = parse("index.tmpl")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.template.article, err = parse("article.tmpl")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := present.Template().Funcs(funcMap)
|
|
s.template.doc, err = p.ParseFiles(filepath.Join(cfg.TemplatePath, "doc.tmpl"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load content.
|
|
err = s.loadDocs(filepath.Clean(cfg.ContentPath))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = s.renderAtomFeed()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = s.renderJSONFeed()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set up content file server.
|
|
s.content = http.FileServer(http.Dir(cfg.ContentPath))
|
|
|
|
return s, nil
|
|
}
|
|
|
|
var funcMap = template.FuncMap{
|
|
"sectioned": sectioned,
|
|
"authors": authors,
|
|
}
|
|
|
|
// sectioned returns true if the provided Doc contains more than one section.
|
|
// This is used to control whether to display the table of contents and headings.
|
|
func sectioned(d *present.Doc) bool {
|
|
return len(d.Sections) > 1
|
|
}
|
|
|
|
// authors returns a comma-separated list of author names.
|
|
func authors(authors []present.Author) string {
|
|
var b bytes.Buffer
|
|
last := len(authors) - 1
|
|
for i, a := range authors {
|
|
if i > 0 {
|
|
if i == last {
|
|
b.WriteString(" and ")
|
|
} else {
|
|
b.WriteString(", ")
|
|
}
|
|
}
|
|
b.WriteString(authorName(a))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// authorName returns the first line of the Author text: the author's name.
|
|
func authorName(a present.Author) string {
|
|
el := a.TextElem()
|
|
if len(el) == 0 {
|
|
return ""
|
|
}
|
|
text, ok := el[0].(present.Text)
|
|
if !ok || len(text.Lines) == 0 {
|
|
return ""
|
|
}
|
|
return text.Lines[0]
|
|
}
|
|
|
|
// loadDocs reads all content from the provided file system root, renders all
|
|
// the articles it finds, adds them to the Server's docs field, computes the
|
|
// denormalized docPaths, docTags, and tags fields, and populates the various
|
|
// helper fields (Next, Previous, Related) for each Doc.
|
|
func (s *Server) loadDocs(root string) error {
|
|
// Read content into docs field.
|
|
const ext = ".article"
|
|
fn := func(p string, info os.FileInfo, err error) error {
|
|
if filepath.Ext(p) != ext {
|
|
return nil
|
|
}
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
d, err := present.Parse(f, p, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
html := new(bytes.Buffer)
|
|
err = d.Render(html, s.template.doc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p = p[len(root) : len(p)-len(ext)] // trim root and extension
|
|
p = filepath.ToSlash(p)
|
|
s.docs = append(s.docs, &Doc{
|
|
Doc: d,
|
|
Path: s.cfg.BasePath + p,
|
|
Permalink: s.cfg.BaseURL + p,
|
|
HTML: template.HTML(html.String()),
|
|
})
|
|
return nil
|
|
}
|
|
err := filepath.Walk(root, fn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sort.Sort(docsByTime(s.docs))
|
|
|
|
// Pull out doc paths and tags and put in reverse-associating maps.
|
|
s.docPaths = make(map[string]*Doc)
|
|
s.docTags = make(map[string][]*Doc)
|
|
for _, d := range s.docs {
|
|
s.docPaths[strings.TrimPrefix(d.Path, s.cfg.BasePath)] = d
|
|
for _, t := range d.Tags {
|
|
s.docTags[t] = append(s.docTags[t], d)
|
|
}
|
|
}
|
|
|
|
// Pull out unique sorted list of tags.
|
|
for t := range s.docTags {
|
|
s.tags = append(s.tags, t)
|
|
}
|
|
sort.Strings(s.tags)
|
|
|
|
// Set up presentation-related fields, Newer, Older, and Related.
|
|
for _, doc := range s.docs {
|
|
// Newer, Older: docs adjacent to doc
|
|
for i := range s.docs {
|
|
if s.docs[i] != doc {
|
|
continue
|
|
}
|
|
if i > 0 {
|
|
doc.Newer = s.docs[i-1]
|
|
}
|
|
if i+1 < len(s.docs) {
|
|
doc.Older = s.docs[i+1]
|
|
}
|
|
break
|
|
}
|
|
|
|
// Related: all docs that share tags with doc.
|
|
related := make(map[*Doc]bool)
|
|
for _, t := range doc.Tags {
|
|
for _, d := range s.docTags[t] {
|
|
if d != doc {
|
|
related[d] = true
|
|
}
|
|
}
|
|
}
|
|
for d := range related {
|
|
doc.Related = append(doc.Related, d)
|
|
}
|
|
sort.Sort(docsByTime(doc.Related))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// renderAtomFeed generates an XML Atom feed and stores it in the Server's
|
|
// atomFeed field.
|
|
func (s *Server) renderAtomFeed() error {
|
|
var updated time.Time
|
|
if len(s.docs) > 0 {
|
|
updated = s.docs[0].Time
|
|
}
|
|
feed := atom.Feed{
|
|
Title: s.cfg.FeedTitle,
|
|
ID: "tag:" + s.cfg.Hostname + ",2013:" + s.cfg.Hostname,
|
|
Updated: atom.Time(updated),
|
|
Link: []atom.Link{{
|
|
Rel: "self",
|
|
Href: s.cfg.BaseURL + "/feed.atom",
|
|
}},
|
|
}
|
|
for i, doc := range s.docs {
|
|
if i >= s.cfg.FeedArticles {
|
|
break
|
|
}
|
|
e := &atom.Entry{
|
|
Title: doc.Title,
|
|
ID: feed.ID + doc.Path,
|
|
Link: []atom.Link{{
|
|
Rel: "alternate",
|
|
Href: doc.Permalink,
|
|
}},
|
|
Published: atom.Time(doc.Time),
|
|
Updated: atom.Time(doc.Time),
|
|
Summary: &atom.Text{
|
|
Type: "html",
|
|
Body: summary(doc),
|
|
},
|
|
Content: &atom.Text{
|
|
Type: "html",
|
|
Body: string(doc.HTML),
|
|
},
|
|
Author: &atom.Person{
|
|
Name: authors(doc.Authors),
|
|
},
|
|
}
|
|
feed.Entry = append(feed.Entry, e)
|
|
}
|
|
data, err := xml.Marshal(&feed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.atomFeed = data
|
|
return nil
|
|
}
|
|
|
|
type jsonItem struct {
|
|
Title string
|
|
Link string
|
|
Time time.Time
|
|
Summary string
|
|
Content string
|
|
Author string
|
|
}
|
|
|
|
// renderJSONFeed generates a JSON feed and stores it in the Server's jsonFeed
|
|
// field.
|
|
func (s *Server) renderJSONFeed() error {
|
|
var feed []jsonItem
|
|
for i, doc := range s.docs {
|
|
if i >= s.cfg.FeedArticles {
|
|
break
|
|
}
|
|
item := jsonItem{
|
|
Title: doc.Title,
|
|
Link: doc.Permalink,
|
|
Time: doc.Time,
|
|
Summary: summary(doc),
|
|
Content: string(doc.HTML),
|
|
Author: authors(doc.Authors),
|
|
}
|
|
feed = append(feed, item)
|
|
}
|
|
data, err := json.Marshal(feed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.jsonFeed = data
|
|
return nil
|
|
}
|
|
|
|
// summary returns the first paragraph of text from the provided Doc.
|
|
func summary(d *Doc) string {
|
|
if len(d.Sections) == 0 {
|
|
return ""
|
|
}
|
|
for _, elem := range d.Sections[0].Elem {
|
|
text, ok := elem.(present.Text)
|
|
if !ok || text.Pre {
|
|
// skip everything but non-text elements
|
|
continue
|
|
}
|
|
var buf bytes.Buffer
|
|
for _, s := range text.Lines {
|
|
buf.WriteString(string(present.Style(s)))
|
|
buf.WriteByte('\n')
|
|
}
|
|
return buf.String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// rootData encapsulates data destined for the root template.
|
|
type rootData struct {
|
|
Doc *Doc
|
|
BasePath string
|
|
GodocURL string
|
|
Data interface{}
|
|
}
|
|
|
|
// ServeHTTP serves the front, index, and article pages
|
|
// as well as the ATOM and JSON feeds.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
d = rootData{BasePath: s.cfg.BasePath, GodocURL: s.cfg.GodocURL}
|
|
t *template.Template
|
|
)
|
|
switch p := strings.TrimPrefix(r.URL.Path, s.cfg.BasePath); p {
|
|
case "/":
|
|
d.Data = s.docs
|
|
if len(s.docs) > s.cfg.HomeArticles {
|
|
d.Data = s.docs[:s.cfg.HomeArticles]
|
|
}
|
|
t = s.template.home
|
|
case "/index":
|
|
d.Data = s.docs
|
|
t = s.template.index
|
|
case "/feed.atom", "/feeds/posts/default":
|
|
w.Header().Set("Content-type", "application/atom+xml; charset=utf-8")
|
|
w.Write(s.atomFeed)
|
|
return
|
|
case "/.json":
|
|
if p := r.FormValue("jsonp"); validJSONPFunc.MatchString(p) {
|
|
w.Header().Set("Content-type", "application/javascript; charset=utf-8")
|
|
fmt.Fprintf(w, "%v(%s)", p, s.jsonFeed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-type", "application/json; charset=utf-8")
|
|
w.Write(s.jsonFeed)
|
|
return
|
|
default:
|
|
doc, ok := s.docPaths[p]
|
|
if !ok {
|
|
// Not a doc; try to just serve static content.
|
|
s.content.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
d.Doc = doc
|
|
t = s.template.article
|
|
}
|
|
err := t.ExecuteTemplate(w, "root", d)
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
}
|
|
|
|
// docsByTime implements sort.Interface, sorting Docs by their Time field.
|
|
type docsByTime []*Doc
|
|
|
|
func (s docsByTime) Len() int { return len(s) }
|
|
func (s docsByTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
|
func (s docsByTime) Less(i, j int) bool { return s[i].Time.After(s[j].Time) }
|