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.
5 // Package blog implements a web server for articles written in present format.
6 package blog // import "golang.org/x/tools/blog"
23 "golang.org/x/tools/blog/atom"
24 "golang.org/x/tools/present"
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`,
36 // Config specifies Server configuration values.
38 ContentPath string // Relative or absolute location of article files and related content.
39 TemplatePath string // Relative or absolute location of template files.
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>.
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
52 ServeLocalLinks bool // rewrite golang.org/{pkg,cmd} links to host-less, relative paths.
55 // Doc represents an article adorned with presentation data.
58 Permalink string // Canonical URL for this document.
59 Path string // Path relative to server root (including base).
60 HTML template.HTML // rendered article
66 // Server implements an http.Handler that serves blog articles.
70 redirects map[string]string
72 docPaths map[string]*Doc // key is path without BasePath.
73 docTags map[string][]*Doc
75 home, index, article, doc *template.Template
77 atomFeed []byte // pre-rendered Atom feed
78 jsonFeed []byte // pre-rendered JSON feed
82 // NewServer constructs a new Server using the specified config.
83 func NewServer(cfg Config) (*Server, error) {
84 present.PlayEnabled = cfg.PlayEnabled
86 if notExist(cfg.TemplatePath) {
87 return nil, fmt.Errorf("template directory not found: %s", cfg.TemplatePath)
89 root := filepath.Join(cfg.TemplatePath, "root.tmpl")
90 parse := func(name string) (*template.Template, error) {
91 path := filepath.Join(cfg.TemplatePath, name)
93 return nil, fmt.Errorf("template %s was not found in %s", name, cfg.TemplatePath)
95 t := template.New("").Funcs(funcMap)
96 return t.ParseFiles(root, path)
99 s := &Server{cfg: cfg}
103 s.template.home, err = parse("home.tmpl")
107 s.template.index, err = parse("index.tmpl")
111 s.template.article, err = parse("article.tmpl")
115 p := present.Template().Funcs(funcMap)
116 s.template.doc, err = p.ParseFiles(filepath.Join(cfg.TemplatePath, "doc.tmpl"))
122 content := filepath.Clean(cfg.ContentPath)
123 err = s.loadDocs(content)
128 err = s.renderAtomFeed()
133 err = s.renderJSONFeed()
138 // Set up content file server.
139 s.content = http.StripPrefix(s.cfg.BasePath, http.FileServer(http.Dir(cfg.ContentPath)))
144 var funcMap = template.FuncMap{
145 "sectioned": sectioned,
149 // sectioned returns true if the provided Doc contains more than one section.
150 // This is used to control whether to display the table of contents and headings.
151 func sectioned(d *present.Doc) bool {
152 return len(d.Sections) > 1
155 // authors returns a comma-separated list of author names.
156 func authors(authors []present.Author) string {
158 last := len(authors) - 1
159 for i, a := range authors {
162 if len(authors) > 2 {
165 b.WriteString(" and ")
170 b.WriteString(authorName(a))
175 // authorName returns the first line of the Author text: the author's name.
176 func authorName(a present.Author) string {
181 text, ok := el[0].(present.Text)
182 if !ok || len(text.Lines) == 0 {
188 // loadDocs reads all content from the provided file system root, renders all
189 // the articles it finds, adds them to the Server's docs field, computes the
190 // denormalized docPaths, docTags, and tags fields, and populates the various
191 // helper fields (Next, Previous, Related) for each Doc.
192 func (s *Server) loadDocs(root string) error {
193 // Read content into docs field.
194 const ext = ".article"
195 fn := func(p string, info os.FileInfo, err error) error {
200 if filepath.Ext(p) != ext {
208 d, err := present.Parse(f, p, 0)
212 var html bytes.Buffer
213 err = d.Render(&html, s.template.doc)
217 p = p[len(root) : len(p)-len(ext)] // trim root and extension
218 p = filepath.ToSlash(p)
219 s.docs = append(s.docs, &Doc{
221 Path: s.cfg.BasePath + p,
222 Permalink: s.cfg.BaseURL + p,
223 HTML: template.HTML(html.String()),
227 err := filepath.Walk(root, fn)
231 sort.Sort(docsByTime(s.docs))
233 // Pull out doc paths and tags and put in reverse-associating maps.
234 s.docPaths = make(map[string]*Doc)
235 s.docTags = make(map[string][]*Doc)
236 s.redirects = make(map[string]string)
237 for _, d := range s.docs {
238 s.docPaths[strings.TrimPrefix(d.Path, s.cfg.BasePath)] = d
239 for _, t := range d.Tags {
240 s.docTags[t] = append(s.docTags[t], d)
243 for _, d := range s.docs {
244 for _, old := range d.OldURL {
245 if !strings.HasPrefix(old, "/") {
248 if _, ok := s.docPaths[old]; ok {
249 return fmt.Errorf("redirect %s -> %s conflicts with document %s", old, d.Path, old)
251 if new, ok := s.redirects[old]; ok {
252 return fmt.Errorf("redirect %s -> %s conflicts with redirect %s -> %s", old, d.Path, old, new)
254 s.redirects[old] = d.Path
258 // Pull out unique sorted list of tags.
259 for t := range s.docTags {
260 s.tags = append(s.tags, t)
264 // Set up presentation-related fields, Newer, Older, and Related.
265 for _, doc := range s.docs {
266 // Newer, Older: docs adjacent to doc
267 for i := range s.docs {
268 if s.docs[i] != doc {
272 doc.Newer = s.docs[i-1]
274 if i+1 < len(s.docs) {
275 doc.Older = s.docs[i+1]
280 // Related: all docs that share tags with doc.
281 related := make(map[*Doc]bool)
282 for _, t := range doc.Tags {
283 for _, d := range s.docTags[t] {
289 for d := range related {
290 doc.Related = append(doc.Related, d)
292 sort.Sort(docsByTime(doc.Related))
298 // renderAtomFeed generates an XML Atom feed and stores it in the Server's
300 func (s *Server) renderAtomFeed() error {
301 var updated time.Time
303 updated = s.docs[0].Time
306 Title: s.cfg.FeedTitle,
307 ID: "tag:" + s.cfg.Hostname + ",2013:" + s.cfg.Hostname,
308 Updated: atom.Time(updated),
311 Href: s.cfg.BaseURL + "/feed.atom",
314 for i, doc := range s.docs {
315 if i >= s.cfg.FeedArticles {
319 // Use original article path as ID in atom feed
320 // to avoid articles being treated as new when renamed.
322 if len(doc.OldURL) > 0 {
324 if !strings.HasPrefix(old, "/") {
332 ID: feed.ID + idPath,
337 Published: atom.Time(doc.Time),
338 Updated: atom.Time(doc.Time),
345 Body: string(doc.HTML),
347 Author: &atom.Person{
348 Name: authors(doc.Authors),
351 feed.Entry = append(feed.Entry, e)
353 data, err := xml.Marshal(&feed)
361 type jsonItem struct {
370 // renderJSONFeed generates a JSON feed and stores it in the Server's jsonFeed
372 func (s *Server) renderJSONFeed() error {
374 for i, doc := range s.docs {
375 if i >= s.cfg.FeedArticles {
382 Summary: summary(doc),
383 Content: string(doc.HTML),
384 Author: authors(doc.Authors),
386 feed = append(feed, item)
388 data, err := json.Marshal(feed)
396 // summary returns the first paragraph of text from the provided Doc.
397 func summary(d *Doc) string {
398 if len(d.Sections) == 0 {
401 for _, elem := range d.Sections[0].Elem {
402 text, ok := elem.(present.Text)
404 // skip everything but non-text elements
408 for _, s := range text.Lines {
409 buf.WriteString(string(present.Style(s)))
417 // rootData encapsulates data destined for the root template.
418 type rootData struct {
422 AnalyticsHTML template.HTML
426 // ServeHTTP serves the front, index, and article pages
427 // as well as the ATOM and JSON feeds.
428 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
431 BasePath: s.cfg.BasePath,
432 GodocURL: s.cfg.GodocURL,
433 AnalyticsHTML: s.cfg.AnalyticsHTML,
437 switch p := strings.TrimPrefix(r.URL.Path, s.cfg.BasePath); p {
440 if len(s.docs) > s.cfg.HomeArticles {
441 d.Data = s.docs[:s.cfg.HomeArticles]
447 case "/feed.atom", "/feeds/posts/default":
448 w.Header().Set("Content-type", "application/atom+xml; charset=utf-8")
452 if p := r.FormValue("jsonp"); validJSONPFunc.MatchString(p) {
453 w.Header().Set("Content-type", "application/javascript; charset=utf-8")
454 fmt.Fprintf(w, "%v(%s)", p, s.jsonFeed)
457 w.Header().Set("Content-type", "application/json; charset=utf-8")
461 if redir, ok := s.redirects[p]; ok {
462 http.Redirect(w, r, redir, http.StatusMovedPermanently)
465 doc, ok := s.docPaths[p]
467 // Not a doc; try to just serve static content.
468 s.content.ServeHTTP(w, r)
472 t = s.template.article
475 if s.cfg.ServeLocalLinks {
477 err = t.ExecuteTemplate(&buf, "root", d)
482 _, err = golangOrgAbsLinkReplacer.WriteString(w, buf.String())
484 err = t.ExecuteTemplate(w, "root", d)
491 // docsByTime implements sort.Interface, sorting Docs by their Time field.
492 type docsByTime []*Doc
494 func (s docsByTime) Len() int { return len(s) }
495 func (s docsByTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
496 func (s docsByTime) Less(i, j int) bool { return s[i].Time.After(s[j].Time) }
498 // notExist reports whether the path exists or not.
499 func notExist(path string) bool {
500 _, err := os.Stat(path)
501 return os.IsNotExist(err)