mirror of
https://github.com/golang/go
synced 2024-11-18 05:44:47 -07:00
644a21fb14
This is the Go blog house style for the rest of the text. We just missed the author list, presumably because it was less code (only two entries still has no comma). Change-Id: I8bb1ab582d6e300d521ac471736ee8ab6e0f61ec Reviewed-on: https://go-review.googlesource.com/c/tools/+/225517 Run-TryBot: Russ Cox <rsc@golang.org> Reviewed-by: Austin Clements <austin@google.com> TryBot-Result: Gobot Gobot <gobot@golang.org>
503 lines
12 KiB
Go
503 lines
12 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 "golang.org/x/tools/blog"
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/tools/blog/atom"
|
|
"golang.org/x/tools/present"
|
|
)
|
|
|
|
var (
|
|
validJSONPFunc = regexp.MustCompile(`(?i)^[a-z_][a-z0-9_.]*$`)
|
|
// used to serve relative paths when ServeLocalLinks is enabled.
|
|
golangOrgAbsLinkReplacer = strings.NewReplacer(
|
|
`href="https://golang.org/pkg`, `href="/pkg`,
|
|
`href="https://golang.org/cmd`, `href="/cmd`,
|
|
)
|
|
)
|
|
|
|
// 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.
|
|
AnalyticsHTML template.HTML // Optional analytics HTML to insert at the beginning of <head>.
|
|
|
|
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
|
|
ServeLocalLinks bool // rewrite golang.org/{pkg,cmd} links to host-less, relative paths.
|
|
}
|
|
|
|
// 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
|
|
redirects map[string]string
|
|
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
|
|
|
|
if notExist(cfg.TemplatePath) {
|
|
return nil, fmt.Errorf("template directory not found: %s", cfg.TemplatePath)
|
|
}
|
|
root := filepath.Join(cfg.TemplatePath, "root.tmpl")
|
|
parse := func(name string) (*template.Template, error) {
|
|
path := filepath.Join(cfg.TemplatePath, name)
|
|
if notExist(path) {
|
|
return nil, fmt.Errorf("template %s was not found in %s", name, cfg.TemplatePath)
|
|
}
|
|
t := template.New("").Funcs(funcMap)
|
|
return t.ParseFiles(root, path)
|
|
}
|
|
|
|
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.
|
|
content := filepath.Clean(cfg.ContentPath)
|
|
err = s.loadDocs(content)
|
|
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.StripPrefix(s.cfg.BasePath, 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 {
|
|
if len(authors) > 2 {
|
|
b.WriteString(",")
|
|
}
|
|
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 err != nil {
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
var html 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)
|
|
s.redirects = make(map[string]string)
|
|
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)
|
|
}
|
|
}
|
|
for _, d := range s.docs {
|
|
for _, old := range d.OldURL {
|
|
if !strings.HasPrefix(old, "/") {
|
|
old = "/" + old
|
|
}
|
|
if _, ok := s.docPaths[old]; ok {
|
|
return fmt.Errorf("redirect %s -> %s conflicts with document %s", old, d.Path, old)
|
|
}
|
|
if new, ok := s.redirects[old]; ok {
|
|
return fmt.Errorf("redirect %s -> %s conflicts with redirect %s -> %s", old, d.Path, old, new)
|
|
}
|
|
s.redirects[old] = d.Path
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Use original article path as ID in atom feed
|
|
// to avoid articles being treated as new when renamed.
|
|
idPath := doc.Path
|
|
if len(doc.OldURL) > 0 {
|
|
old := doc.OldURL[0]
|
|
if !strings.HasPrefix(old, "/") {
|
|
old = "/" + old
|
|
}
|
|
idPath = old
|
|
}
|
|
|
|
e := &atom.Entry{
|
|
Title: doc.Title,
|
|
ID: feed.ID + idPath,
|
|
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
|
|
AnalyticsHTML template.HTML
|
|
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,
|
|
AnalyticsHTML: s.cfg.AnalyticsHTML,
|
|
}
|
|
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:
|
|
if redir, ok := s.redirects[p]; ok {
|
|
http.Redirect(w, r, redir, http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
var err error
|
|
if s.cfg.ServeLocalLinks {
|
|
var buf bytes.Buffer
|
|
err = t.ExecuteTemplate(&buf, "root", d)
|
|
if err != nil {
|
|
log.Println(err)
|
|
return
|
|
}
|
|
_, err = golangOrgAbsLinkReplacer.WriteString(w, buf.String())
|
|
} else {
|
|
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) }
|
|
|
|
// notExist reports whether the path exists or not.
|
|
func notExist(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return os.IsNotExist(err)
|
|
}
|