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