blob: e1fba85bce000c5422763c828c5df9de0fac86c1 [file] [log] [blame]
// Copyright 2013 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 web implements a basic web site serving framework.
// The two fundamental types in this package are Site and Page.
//
// # Sites
//
// A Site is an http.Handler that serves requests from a file system.
// Use NewSite(fsys) to create a new Site.
//
// The Site is defined primarily by the content of its file system fsys,
// which holds files to be served as well as templates for
// converting Markdown or HTML fragments into full HTML pages.
//
// # Pages
//
// A Page, which is a map[string]interface{}, is the raw data that a Site renders into a web page.
// Typically a Page is loaded from a *.html or *.md file in the file system fsys, although
// dynamic pages can be computed and passed to ServePage as well,
// as described in “Serving Dynamic Pages” below.
//
// For a Page loaded from the file system, the key-value pairs in the map
// are initialized from the YAML or JSON metadata block at the top of a Markdown or HTML file,
// which looks like (YAML):
//
// ---
// key: value
// ...
// ---
//
// or (JSON):
//
// <!--{
// "Key": "value",
// ...
// }-->
//
// By convention, key-value pairs loaded from a metadata block use lower-case keys.
// For historical reasons, keys in JSON metadata are converted to lower-case when read,
// so that the two headers above both refer to a key with a lower-case k.
//
// A few keys have special meanings:
//
// The key-value pair “status: n” sets the HTTP response status to the integer code n.
//
// The key-value pair “redirect: url” causes requests for this page redirect to the given
// relative or absolute URL.
//
// The key-value pair “layout: name” selects the page layout template with the given name.
// See the next section, “Page Rendering”, for details about layout and rendering.
//
// In addition to these explicit key-value pairs, pages loaded from the file system
// have a few implicit key-value pairs added by the page loading process:
//
// - File: the path in fsys to the file containing the page
// - FileData: the file body, with the key-value metadata stripped
// - URL: this page's URL path (/x/y/z for x/y/z.md, /x/y/ for x/y/index.md)
//
// The key “Content” is added during during the rendering process.
// See “Page Rendering” for details.
//
// # Page Rendering
//
// A Page's content is rendered in two steps: conversion to content, and framing of content.
//
// To convert a page to content, the page's file body (its FileData key, a []byte) is parsed
// and executed as an HTML template, with the page itself passed as the template input data.
// The template output is then interpreted as Markdown (perhaps with embedded HTML),
// and converted to HTML. The result is stored in the page under the key “Content”,
// with type template.HTML.
//
// A page's conversion to content can be skipped entirely in dynamically-generated pages
// by setting the “Content” key before passing the page to ServePage.
//
// The second step is framing the content in the overall site HTML, which is done by
// executing the site template, again using the Page itself as the template input data.
//
// The site template is constructed from two files in the file system.
// The first file is the fsys's “site.tmpl”, which provides the overall HTML frame for the site.
// The second file is a layout-specific template file, selected by the Page's
// “layout: name” key-value pair.
// The renderer searches for “name.tmpl” in the directory containing the page's file,
// then in the parent of that directory, and so on up to the root.
// If no such template is found, the rendering fails and reports that error.
// As a special case, “layout: none” skips the second file entirely.
//
// If there is no “layout: name” key-value pair, then the renderer tries using an
// implicit “layout: default”, but if no such “default.tmpl” template file can be found,
// the renderer uses an implicit “layout: none” instead.
//
// By convention, the site template and the layout-specific template are connected as follows.
// The site template, at the point where the content should be rendered, executes:
//
// {{block "layout" .}}{{.Content}}{{end}}
//
// The layout-specific template overrides this block by defining its own template named “layout”.
// For example:
//
// {{define "layout"}}
// Here's some <blink>great</blink> content: {{.Content}}
// {{end}}
//
// The use of the “block” template construct ensures that
// if there is no layout-specific template,
// the content will still be rendered.
//
// # Page Template Functions
//
// In this web server, templates can themselves be invoked as functions.
// See https://pkg.go.dev/rsc.io/tmplfunc for more details about that feature.
//
// During page rendering, both when rendering a page to content and when framing the content,
// the following template functions are available (in addition to those provided by the
// template package itself and the per-template functions just mentioned).
//
// In all functions taking a file path f, if the path begins with a slash,
// it is interpreted relative to the fsys root.
// Otherwise, it is interpreted relative to the directory of the current page's URL.
//
// The “{{add x y}}”, “{{sub x y}}”, “{{mul x y}}”, and “{{div x y}}” functions
// provide basic math on arguments of type int.
//
// The “{{code f [start [end]]}}” function returns a template.HTML of a formatted display
// of code lines from the file f.
// If both start and end are omitted, then the display shows the entire file.
// If only the start line is specified, then the display shows that single line.
// If both start and end are specified, then the display shows a range of lines
// starting at start up to and including end.
// The arguments start and end can take two forms: a number indicates a specific line number,
// and a string is taken to be a regular expresion indicating the earliest matching line
// in the file (or, for end, the earliest matching line after the start line).
// Any lines ending in “OMIT” are elided from the display.
//
// For example:
//
// {{code "hello.go" `^func main` `^}`}}
//
// The “{{data f}}” function reads the file f,
// decodes it as YAML, and then returns the resulting data,
// typically a map[string]interface{}.
// It is effectively shorthand for “{{yaml (file f)}}”.
//
// The “{{file f}}” function reads the file f and returns its content as a string.
//
// The “{{first n slice}}” function returns a slice of the first n elements of slice,
// or else slice itself when slice has fewer than n elements.
//
// The “{{markdown text}}” function interprets text (a string) as Markdown
// and returns the equivalent HTML as a template.HTML.
//
// The “{{page f}}” function returns the page data (a Page)
// for the static page contained in the file f.
// The lookup ignores trailing slashes in f as well as the presence or absence
// of extensions like .md, .html, /index.md, and /index.html,
// making it possible for f to be a relative or absolute URL path instead of a file path.
//
// The “{{pages glob}}” function returns a slice of page data (a []Page)
// for all pages loaded from files or directories
// in fsys matching the given glob (a string),
// according to the usual file path rules (if the glob starts with slash,
// it is interpreted relative to the fsys root, and otherwise
// relative to the directory of the page's URL).
// If the glob pattern matches a directory,
// the page for the directory's index.md or index.html is used.
//
// For example:
//
// Here are all the articles:
// {{range (pages "/articles/*")}}
// - [{{.title}}]({{.URL}})
// {{end}}
//
// The “{{raw s}}” function converts s (a string) to type template.HTML without any escaping,
// to allow using s as raw Markdown or HTML in the final output.
//
// The “{{yaml s}}” function decodes s (a string) as YAML and returns the resulting data.
// It is most useful for defining templates that accept YAML-structured data as a literal argument.
// For example:
//
// {{define "quote info"}}
// {{with (yaml .info)}}
// .text
// — .name{{if .title}}, .title{{end}}
// {{end}}
//
// {{quote `
// text: If a program is too slow, it must have a loop.
// name: Ken Thompson
// `}}
//
// The “path” and “strings” functions return package objects with methods for every top-level
// function in these packages (except path.Split, which has more than one non-error result
// and would not be invokable). For example, “{{strings.ToUpper "abc"}}”.
//
// # Serving Requests
//
// A Site is an http.Handler that serves requests by consulting the underlying
// file system and constructing and rendering pages, as well as serving binary
// and text files.
//
// To serve a request for URL path /p, if fsys has a file
// p/index.md, p/index.html, p.md, or p.html
// (in that order of preference), then the Site opens that file,
// parses it into a Page, renders the page as described
// in the “Page Rendering” section above,
// and responds to the request with the generated HTML.
// If the request URL does not match the parsed page's URL,
// then the Site responds with a redirect to the canonical URL.
//
// Otherwise, if fsys has a directory p and the Site
// can find a template “dir.tmpl” in that directory or a parent,
// then the Site responds with the rendering of
//
// Page{
// "URL": "/p/",
// "File": "p",
// "layout": "dir",
// "dir": []fs.FileInfo(dir),
// }
//
// where dir is the directory contents.
//
// Otherwise, if fsys has a file p containing valid UTF-8 text
// (at least up to the first kilobyte of the file) and the Site
// can find a template “text.tmpl” in that file's directory or a parent,
// and the file is not named robots.txt,
// and the file does not have a .css, .js, .svg, or .ts extension,
// then the Site responds with the rendering of
//
// Page{
// "URL": "/p",
// "File": "p",
// "layout": "texthtml",
// "texthtml": template.HTML(texthtml),
// }
//
// where texthtml is the text file as rendered by the
// golang.org/x/website/internal/texthtml package.
// In the texthtml.Config, GoComments is set to true for
// file names ending in .go;
// the h URL query parameter, if present, is passed as Highlight,
// and the s URL query parameter, if set to lo:hi, is passed as a
// single-range Selection.
//
// If the request has the URL query parameter m=text,
// then the text file content is not rendered or framed and is instead
// served directly as a plain text response.
//
// If the request is for a file with a .ts extension the file contents
// are transformed from TypeScript to JavaScript and then served with
// a Content-Type=text/javascript header.
//
// Otherwise, if none of those cases apply but the request path p
// does exist in the file system, then the Site passes the
// request to an http.FileServer serving from fsys.
// This last case handles binary static content as well as
// textual static content excluded from the text file case above.
//
// Otherwise, the Site responds with the rendering of
//
// Page{
// "URL": r.URL.Path,
// "status": 404,
// "layout": "error",
// "error": err,
// }
//
// where err is the “not exist” error returned by fs.Stat(fsys, p).
// (See also the “Serving Errors” section below.)
//
// # Serving Dynamic Requests
//
// Of course, a web site may wish to serve more than static content.
// To allow dynamically generated web pages to make use of page
// rendering and site templates, the Site.ServePage method can be
// called with a dynamically generated Page value, which will then
// be rendered and served as the result of the request.
//
// # Serving Errors
//
// If an error occurs while serving a request r,
// the Site responds with the rendering of
//
// Page{
// "URL": r.URL.Path,
// "status": 500,
// "layout": "error",
// "error": err,
// }
//
// If that rendering itself fails, the Site responds with status 500
// and the cryptic page text “error rendering error”.
//
// The Site.ServeError and Site.ServeErrorStatus methods provide a way
// for dynamic servers to generate similar responses.
package web
import (
"bytes"
"errors"
"fmt"
"html"
"io"
"io/fs"
"log"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"sync"
"github.com/evanw/esbuild/pkg/api"
"golang.org/x/website/internal/backport/html/template"
"golang.org/x/website/internal/spec"
"golang.org/x/website/internal/texthtml"
)
// A Site is an http.Handler that serves requests from a file system.
// See the package doc comment for details.
type Site struct {
fs fs.FS // from NewSite
fileServer http.Handler // http.FileServer(http.FS(fs))
funcs template.FuncMap // accumulated from s.Funcs
cache sync.Map // canonical file path -> *pageFile, for site.openPage
}
// NewSite returns a new Site for serving pages from the file system fsys.
func NewSite(fsys fs.FS) *Site {
return &Site{
fs: fsys,
fileServer: http.FileServer(http.FS(fsys)),
}
}
// Funcs adds the functions in m to the set of functions available to templates.
// Funcs must not be called concurrently with any page rendering.
func (s *Site) Funcs(m template.FuncMap) {
if s.funcs == nil {
s.funcs = make(template.FuncMap)
}
for k, v := range m {
s.funcs[k] = v
}
}
// readFile returns the content of the named file in the site's file system.
// If file begins with a slash, it is interpreted relative to the root of the file system.
// Otherwise, it is interpreted relative to dir.
func (site *Site) readFile(dir, file string) ([]byte, error) {
if strings.HasPrefix(file, "/") {
file = path.Clean(file)
} else {
file = path.Join(dir, file)
}
file = strings.Trim(file, "/")
if file == "" {
file = "."
}
return fs.ReadFile(site.fs, file)
}
// ServeError is ServeErrorStatus with HTTP status code 500 (internal server error).
func (s *Site) ServeError(w http.ResponseWriter, r *http.Request, err error) {
s.ServeErrorStatus(w, r, err, http.StatusInternalServerError)
}
// ServeErrorStatus responds to the request
// with the given error and HTTP status.
// It is equivalent to calling ServePage(w, r, p) where p is:
//
// Page{
// "URL": r.URL.Path,
// "status": status,
// "layout": error,
// "error": err,
// }
func (s *Site) ServeErrorStatus(w http.ResponseWriter, r *http.Request, err error, status int) {
s.serveErrorStatus(w, r, err, status, false)
}
func (s *Site) serveErrorStatus(w http.ResponseWriter, r *http.Request, err error, status int, renderingError bool) {
if renderingError {
log.Printf("error rendering error: %v", err)
w.WriteHeader(status)
w.Write([]byte("error rendering error"))
return
}
p := Page{
"URL": r.URL.Path,
"status": status,
"layout": "error",
"error": err,
}
s.servePage(w, r, p, true)
}
// ServePage renders the page p to HTML and writes that HTML to w.
// See the package doc comment for details about page rendering.
//
// So that all templates can assume the presence of p["URL"],
// if p["URL"] is unset or does not have type string, then ServePage
// sets p["URL"] to r.URL.Path in a clone of p before rendering the page.
func (s *Site) ServePage(w http.ResponseWriter, r *http.Request, p Page) {
s.servePage(w, r, p, false)
}
func (s *Site) servePage(w http.ResponseWriter, r *http.Request, p Page, renderingError bool) {
html, err := s.renderHTML(p, "site.tmpl", r)
if err != nil {
s.serveErrorStatus(w, r, fmt.Errorf("template execution: %v", err), http.StatusInternalServerError, renderingError)
return
}
if code, ok := p["status"].(int); ok {
w.WriteHeader(code)
}
w.Write(html)
}
// ServeHTTP implements http.Handler, serving from a file in the site.
// See the Site type documentation for details about how requests are handled.
func (s *Site) ServeHTTP(w http.ResponseWriter, r *http.Request) {
abspath := r.URL.Path
relpath := path.Clean(strings.TrimPrefix(abspath, "/"))
// Is it a TypeScript file?
if strings.HasSuffix(relpath, ".ts") {
s.serveTypeScript(w, r)
return
}
// Is it a page we can generate?
if p, err := s.openPage(relpath); err == nil {
if p.url != abspath {
// Redirect to canonical path.
status := http.StatusMovedPermanently
if i, ok := p.page["status"].(int); ok {
status = i
}
http.Redirect(w, r, p.url, status)
return
}
// Serve from the actual filesystem path.
s.serveHTML(w, r, p)
return
}
// Is it a directory or file we can serve?
info, err := fs.Stat(s.fs, relpath)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, fs.ErrNotExist) {
status = http.StatusNotFound
}
s.ServeErrorStatus(w, r, err, status)
return
}
// Serve directory.
if info != nil && info.IsDir() {
if _, ok := s.findLayout(relpath, "dir"); ok {
if !maybeRedirect(w, r) {
s.serveDir(w, r, relpath)
}
return
}
}
// Serve text file.
if isTextFile(s.fs, relpath) {
if _, ok := s.findLayout(path.Dir(relpath), "texthtml"); ok {
if !maybeRedirectFile(w, r) {
s.serveText(w, r, relpath)
}
return
}
}
// Serve raw bytes.
s.fileServer.ServeHTTP(w, r)
}
func maybeRedirect(w http.ResponseWriter, r *http.Request) (redirected bool) {
canonical := path.Clean(r.URL.Path)
if !strings.HasSuffix(canonical, "/") {
canonical += "/"
}
if r.URL.Path != canonical {
url := *r.URL
url.Path = canonical
http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
redirected = true
}
return
}
func maybeRedirectFile(w http.ResponseWriter, r *http.Request) (redirected bool) {
c := path.Clean(r.URL.Path)
c = strings.TrimRight(c, "/")
if r.URL.Path != c {
url := *r.URL
url.Path = c
http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
redirected = true
}
return
}
func (s *Site) serveHTML(w http.ResponseWriter, r *http.Request, p *pageFile) {
src, _ := p.page["FileData"].(string)
filePath, _ := p.page["File"].(string)
isMarkdown := strings.HasSuffix(filePath, ".md")
// if it begins with "<!DOCTYPE " assume it is standalone
// html that doesn't need the template wrapping.
if strings.HasPrefix(src, "<!DOCTYPE ") {
w.Write([]byte(src))
return
}
// if it's the language spec, add tags to EBNF productions
if strings.HasSuffix(filePath, "ref/spec.html") {
var buf bytes.Buffer
spec.Linkify(&buf, []byte(src))
src = buf.String()
}
// Template is enabled always in Markdown.
// It can only be disabled for HTML files.
isTemplate, _ := p.page["template"].(bool)
if !isTemplate && !isMarkdown {
p.page["Content"] = template.HTML(src)
}
s.ServePage(w, r, p.page)
}
func (s *Site) serveDir(w http.ResponseWriter, r *http.Request, relpath string) {
if maybeRedirect(w, r) {
return
}
list, err := fs.ReadDir(s.fs, relpath)
if err != nil {
s.ServeError(w, r, err)
return
}
var info []fs.FileInfo
for _, d := range list {
i, err := d.Info()
if err == nil {
info = append(info, i)
}
}
s.ServePage(w, r, Page{
"URL": r.URL.Path,
"File": relpath,
"layout": "dir",
"dir": info,
})
}
func (s *Site) serveText(w http.ResponseWriter, r *http.Request, relpath string) {
src, err := fs.ReadFile(s.fs, relpath)
if err != nil {
log.Printf("ReadFile: %s", err)
s.ServeError(w, r, err)
return
}
if r.FormValue("m") == "text" {
s.serveRawText(w, src)
return
}
cfg := texthtml.Config{
GoComments: path.Ext(relpath) == ".go",
Highlight: r.FormValue("h"),
Selection: rangeSelection(r.FormValue("s")),
Line: 1,
}
var buf bytes.Buffer
buf.WriteString("<pre>")
buf.Write(texthtml.Format(src, cfg))
buf.WriteString("</pre>")
fmt.Fprintf(&buf, `<p><a href="/%s?m=text">View as plain text</a></p>`, html.EscapeString(relpath))
s.ServePage(w, r, Page{
"URL": r.URL.Path,
"File": relpath,
"layout": "texthtml",
"texthtml": template.HTML(buf.String()),
})
}
var selRx = regexp.MustCompile(`^([0-9]+):([0-9]+)`)
// rangeSelection computes the Selection for a text range described
// by the argument str, of the form Start:End, where Start and End
// are decimal byte offsets.
func rangeSelection(str string) texthtml.Selection {
m := selRx.FindStringSubmatch(str)
if len(m) >= 2 {
from, _ := strconv.Atoi(m[1])
to, _ := strconv.Atoi(m[2])
if from < to {
return texthtml.Spans(texthtml.Span{Start: from, End: to})
}
}
return nil
}
func (s *Site) serveRawText(w http.ResponseWriter, text []byte) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(text)
}
const cacheHeader = "X-Go-Dev-Cache-Hit"
type jsout struct {
output []byte
stat fs.FileInfo // stat for file when page was loaded
}
func (s *Site) serveTypeScript(w http.ResponseWriter, r *http.Request) {
filename := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
if cjs, ok := s.cache.Load(filename); ok {
js := cjs.(*jsout)
info, err := fs.Stat(s.fs, filename)
if err == nil && info.ModTime().Equal(js.stat.ModTime()) {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set(cacheHeader, "true")
http.ServeContent(w, r, filename, info.ModTime(), bytes.NewReader(js.output))
return
}
}
file, err := s.fs.Open(filename)
if err != nil {
s.ServeError(w, r, err)
return
}
var contents bytes.Buffer
_, err = io.Copy(&contents, file)
if err != nil {
s.ServeError(w, r, err)
return
}
result := api.Transform(contents.String(), api.TransformOptions{
Loader: api.LoaderTS,
Target: api.ES2018,
})
var buf bytes.Buffer
for _, v := range result.Errors {
fmt.Fprintln(&buf, v.Text)
}
if buf.Len() > 0 {
s.ServeError(w, r, errors.New(buf.String()))
return
}
info, err := file.Stat()
if err != nil {
s.ServeError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
http.ServeContent(w, r, filename, info.ModTime(), bytes.NewReader(result.Code))
s.cache.Store(filename, &jsout{
output: result.Code,
stat: info,
})
}