[x/go.dev] cmd/internal/site: add new Hugo-like implementation

This package implements just enough of Hugo's behavior to
render all of go.dev exactly as Hugo does. This will let us remove
Hugo from the build process and then adapt the website to
merge it into the x/website repo, all while being careful not to
break any existing pages.

Change-Id: I42cf9fa99c1c3a66a57cc0c1066f10b40ff36c96
X-GoDev-Commit: a30533dcc2b016d57ba0a0b56b1d66f80877f5a4
diff --git a/go.dev/cmd/internal/site/md.go b/go.dev/cmd/internal/site/md.go
new file mode 100644
index 0000000..bd0db59
--- /dev/null
+++ b/go.dev/cmd/internal/site/md.go
@@ -0,0 +1,79 @@
+// Copyright 2021 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 site
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/russross/blackfriday"
+	"golang.org/x/go.dev/cmd/internal/html/template"
+)
+
+// markdownToHTML converts markdown to HTML using the renderer and settings that Hugo uses.
+func markdownToHTML(markdown string) template.HTML {
+	markdown = strings.TrimLeft(markdown, "\n")
+	renderer := blackfriday.HtmlRenderer(blackfriday.HTML_USE_XHTML|
+		blackfriday.HTML_USE_SMARTYPANTS|
+		blackfriday.HTML_SMARTYPANTS_FRACTIONS|
+		blackfriday.HTML_SMARTYPANTS_DASHES|
+		blackfriday.HTML_SMARTYPANTS_LATEX_DASHES|
+		blackfriday.HTML_NOREFERRER_LINKS|
+		blackfriday.HTML_HREF_TARGET_BLANK,
+		"", "")
+	options := blackfriday.Options{
+		Extensions: blackfriday.EXTENSION_NO_INTRA_EMPHASIS |
+			blackfriday.EXTENSION_TABLES |
+			blackfriday.EXTENSION_FENCED_CODE |
+			blackfriday.EXTENSION_AUTOLINK |
+			blackfriday.EXTENSION_STRIKETHROUGH |
+			blackfriday.EXTENSION_SPACE_HEADERS |
+			blackfriday.EXTENSION_HEADER_IDS |
+			blackfriday.EXTENSION_BACKSLASH_LINE_BREAK |
+			blackfriday.EXTENSION_DEFINITION_LISTS |
+			blackfriday.EXTENSION_AUTO_HEADER_IDS,
+	}
+	return template.HTML(blackfriday.MarkdownOptions([]byte(markdown), renderer, options))
+}
+
+// markdownWithShortCodesToHTML converts markdown to HTML,
+// first expanding Hugo shortcodes in the markdown input.
+// Shortcodes templates are given access to p as .Page.
+func markdownWithShortCodesToHTML(markdown string, p *Page) (template.HTML, error) {
+	// We replace each shortcode invocation in the markdown with
+	// a keyword HUGOREPLACECODE0001 etc and then run the result
+	// through markdown conversion, and then we substitute the actual
+	// shortcode ouptuts for the keywords.
+	var md string         // current markdown chunk
+	var replaces []string // replacements to apply to all at end
+
+	for i, elem := range p.parseCodes(markdown) {
+		switch elem := elem.(type) {
+		default:
+			return "", fmt.Errorf("unexpected elem %T", elem)
+		case string:
+			md += elem
+
+		case *ShortCode:
+			code := elem
+			html, err := code.run()
+			if err != nil {
+				return "", err
+			}
+			// Adjust shortcode output to match Hugo's line breaks.
+			// This is weird but will go away when we retire shortcodes.
+			if code.Inner != "" {
+				html = "\n\n" + html
+			} else if code.Kind == "%" {
+				html = template.HTML(strings.TrimLeft(string(html), " \n"))
+			}
+			key := fmt.Sprintf("HUGOREPLACECODE%04d", i)
+			md += key
+			replaces = append(replaces, key, string(html), "<p>"+key+"</p>", string(html))
+		}
+	}
+	html := markdownToHTML(md)
+	return template.HTML(strings.NewReplacer(replaces...).Replace(string(html))), nil
+}
diff --git a/go.dev/cmd/internal/site/page.go b/go.dev/cmd/internal/site/page.go
new file mode 100644
index 0000000..fdac90f
--- /dev/null
+++ b/go.dev/cmd/internal/site/page.go
@@ -0,0 +1,211 @@
+// Copyright 2021 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 site
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"strings"
+	"time"
+
+	"golang.org/x/go.dev/cmd/internal/html/template"
+	"golang.org/x/go.dev/cmd/internal/tmplfunc"
+	"gopkg.in/yaml.v3"
+)
+
+// A Page is a single web page.
+// It corresponds to some .md file in the content tree.
+// Although page is not exported for use by other Go code,
+// its exported fields and methods are available to templates.
+type Page struct {
+	id      string // page ID (url path excluding site.BaseURL and trailing slash)
+	file    string // .md file for page
+	section string // page section ID
+	parent  string // parent page ID
+	data    []byte // page data (markdown)
+	html    []byte // rendered page (HTML)
+
+	// yaml metadata and data available to templates
+	Aliases      []string
+	Content      template.HTML
+	Date         anyTime
+	Description  string `yaml:"description"`
+	IsHome       bool
+	LinkTitle    string `yaml:"linkTitle"`
+	Pages        []*Page
+	Params       map[string]interface{}
+	Site         *Site
+	TheResources []*Resource `yaml:"resources"`
+	Title        string
+	Weight       int
+}
+
+// loadPage loads the site's page from the given file.
+// It returns the page but also adds the page to site.pages and site.pagesByID.
+func (site *Site) loadPage(file string) (*Page, error) {
+	id := strings.TrimPrefix(file, "content/")
+	if strings.HasSuffix(id, "/_index.md") {
+		id = strings.TrimSuffix(id, "/_index.md")
+	} else if strings.HasSuffix(id, "/index.md") {
+		id = strings.TrimSuffix(id, "/index.md")
+	} else {
+		id = strings.TrimSuffix(id, ".md")
+	}
+
+	p := site.newPage(id)
+	p.file = file
+	p.Params["Series"] = ""
+	p.Params["series"] = ""
+
+	// Determine section.
+	for dir := path.Dir(file); dir != "."; dir = path.Dir(dir) {
+		if _, err := os.Stat(site.file(dir + "/_index.md")); err == nil {
+			p.section = strings.TrimPrefix(dir, "content/")
+			break
+		}
+	}
+
+	// Determine parent.
+	p.parent = p.section
+	if p.parent == p.id {
+		p.parent = ""
+		for dir := path.Dir("content/" + p.id); dir != "."; dir = path.Dir(dir) {
+			if _, err := os.Stat(site.file(dir + "/_index.md")); err == nil {
+				p.parent = strings.TrimPrefix(dir, "content/")
+				break
+			}
+		}
+	}
+
+	// Load content, including leading yaml.
+	data, err := ioutil.ReadFile(site.file(file))
+	if err != nil {
+		return nil, err
+	}
+	if bytes.HasPrefix(data, []byte("---\n")) {
+		i := bytes.Index(data, []byte("\n---\n"))
+		if i < 0 {
+			if bytes.HasSuffix(data, []byte("\n---")) {
+				i = len(data) - 4
+			}
+		}
+		if i >= 0 {
+			meta := data[4 : i+1]
+			err := yaml.Unmarshal(meta, p.Params)
+			if err != nil {
+				return nil, fmt.Errorf("load %s: %v", file, err)
+			}
+			err = yaml.Unmarshal(meta, p)
+			if err != nil {
+				return nil, fmt.Errorf("load %s: %v", file, err)
+			}
+
+			// Drop YAML but insert the right number of newlines to keep line numbers correct in template errors.
+			nl := 0
+			for _, c := range data[:i+4] {
+				if c == '\n' {
+					nl++
+				}
+			}
+			i += 4
+			for ; nl > 0; nl-- {
+				i--
+				data[i] = '\n'
+			}
+			data = data[i:]
+		}
+	}
+	p.data = data
+
+	// Set a few defaults.
+	p.Params["Series"] = p.Params["series"]
+	if p.LinkTitle == "" {
+		p.LinkTitle = p.Title
+	}
+
+	// Register aliases.
+	for _, alias := range p.Aliases {
+		site.redirects[strings.Trim(alias, "/")] = p.Permalink()
+	}
+
+	return p, nil
+}
+
+// renderHTML renders the HTML for the page, leaving it in p.html.
+func (p *Page) renderHTML() error {
+	var err error
+	p.Content, err = markdownWithShortCodesToHTML(string(p.data), p)
+	if err != nil {
+		return err
+	}
+
+	// Load base template.
+	base, err := ioutil.ReadFile(p.Site.file("layouts/_default/baseof.html"))
+	if err != nil {
+		return err
+	}
+	t := p.Site.clone().New("layouts/_default/baseof.html")
+	if err := tmplfunc.Parse(t, string(base)); err != nil {
+		return err
+	}
+
+	// Load page-specific layout template.
+	// There are general rules in Hugo, but we don't need to reproduce them here
+	// since this will go away.
+	layout := "layouts/_default/single.html"
+	switch p.id {
+	case "":
+		layout = "layouts/index.html"
+	case "learn":
+		layout = "layouts/learn/section.html"
+	case "solutions":
+		layout = "layouts/solutions/section.html"
+	}
+	if strings.HasPrefix(p.id, "solutions/") {
+		layout = "layouts/solutions/single.html"
+	}
+	data, err := ioutil.ReadFile(p.Site.file(layout))
+	if err != nil {
+		return err
+	}
+	if err := tmplfunc.Parse(t.New(layout), string(data)); err != nil {
+		return err
+	}
+
+	var buf bytes.Buffer
+	if err := t.Execute(&buf, p); err != nil {
+		return err
+	}
+	html := buf.Bytes()
+	if p.IsHome {
+		// Match Hugo <meta> for now.
+		html = bytes.Replace(html, []byte("<head>"), []byte("<head>\n\t<meta name=\"generator\" content=\"Hugo 0.59.1\" />"), 1)
+	}
+	p.html = html
+	return nil
+}
+
+// An anyTime is a time.Time that accepts any of the anyTimeFormats when unmarshaling.
+type anyTime struct {
+	time.Time
+}
+
+var anyTimeFormats = []string{
+	"2006-01-02",
+	time.RFC3339,
+}
+
+func (t *anyTime) UnmarshalText(data []byte) error {
+	for _, f := range anyTimeFormats {
+		if tt, err := time.Parse(f, string(data)); err == nil {
+			t.Time = tt
+			return nil
+		}
+	}
+	return fmt.Errorf("invalid time: %s", data)
+}
diff --git a/go.dev/cmd/internal/site/shortcode.go b/go.dev/cmd/internal/site/shortcode.go
new file mode 100644
index 0000000..97c7a07
--- /dev/null
+++ b/go.dev/cmd/internal/site/shortcode.go
@@ -0,0 +1,163 @@
+// Copyright 2021 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 site
+
+import (
+	"fmt"
+	"log"
+	"strconv"
+	"strings"
+	"unicode/utf8"
+
+	"golang.org/x/go.dev/cmd/internal/html/template"
+)
+
+// A ShortCode is a parsed Hugo shortcode like {{% foo %}} or {{< foo >}}.
+// It is Hugo's wrapping of a template call and will be replaced by actual template calls.
+type ShortCode struct {
+	Kind  string
+	Name  string
+	Args  []string
+	Keys  map[string]string
+	Inner template.HTML
+	Page  *Page
+}
+
+func (c *ShortCode) run() (template.HTML, error) {
+	return c.Page.Site.runTemplate("layouts/shortcodes/"+c.Name+".html", c)
+}
+
+func (c *ShortCode) String() string {
+	if c == nil {
+		return ""
+	}
+	return fmt.Sprintf("<code %s %v %v %q>", c.Name, c.Args, c.Keys, c.Inner)
+}
+
+func (c *ShortCode) Get(x interface{}) string {
+	switch x := x.(type) {
+	case int:
+		if 0 <= x && x < len(c.Args) {
+			return c.Args[x]
+		}
+		return ""
+	case string:
+		return c.Keys[x]
+	}
+	panic(fmt.Sprintf("bad Get %v", x))
+}
+
+func (c *ShortCode) IsNamedParams() bool {
+	return len(c.Keys) != 0
+}
+
+// parseCodes parses the shortcode invocations in the Hugo markdown file.
+// It returns a slice containing only two types of elements: string and *ShortCode.
+// The even indexes are strings and the odd indexes are *ShortCode.
+// There are an odd number of elements (the slice begins and ends with a string).
+func (p *Page) parseCodes(markdown string) []interface{} {
+	t1, c1, t2, kind1 := findCode(markdown)
+	t2, c2, t3, kind2 := findCode(t2)
+	var ret []interface{}
+	for c1 != nil {
+		c1.Kind = kind1
+		c1.Page = p
+		if c2 != nil && c2.Name == "/"+c1.Name {
+			c1.Inner = template.HTML(t2)
+			t2, c2, t3, kind2 = findCode(t3)
+			continue
+		}
+		ret = append(ret, t1)
+		ret = append(ret, c1)
+		t1, c1, kind1 = t2, c2, kind2
+		t2, c2, t3, kind2 = findCode(t3)
+	}
+	ret = append(ret, t1)
+	return ret
+}
+
+func findCode(text string) (before string, code *ShortCode, after string, kind string) {
+	end := "%}}"
+	kind = "%"
+	i := strings.Index(text, "{{%")
+	j := strings.Index(text, "{{<")
+	if i < 0 || j >= 0 && j < i {
+		i = j
+		kind = "<"
+		end = ">}}"
+	}
+	if i < 0 {
+		return text, nil, "", ""
+	}
+	j = strings.Index(text[i+3:], end)
+	if j < 0 {
+		return text, nil, "", ""
+	}
+	before, codeText, after := text[:i], text[i+3:i+3+j], text[i+3+j+3:]
+	codeText = strings.TrimSpace(codeText)
+	name, args, _ := cutAny(codeText, " \t\r\n")
+	if name == "" {
+		log.Fatalf("empty code")
+	}
+	args = strings.TrimSpace(args)
+	code = &ShortCode{Name: name, Keys: make(map[string]string)}
+	for args != "" {
+		k, v := "", args
+		if strings.HasPrefix(args, `"`) {
+			goto Value
+		}
+		{
+			i := strings.Index(args, "=")
+			if i < 0 {
+				goto Value
+			}
+			for j := 0; j < i; j++ {
+				if args[j] == ' ' || args[j] == '\t' {
+					goto Value
+				}
+			}
+			k, v = args[:i], args[i+1:]
+		}
+	Value:
+		v = strings.TrimSpace(v)
+		if strings.HasPrefix(v, `"`) {
+			j := 1
+			for ; ; j++ {
+				if j >= len(v) {
+					log.Fatalf("unterminated quoted string: %s", args)
+				}
+				if v[j] == '"' {
+					v, args = v[:j+1], v[j+1:]
+					break
+				}
+				if v[j] == '\\' {
+					j++
+				}
+			}
+			u, err := strconv.Unquote(v)
+			if err != nil {
+				log.Fatalf("malformed k=v: %s=%s", k, v)
+			}
+			v = u
+		} else {
+			v, args, _ = cutAny(v, " \t\r\n")
+		}
+		if k == "" {
+			code.Args = append(code.Args, v)
+		} else {
+			code.Keys[k] = v
+		}
+		args = strings.TrimSpace(args)
+	}
+	return
+}
+
+func cutAny(s, any string) (before, after string, ok bool) {
+	if i := strings.IndexAny(s, any); i >= 0 {
+		_, size := utf8.DecodeRuneInString(s[i:])
+		return s[:i], s[i+size:], true
+	}
+	return s, "", false
+}
diff --git a/go.dev/cmd/internal/site/site.go b/go.dev/cmd/internal/site/site.go
new file mode 100644
index 0000000..d87e236
--- /dev/null
+++ b/go.dev/cmd/internal/site/site.go
@@ -0,0 +1,320 @@
+// Copyright 2021 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 site implements generation of content for serving from go.dev.
+// It is meant to support a transition from being a Hugo-based web site
+// to being a site compatible with x/website.
+package site
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"path"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/BurntSushi/toml"
+	"golang.org/x/go.dev/cmd/internal/html/template"
+	"gopkg.in/yaml.v3"
+)
+
+// A Site holds metadata about the entire site.
+type Site struct {
+	BaseURL      string
+	LanguageCode string
+	Title        string
+	Menus        map[string][]*MenuItem `toml:"menu"`
+	IsServer     bool
+	Data         map[string]interface{}
+	pages        []*Page
+	pagesByID    map[string]*Page
+	dir          string
+	redirects    map[string]string
+	base         *template.Template
+}
+
+// A MenuItem is a single entry in a menu.
+type MenuItem struct {
+	Identifier string
+	Name       string
+	Title      string
+	URL        string
+	Parent     string
+	Weight     int
+	Children   []*MenuItem
+}
+
+// Load loads and returns the site in the directory rooted at dir.
+func Load(dir string) (*Site, error) {
+	dir, err := filepath.Abs(dir)
+	if err != nil {
+		return nil, err
+	}
+	site := &Site{
+		dir:       dir,
+		redirects: make(map[string]string),
+		pagesByID: make(map[string]*Page),
+	}
+	if err := site.initTemplate(); err != nil {
+		return nil, err
+	}
+
+	// Read site config from config.toml.
+	if _, err := toml.DecodeFile(site.file("config.toml"), &site); err != nil {
+		return nil, fmt.Errorf("parsing site config.toml: %v", err)
+	}
+
+	// Group and sort menus.
+	for name, list := range site.Menus {
+		// Collect top-level items and assign children.
+		topsByID := make(map[string]*MenuItem)
+		var tops []*MenuItem
+		for _, item := range list {
+			if p := topsByID[item.Parent]; p != nil {
+				p.Children = append(p.Children, item)
+				continue
+			}
+			tops = append(tops, item)
+			if item.Identifier != "" {
+				topsByID[item.Identifier] = item
+			}
+		}
+		// Sort each top-level item's child list.
+		for _, item := range tops {
+			c := item.Children
+			sort.Slice(c, func(i, j int) bool { return c[i].Weight < c[j].Weight })
+		}
+		site.Menus[name] = tops
+	}
+
+	// Load site data files.
+	// site.Data is a directory tree in which each key points at
+	// either another directory tree (a subdirectory)
+	// or a parsed yaml file.
+	site.Data = make(map[string]interface{})
+	root := site.file("data")
+	err = filepath.Walk(root, func(name string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if name == root {
+			name = "."
+		} else {
+			name = name[len(root)+1:]
+		}
+		if info.IsDir() {
+			site.Data[name] = make(map[string]interface{})
+			return nil
+		}
+		if strings.HasSuffix(name, ".yaml") {
+			data, err := ioutil.ReadFile(filepath.Join(root, name))
+			if err != nil {
+				return err
+			}
+			var d interface{}
+			if err := yaml.Unmarshal(data, &d); err != nil {
+				return fmt.Errorf("unmarshaling %v: %v", name, err)
+			}
+
+			elems := strings.Split(name, "/")
+			m := site.Data
+			for _, elem := range elems[:len(elems)-1] {
+				m = m[elem].(map[string]interface{})
+			}
+			m[strings.TrimSuffix(elems[len(elems)-1], ".yaml")] = d
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("loading data: %v", err)
+	}
+
+	// Implicit home page.
+	home := site.newPage("")
+	home.Params["Series"] = ""
+	home.IsHome = true
+	home.Title = site.Title
+
+	// Load site pages from md files.
+	err = filepath.Walk(site.file("content"), func(name string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if strings.HasSuffix(name, ".md") {
+			_, err := site.loadPage(name[len(site.file("."))+1:])
+			return err
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("loading pages: %v", err)
+	}
+
+	// Assign pages to sections and sort section lists.
+	for _, p := range site.pages {
+		p.Pages = append(p.Pages, p)
+	}
+	for _, p := range site.pages {
+		if parent := site.pagesByID[p.parent]; parent != nil {
+			parent.Pages = append(parent.Pages, p)
+		}
+	}
+	for _, p := range site.pages {
+		pages := p.Pages[1:]
+		sort.Slice(pages, func(i, j int) bool {
+			pi := pages[i]
+			pj := pages[j]
+			if !pi.Date.Equal(pj.Date.Time) {
+				return pi.Date.After(pj.Date.Time)
+			}
+			if pi.Weight != pj.Weight {
+				return pi.Weight > pj.Weight
+			}
+			ti := pi.LinkTitle
+			tj := pj.LinkTitle
+			if ti != tj {
+				return ti < tj
+			}
+			return false
+		})
+	}
+
+	// Now that all pages are loaded and set up, can render all.
+	// (Pages can refer to other pages.)
+	for _, p := range site.pages {
+		if err := p.renderHTML(); err != nil {
+			return nil, err
+		}
+	}
+
+	return site, nil
+}
+
+// file returns the full path to the named file within the site.
+func (site *Site) file(name string) string { return filepath.Join(site.dir, name) }
+
+// newPage returns a new page belonging to site.
+func (site *Site) newPage(short string) *Page {
+	p := &Page{
+		Site:   site,
+		id:     short,
+		Params: make(map[string]interface{}),
+	}
+	site.pages = append(site.pages, p)
+	site.pagesByID[p.id] = p
+	return p
+}
+
+// Open returns the content to serve at the given path.
+// This function makes Site an http.FileServer, for easy HTTP serving.
+func (site *Site) Open(name string) (http.File, error) {
+	name = strings.TrimPrefix(name, "/")
+	switch ext := path.Ext(name); ext {
+	case ".css", ".jpeg", ".jpg", ".js", ".png", ".svg", ".txt":
+		if f, err := os.Open(site.file("content/" + name)); err == nil {
+			return f, nil
+		}
+		if f, err := os.Open(site.file("static/" + name)); err == nil {
+			return f, nil
+		}
+
+		// Maybe it is name.hash.ext. Check hash.
+		// We will stop generating these eventually,
+		// so it doesn't matter that this is slow.
+		prefix := name[:len(name)-len(ext)]
+		hash := path.Ext(prefix)
+		prefix = prefix[:len(prefix)-len(hash)]
+		if len(hash) == 1+64 {
+			file := site.file("assets/" + prefix + ext)
+			if data, err := ioutil.ReadFile(file); err == nil && fmt.Sprintf(".%x", sha256.Sum256(data)) == hash {
+				if f, err := os.Open(file); err == nil {
+					return f, nil
+				}
+			}
+		}
+
+	case ".html":
+		id := strings.TrimSuffix(name, "/index.html")
+		if name == "index.html" {
+			id = ""
+		}
+		if target := site.redirects[id]; target != "" {
+			s := fmt.Sprintf(redirectFmt, target)
+			return &httpFile{strings.NewReader(s), int64(len(s))}, nil
+		}
+		if p := site.pagesByID[id]; p != nil {
+			return &httpFile{bytes.NewReader(p.html), int64(len(p.html))}, nil
+		}
+	}
+
+	if !strings.HasSuffix(name, ".html") {
+		if f, err := site.Open(name + "/index.html"); err == nil {
+			size, err := f.Seek(0, io.SeekEnd)
+			f.Close()
+			if err == nil {
+				return &httpDir{httpFileInfo{"index.html", size, false}, 0}, nil
+			}
+		}
+	}
+
+	return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist}
+}
+
+type httpFile struct {
+	io.ReadSeeker
+	size int64
+}
+
+func (*httpFile) Close() error                 { return nil }
+func (f *httpFile) Stat() (os.FileInfo, error) { return &httpFileInfo{".", f.size, false}, nil }
+func (*httpFile) Readdir(count int) ([]os.FileInfo, error) {
+	return nil, fmt.Errorf("readdir not available")
+}
+
+const redirectFmt = `<!DOCTYPE html><html><head><title>%s</title><link rel="canonical" href="%[1]s"/><meta name="robots" content="noindex"><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=%[1]s" /></head></html>`
+
+type httpDir struct {
+	info httpFileInfo
+	off  int // 0 or 1
+}
+
+func (*httpDir) Close() error                   { return nil }
+func (*httpDir) Read([]byte) (int, error)       { return 0, fmt.Errorf("read not available") }
+func (*httpDir) Seek(int64, int) (int64, error) { return 0, fmt.Errorf("seek not available") }
+func (*httpDir) Stat() (os.FileInfo, error)     { return &httpFileInfo{".", 0, true}, nil }
+func (d *httpDir) Readdir(count int) ([]os.FileInfo, error) {
+	if count == 0 {
+		return nil, nil
+	}
+	if d.off > 0 {
+		return nil, io.EOF
+	}
+	d.off = 1
+	return []os.FileInfo{&d.info}, nil
+}
+
+type httpFileInfo struct {
+	name string
+	size int64
+	dir  bool
+}
+
+func (info *httpFileInfo) Name() string { return info.name }
+func (info *httpFileInfo) Size() int64  { return info.size }
+func (info *httpFileInfo) Mode() os.FileMode {
+	if info.dir {
+		return os.ModeDir | 0555
+	}
+	return 0444
+}
+func (info *httpFileInfo) ModTime() time.Time { return time.Time{} }
+func (info *httpFileInfo) IsDir() bool        { return info.dir }
+func (info *httpFileInfo) Sys() interface{}   { return nil }
diff --git a/go.dev/cmd/internal/site/site_test.go b/go.dev/cmd/internal/site/site_test.go
new file mode 100644
index 0000000..566793d
--- /dev/null
+++ b/go.dev/cmd/internal/site/site_test.go
@@ -0,0 +1,116 @@
+// Copyright 2021 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 site
+
+import (
+	"bytes"
+	"io/ioutil"
+	"os"
+	"path"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"rsc.io/rf/diff"
+)
+
+func TestGolden(t *testing.T) {
+	start := time.Now()
+	site, err := Load("../../..")
+	if err != nil {
+		t.Fatal(err)
+	}
+	total := time.Since(start)
+	t.Logf("Load %v\n", total)
+
+	root := "../../../testdata/golden"
+	err = filepath.Walk(root, func(name string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			return nil
+		}
+		name = filepath.ToSlash(name[len(root)+1:])
+		switch name {
+		case "index.xml",
+			"categories/index.html",
+			"categories/index.xml",
+			"learn/index.xml",
+			"series/index.html",
+			"series/index.xml",
+			"series/case-studies/index.html",
+			"series/case-studies/index.xml",
+			"series/use-cases/index.html",
+			"series/use-cases/index.xml",
+			"sitemap.xml",
+			"solutions/google/index.xml",
+			"solutions/index.xml",
+			"tags/index.html",
+			"tags/index.xml":
+			t.Logf("%s <- SKIP\n", name)
+			return nil
+		}
+
+		want, err := ioutil.ReadFile(site.file("testdata/golden/" + name))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		start := time.Now()
+		f, err := site.Open(name)
+		if err != nil {
+			t.Fatal(err)
+		}
+		have, err := ioutil.ReadAll(f)
+		if err != nil {
+			t.Fatalf("%v: %v", name, err)
+		}
+		total += time.Since(start)
+
+		if path.Ext(name) == ".html" {
+			have = canonicalize(have)
+			want = canonicalize(want)
+			if !bytes.Equal(have, want) {
+				d, err := diff.Diff("hugo", want, "newgo", have)
+				if err != nil {
+					panic(err)
+				}
+				t.Fatalf("%s: diff:\n%s", name, d)
+			}
+			t.Logf("%s <- OK!\n", name)
+			return nil
+		}
+
+		if !bytes.Equal(have, want) {
+			t.Fatalf("%s: wrong bytes", name)
+		}
+		return nil
+	})
+	t.Logf("total %v", total)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+// canonicalize trims trailing spaces and tabs at the ends of lines,
+// removes blank lines, and removes leading spaces before HTML tags.
+// This gives us a little more leeway in cases where it is difficult
+// to match Hugo's whitespace heuristics exactly or where we are
+// refactoring templates a little which changes spacing in inconsequential ways.
+func canonicalize(data []byte) []byte {
+	lines := bytes.Split(data, []byte("\n"))
+	for i, line := range lines {
+		lines[i] = bytes.Trim(line, " \t")
+	}
+	var out [][]byte
+	for _, line := range lines {
+		if len(line) > 0 {
+			out = append(out, line)
+		}
+	}
+	return bytes.Join(out, []byte("\n"))
+}
diff --git a/go.dev/cmd/internal/site/tmpl.go b/go.dev/cmd/internal/site/tmpl.go
new file mode 100644
index 0000000..1aab413
--- /dev/null
+++ b/go.dev/cmd/internal/site/tmpl.go
@@ -0,0 +1,357 @@
+// Copyright 2021 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 site
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"path/filepath"
+	"reflect"
+	"regexp"
+	"sort"
+	"strings"
+
+	"golang.org/x/go.dev/cmd/internal/html/template"
+	"golang.org/x/go.dev/cmd/internal/tmplfunc"
+	"gopkg.in/yaml.v3"
+)
+
+func (site *Site) initTemplate() error {
+	funcs := template.FuncMap{
+		"absURL":      absURL,
+		"default":     defaultFn,
+		"dict":        dict,
+		"fingerprint": fingerprint,
+		"first":       first,
+		"isset":       isset,
+		"list":        list,
+		"markdownify": markdownify,
+		"partial":     site.partial,
+		"path":        pathFn,
+		"replace":     replace,
+		"replaceRE":   replaceRE,
+		"resources":   site.resources,
+		"safeHTML":    safeHTML,
+		"sort":        sortFn,
+		"where":       where,
+		"yaml":        yamlFn,
+	}
+
+	site.base = template.New("site").Funcs(funcs)
+	if err := tmplfunc.ParseGlob(site.base, site.file("templates/*.tmpl")); err != nil && !strings.Contains(err.Error(), "pattern matches no files") {
+		return err
+	}
+	return nil
+}
+
+func (site *Site) clone() *template.Template {
+	t := template.Must(site.base.Clone())
+	if err := tmplfunc.Funcs(t); err != nil {
+		panic(err)
+	}
+	return t
+}
+
+func (site *Site) runTemplate(name string, arg interface{}) (template.HTML, error) {
+	data, err := ioutil.ReadFile(site.file(name))
+	if err != nil {
+		return "", err
+	}
+	t := site.clone().New(name)
+	if err := tmplfunc.Parse(t, string(data)); err != nil {
+		return "", err
+	}
+	var buf bytes.Buffer
+	if err := t.Execute(&buf, arg); err != nil {
+		return "", err
+	}
+	return template.HTML(buf.String()), nil
+}
+
+func toString(x interface{}) string {
+	switch x := x.(type) {
+	case string:
+		return x
+	case template.HTML:
+		return string(x)
+	case nil:
+		return ""
+	default:
+		panic(fmt.Sprintf("cannot toString %T", x))
+	}
+}
+
+func absURL(u string) string { return u }
+
+func defaultFn(x, y string) string {
+	if y != "" {
+		return y
+	}
+	return x
+}
+
+type Fingerprint struct {
+	r    *Resource
+	Data struct {
+		Integrity string
+	}
+	RelPermalink string
+}
+
+func fingerprint(r *Resource) *Fingerprint {
+	f := &Fingerprint{r: r}
+	sum := sha256.Sum256(r.data)
+	ext := path.Ext(r.RelPermalink)
+	f.RelPermalink = "/" + strings.TrimSuffix(r.RelPermalink, ext) + "." + hex.EncodeToString(sum[:]) + ext
+	f.Data.Integrity = "sha256-" + base64.StdEncoding.EncodeToString(sum[:])
+	return f
+}
+
+func first(n int, list reflect.Value) reflect.Value {
+	if !list.IsValid() {
+		return list
+	}
+	if list.Kind() == reflect.Interface {
+		if list.IsNil() {
+			return list
+		}
+		list = list.Elem()
+	}
+	out := reflect.Zero(list.Type())
+
+	for i := 0; i < list.Len() && i < n; i++ {
+		out = reflect.Append(out, list.Index(i))
+	}
+	return out
+}
+
+func isset(m map[string]interface{}, name string) bool {
+	_, ok := m[name]
+	return ok
+}
+
+func dict(args ...interface{}) map[string]interface{} {
+	m := make(map[string]interface{})
+	for i := 0; i < len(args); i += 2 {
+		m[args[i].(string)] = args[i+1]
+	}
+	m["Identifier"] = "IDENT"
+	return m
+}
+
+func list(args ...interface{}) []interface{} {
+	return args
+}
+
+// markdownify is the function provided to templates.
+func markdownify(data interface{}) template.HTML {
+	h := markdownToHTML(toString(data))
+	s := strings.TrimSpace(string(h))
+	if strings.HasPrefix(s, "<p>") && strings.HasSuffix(s, "</p>") && strings.Count(s, "<p>") == 1 {
+		h = template.HTML(strings.TrimSpace(s[len("<p>") : len(s)-len("</p>")]))
+	}
+	return h
+}
+
+func (site *Site) partial(name string, data interface{}) (template.HTML, error) {
+	return site.runTemplate("layouts/partials/"+name, data)
+}
+
+func pathFn() pathPkg { return pathPkg{} }
+
+type pathPkg struct{}
+
+func (pathPkg) Base(s interface{}) string { return path.Base(toString(s)) }
+func (pathPkg) Dir(s interface{}) string  { return path.Dir(toString(s)) }
+func (pathPkg) Join(args ...interface{}) string {
+	var elem []string
+	for _, a := range args {
+		elem = append(elem, toString(a))
+	}
+	return path.Join(elem...)
+}
+
+func replace(input, x, y interface{}) string {
+	return strings.ReplaceAll(toString(input), toString(x), toString(y))
+}
+
+func replaceRE(pattern, repl, input interface{}) string {
+	re := regexp.MustCompile(toString(pattern))
+	return re.ReplaceAllString(toString(input), toString(repl))
+}
+
+func safeHTML(s interface{}) template.HTML { return template.HTML(toString(s)) }
+
+func sortFn(list reflect.Value, key, asc string) (reflect.Value, error) {
+	out := reflect.Zero(list.Type())
+	var keys []string
+	var perm []int
+	for i := 0; i < list.Len(); i++ {
+		elem := list.Index(i)
+		v, ok := eval(elem, key)
+		if !ok {
+			return reflect.Value{}, fmt.Errorf("no key %s", key)
+		}
+		keys = append(keys, strings.ToLower(v))
+		perm = append(perm, i)
+	}
+	sort.Slice(perm, func(i, j int) bool {
+		return keys[perm[i]] < keys[perm[j]]
+	})
+	for _, i := range perm {
+		out = reflect.Append(out, list.Index(i))
+	}
+	return out, nil
+}
+
+func where(list reflect.Value, key, val string) reflect.Value {
+	out := reflect.Zero(list.Type())
+	for i := 0; i < list.Len(); i++ {
+		elem := list.Index(i)
+		v, ok := eval(elem, key)
+		if ok && v == val {
+			out = reflect.Append(out, elem)
+		}
+	}
+	return out
+}
+
+func eval(elem reflect.Value, key string) (string, bool) {
+	for _, k := range strings.Split(key, ".") {
+		if !elem.IsValid() {
+			return "", false
+		}
+		m := elem.MethodByName(k)
+		if m.IsValid() {
+			elem = m.Call(nil)[0]
+			continue
+		}
+		if elem.Kind() == reflect.Interface || elem.Kind() == reflect.Ptr {
+			if elem.IsNil() {
+				return "", false
+			}
+			elem = elem.Elem()
+		}
+		switch elem.Kind() {
+		case reflect.Struct:
+			elem = elem.FieldByName(k)
+			continue
+		case reflect.Map:
+			elem = elem.MapIndex(reflect.ValueOf(k))
+			continue
+		}
+		return "", false
+	}
+	if !elem.IsValid() {
+		return "", false
+	}
+	if elem.Kind() == reflect.Interface || elem.Kind() == reflect.Ptr {
+		if elem.IsNil() {
+			return "", false
+		}
+		elem = elem.Elem()
+	}
+	if elem.Kind() != reflect.String {
+		return "", false
+	}
+	return elem.String(), true
+}
+
+func (p *Page) CurrentSection() *Page {
+	return p.Site.pagesByID[p.section]
+}
+
+func (d *Page) HasMenuCurrent(x string, y *MenuItem) bool {
+	return false
+}
+
+func (d *Page) IsMenuCurrent(x string, y *MenuItem) bool {
+	return d.Permalink() == y.URL
+}
+
+func (p *Page) Param(key string) interface{} { return p.Params[key] }
+
+func (p *Page) Parent() *Page {
+	if p.IsHome {
+		return nil
+	}
+	return p.Site.pagesByID[p.parent]
+}
+
+func (p *Page) Permalink() string {
+	return strings.TrimRight(p.Site.BaseURL, "/") + p.RelPermalink()
+}
+
+func (p *Page) RelPermalink() string {
+	if p.id == "" {
+		return "/"
+	}
+	return "/" + p.id + "/"
+}
+
+func (p *Page) Resources() *PageResources {
+	return &PageResources{p}
+}
+
+func (p *Page) Section() string {
+	i := strings.Index(p.section, "/")
+	if i < 0 {
+		return p.section
+	}
+	return p.section[:i]
+}
+
+type PageResources struct{ p *Page }
+
+func (r *PageResources) GetMatch(name string) (*Resource, error) {
+	for _, rs := range r.p.TheResources {
+		if name == rs.Name {
+			if rs.data == nil {
+				rs.RelPermalink = strings.TrimPrefix(filepath.ToSlash(filepath.Join(r.p.file, "../"+rs.Src)), "content")
+				data, err := os.ReadFile(r.p.Site.file(r.p.file + "/../" + rs.Src))
+				if err != nil {
+					return nil, err
+				}
+				rs.data = data
+			}
+			return rs, nil
+		}
+	}
+	return nil, nil
+}
+
+type Resource struct {
+	data         []byte
+	RelPermalink string
+	Name         string
+	Src          string
+	Params       map[string]string
+}
+
+func (site *Site) resources() Resources { return Resources{site} }
+
+type Resources struct{ site *Site }
+
+func (r Resources) Get(name string) (*Resource, error) {
+	data, err := os.ReadFile(r.site.file("assets/" + name))
+	if err != nil {
+		return nil, err
+	}
+	return &Resource{data: data, RelPermalink: name}, nil
+}
+
+func yamlFn(s string) (interface{}, error) {
+	var d interface{}
+	if err := yaml.Unmarshal([]byte(s), &d); err != nil {
+		return nil, err
+	}
+	return d, nil
+}
diff --git a/go.dev/go.mod b/go.dev/go.mod
index eeec485..55634a6 100644
--- a/go.dev/go.mod
+++ b/go.dev/go.mod
@@ -3,8 +3,12 @@
 go 1.13
 
 require (
+	github.com/BurntSushi/toml v0.3.1
 	github.com/google/go-cmp v0.3.1
 	github.com/microcosm-cc/bluemonday v1.0.2
+	github.com/russross/blackfriday v1.6.0
 	google.golang.org/api v0.13.0
 	gopkg.in/yaml.v2 v2.2.2
+	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
+	rsc.io/rf v0.0.0-20210401221041-ba8df2a1fd6c
 )
diff --git a/go.dev/go.sum b/go.dev/go.sum
index f7b5507..0fe3626 100644
--- a/go.dev/go.sum
+++ b/go.dev/go.sum
@@ -2,6 +2,7 @@
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo=
 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
@@ -27,24 +28,32 @@
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
 github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
+github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
+github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
 golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
@@ -54,14 +63,18 @@
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b h1:ag/x1USPSsqHud38I9BAC88qdNLDHHtQ4mlgQIZPPNA=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -69,6 +82,11 @@
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20201111224557-41a3a589386c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.13.0 h1:Q3Ui3V3/CVinFWFiW39Iw0kMuVrRzYX0wN6OPFp0lTA=
 google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
@@ -88,6 +106,10 @@
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+rsc.io/rf v0.0.0-20210401221041-ba8df2a1fd6c h1:lZl/oVdEEh5FuRJIt6ms8oLEkycLohVgloElXrnx+uw=
+rsc.io/rf v0.0.0-20210401221041-ba8df2a1fd6c/go.mod h1:4rdFt/SlKutY8W9onF7XZvD3H0hD+Xpz/uodEcQLuM4=