godoc: convert Markdown files to HTML during serving

For golang.org today, Markdown is converted to HTML during
the static file embedding, but that precludes using Markdown with
"live serving".

Moving the code here lets godoc itself do the conversion and
therefore works with live serving. It is also more consistent with
re-executing templates during serving for Template:true files.

When a file is .md but also has Template: true, templates apply
first, so that templates can generate Markdown.
This is reversed from what x/website was doing (Markdown before templates)
but that decision was mostly forced by doing it during static
embedding and not necessarily the right one.
There's no reason to force switching to raw HTML just because
you want to use a template.
(A template can of course still generate HTML.)

Change-Id: I7db6d54b43e45803e965df7a1ab2f26293285cfd
Reviewed-on: https://go-review.googlesource.com/c/tools/+/251343
Trust: Russ Cox <rsc@golang.org>
Run-TryBot: Russ Cox <rsc@golang.org>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/godoc/markdown.go b/godoc/markdown.go
new file mode 100644
index 0000000..fd61aa5
--- /dev/null
+++ b/godoc/markdown.go
@@ -0,0 +1,31 @@
+// Copyright 2020 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 godoc
+
+import (
+	"bytes"
+
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/parser"
+	"github.com/yuin/goldmark/renderer/html"
+)
+
+// renderMarkdown converts a limited and opinionated flavor of Markdown (compliant with
+// CommonMark 0.29) to HTML for the purposes of Go websites.
+//
+// The Markdown source may contain raw HTML,
+// but Go templates have already been processed.
+func renderMarkdown(src []byte) ([]byte, error) {
+	// parser.WithHeadingAttribute allows custom ids on headings.
+	// html.WithUnsafe allows use of raw HTML, which we need for tables.
+	md := goldmark.New(
+		goldmark.WithParserOptions(parser.WithHeadingAttribute()),
+		goldmark.WithRendererOptions(html.WithUnsafe()))
+	var buf bytes.Buffer
+	if err := md.Convert(src, &buf); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
diff --git a/godoc/meta.go b/godoc/meta.go
index 260833d..8d3b825 100644
--- a/godoc/meta.go
+++ b/godoc/meta.go
@@ -26,12 +26,15 @@
 // ----------------------------------------------------------------------------
 // Documentation Metadata
 
-// TODO(adg): why are some exported and some aren't? -brad
 type Metadata struct {
+	// These fields can be set in the JSON header at the top of a doc.
 	Title    string
 	Subtitle string
-	Template bool   // execute as template
-	Path     string // canonical path for this page
+	Template bool     // execute as template
+	Path     string   // canonical path for this page
+	AltPaths []string // redirect these other paths to this page
+
+	// These are internal to the implementation.
 	filePath string // filesystem path relative to goroot
 }
 
@@ -58,7 +61,7 @@
 	return
 }
 
-// UpdateMetadata scans $GOROOT/doc for HTML files, reads their metadata,
+// UpdateMetadata scans $GOROOT/doc for HTML and Markdown files, reads their metadata,
 // and updates the DocMetadata map.
 func (c *Corpus) updateMetadata() {
 	metadata := make(map[string]*Metadata)
@@ -79,7 +82,7 @@
 				scan(name) // recurse
 				continue
 			}
-			if !strings.HasSuffix(name, ".html") {
+			if !strings.HasSuffix(name, ".html") && !strings.HasSuffix(name, ".md") {
 				continue
 			}
 			// Extract metadata from the file.
@@ -93,15 +96,23 @@
 				log.Printf("updateMetadata: %s: %v", name, err)
 				continue
 			}
+			// Present all .md as if they were .html,
+			// so that it doesn't matter which one a page is written in.
+			if strings.HasSuffix(name, ".md") {
+				name = strings.TrimSuffix(name, ".md") + ".html"
+			}
 			// Store relative filesystem path in Metadata.
 			meta.filePath = name
 			if meta.Path == "" {
-				// If no Path, canonical path is actual path.
-				meta.Path = meta.filePath
+				// If no Path, canonical path is actual path with .html removed.
+				meta.Path = strings.TrimSuffix(name, ".html")
 			}
 			// Store under both paths.
 			metadata[meta.Path] = &meta
 			metadata[meta.filePath] = &meta
+			for _, path := range meta.AltPaths {
+				metadata[path] = &meta
+			}
 		}
 	}
 	scan("/doc")
