| // 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. |
| // |
| // The key-value pair “template: bool” controls whether the page is treated as an HTML template |
| // (see the next section, “Page Rendering”). The default is false for HTML |
| // and true for markdown. |
| // |
| // 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 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 expression 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" |
| "html/template" |
| "io" |
| "io/fs" |
| "log" |
| "net/http" |
| "path" |
| "regexp" |
| "strconv" |
| "strings" |
| "sync" |
| |
| "github.com/evanw/esbuild/pkg/api" |
| "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() |
| } |
| |
| // If the file doesn't ask to be treated as a template and isn't Markdown, |
| // set the page's content to skip templating later, in Site.renderHTML. |
| 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, |
| }) |
| } |