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