diff --git a/godoc/server.go b/godoc/server.go
index 8724291..8c9b1b9 100644
--- a/godoc/server.go
+++ b/godoc/server.go
@@ -695,7 +695,15 @@
 
 func (p *Presentation) ServeHTMLDoc(w http.ResponseWriter, r *http.Request, abspath, relpath string) {
 	// get HTML body contents
+	isMarkdown := false
 	src, err := vfs.ReadFile(p.Corpus.fs, abspath)
+	if err != nil && strings.HasSuffix(abspath, ".html") {
+		if md, errMD := vfs.ReadFile(p.Corpus.fs, strings.TrimSuffix(abspath, ".html")+".md"); errMD == nil {
+			src = md
+			isMarkdown = true
+			err = nil
+		}
+	}
 	if err != nil {
 		log.Printf("ReadFile: %s", err)
 		p.ServeError(w, r, relpath, err)
@@ -738,6 +746,18 @@
 		src = buf.Bytes()
 	}
 
+	// Apply markdown as indicated.
+	// (Note template applies before Markdown.)
+	if isMarkdown {
+		html, err := renderMarkdown(src)
+		if err != nil {
+			log.Printf("executing markdown %s: %v", relpath, err)
+			p.ServeError(w, r, relpath, err)
+			return
+		}
+		src = html
+	}
+
 	// if it's the language spec, add tags to EBNF productions
 	if strings.HasSuffix(abspath, "go_spec.html") {
 		var buf bytes.Buffer
@@ -797,7 +817,8 @@
 		if redirect(w, r) {
 			return
 		}
-		if index := pathpkg.Join(abspath, "index.html"); util.IsTextFile(p.Corpus.fs, index) {
+		index := pathpkg.Join(abspath, "index.html")
+		if util.IsTextFile(p.Corpus.fs, index) || util.IsTextFile(p.Corpus.fs, pathpkg.Join(abspath, "index.md")) {
 			p.ServeHTMLDoc(w, r, index, index)
 			return
 		}
diff --git a/godoc/server_test.go b/godoc/server_test.go
index f862135..0d48e9f 100644
--- a/godoc/server_test.go
+++ b/godoc/server_test.go
@@ -73,6 +73,17 @@
 	}
 }
 
+func testServeBody(t *testing.T, p *Presentation, path, body string) {
+	t.Helper()
+	r := &http.Request{URL: &url.URL{Path: path}}
+	rw := httptest.NewRecorder()
+	p.ServeFile(rw, r)
+	if rw.Code != 200 || !strings.Contains(rw.Body.String(), body) {
+		t.Fatalf("GET %s: expected 200 w/ %q: got %d w/ body:\n%s",
+			path, body, rw.Code, rw.Body)
+	}
+}
+
 func TestRedirectAndMetadata(t *testing.T) {
 	c := NewCorpus(mapfs.New(map[string]string{
 		"doc/y/index.html": "Hello, y.",
@@ -87,13 +98,13 @@
 		Corpus:    c,
 		GodocHTML: template.Must(template.New("").Parse(`{{printf "%s" .Body}}`)),
 	}
-	r := &http.Request{URL: &url.URL{}}
 
 	// Test that redirect is sent back correctly.
 	// Used to panic. See golang.org/issue/40665.
 	for _, elem := range []string{"x", "y"} {
 		dir := "/doc/" + elem + "/"
-		r.URL.Path = dir + "index.html"
+
+		r := &http.Request{URL: &url.URL{Path: dir + "index.html"}}
 		rw := httptest.NewRecorder()
 		p.ServeFile(rw, r)
 		loc := rw.Result().Header.Get("Location")
@@ -101,12 +112,19 @@
 			t.Errorf("GET %s: expected 301 -> %q, got %d -> %q", r.URL.Path, dir, rw.Code, loc)
 		}
 
-		r.URL.Path = dir
-		rw = httptest.NewRecorder()
-		p.ServeFile(rw, r)
-		if rw.Code != 200 || !strings.Contains(rw.Body.String(), "Hello, "+elem) {
-			t.Fatalf("GET %s: expected 200 w/ Hello, %s: got %d w/ body:\n%s",
-				r.URL.Path, elem, rw.Code, rw.Body)
-		}
+		testServeBody(t, p, dir, "Hello, "+elem)
 	}
 }
+
+func TestMarkdown(t *testing.T) {
+	p := &Presentation{
+		Corpus: NewCorpus(mapfs.New(map[string]string{
+			"doc/test.md":  "**bold**",
+			"doc/test2.md": `{{"*template*"}}`,
+		})),
+		GodocHTML: template.Must(template.New("").Parse(`{{printf "%s" .Body}}`)),
+	}
+
+	testServeBody(t, p, "/doc/test.html", "<strong>bold</strong>")
+	testServeBody(t, p, "/doc/test2.html", "<em>template</em>")
+}