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