| // Copyright 2013 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // Package blog implements a web server for articles written in present format. |
| package blog // import "golang.org/x/tools/blog" |
| |
| import ( |
| "bytes" |
| "encoding/json" |
| "encoding/xml" |
| "fmt" |
| "html/template" |
| "log" |
| "net/http" |
| "os" |
| "path/filepath" |
| "regexp" |
| "sort" |
| "strings" |
| "time" |
| |
| "golang.org/x/tools/blog/atom" |
| "golang.org/x/tools/present" |
| ) |
| |
| var ( |
| validJSONPFunc = regexp.MustCompile(`(?i)^[a-z_][a-z0-9_.]*$`) |
| // used to serve relative paths when ServeLocalLinks is enabled. |
| golangOrgAbsLinkReplacer = strings.NewReplacer( |
| `href="https://golang.org/pkg`, `href="/pkg`, |
| `href="https://golang.org/cmd`, `href="/cmd`, |
| ) |
| ) |
| |
| // Config specifies Server configuration values. |
| type Config struct { |
| ContentPath string // Relative or absolute location of article files and related content. |
| TemplatePath string // Relative or absolute location of template files. |
| |
| BaseURL string // Absolute base URL (for permalinks; no trailing slash). |
| BasePath string // Base URL path relative to server root (no trailing slash). |
| GodocURL string // The base URL of godoc (for menu bar; no trailing slash). |
| Hostname string // Server host name, used for rendering ATOM feeds. |
| AnalyticsHTML template.HTML // Optional analytics HTML to insert at the beginning of <head>. |
| |
| HomeArticles int // Articles to display on the home page. |
| FeedArticles int // Articles to include in Atom and JSON feeds. |
| FeedTitle string // The title of the Atom XML feed |
| |
| PlayEnabled bool |
| ServeLocalLinks bool // rewrite golang.org/{pkg,cmd} links to host-less, relative paths. |
| } |
| |
| // Doc represents an article adorned with presentation data. |
| type Doc struct { |
| *present.Doc |
| Permalink string // Canonical URL for this document. |
| Path string // Path relative to server root (including base). |
| HTML template.HTML // rendered article |
| |
| Related []*Doc |
| Newer, Older *Doc |
| } |
| |
| // Server implements an http.Handler that serves blog articles. |
| type Server struct { |
| cfg Config |
| docs []*Doc |
| redirects map[string]string |
| tags []string |
| docPaths map[string]*Doc // key is path without BasePath. |
| docTags map[string][]*Doc |
| template struct { |
| home, index, article, doc *template.Template |
| } |
| atomFeed []byte // pre-rendered Atom feed |
| jsonFeed []byte // pre-rendered JSON feed |
| content http.Handler |
| } |
| |
| // NewServer constructs a new Server using the specified config. |
| func NewServer(cfg Config) (*Server, error) { |
| present.PlayEnabled = cfg.PlayEnabled |
| |
| if notExist(cfg.TemplatePath) { |
| return nil, fmt.Errorf("template directory not found: %s", cfg.TemplatePath) |
| } |
| root := filepath.Join(cfg.TemplatePath, "root.tmpl") |
| parse := func(name string) (*template.Template, error) { |
| path := filepath.Join(cfg.TemplatePath, name) |
| if notExist(path) { |
| return nil, fmt.Errorf("template %s was not found in %s", name, cfg.TemplatePath) |
| } |
| t := template.New("").Funcs(funcMap) |
| return t.ParseFiles(root, path) |
| } |
| |
| s := &Server{cfg: cfg} |
| |
| // Parse templates. |
| var err error |
| s.template.home, err = parse("home.tmpl") |
| if err != nil { |
| return nil, err |
| } |
| s.template.index, err = parse("index.tmpl") |
| if err != nil { |
| return nil, err |
| } |
| s.template.article, err = parse("article.tmpl") |
| if err != nil { |
| return nil, err |
| } |
| p := present.Template().Funcs(funcMap) |
| s.template.doc, err = p.ParseFiles(filepath.Join(cfg.TemplatePath, "doc.tmpl")) |
| if err != nil { |
| return nil, err |
| } |
| |
| // Load content. |
| content := filepath.Clean(cfg.ContentPath) |
| err = s.loadDocs(content) |
| if err != nil { |
| return nil, err |
| } |
| |
| err = s.renderAtomFeed() |
| if err != nil { |
| return nil, err |
| } |
| |
| err = s.renderJSONFeed() |
| if err != nil { |
| return nil, err |
| } |
| |
| // Set up content file server. |
| s.content = http.StripPrefix(s.cfg.BasePath, http.FileServer(http.Dir(cfg.ContentPath))) |
| |
| return s, nil |
| } |
| |
| var funcMap = template.FuncMap{ |
| "sectioned": sectioned, |
| "authors": authors, |
| } |
| |
| // sectioned returns true if the provided Doc contains more than one section. |
| // This is used to control whether to display the table of contents and headings. |
| func sectioned(d *present.Doc) bool { |
| return len(d.Sections) > 1 |
| } |
| |
| // authors returns a comma-separated list of author names. |
| func authors(authors []present.Author) string { |
| var b bytes.Buffer |
| last := len(authors) - 1 |
| for i, a := range authors { |
| if i > 0 { |
| if i == last { |
| if len(authors) > 2 { |
| b.WriteString(",") |
| } |
| b.WriteString(" and ") |
| } else { |
| b.WriteString(", ") |
| } |
| } |
| b.WriteString(authorName(a)) |
| } |
| return b.String() |
| } |
| |
| // authorName returns the first line of the Author text: the author's name. |
| func authorName(a present.Author) string { |
| el := a.TextElem() |
| if len(el) == 0 { |
| return "" |
| } |
| text, ok := el[0].(present.Text) |
| if !ok || len(text.Lines) == 0 { |
| return "" |
| } |
| return text.Lines[0] |
| } |
| |
| // loadDocs reads all content from the provided file system root, renders all |
| // the articles it finds, adds them to the Server's docs field, computes the |
| // denormalized docPaths, docTags, and tags fields, and populates the various |
| // helper fields (Next, Previous, Related) for each Doc. |
| func (s *Server) loadDocs(root string) error { |
| // Read content into docs field. |
| const ext = ".article" |
| fn := func(p string, info os.FileInfo, err error) error { |
| if err != nil { |
| return err |
| } |
| |
| if filepath.Ext(p) != ext { |
| return nil |
| } |
| f, err := os.Open(p) |
| if err != nil { |
| return err |
| } |
| defer f.Close() |
| d, err := present.Parse(f, p, 0) |
| if err != nil { |
| return err |
| } |
| var html bytes.Buffer |
| err = d.Render(&html, s.template.doc) |
| if err != nil { |
| return err |
| } |
| p = p[len(root) : len(p)-len(ext)] // trim root and extension |
| p = filepath.ToSlash(p) |
| s.docs = append(s.docs, &Doc{ |
| Doc: d, |
| Path: s.cfg.BasePath + p, |
| Permalink: s.cfg.BaseURL + p, |
| HTML: template.HTML(html.String()), |
| }) |
| return nil |
| } |
| err := filepath.Walk(root, fn) |
| if err != nil { |
| return err |
| } |
| sort.Sort(docsByTime(s.docs)) |
| |
| // Pull out doc paths and tags and put in reverse-associating maps. |
| s.docPaths = make(map[string]*Doc) |
| s.docTags = make(map[string][]*Doc) |
| s.redirects = make(map[string]string) |
| for _, d := range s.docs { |
| s.docPaths[strings.TrimPrefix(d.Path, s.cfg.BasePath)] = d |
| for _, t := range d.Tags { |
| s.docTags[t] = append(s.docTags[t], d) |
| } |
| } |
| for _, d := range s.docs { |
| for _, old := range d.OldURL { |
| if !strings.HasPrefix(old, "/") { |
| old = "/" + old |
| } |
| if _, ok := s.docPaths[old]; ok { |
| return fmt.Errorf("redirect %s -> %s conflicts with document %s", old, d.Path, old) |
| } |
| if new, ok := s.redirects[old]; ok { |
| return fmt.Errorf("redirect %s -> %s conflicts with redirect %s -> %s", old, d.Path, old, new) |
| } |
| s.redirects[old] = d.Path |
| } |
| } |
| |
| // Pull out unique sorted list of tags. |
| for t := range s.docTags { |
| s.tags = append(s.tags, t) |
| } |
| sort.Strings(s.tags) |
| |
| // Set up presentation-related fields, Newer, Older, and Related. |
| for _, doc := range s.docs { |
| // Newer, Older: docs adjacent to doc |
| for i := range s.docs { |
| if s.docs[i] != doc { |
| continue |
| } |
| if i > 0 { |
| doc.Newer = s.docs[i-1] |
| } |
| if i+1 < len(s.docs) { |
| doc.Older = s.docs[i+1] |
| } |
| break |
| } |
| |
| // Related: all docs that share tags with doc. |
| related := make(map[*Doc]bool) |
| for _, t := range doc.Tags { |
| for _, d := range s.docTags[t] { |
| if d != doc { |
| related[d] = true |
| } |
| } |
| } |
| for d := range related { |
| doc.Related = append(doc.Related, d) |
| } |
| sort.Sort(docsByTime(doc.Related)) |
| } |
| |
| return nil |
| } |
| |
| // renderAtomFeed generates an XML Atom feed and stores it in the Server's |
| // atomFeed field. |
| func (s *Server) renderAtomFeed() error { |
| var updated time.Time |
| if len(s.docs) > 0 { |
| updated = s.docs[0].Time |
| } |
| feed := atom.Feed{ |
| Title: s.cfg.FeedTitle, |
| ID: "tag:" + s.cfg.Hostname + ",2013:" + s.cfg.Hostname, |
| Updated: atom.Time(updated), |
| Link: []atom.Link{{ |
| Rel: "self", |
| Href: s.cfg.BaseURL + "/feed.atom", |
| }}, |
| } |
| for i, doc := range s.docs { |
| if i >= s.cfg.FeedArticles { |
| break |
| } |
| |
| // Use original article path as ID in atom feed |
| // to avoid articles being treated as new when renamed. |
| idPath := doc.Path |
| if len(doc.OldURL) > 0 { |
| old := doc.OldURL[0] |
| if !strings.HasPrefix(old, "/") { |
| old = "/" + old |
| } |
| idPath = old |
| } |
| |
| e := &atom.Entry{ |
| Title: doc.Title, |
| ID: feed.ID + idPath, |
| Link: []atom.Link{{ |
| Rel: "alternate", |
| Href: doc.Permalink, |
| }}, |
| Published: atom.Time(doc.Time), |
| Updated: atom.Time(doc.Time), |
| Summary: &atom.Text{ |
| Type: "html", |
| Body: summary(doc), |
| }, |
| Content: &atom.Text{ |
| Type: "html", |
| Body: string(doc.HTML), |
| }, |
| Author: &atom.Person{ |
| Name: authors(doc.Authors), |
| }, |
| } |
| feed.Entry = append(feed.Entry, e) |
| } |
| data, err := xml.Marshal(&feed) |
| if err != nil { |
| return err |
| } |
| s.atomFeed = data |
| return nil |
| } |
| |
| type jsonItem struct { |
| Title string |
| Link string |
| Time time.Time |
| Summary string |
| Content string |
| Author string |
| } |
| |
| // renderJSONFeed generates a JSON feed and stores it in the Server's jsonFeed |
| // field. |
| func (s *Server) renderJSONFeed() error { |
| var feed []jsonItem |
| for i, doc := range s.docs { |
| if i >= s.cfg.FeedArticles { |
| break |
| } |
| item := jsonItem{ |
| Title: doc.Title, |
| Link: doc.Permalink, |
| Time: doc.Time, |
| Summary: summary(doc), |
| Content: string(doc.HTML), |
| Author: authors(doc.Authors), |
| } |
| feed = append(feed, item) |
| } |
| data, err := json.Marshal(feed) |
| if err != nil { |
| return err |
| } |
| s.jsonFeed = data |
| return nil |
| } |
| |
| // summary returns the first paragraph of text from the provided Doc. |
| func summary(d *Doc) string { |
| if len(d.Sections) == 0 { |
| return "" |
| } |
| for _, elem := range d.Sections[0].Elem { |
| text, ok := elem.(present.Text) |
| if !ok || text.Pre { |
| // skip everything but non-text elements |
| continue |
| } |
| var buf bytes.Buffer |
| for _, s := range text.Lines { |
| buf.WriteString(string(present.Style(s))) |
| buf.WriteByte('\n') |
| } |
| return buf.String() |
| } |
| return "" |
| } |
| |
| // rootData encapsulates data destined for the root template. |
| type rootData struct { |
| Doc *Doc |
| BasePath string |
| GodocURL string |
| AnalyticsHTML template.HTML |
| Data interface{} |
| } |
| |
| // ServeHTTP serves the front, index, and article pages |
| // as well as the ATOM and JSON feeds. |
| func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| var ( |
| d = rootData{ |
| BasePath: s.cfg.BasePath, |
| GodocURL: s.cfg.GodocURL, |
| AnalyticsHTML: s.cfg.AnalyticsHTML, |
| } |
| t *template.Template |
| ) |
| switch p := strings.TrimPrefix(r.URL.Path, s.cfg.BasePath); p { |
| case "/": |
| d.Data = s.docs |
| if len(s.docs) > s.cfg.HomeArticles { |
| d.Data = s.docs[:s.cfg.HomeArticles] |
| } |
| t = s.template.home |
| case "/index": |
| d.Data = s.docs |
| t = s.template.index |
| case "/feed.atom", "/feeds/posts/default": |
| w.Header().Set("Content-type", "application/atom+xml; charset=utf-8") |
| w.Write(s.atomFeed) |
| return |
| case "/.json": |
| if p := r.FormValue("jsonp"); validJSONPFunc.MatchString(p) { |
| w.Header().Set("Content-type", "application/javascript; charset=utf-8") |
| fmt.Fprintf(w, "%v(%s)", p, s.jsonFeed) |
| return |
| } |
| w.Header().Set("Content-type", "application/json; charset=utf-8") |
| w.Write(s.jsonFeed) |
| return |
| default: |
| if redir, ok := s.redirects[p]; ok { |
| http.Redirect(w, r, redir, http.StatusMovedPermanently) |
| return |
| } |
| doc, ok := s.docPaths[p] |
| if !ok { |
| // Not a doc; try to just serve static content. |
| s.content.ServeHTTP(w, r) |
| return |
| } |
| d.Doc = doc |
| t = s.template.article |
| } |
| var err error |
| if s.cfg.ServeLocalLinks { |
| var buf bytes.Buffer |
| err = t.ExecuteTemplate(&buf, "root", d) |
| if err != nil { |
| log.Println(err) |
| return |
| } |
| _, err = golangOrgAbsLinkReplacer.WriteString(w, buf.String()) |
| } else { |
| err = t.ExecuteTemplate(w, "root", d) |
| } |
| if err != nil { |
| log.Println(err) |
| } |
| } |
| |
| // docsByTime implements sort.Interface, sorting Docs by their Time field. |
| type docsByTime []*Doc |
| |
| func (s docsByTime) Len() int { return len(s) } |
| func (s docsByTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } |
| func (s docsByTime) Less(i, j int) bool { return s[i].Time.After(s[j].Time) } |
| |
| // notExist reports whether the path exists or not. |
| func notExist(path string) bool { |
| _, err := os.Stat(path) |
| return os.IsNotExist(err) |
| } |