cmd/golangorg: add doc/next page to preview draft release notes

The improved release note process is being used starting with Go 1.23.
That means instead of a single doc/go1.23.html draft file in the main
Go repository, doc/next contains a set of release note fragments.
Having small, orthogonal files avoids merge conflicts, and a release
note test requires that release notes are written and included in the
same CL that's adding new APIs. As a result, the set of completed
release notes even before we enter the release freeze is greater than
ever before.

While it's possible to view those fragments using tip.golang.org
(e.g., by visiting https://tip.golang.org/doc/next), reading them
that way isn't practical. The relnote generate tool exists to merge
fragments into a complete Markdown document, and this tool will be
used when eventually moving a complete draft of Go 1.23 release notes
to x/website.

To aid the remaining work of completing the release note draft, this
change adds a dynamic /doc/next page to preview what the relnote
generate tool will produce. Combined with existing functionality of
the -tip flag, it makes https://tip.golang.org/doc/next display a live
preview of the checked-in release notes draft.

It can also be used to preview release note draft locally. For example,
if $HOME/gotip is a Go checkout where one is editing doc/next content:

go run golang.org/x/website/cmd/golangorg@latest -goroot=$HOME/gotip

Will serve a live preview at http://localhost:6060/go.dev/doc/next.
It can be slightly more convenient to refresh a browser without having
to re-run 'relnote generate'.

For golang/go#64169.
For golang/go#65614.

Change-Id: Ie1d3650076421a95a691dd84a554a113dd1187b1
Reviewed-on: https://go-review.googlesource.com/c/website/+/587436
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/_content/doc/next.md b/_content/doc/next.md
new file mode 100644
index 0000000..2eb58d9
--- /dev/null
+++ b/_content/doc/next.md
@@ -0,0 +1,6 @@
+---
+title: Next Release Notes Draft
+template: true
+---
+
+{{with docNext}}{{.}}{{else}}No next release note fragments available.{{end}}
diff --git a/cmd/golangorg/server.go b/cmd/golangorg/server.go
index 4f87330..6c42ade 100644
--- a/cmd/golangorg/server.go
+++ b/cmd/golangorg/server.go
@@ -33,6 +33,7 @@
 	"time"
 
 	"cloud.google.com/go/datastore"
+	"golang.org/x/build/relnote"
 	"golang.org/x/build/repos"
 	"golang.org/x/website"
 	"golang.org/x/website/internal/blog"
@@ -49,6 +50,7 @@
 	"golang.org/x/website/internal/tour"
 	"golang.org/x/website/internal/web"
 	"golang.org/x/website/internal/webtest"
+	"rsc.io/markdown"
 )
 
 var (
@@ -277,6 +279,7 @@
 		"rfc3339":         parseRFC3339,
 		"section":         section,
 		"version":         func() string { return runtime.Version() },
+		"docNext":         releaseNotePreview{goroot}.MergedFragments,
 	})
 	docs, err := pkgdoc.NewServer(fsys, site, googleCN)
 	if err != nil {
@@ -290,6 +293,34 @@
 	return site, nil
 }
 
+// releaseNotePreview implements a preview of upcoming release notes.
+type releaseNotePreview struct {
+	goroot fs.FS // goroot provides the doc/next content to use, if any.
+}
+
+// MergedFragments returns Markdown obtained by merging release note fragments
+// found in the doc/next directory in goroot, to preview relnote generate output.
+// An empty string and no error is returned if the doc/next directory doesn't exist.
+func (p releaseNotePreview) MergedFragments() (markdownWithEmbeddedHTML template.HTML, _ error) {
+	next, err := fs.Sub(p.goroot, "doc/next")
+	if err != nil {
+		return "", err
+	}
+	if _, err := fs.Stat(next, "."); os.IsNotExist(err) || errors.Is(err, errNoFileSystem) {
+		// No next release note fragments.
+		return "", nil
+	}
+	doc, err := relnote.Merge(next)
+	if err != nil {
+		return "", fmt.Errorf("relnote.Merge: %v", err)
+	}
+	// Note: It's possible to render doc, a parsed Markdown document with embedded HTML,
+	// into Markdown or HTML. We choose to render to Markdown and let x/website/internal/web
+	// handle the remaining conversion to HTML. This means the rendering is more consistent
+	// with what'll happen when relnote generate output is added as _content/doc/go1.N.md.
+	return template.HTML(markdown.ToMarkdown(doc)), nil
+}
+
 func parseRFC3339(s string) (time.Time, error) {
 	return time.Parse(time.RFC3339, s)
 }
@@ -831,12 +862,14 @@
 	return m.old.Open(name)
 }
 
+var errNoFileSystem = errors.New("no file system")
+
 // Open returns fsys.Open(name) where fsys is the file system passed to the most recent call to Set.
-// If there has been no call to Set, Open returns an error with text “no file system”.
+// If there has been no call to Set, Open returns errNoFileSystem, an error with text “no file system”.
 func (a *atomicFS) Open(name string) (fs.File, error) {
 	fsys, _ := a.v.Load().(*fs.FS)
 	if fsys == nil {
-		return nil, &fs.PathError{Path: name, Op: "open", Err: fmt.Errorf("no file system")}
+		return nil, &fs.PathError{Path: name, Op: "open", Err: errNoFileSystem}
 	}
 	return (*fsys).Open(name)
 }
diff --git a/cmd/golangorg/testdata/web.txt b/cmd/golangorg/testdata/web.txt
index 834a928..da95c42 100644
--- a/cmd/golangorg/testdata/web.txt
+++ b/cmd/golangorg/testdata/web.txt
@@ -458,3 +458,9 @@
 
 GET https://go.dev/CONTRIBUTORS
 redirect == /AUTHORS
+
+GET https://go.dev/doc/next
+body contains <h1>Next Release Notes Draft</h1>
+
+GET https://tip.golang.org/doc/next
+body contains <h1>Next Release Notes Draft</h1>
diff --git a/go.mod b/go.mod
index 118a5e2..14ecfca 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@
 	golang.org/x/tools v0.21.0
 	google.golang.org/api v0.136.0
 	gopkg.in/yaml.v3 v3.0.1
+	rsc.io/markdown v0.0.0-20240306144322-0bf8f97ee8ef
 )
 
 require (
diff --git a/go.sum b/go.sum
index 3d19000..35f32bf 100644
--- a/go.sum
+++ b/go.sum
@@ -255,3 +255,5 @@
 gopkg.in/yaml.v3 v3.0.1/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-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+rsc.io/markdown v0.0.0-20240306144322-0bf8f97ee8ef h1:mqLYrXCXYEZOop9/Dbo6RPX11539nwiCNBb1icVPmw8=
+rsc.io/markdown v0.0.0-20240306144322-0bf8f97ee8ef/go.mod h1:8xcPgWmwlZONN1D9bjxtHEjrUtSEa3fakVF8iaewYKQ=