present: add OldURL metadata and use for redirects in blog

This will allow renaming blog pages to have shorter,
more easily typed URLs, while keeping the old links working.

Change-Id: I2cd6733eaaf02a4b8e73afc773173c655d317ee6
Reviewed-on: https://go-review.googlesource.com/c/tools/+/223603
Reviewed-by: Andrew Bonventre <andybons@golang.org>
diff --git a/blog/blog.go b/blog/blog.go
index 1c3bc54..3e8f873 100644
--- a/blog/blog.go
+++ b/blog/blog.go
@@ -65,12 +65,13 @@
 
 // Server implements an http.Handler that serves blog articles.
 type Server struct {
-	cfg      Config
-	docs     []*Doc
-	tags     []string
-	docPaths map[string]*Doc // key is path without BasePath.
-	docTags  map[string][]*Doc
-	template 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
@@ -118,7 +119,8 @@
 	}
 
 	// Load content.
-	err = s.loadDocs(filepath.Clean(cfg.ContentPath))
+	content := filepath.Clean(cfg.ContentPath)
+	err = s.loadDocs(content)
 	if err != nil {
 		return nil, err
 	}
@@ -191,6 +193,7 @@
 		if err != nil {
 			return err
 		}
+
 		if filepath.Ext(p) != ext {
 			return nil
 		}
@@ -227,12 +230,27 @@
 	// 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 {
@@ -425,6 +443,10 @@
 		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.
diff --git a/present/doc.go b/present/doc.go
index fb84e12..cdee8ad 100644
--- a/present/doc.go
+++ b/present/doc.go
@@ -17,6 +17,7 @@
 	15:04 2 Jan 2006
 	Tags: foo, bar, baz
 	Summary: This is a great document you want to read.
+	OldURL: former-path-for-this-doc
 
 The "# " prefix before the title indicates that this is
 a Markdown-enabled present file: it uses
@@ -33,8 +34,12 @@
 
 The summary line gives a short summary used in blog feeds.
 
+The old URL line, which may be repeated, gives an older (perhaps relative) URL
+for this document.
+A server might use these to generate appropriate redirects.
+
 Only the title is required;
-the subtitle, date, tags, and summary lines are optional.
+the subtitle, date, tags, summary, and old URL lines are optional.
 In Markdown-enabled present, the summary defaults to being empty.
 In legacy present, the summary defaults to the first paragraph of text.
 
diff --git a/present/parse.go b/present/parse.go
index 7f38e6e..672a6ff 100644
--- a/present/parse.go
+++ b/present/parse.go
@@ -79,6 +79,7 @@
 	TitleNotes []string
 	Sections   []Section
 	Tags       []string
+	OldURL     []string
 }
 
 // Author represents the person who wrote and/or is presenting the document.
@@ -546,6 +547,8 @@
 			doc.Tags = append(doc.Tags, tags...)
 		} else if strings.HasPrefix(text, "Summary:") {
 			doc.Summary = strings.TrimSpace(text[len("Summary:"):])
+		} else if strings.HasPrefix(text, "OldURL:") {
+			doc.OldURL = append(doc.OldURL, strings.TrimSpace(text[len("OldURL:"):]))
 		} else if t, ok := parseTime(text); ok {
 			doc.Time = t
 		} else if doc.Subtitle == "" {