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