1// Copyright 2013 The Go Authors.  All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5// Package blog implements a web server for articles written in present format.
6package blog // import "golang.org/x/tools/blog"
7
8import (
9	"bytes"
10	"encoding/json"
11	"encoding/xml"
12	"fmt"
13	"html/template"
14	"log"
15	"net/http"
16	"os"
17	"path/filepath"
18	"regexp"
19	"sort"
20	"strings"
21	"time"
22
23	"golang.org/x/tools/blog/atom"
24	"golang.org/x/tools/present"
25)
26
27var validJSONPFunc = regexp.MustCompile(`(?i)^[a-z_][a-z0-9_.]*$`)
28
29// Config specifies Server configuration values.
30type Config struct {
31	ContentPath  string // Relative or absolute location of article files and related content.
32	TemplatePath string // Relative or absolute location of template files.
33
34	BaseURL  string // Absolute base URL (for permalinks; no trailing slash).
35	BasePath string // Base URL path relative to server root (no trailing slash).
36	GodocURL string // The base URL of godoc (for menu bar; no trailing slash).
37	Hostname string // Server host name, used for rendering ATOM feeds.
38
39	HomeArticles int    // Articles to display on the home page.
40	FeedArticles int    // Articles to include in Atom and JSON feeds.
41	FeedTitle    string // The title of the Atom XML feed
42
43	PlayEnabled bool
44}
45
46// Doc represents an article adorned with presentation data.
47type Doc struct {
48	*present.Doc
49	Permalink string        // Canonical URL for this document.
50	Path      string        // Path relative to server root (including base).
51	HTML      template.HTML // rendered article
52
53	Related      []*Doc
54	Newer, Older *Doc
55}
56
57// Server implements an http.Handler that serves blog articles.
58type Server struct {
59	cfg      Config
60	docs     []*Doc
61	tags     []string
62	docPaths map[string]*Doc // key is path without BasePath.
63	docTags  map[string][]*Doc
64	template struct {
65		home, index, article, doc *template.Template
66	}
67	atomFeed []byte // pre-rendered Atom feed
68	jsonFeed []byte // pre-rendered JSON feed
69	content  http.Handler
70}
71
72// NewServer constructs a new Server using the specified config.
73func NewServer(cfg Config) (*Server, error) {
74	present.PlayEnabled = cfg.PlayEnabled
75
76	if notExist(cfg.TemplatePath) {
77		return nil, fmt.Errorf("template directory not found: %s", cfg.TemplatePath)
78	}
79	root := filepath.Join(cfg.TemplatePath, "root.tmpl")
80	parse := func(name string) (*template.Template, error) {
81		path := filepath.Join(cfg.TemplatePath, name)
82		if notExist(path) {
83			return nil, fmt.Errorf("template %s was not found in %s", name, cfg.TemplatePath)
84		}
85		t := template.New("").Funcs(funcMap)
86		return t.ParseFiles(root, path)
87	}
88
89	s := &Server{cfg: cfg}
90
91	// Parse templates.
92	var err error
93	s.template.home, err = parse("home.tmpl")
94	if err != nil {
95		return nil, err
96	}
97	s.template.index, err = parse("index.tmpl")
98	if err != nil {
99		return nil, err
100	}
101	s.template.article, err = parse("article.tmpl")
102	if err != nil {
103		return nil, err
104	}
105	p := present.Template().Funcs(funcMap)
106	s.template.doc, err = p.ParseFiles(filepath.Join(cfg.TemplatePath, "doc.tmpl"))
107	if err != nil {
108		return nil, err
109	}
110
111	// Load content.
112	err = s.loadDocs(filepath.Clean(cfg.ContentPath))
113	if err != nil {
114		return nil, err
115	}
116
117	err = s.renderAtomFeed()
118	if err != nil {
119		return nil, err
120	}
121
122	err = s.renderJSONFeed()
123	if err != nil {
124		return nil, err
125	}
126
127	// Set up content file server.
128	s.content = http.StripPrefix(s.cfg.BasePath, http.FileServer(http.Dir(cfg.ContentPath)))
129
130	return s, nil
131}
132
133var funcMap = template.FuncMap{
134	"sectioned": sectioned,
135	"authors":   authors,
136}
137
138// sectioned returns true if the provided Doc contains more than one section.
139// This is used to control whether to display the table of contents and headings.
140func sectioned(d *present.Doc) bool {
141	return len(d.Sections) > 1
142}
143
144// authors returns a comma-separated list of author names.
145func authors(authors []present.Author) string {
146	var b bytes.Buffer
147	last := len(authors) - 1
148	for i, a := range authors {
149		if i > 0 {
150			if i == last {
151				b.WriteString(" and ")
152			} else {
153				b.WriteString(", ")
154			}
155		}
156		b.WriteString(authorName(a))
157	}
158	return b.String()
159}
160
161// authorName returns the first line of the Author text: the author's name.
162func authorName(a present.Author) string {
163	el := a.TextElem()
164	if len(el) == 0 {
165		return ""
166	}
167	text, ok := el[0].(present.Text)
168	if !ok || len(text.Lines) == 0 {
169		return ""
170	}
171	return text.Lines[0]
172}
173
174// loadDocs reads all content from the provided file system root, renders all
175// the articles it finds, adds them to the Server's docs field, computes the
176// denormalized docPaths, docTags, and tags fields, and populates the various
177// helper fields (Next, Previous, Related) for each Doc.
178func (s *Server) loadDocs(root string) error {
179	// Read content into docs field.
180	const ext = ".article"
181	fn := func(p string, info os.FileInfo, err error) error {
182		if filepath.Ext(p) != ext {
183			return nil
184		}
185		f, err := os.Open(p)
186		if err != nil {
187			return err
188		}
189		defer f.Close()
190		d, err := present.Parse(f, p, 0)
191		if err != nil {
192			return err
193		}
194		html := new(bytes.Buffer)
195		err = d.Render(html, s.template.doc)
196		if err != nil {
197			return err
198		}
199		p = p[len(root) : len(p)-len(ext)] // trim root and extension
200		p = filepath.ToSlash(p)
201		s.docs = append(s.docs, &Doc{
202			Doc:       d,
203			Path:      s.cfg.BasePath + p,
204			Permalink: s.cfg.BaseURL + p,
205			HTML:      template.HTML(html.String()),
206		})
207		return nil
208	}
209	err := filepath.Walk(root, fn)
210	if err != nil {
211		return err
212	}
213	sort.Sort(docsByTime(s.docs))
214
215	// Pull out doc paths and tags and put in reverse-associating maps.
216	s.docPaths = make(map[string]*Doc)
217	s.docTags = make(map[string][]*Doc)
218	for _, d := range s.docs {
219		s.docPaths[strings.TrimPrefix(d.Path, s.cfg.BasePath)] = d
220		for _, t := range d.Tags {
221			s.docTags[t] = append(s.docTags[t], d)
222		}
223	}
224
225	// Pull out unique sorted list of tags.
226	for t := range s.docTags {
227		s.tags = append(s.tags, t)
228	}
229	sort.Strings(s.tags)
230
231	// Set up presentation-related fields, Newer, Older, and Related.
232	for _, doc := range s.docs {
233		// Newer, Older: docs adjacent to doc
234		for i := range s.docs {
235			if s.docs[i] != doc {
236				continue
237			}
238			if i > 0 {
239				doc.Newer = s.docs[i-1]
240			}
241			if i+1 < len(s.docs) {
242				doc.Older = s.docs[i+1]
243			}
244			break
245		}
246
247		// Related: all docs that share tags with doc.
248		related := make(map[*Doc]bool)
249		for _, t := range doc.Tags {
250			for _, d := range s.docTags[t] {
251				if d != doc {
252					related[d] = true
253				}
254			}
255		}
256		for d := range related {
257			doc.Related = append(doc.Related, d)
258		}
259		sort.Sort(docsByTime(doc.Related))
260	}
261
262	return nil
263}
264
265// renderAtomFeed generates an XML Atom feed and stores it in the Server's
266// atomFeed field.
267func (s *Server) renderAtomFeed() error {
268	var updated time.Time
269	if len(s.docs) > 0 {
270		updated = s.docs[0].Time
271	}
272	feed := atom.Feed{
273		Title:   s.cfg.FeedTitle,
274		ID:      "tag:" + s.cfg.Hostname + ",2013:" + s.cfg.Hostname,
275		Updated: atom.Time(updated),
276		Link: []atom.Link{{
277			Rel:  "self",
278			Href: s.cfg.BaseURL + "/feed.atom",
279		}},
280	}
281	for i, doc := range s.docs {
282		if i >= s.cfg.FeedArticles {
283			break
284		}
285		e := &atom.Entry{
286			Title: doc.Title,
287			ID:    feed.ID + doc.Path,
288			Link: []atom.Link{{
289				Rel:  "alternate",
290				Href: doc.Permalink,
291			}},
292			Published: atom.Time(doc.Time),
293			Updated:   atom.Time(doc.Time),
294			Summary: &atom.Text{
295				Type: "html",
296				Body: summary(doc),
297			},
298			Content: &atom.Text{
299				Type: "html",
300				Body: string(doc.HTML),
301			},
302			Author: &atom.Person{
303				Name: authors(doc.Authors),
304			},
305		}
306		feed.Entry = append(feed.Entry, e)
307	}
308	data, err := xml.Marshal(&feed)
309	if err != nil {
310		return err
311	}
312	s.atomFeed = data
313	return nil
314}
315
316type jsonItem struct {
317	Title   string
318	Link    string
319	Time    time.Time
320	Summary string
321	Content string
322	Author  string
323}
324
325// renderJSONFeed generates a JSON feed and stores it in the Server's jsonFeed
326// field.
327func (s *Server) renderJSONFeed() error {
328	var feed []jsonItem
329	for i, doc := range s.docs {
330		if i >= s.cfg.FeedArticles {
331			break
332		}
333		item := jsonItem{
334			Title:   doc.Title,
335			Link:    doc.Permalink,
336			Time:    doc.Time,
337			Summary: summary(doc),
338			Content: string(doc.HTML),
339			Author:  authors(doc.Authors),
340		}
341		feed = append(feed, item)
342	}
343	data, err := json.Marshal(feed)
344	if err != nil {
345		return err
346	}
347	s.jsonFeed = data
348	return nil
349}
350
351// summary returns the first paragraph of text from the provided Doc.
352func summary(d *Doc) string {
353	if len(d.Sections) == 0 {
354		return ""
355	}
356	for _, elem := range d.Sections[0].Elem {
357		text, ok := elem.(present.Text)
358		if !ok || text.Pre {
359			// skip everything but non-text elements
360			continue
361		}
362		var buf bytes.Buffer
363		for _, s := range text.Lines {
364			buf.WriteString(string(present.Style(s)))
365			buf.WriteByte('\n')
366		}
367		return buf.String()
368	}
369	return ""
370}
371
372// rootData encapsulates data destined for the root template.
373type rootData struct {
374	Doc      *Doc
375	BasePath string
376	GodocURL string
377	Data     interface{}
378}
379
380// ServeHTTP serves the front, index, and article pages
381// as well as the ATOM and JSON feeds.
382func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
383	var (
384		d = rootData{BasePath: s.cfg.BasePath, GodocURL: s.cfg.GodocURL}
385		t *template.Template
386	)
387	switch p := strings.TrimPrefix(r.URL.Path, s.cfg.BasePath); p {
388	case "/":
389		d.Data = s.docs
390		if len(s.docs) > s.cfg.HomeArticles {
391			d.Data = s.docs[:s.cfg.HomeArticles]
392		}
393		t = s.template.home
394	case "/index":
395		d.Data = s.docs
396		t = s.template.index
397	case "/feed.atom", "/feeds/posts/default":
398		w.Header().Set("Content-type", "application/atom+xml; charset=utf-8")
399		w.Write(s.atomFeed)
400		return
401	case "/.json":
402		if p := r.FormValue("jsonp"); validJSONPFunc.MatchString(p) {
403			w.Header().Set("Content-type", "application/javascript; charset=utf-8")
404			fmt.Fprintf(w, "%v(%s)", p, s.jsonFeed)
405			return
406		}
407		w.Header().Set("Content-type", "application/json; charset=utf-8")
408		w.Write(s.jsonFeed)
409		return
410	default:
411		doc, ok := s.docPaths[p]
412		if !ok {
413			// Not a doc; try to just serve static content.
414			s.content.ServeHTTP(w, r)
415			return
416		}
417		d.Doc = doc
418		t = s.template.article
419	}
420	err := t.ExecuteTemplate(w, "root", d)
421	if err != nil {
422		log.Println(err)
423	}
424}
425
426// docsByTime implements sort.Interface, sorting Docs by their Time field.
427type docsByTime []*Doc
428
429func (s docsByTime) Len() int           { return len(s) }
430func (s docsByTime) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
431func (s docsByTime) Less(i, j int) bool { return s[i].Time.After(s[j].Time) }
432
433// notExist reports whether the path exists or not.
434func notExist(path string) bool {
435	_, err := os.Stat(path)
436	return os.IsNotExist(err)
437}
438