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