github.com/powerman/golang-tools@v0.1.11-0.20220410185822-5ad214d8d803/blog/blog.go (about)

     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.
     6  package blog // import "github.com/powerman/golang-tools/blog"
     7  
     8  import (
     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  	"github.com/powerman/golang-tools/blog/atom"
    24  	"github.com/powerman/golang-tools/present"
    25  )
    26  
    27  var (
    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.
    37  type 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.
    56  type 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.
    67  type Server struct {
    68  	cfg       Config
    69  	docs      []*Doc
    70  	redirects map[string]string
    71  	tags      []string
    72  	docPaths  map[string]*Doc // key is path without BasePath.
    73  	docTags   map[string][]*Doc
    74  	template  struct {
    75  		home, index, article, doc *template.Template
    76  	}
    77  	atomFeed []byte // pre-rendered Atom feed
    78  	jsonFeed []byte // pre-rendered JSON feed
    79  	content  http.Handler
    80  }
    81  
    82  // NewServer constructs a new Server using the specified config.
    83  func NewServer(cfg Config) (*Server, error) {
    84  	present.PlayEnabled = cfg.PlayEnabled
    85  
    86  	if notExist(cfg.TemplatePath) {
    87  		return nil, fmt.Errorf("template directory not found: %s", cfg.TemplatePath)
    88  	}
    89  	root := filepath.Join(cfg.TemplatePath, "root.tmpl")
    90  	parse := func(name string) (*template.Template, error) {
    91  		path := filepath.Join(cfg.TemplatePath, name)
    92  		if notExist(path) {
    93  			return nil, fmt.Errorf("template %s was not found in %s", name, cfg.TemplatePath)
    94  		}
    95  		t := template.New("").Funcs(funcMap)
    96  		return t.ParseFiles(root, path)
    97  	}
    98  
    99  	s := &Server{cfg: cfg}
   100  
   101  	// Parse templates.
   102  	var err error
   103  	s.template.home, err = parse("home.tmpl")
   104  	if err != nil {
   105  		return nil, err
   106  	}
   107  	s.template.index, err = parse("index.tmpl")
   108  	if err != nil {
   109  		return nil, err
   110  	}
   111  	s.template.article, err = parse("article.tmpl")
   112  	if err != nil {
   113  		return nil, err
   114  	}
   115  	p := present.Template().Funcs(funcMap)
   116  	s.template.doc, err = p.ParseFiles(filepath.Join(cfg.TemplatePath, "doc.tmpl"))
   117  	if err != nil {
   118  		return nil, err
   119  	}
   120  
   121  	// Load content.
   122  	content := filepath.Clean(cfg.ContentPath)
   123  	err = s.loadDocs(content)
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	err = s.renderAtomFeed()
   129  	if err != nil {
   130  		return nil, err
   131  	}
   132  
   133  	err = s.renderJSONFeed()
   134  	if err != nil {
   135  		return nil, err
   136  	}
   137  
   138  	// Set up content file server.
   139  	s.content = http.StripPrefix(s.cfg.BasePath, http.FileServer(http.Dir(cfg.ContentPath)))
   140  
   141  	return s, nil
   142  }
   143  
   144  var funcMap = template.FuncMap{
   145  	"sectioned": sectioned,
   146  	"authors":   authors,
   147  }
   148  
   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
   153  }
   154  
   155  // authors returns a comma-separated list of author names.
   156  func authors(authors []present.Author) string {
   157  	var b bytes.Buffer
   158  	last := len(authors) - 1
   159  	for i, a := range authors {
   160  		if i > 0 {
   161  			if i == last {
   162  				if len(authors) > 2 {
   163  					b.WriteString(",")
   164  				}
   165  				b.WriteString(" and ")
   166  			} else {
   167  				b.WriteString(", ")
   168  			}
   169  		}
   170  		b.WriteString(authorName(a))
   171  	}
   172  	return b.String()
   173  }
   174  
   175  // authorName returns the first line of the Author text: the author's name.
   176  func authorName(a present.Author) string {
   177  	el := a.TextElem()
   178  	if len(el) == 0 {
   179  		return ""
   180  	}
   181  	text, ok := el[0].(present.Text)
   182  	if !ok || len(text.Lines) == 0 {
   183  		return ""
   184  	}
   185  	return text.Lines[0]
   186  }
   187  
   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 {
   196  		if err != nil {
   197  			return err
   198  		}
   199  
   200  		if filepath.Ext(p) != ext {
   201  			return nil
   202  		}
   203  		f, err := os.Open(p)
   204  		if err != nil {
   205  			return err
   206  		}
   207  		defer f.Close()
   208  		d, err := present.Parse(f, p, 0)
   209  		if err != nil {
   210  			return err
   211  		}
   212  		var html bytes.Buffer
   213  		err = d.Render(&html, s.template.doc)
   214  		if err != nil {
   215  			return err
   216  		}
   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{
   220  			Doc:       d,
   221  			Path:      s.cfg.BasePath + p,
   222  			Permalink: s.cfg.BaseURL + p,
   223  			HTML:      template.HTML(html.String()),
   224  		})
   225  		return nil
   226  	}
   227  	err := filepath.Walk(root, fn)
   228  	if err != nil {
   229  		return err
   230  	}
   231  	sort.Sort(docsByTime(s.docs))
   232  
   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)
   241  		}
   242  	}
   243  	for _, d := range s.docs {
   244  		for _, old := range d.OldURL {
   245  			if !strings.HasPrefix(old, "/") {
   246  				old = "/" + old
   247  			}
   248  			if _, ok := s.docPaths[old]; ok {
   249  				return fmt.Errorf("redirect %s -> %s conflicts with document %s", old, d.Path, old)
   250  			}
   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)
   253  			}
   254  			s.redirects[old] = d.Path
   255  		}
   256  	}
   257  
   258  	// Pull out unique sorted list of tags.
   259  	for t := range s.docTags {
   260  		s.tags = append(s.tags, t)
   261  	}
   262  	sort.Strings(s.tags)
   263  
   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 {
   269  				continue
   270  			}
   271  			if i > 0 {
   272  				doc.Newer = s.docs[i-1]
   273  			}
   274  			if i+1 < len(s.docs) {
   275  				doc.Older = s.docs[i+1]
   276  			}
   277  			break
   278  		}
   279  
   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] {
   284  				if d != doc {
   285  					related[d] = true
   286  				}
   287  			}
   288  		}
   289  		for d := range related {
   290  			doc.Related = append(doc.Related, d)
   291  		}
   292  		sort.Sort(docsByTime(doc.Related))
   293  	}
   294  
   295  	return nil
   296  }
   297  
   298  // renderAtomFeed generates an XML Atom feed and stores it in the Server's
   299  // atomFeed field.
   300  func (s *Server) renderAtomFeed() error {
   301  	var updated time.Time
   302  	if len(s.docs) > 0 {
   303  		updated = s.docs[0].Time
   304  	}
   305  	feed := atom.Feed{
   306  		Title:   s.cfg.FeedTitle,
   307  		ID:      "tag:" + s.cfg.Hostname + ",2013:" + s.cfg.Hostname,
   308  		Updated: atom.Time(updated),
   309  		Link: []atom.Link{{
   310  			Rel:  "self",
   311  			Href: s.cfg.BaseURL + "/feed.atom",
   312  		}},
   313  	}
   314  	for i, doc := range s.docs {
   315  		if i >= s.cfg.FeedArticles {
   316  			break
   317  		}
   318  
   319  		// Use original article path as ID in atom feed
   320  		// to avoid articles being treated as new when renamed.
   321  		idPath := doc.Path
   322  		if len(doc.OldURL) > 0 {
   323  			old := doc.OldURL[0]
   324  			if !strings.HasPrefix(old, "/") {
   325  				old = "/" + old
   326  			}
   327  			idPath = old
   328  		}
   329  
   330  		e := &atom.Entry{
   331  			Title: doc.Title,
   332  			ID:    feed.ID + idPath,
   333  			Link: []atom.Link{{
   334  				Rel:  "alternate",
   335  				Href: doc.Permalink,
   336  			}},
   337  			Published: atom.Time(doc.Time),
   338  			Updated:   atom.Time(doc.Time),
   339  			Summary: &atom.Text{
   340  				Type: "html",
   341  				Body: summary(doc),
   342  			},
   343  			Content: &atom.Text{
   344  				Type: "html",
   345  				Body: string(doc.HTML),
   346  			},
   347  			Author: &atom.Person{
   348  				Name: authors(doc.Authors),
   349  			},
   350  		}
   351  		feed.Entry = append(feed.Entry, e)
   352  	}
   353  	data, err := xml.Marshal(&feed)
   354  	if err != nil {
   355  		return err
   356  	}
   357  	s.atomFeed = data
   358  	return nil
   359  }
   360  
   361  type jsonItem struct {
   362  	Title   string
   363  	Link    string
   364  	Time    time.Time
   365  	Summary string
   366  	Content string
   367  	Author  string
   368  }
   369  
   370  // renderJSONFeed generates a JSON feed and stores it in the Server's jsonFeed
   371  // field.
   372  func (s *Server) renderJSONFeed() error {
   373  	var feed []jsonItem
   374  	for i, doc := range s.docs {
   375  		if i >= s.cfg.FeedArticles {
   376  			break
   377  		}
   378  		item := jsonItem{
   379  			Title:   doc.Title,
   380  			Link:    doc.Permalink,
   381  			Time:    doc.Time,
   382  			Summary: summary(doc),
   383  			Content: string(doc.HTML),
   384  			Author:  authors(doc.Authors),
   385  		}
   386  		feed = append(feed, item)
   387  	}
   388  	data, err := json.Marshal(feed)
   389  	if err != nil {
   390  		return err
   391  	}
   392  	s.jsonFeed = data
   393  	return nil
   394  }
   395  
   396  // summary returns the first paragraph of text from the provided Doc.
   397  func summary(d *Doc) string {
   398  	if len(d.Sections) == 0 {
   399  		return ""
   400  	}
   401  	for _, elem := range d.Sections[0].Elem {
   402  		text, ok := elem.(present.Text)
   403  		if !ok || text.Pre {
   404  			// skip everything but non-text elements
   405  			continue
   406  		}
   407  		var buf bytes.Buffer
   408  		for _, s := range text.Lines {
   409  			buf.WriteString(string(present.Style(s)))
   410  			buf.WriteByte('\n')
   411  		}
   412  		return buf.String()
   413  	}
   414  	return ""
   415  }
   416  
   417  // rootData encapsulates data destined for the root template.
   418  type rootData struct {
   419  	Doc           *Doc
   420  	BasePath      string
   421  	GodocURL      string
   422  	AnalyticsHTML template.HTML
   423  	Data          interface{}
   424  }
   425  
   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) {
   429  	var (
   430  		d = rootData{
   431  			BasePath:      s.cfg.BasePath,
   432  			GodocURL:      s.cfg.GodocURL,
   433  			AnalyticsHTML: s.cfg.AnalyticsHTML,
   434  		}
   435  		t *template.Template
   436  	)
   437  	switch p := strings.TrimPrefix(r.URL.Path, s.cfg.BasePath); p {
   438  	case "/":
   439  		d.Data = s.docs
   440  		if len(s.docs) > s.cfg.HomeArticles {
   441  			d.Data = s.docs[:s.cfg.HomeArticles]
   442  		}
   443  		t = s.template.home
   444  	case "/index":
   445  		d.Data = s.docs
   446  		t = s.template.index
   447  	case "/feed.atom", "/feeds/posts/default":
   448  		w.Header().Set("Content-type", "application/atom+xml; charset=utf-8")
   449  		w.Write(s.atomFeed)
   450  		return
   451  	case "/.json":
   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)
   455  			return
   456  		}
   457  		w.Header().Set("Content-type", "application/json; charset=utf-8")
   458  		w.Write(s.jsonFeed)
   459  		return
   460  	default:
   461  		if redir, ok := s.redirects[p]; ok {
   462  			http.Redirect(w, r, redir, http.StatusMovedPermanently)
   463  			return
   464  		}
   465  		doc, ok := s.docPaths[p]
   466  		if !ok {
   467  			// Not a doc; try to just serve static content.
   468  			s.content.ServeHTTP(w, r)
   469  			return
   470  		}
   471  		d.Doc = doc
   472  		t = s.template.article
   473  	}
   474  	var err error
   475  	if s.cfg.ServeLocalLinks {
   476  		var buf bytes.Buffer
   477  		err = t.ExecuteTemplate(&buf, "root", d)
   478  		if err != nil {
   479  			log.Println(err)
   480  			return
   481  		}
   482  		_, err = golangOrgAbsLinkReplacer.WriteString(w, buf.String())
   483  	} else {
   484  		err = t.ExecuteTemplate(w, "root", d)
   485  	}
   486  	if err != nil {
   487  		log.Println(err)
   488  	}
   489  }
   490  
   491  // docsByTime implements sort.Interface, sorting Docs by their Time field.
   492  type docsByTime []*Doc
   493  
   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) }
   497  
   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)
   502  }