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