├── go.mod ├── testdata ├── error.tmpl ├── error.ts ├── site.tmpl ├── example.js └── example.ts ├── README.md ├── go.sum ├── LICENSE ├── istext.go ├── site_test.go ├── tmpl.go ├── pkg.go ├── page.go ├── code.go ├── render.go ├── internal └── texthtml │ ├── ast.go │ └── texthtml.go └── site.go /go.mod: -------------------------------------------------------------------------------- 1 | module rsc.io/web 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/yuin/goldmark v1.4.7 7 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b 8 | rsc.io/tmplfunc v0.0.3 9 | ) 10 | -------------------------------------------------------------------------------- /testdata/error.tmpl: -------------------------------------------------------------------------------- 1 | 6 | 7 | {{define "layout"}}{{.error}}{{end}} 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://pkg.go.dev/rsc.io/web) 2 | 3 | Package web implements a basic web site serving framework. 4 | 5 | See the [package documentation](https://pkg.go.dev/rsc.io/web) for details. 6 | -------------------------------------------------------------------------------- /testdata/error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 The Go Authors. All rights reserved. 4 | * Use of this source code is governed by a BSD-style 5 | * license that can be found in the LICENSE file. 6 | */ 7 | 8 | const function = () => {}; 9 | -------------------------------------------------------------------------------- /testdata/site.tmpl: -------------------------------------------------------------------------------- 1 | 6 | 7 | {{block "entirepage" .}}{{block "layout" .}}{{.Content}}{{end}}{{end}} 8 | -------------------------------------------------------------------------------- /testdata/example.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 The Go Authors. All rights reserved. 4 | * Use of this source code is governed by a BSD-style 5 | * license that can be found in the LICENSE file. 6 | */ 7 | function sayHello(to) { 8 | console.log("Hello, " + to + "!"); 9 | } 10 | const world = { 11 | name: "World", 12 | toString() { 13 | return this.name; 14 | } 15 | }; 16 | sayHello(world); 17 | -------------------------------------------------------------------------------- /testdata/example.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2021 The Go Authors. All rights reserved. 4 | * Use of this source code is governed by a BSD-style 5 | * license that can be found in the LICENSE file. 6 | */ 7 | 8 | interface Target { 9 | toString(): string; 10 | } 11 | 12 | function sayHello(to: Target): void { 13 | console.log('Hello, ' + to + '!'); 14 | } 15 | 16 | const world = { 17 | name: 'World', 18 | toString(): string { 19 | return this.name; 20 | }, 21 | }; 22 | 23 | sayHello(world); 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/yuin/goldmark v1.4.7 h1:KHHlQL4EKBZ43vpA1KBEQHfodk4JeIgeb0xJLg7rvDI= 2 | github.com/yuin/goldmark v1.4.7/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= 3 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 4 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 5 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 6 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 7 | rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= 8 | rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 The Go Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /istext.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package web 6 | 7 | import ( 8 | "io/fs" 9 | "path" 10 | "strings" 11 | "unicode/utf8" 12 | ) 13 | 14 | // isText reports whether a significant prefix of s looks like correct UTF-8; 15 | // that is, if it is likely that s is human-readable text. 16 | func isText(s []byte) bool { 17 | const max = 1024 // at least utf8.UTFMax 18 | if len(s) > max { 19 | s = s[0:max] 20 | } 21 | for i, c := range string(s) { 22 | if i+utf8.UTFMax > len(s) { 23 | // last char may be incomplete - ignore 24 | break 25 | } 26 | if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' { 27 | // decoding error or control character - not a text file 28 | return false 29 | } 30 | } 31 | return true 32 | } 33 | 34 | // isTextFile reports whether the file has a known extension indicating 35 | // a text file, or if a significant chunk of the specified file looks like 36 | // correct UTF-8; that is, if it is likely that the file contains human- 37 | // readable text. 38 | func isTextFile(fsys fs.FS, filename string) bool { 39 | // Various special cases must be served raw, not converted to nice HTML. 40 | if filename == "robots.txt" || strings.HasPrefix(filename, "doc/play/") { 41 | return false 42 | } 43 | switch path.Ext(filename) { 44 | case ".css", ".js", ".svg", ".ts": 45 | return false 46 | } 47 | 48 | // the extension is not known; read an initial chunk 49 | // of the file and check if it looks like text 50 | f, err := fsys.Open(filename) 51 | if err != nil { 52 | return false 53 | } 54 | defer f.Close() 55 | 56 | var buf [1024]byte 57 | n, err := f.Read(buf[0:]) 58 | if err != nil { 59 | return false 60 | } 61 | 62 | return isText(buf[0:n]) 63 | } 64 | -------------------------------------------------------------------------------- /site_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package web 6 | 7 | import ( 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | "testing/fstest" 14 | ) 15 | 16 | func testServeBody(t *testing.T, p *Site, path, body string) { 17 | t.Helper() 18 | r := &http.Request{URL: &url.URL{Path: path}} 19 | rw := httptest.NewRecorder() 20 | p.ServeHTTP(rw, r) 21 | if rw.Code != 200 || !strings.Contains(rw.Body.String(), body) { 22 | t.Fatalf("GET %s: expected 200 w/ %q: got %d w/ body:\n%s", 23 | path, body, rw.Code, rw.Body) 24 | } 25 | } 26 | 27 | func TestRedirectAndMetadata(t *testing.T) { 28 | fsys := fstest.MapFS{ 29 | "site.tmpl": {Data: []byte(`{{.Content}}`)}, 30 | "doc/x/index.html": {Data: []byte("Hello, x.")}, 31 | "lib/godoc/site.html": {Data: []byte(`{{.Data}}`)}, 32 | } 33 | site := NewSite(fsys) 34 | 35 | // Test that redirect is sent back correctly. 36 | // Used to panic. See golang.org/issue/40665. 37 | dir := "/doc/x/" 38 | 39 | r := &http.Request{URL: &url.URL{Path: dir + "index.html"}} 40 | rw := httptest.NewRecorder() 41 | site.ServeHTTP(rw, r) 42 | loc := rw.Result().Header.Get("Location") 43 | if rw.Code != 301 || loc != dir { 44 | t.Errorf("GET %s: expected 301 -> %q, got %d -> %q", r.URL.Path, dir, rw.Code, loc) 45 | } 46 | 47 | testServeBody(t, site, dir, "Hello, x") 48 | } 49 | 50 | func TestMarkdown(t *testing.T) { 51 | site := NewSite(fstest.MapFS{ 52 | "site.tmpl": {Data: []byte(`{{.Content}}`)}, 53 | "doc/test.md": {Data: []byte("**bold**")}, 54 | "doc/test2.md": {Data: []byte(`{{"*template*"}}`)}, 55 | "lib/godoc/site.html": {Data: []byte(`{{.Data}}`)}, 56 | }) 57 | 58 | testServeBody(t, site, "/doc/test", "bold") 59 | testServeBody(t, site, "/doc/test2", "template") 60 | } 61 | -------------------------------------------------------------------------------- /tmpl.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package web 6 | 7 | import ( 8 | "fmt" 9 | "html/template" 10 | "io/fs" 11 | "path" 12 | "reflect" 13 | "sort" 14 | "strings" 15 | 16 | "gopkg.in/yaml.v3" 17 | ) 18 | 19 | // A siteDir is a site extended with a known directory for interpreting relative paths. 20 | type siteDir struct { 21 | *Site 22 | dir string 23 | } 24 | 25 | func toString(x interface{}) string { 26 | switch x := x.(type) { 27 | case string: 28 | return x 29 | case template.HTML: 30 | return string(x) 31 | case nil: 32 | return "" 33 | default: 34 | panic(fmt.Sprintf("cannot toString %T", x)) 35 | } 36 | } 37 | 38 | // data parses the named yaml file (relative to dir) and returns its structured data. 39 | func (site *siteDir) data(name string) (interface{}, error) { 40 | data, err := site.readFile(site.dir, name) 41 | if err != nil { 42 | return nil, err 43 | } 44 | var d interface{} 45 | if err := yaml.Unmarshal(data, &d); err != nil { 46 | return nil, err 47 | } 48 | return d, nil 49 | } 50 | 51 | func first(n int, list reflect.Value) reflect.Value { 52 | if !list.IsValid() { 53 | return list 54 | } 55 | if list.Kind() == reflect.Interface { 56 | if list.IsNil() { 57 | return list 58 | } 59 | list = list.Elem() 60 | } 61 | 62 | if list.Len() < n { 63 | return list 64 | } 65 | return list.Slice(0, n) 66 | } 67 | 68 | // markdown is the function provided to templates. 69 | func markdown(data interface{}) (template.HTML, error) { 70 | h, err := markdownToHTML(toString(data)) 71 | if err != nil { 72 | return "", err 73 | } 74 | s := strings.TrimSpace(string(h)) 75 | if strings.HasPrefix(s, "
") && strings.HasSuffix(s, "
") && strings.Count(s, "") == 1 { 76 | h = template.HTML(strings.TrimSpace(s[len("
") : len(s)-len("
")])) 77 | } 78 | return h, nil 79 | } 80 | 81 | func (site *siteDir) readfile(name string) (string, error) { 82 | data, err := site.readFile(site.dir, name) 83 | return string(data), err 84 | } 85 | 86 | // page returns the page params for the page with a given url u. 87 | // The url may or may not have its leading slash. 88 | func (site *siteDir) page(u string) (Page, error) { 89 | if !path.IsAbs(u) { 90 | u = path.Join(site.dir, u) 91 | } 92 | p, err := site.openPage(strings.Trim(u, "/")) 93 | if err != nil { 94 | return nil, err 95 | } 96 | return p.page, nil 97 | } 98 | 99 | // Pages returns the pages found in files matching glob. 100 | func (site *Site) Pages(glob string) ([]Page, error) { 101 | return (&siteDir{site, "."}).pages(glob) 102 | } 103 | 104 | // pages returns the page params for pages with urls matching glob. 105 | func (site *siteDir) pages(glob string) ([]Page, error) { 106 | if !path.IsAbs(glob) { 107 | glob = path.Join(site.dir, glob) 108 | } 109 | // TODO(rsc): Add a cache? 110 | _, err := path.Match(glob, "") 111 | if err != nil { 112 | return nil, err 113 | } 114 | glob = strings.Trim(glob, "/") 115 | if glob == "" { 116 | glob = "." 117 | } 118 | matches, err := fs.Glob(site.fs, glob) 119 | if err != nil { 120 | return nil, err 121 | } 122 | var out []Page 123 | for _, file := range matches { 124 | if !strings.HasSuffix(file, ".md") && !strings.HasSuffix(file, ".html") { 125 | f := path.Join(file, "index.md") 126 | if _, err := fs.Stat(site.fs, f); err != nil { 127 | f = path.Join(file, "index.html") 128 | if _, err = fs.Stat(site.fs, f); err != nil { 129 | continue 130 | } 131 | } 132 | file = f 133 | } 134 | p, err := site.openPage(file) 135 | if err != nil { 136 | return nil, fmt.Errorf("%s: %v", file, err) 137 | } 138 | out = append(out, p.page) 139 | } 140 | 141 | sort.Slice(out, func(i, j int) bool { 142 | return out[i]["URL"].(string) < out[j]["URL"].(string) 143 | }) 144 | return out, nil 145 | } 146 | 147 | // file parses the named file (relative to dir) and returns its content as a string. 148 | func (site *siteDir) file(name string) (string, error) { 149 | data, err := site.readFile(site.dir, name) 150 | if err != nil { 151 | return "", err 152 | } 153 | return string(data), nil 154 | } 155 | 156 | func raw(s interface{}) template.HTML { 157 | return template.HTML(toString(s)) 158 | } 159 | 160 | func yamlFn(s string) (interface{}, error) { 161 | var d interface{} 162 | if err := yaml.Unmarshal([]byte(s), &d); err != nil { 163 | return nil, err 164 | } 165 | return d, nil 166 | } 167 | -------------------------------------------------------------------------------- /pkg.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package web 6 | 7 | import ( 8 | "path" 9 | "strings" 10 | "unicode" 11 | ) 12 | 13 | type pkgPath struct{} 14 | 15 | func (pkgPath) Base(a string) string { return path.Base(a) } 16 | func (pkgPath) Clean(a string) string { return path.Clean(a) } 17 | func (pkgPath) Dir(a string) string { return path.Dir(a) } 18 | func (pkgPath) Ext(a string) string { return path.Ext(a) } 19 | func (pkgPath) IsAbs(a string) bool { return path.IsAbs(a) } 20 | func (pkgPath) Join(a ...string) string { return path.Join(a...) } 21 | func (pkgPath) Match(a, b string) (bool, error) { return path.Match(a, b) } 22 | func (pkgPath) Split(a string) (string, string) { return path.Split(a) } 23 | 24 | type pkgStrings struct{} 25 | 26 | func (pkgStrings) Compare(a, b string) int { return strings.Compare(a, b) } 27 | func (pkgStrings) Contains(a, b string) bool { return strings.Contains(a, b) } 28 | func (pkgStrings) ContainsAny(a, b string) bool { return strings.ContainsAny(a, b) } 29 | func (pkgStrings) ContainsRune(a string, b rune) bool { return strings.ContainsRune(a, b) } 30 | func (pkgStrings) Count(a, b string) int { return strings.Count(a, b) } 31 | func (pkgStrings) EqualFold(a, b string) bool { return strings.EqualFold(a, b) } 32 | func (pkgStrings) Fields(a string) []string { return strings.Fields(a) } 33 | func (pkgStrings) FieldsFunc(a string, b func(rune) bool) []string { return strings.FieldsFunc(a, b) } 34 | func (pkgStrings) HasPrefix(a, b string) bool { return strings.HasPrefix(a, b) } 35 | func (pkgStrings) HasSuffix(a, b string) bool { return strings.HasSuffix(a, b) } 36 | func (pkgStrings) Index(a, b string) int { return strings.Index(a, b) } 37 | func (pkgStrings) IndexAny(a, b string) int { return strings.IndexAny(a, b) } 38 | func (pkgStrings) IndexByte(a string, b byte) int { return strings.IndexByte(a, b) } 39 | func (pkgStrings) IndexFunc(a string, b func(rune) bool) int { return strings.IndexFunc(a, b) } 40 | func (pkgStrings) IndexRune(a string, b rune) int { return strings.IndexRune(a, b) } 41 | func (pkgStrings) Join(a []string, b string) string { return strings.Join(a, b) } 42 | func (pkgStrings) LastIndex(a, b string) int { return strings.LastIndex(a, b) } 43 | func (pkgStrings) LastIndexAny(a, b string) int { return strings.LastIndexAny(a, b) } 44 | func (pkgStrings) LastIndexByte(a string, b byte) int { return strings.LastIndexByte(a, b) } 45 | func (pkgStrings) LastIndexFunc(a string, b func(rune) bool) int { 46 | return strings.LastIndexFunc(a, b) 47 | } 48 | func (pkgStrings) Map(a func(rune) rune, b string) string { return strings.Map(a, b) } 49 | func (pkgStrings) NewReader(a string) *strings.Reader { return strings.NewReader(a) } 50 | func (pkgStrings) NewReplacer(a ...string) *strings.Replacer { return strings.NewReplacer(a...) } 51 | func (pkgStrings) Repeat(a string, b int) string { return strings.Repeat(a, b) } 52 | func (pkgStrings) Replace(a, b, c string, d int) string { return strings.Replace(a, b, c, d) } 53 | func (pkgStrings) ReplaceAll(a, b, c string) string { return strings.ReplaceAll(a, b, c) } 54 | func (pkgStrings) Split(a, b string) []string { return strings.Split(a, b) } 55 | func (pkgStrings) SplitAfter(a, b string) []string { return strings.SplitAfter(a, b) } 56 | func (pkgStrings) SplitAfterN(a, b string, c int) []string { return strings.SplitAfterN(a, b, c) } 57 | func (pkgStrings) SplitN(a, b string, c int) []string { return strings.SplitN(a, b, c) } 58 | func (pkgStrings) Title(a string) string { return strings.Title(a) } 59 | func (pkgStrings) ToLower(a string) string { return strings.ToLower(a) } 60 | func (pkgStrings) ToLowerSpecial(a unicode.SpecialCase, b string) string { 61 | return strings.ToLowerSpecial(a, b) 62 | } 63 | func (pkgStrings) ToTitle(a string) string { return strings.ToTitle(a) } 64 | func (pkgStrings) ToTitleSpecial(a unicode.SpecialCase, b string) string { 65 | return strings.ToTitleSpecial(a, b) 66 | } 67 | func (pkgStrings) ToUpper(a string) string { return strings.ToUpper(a) } 68 | func (pkgStrings) ToUpperSpecial(a unicode.SpecialCase, b string) string { 69 | return strings.ToUpperSpecial(a, b) 70 | } 71 | func (pkgStrings) ToValidUTF8(a, b string) string { return strings.ToValidUTF8(a, b) } 72 | func (pkgStrings) Trim(a, b string) string { return strings.Trim(a, b) } 73 | func (pkgStrings) TrimFunc(a string, b func(rune) bool) string { return strings.TrimFunc(a, b) } 74 | func (pkgStrings) TrimLeft(a, b string) string { return strings.TrimLeft(a, b) } 75 | func (pkgStrings) TrimLeftFunc(a string, b func(rune) bool) string { return strings.TrimLeftFunc(a, b) } 76 | func (pkgStrings) TrimPrefix(a, b string) string { return strings.TrimPrefix(a, b) } 77 | func (pkgStrings) TrimRight(a, b string) string { return strings.TrimRight(a, b) } 78 | func (pkgStrings) TrimRightFunc(a string, b func(rune) bool) string { 79 | return strings.TrimRightFunc(a, b) 80 | } 81 | func (pkgStrings) TrimSpace(a string) string { return strings.TrimSpace(a) } 82 | func (pkgStrings) TrimSuffix(a, b string) string { return strings.TrimSuffix(a, b) } 83 | -------------------------------------------------------------------------------- /page.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package web 6 | 7 | import ( 8 | "bytes" 9 | "encoding/json" 10 | "io/fs" 11 | "path" 12 | "strings" 13 | "sync/atomic" 14 | "time" 15 | 16 | "gopkg.in/yaml.v3" 17 | ) 18 | 19 | // A pageFile is a Page loaded from a file. 20 | // It corresponds to some .md or .html file in the content tree. 21 | type pageFile struct { 22 | file string // .md file for page 23 | stat fs.FileInfo // stat for file when page was loaded 24 | url string // url excluding site.BaseURL; always begins with slash 25 | data []byte // page data (markdown) 26 | page Page // parameters passed to templates 27 | 28 | checked int64 // unix nano, atomically updated 29 | } 30 | 31 | // A Page is the data for a web page. 32 | // See the package doc comment for details. 33 | type Page map[string]interface{} 34 | 35 | func (site *Site) openPage(file string) (*pageFile, error) { 36 | // Strip trailing .html or .md or /; it all names the same page. 37 | if strings.HasSuffix(file, "/index.md") { 38 | file = strings.TrimSuffix(file, "/index.md") 39 | } else if strings.HasSuffix(file, "/index.html") { 40 | file = strings.TrimSuffix(file, "/index.html") 41 | } else if file == "index.md" || file == "index.html" { 42 | file = "." 43 | } else if strings.HasSuffix(file, "/") { 44 | file = strings.TrimSuffix(file, "/") 45 | } else if strings.HasSuffix(file, ".html") { 46 | file = strings.TrimSuffix(file, ".html") 47 | } else { 48 | file = strings.TrimSuffix(file, ".md") 49 | } 50 | 51 | now := time.Now().UnixNano() 52 | if cp, ok := site.cache.Load(file); ok { 53 | // Have cache entry; only use if the underlying file hasn't changed. 54 | // To avoid continuous stats, only check it has been 3s since the last one. 55 | // TODO(rsc): Move caching into a more general layer and cache templates. 56 | p := cp.(*pageFile) 57 | if now-atomic.LoadInt64(&p.checked) >= 3e9 { 58 | info, err := fs.Stat(site.fs, p.file) 59 | if err == nil && info.ModTime().Equal(p.stat.ModTime()) && info.Size() == p.stat.Size() { 60 | atomic.StoreInt64(&p.checked, now) 61 | return p, nil 62 | } 63 | } 64 | } 65 | 66 | // Check md before html to work correctly when x/website is layered atop Go 1.15 goroot during Go 1.15 tests. 67 | // Want to find x/website's debugging_with_gdb.md not Go 1.15's debuging_with_gdb.html. 68 | files := []string{file + ".md", file + ".html", path.Join(file, "index.md"), path.Join(file, "index.html")} 69 | var filePath string 70 | var b []byte 71 | var err error 72 | var stat fs.FileInfo 73 | for _, filePath = range files { 74 | stat, err = fs.Stat(site.fs, filePath) 75 | if err == nil { 76 | b, err = site.readFile(".", filePath) 77 | if err == nil { 78 | break 79 | } 80 | } 81 | } 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | // If we read an index.md or index.html, the canonical relpath is without the index.md/index.html suffix. 87 | url := path.Join("/", file) 88 | if name := path.Base(filePath); name == "index.html" || name == "index.md" { 89 | url, _ = path.Split(path.Join("/", filePath)) 90 | } 91 | 92 | params, body, err := parseMeta(b) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | p := &pageFile{ 98 | file: filePath, 99 | stat: stat, 100 | url: url, 101 | data: body, 102 | page: params, 103 | checked: now, 104 | } 105 | 106 | // File, FileData, URL 107 | p.page["File"] = filePath 108 | p.page["FileData"] = string(body) 109 | p.page["URL"] = p.url 110 | 111 | // User-specified redirect: overrides url but not URL. 112 | if redir, _ := p.page["redirect"].(string); redir != "" { 113 | p.url = redir 114 | } 115 | 116 | site.cache.Store(file, p) 117 | 118 | return p, nil 119 | } 120 | 121 | var ( 122 | jsonStart = []byte("") 124 | 125 | yamlStart = []byte("---\n") 126 | yamlEnd = []byte("\n---\n") 127 | ) 128 | 129 | // parseMeta extracts top-of-file metadata from the file contents b. 130 | // If there is no metadata, parseMeta returns Page{}, b, nil. 131 | // Otherwise, the metdata is extracted, and parseMeta returns 132 | // the metadata and the remainder of the file. 133 | // The end of the metadata is overwritten in b to preserve 134 | // the correct number of newlines so that the line numbers in tail 135 | // match the line numbers in b. 136 | // 137 | // A JSON metadata object is bracketed by . 138 | // A YAML metadata object is bracketed by "---\n" above and below the YAML. 139 | // 140 | // JSON is typically used in HTML; YAML is typically used in Markdown. 141 | func parseMeta(b []byte) (meta Page, tail []byte, err error) { 142 | tail = b 143 | meta = make(Page) 144 | var end int 145 | if bytes.HasPrefix(b, jsonStart) { 146 | end = bytes.Index(b, jsonEnd) 147 | if end < 0 { 148 | return 149 | } 150 | b = b[len(jsonStart)-1 : end+1] // drop leading 39 | // 40 | // By convention, key-value pairs loaded from a metadata block use lower-case keys. 41 | // For historical reasons, keys in JSON metadata are converted to lower-case when read, 42 | // so that the two headers above both refer to a key with a lower-case k. 43 | // 44 | // A few keys have special meanings: 45 | // 46 | // The key-value pair “status: n” sets the HTTP response status to the integer code n. 47 | // 48 | // The key-value pair “redirect: url” causes requests for this page redirect to the given 49 | // relative or absolute URL. 50 | // 51 | // The key-value pair “layout: name” selects the page layout template with the given name. 52 | // See the next section, “Page Rendering”, for details about layout and rendering. 53 | // 54 | // In addition to these explicit key-value pairs, pages loaded from the file system 55 | // have a few implicit key-value pairs added by the page loading process: 56 | // 57 | // - File: the path in fsys to the file containing the page 58 | // - FileData: the file body, with the key-value metadata stripped 59 | // - URL: this page's URL path (/x/y/z for x/y/z.md, /x/y/ for x/y/index.md) 60 | // 61 | // The key “Content” is added during during the rendering process. 62 | // See “Page Rendering” for details. 63 | // 64 | // Page Rendering 65 | // 66 | // A Page's content is rendered in two steps: conversion to content, and framing of content. 67 | // 68 | // To convert a page to content, the page's file body (its FileData key, a []byte) is parsed 69 | // and executed as an HTML template, with the page itself passed as the template input data. 70 | // The template output is then interpreted as Markdown (perhaps with embedded HTML), 71 | // and converted to HTML. The result is stored in the page under the key “Content”, 72 | // with type template.HTML. 73 | // 74 | // A page's conversion to content can be skipped entirely in dynamically-generated pages 75 | // by setting the “Content” key before passing the page to ServePage. 76 | // 77 | // The second step is framing the content in the overall site HTML, which is done by 78 | // executing the site template, again using the Page itself as the template input data. 79 | // 80 | // The site template is constructed from two files in the file system. 81 | // The first file is the fsys's “site.tmpl”, which provides the overall HTML frame for the site. 82 | // The second file is a layout-specific template file, selected by the Page's 83 | // “layout: name” key-value pair. 84 | // The renderer searches for “name.tmpl” in the directory containing the page's file, 85 | // then in the parent of that directory, and so on up to the root. 86 | // If no such template is found, the rendering fails and reports that error. 87 | // As a special case, “layout: none” skips the second file entirely. 88 | // 89 | // If there is no “layout: name” key-value pair, then the renderer tries using an 90 | // implicit “layout: default”, but if no such “default.tmpl” template file can be found, 91 | // the renderer uses an implicit “layout: none” instead. 92 | // 93 | // By convention, the site template and the layout-specific template are connected as follows. 94 | // The site template, at the point where the content should be rendered, executes: 95 | // 96 | // {{block "layout" .}}{{.Content}}{{end}} 97 | // 98 | // The layout-specific template overrides this block by defining its own template named “layout”. 99 | // For example: 100 | // 101 | // {{define "layout"}} 102 | // Here's some content: {{.Content}} 103 | // {{end}} 104 | // 105 | // The use of the “block” template construct ensures that 106 | // if there is no layout-specific template, 107 | // the content will still be rendered. 108 | // 109 | // Page Template Functions 110 | // 111 | // In this web server, templates can themselves be invoked as functions. 112 | // See https://pkg.go.dev/rsc.io/tmplfunc for more details about that feature. 113 | // 114 | // During page rendering, both when rendering a page to content and when framing the content, 115 | // the following template functions are available (in addition to those provided by the 116 | // template package itself and the per-template functions just mentioned). 117 | // 118 | // In all functions taking a file path f, if the path begins with a slash, 119 | // it is interpreted relative to the fsys root. 120 | // Otherwise, it is interpreted relative to the directory of the current page's URL. 121 | // 122 | // The “{{add x y}}”, “{{sub x y}}”, “{{mul x y}}”, and “{{div x y}}” functions 123 | // provide basic math on arguments of type int. 124 | // 125 | // The “{{code f [start [end]]}}” function returns a template.HTML of a formatted display 126 | // of code lines from the file f. 127 | // If both start and end are omitted, then the display shows the entire file. 128 | // If only the start line is specified, then the display shows that single line. 129 | // If both start and end are specified, then the display shows a range of lines 130 | // starting at start up to and including end. 131 | // The arguments start and end can take two forms: a number indicates a specific line number, 132 | // and a string is taken to be a regular expresion indicating the earliest matching line 133 | // in the file (or, for end, the earliest matching line after the start line). 134 | // Any lines ending in “OMIT” are elided from the display. 135 | // 136 | // For example: 137 | // 138 | // {{code "hello.go" `^func main` `^}`}} 139 | // 140 | // The “{{data f}}” function reads the file f, 141 | // decodes it as YAML, and then returns the resulting data, 142 | // typically a map[string]interface{}. 143 | // It is effectively shorthand for “{{yaml (file f)}}”. 144 | // 145 | // The “{{file f}}” function reads the file f and returns its content as a string. 146 | // 147 | // The “{{first n slice}}” function returns a slice of the first n elements of slice, 148 | // or else slice itself when slice has fewer than n elements. 149 | // 150 | // The “{{markdown text}}” function interprets text (a string) as Markdown 151 | // and returns the equivalent HTML as a template.HTML. 152 | // 153 | // The “{{page f}}” function returns the page data (a Page) 154 | // for the static page contained in the file f. 155 | // The lookup ignores trailing slashes in f as well as the presence or absence 156 | // of extensions like .md, .html, /index.md, and /index.html, 157 | // making it possible for f to be a relative or absolute URL path instead of a file path. 158 | // 159 | // The “{{pages glob}}” function returns a slice of page data (a []Page) 160 | // for all pages loaded from files or directories 161 | // in fsys matching the given glob (a string), 162 | // according to the usual file path rules (if the glob starts with slash, 163 | // it is interpreted relative to the fsys root, and otherwise 164 | // relative to the directory of the page's URL). 165 | // If the glob pattern matches a directory, 166 | // the page for the directory's index.md or index.html is used. 167 | // 168 | // For example: 169 | // 170 | // Here are all the articles: 171 | // {{range (pages "/articles/*")}} 172 | // - [{{.title}}]({{.URL}}) 173 | // {{end}} 174 | // 175 | // The “{{raw s}}” function converts s (a string) to type template.HTML without any escaping, 176 | // to allow using s as raw Markdown or HTML in the final output. 177 | // 178 | // The “{{yaml s}}” function decodes s (a string) as YAML and returns the resulting data. 179 | // It is most useful for defining templates that accept YAML-structured data as a literal argument. 180 | // For example: 181 | // 182 | // {{define "quote info"}} 183 | // {{with (yaml .info)}} 184 | // .text 185 | // — .name{{if .title}}, .title{{end}} 186 | // {{end}} 187 | // 188 | // {{quote ` 189 | // text: If a program is too slow, it must have a loop. 190 | // name: Ken Thompson 191 | // `}} 192 | // 193 | // The “path” and “strings” functions return package objects with methods for every top-level 194 | // function in these packages (except path.Split, which has more than one non-error result 195 | // and would not be invokable). For example, “{{strings.ToUpper "abc"}}”. 196 | // 197 | // Serving Requests 198 | // 199 | // A Site is an http.Handler that serves requests by consulting the underlying 200 | // file system and constructing and rendering pages, as well as serving binary 201 | // and text files. 202 | // 203 | // To serve a request for URL path /p, if fsys has a file 204 | // p/index.md, p/index.html, p.md, or p.html 205 | // (in that order of preference), then the Site opens that file, 206 | // parses it into a Page, renders the page as described 207 | // in the “Page Rendering” section above, 208 | // and responds to the request with the generated HTML. 209 | // If the request URL does not match the parsed page's URL, 210 | // then the Site responds with a redirect to the canonical URL. 211 | // 212 | // Otherwise, if fsys has a directory p and the Site 213 | // can find a template “dir.tmpl” in that directory or a parent, 214 | // then the Site responds with the rendering of 215 | // 216 | // Page{ 217 | // "URL": "/p/", 218 | // "File": "p", 219 | // "layout": "dir", 220 | // "dir": []fs.FileInfo(dir), 221 | // } 222 | // 223 | // where dir is the directory contents. 224 | // 225 | // Otherwise, if fsys has a file p containing valid UTF-8 text 226 | // (at least up to the first kilobyte of the file) and the Site 227 | // can find a template “text.tmpl” in that file's directory or a parent, 228 | // and the file is not named robots.txt, 229 | // and the file does not have a .css, .js, .svg, or .ts extension, 230 | // then the Site responds with the rendering of 231 | // 232 | // Page{ 233 | // "URL": "/p", 234 | // "File": "p", 235 | // "layout": "texthtml", 236 | // "texthtml": template.HTML(texthtml), 237 | // } 238 | // 239 | // where texthtml is the text file as rendered by the 240 | // golang.org/x/website/internal/texthtml package. 241 | // In the texthtml.Config, GoComments is set to true for 242 | // file names ending in .go; 243 | // the h URL query parameter, if present, is passed as Highlight, 244 | // and the s URL query parameter, if set to lo:hi, is passed as a 245 | // single-range Selection. 246 | // 247 | // If the request has the URL query parameter m=text, 248 | // then the text file content is not rendered or framed and is instead 249 | // served directly as a plain text response. 250 | // 251 | // If the request is for a file with a .ts extension the file contents 252 | // are transformed from TypeScript to JavaScript and then served with 253 | // a Content-Type=text/javascript header. 254 | // 255 | // Otherwise, if none of those cases apply but the request path p 256 | // does exist in the file system, then the Site passes the 257 | // request to an http.FileServer serving from fsys. 258 | // This last case handles binary static content as well as 259 | // textual static content excluded from the text file case above. 260 | // 261 | // Otherwise, the Site responds with the rendering of 262 | // 263 | // Page{ 264 | // "URL": r.URL.Path, 265 | // "status": 404, 266 | // "layout": "error", 267 | // "error": err, 268 | // } 269 | // 270 | // where err is the “not exist” error returned by fs.Stat(fsys, p). 271 | // (See also the “Serving Errors” section below.) 272 | // 273 | // Serving Dynamic Requests 274 | // 275 | // Of course, a web site may wish to serve more than static content. 276 | // To allow dynamically generated web pages to make use of page 277 | // rendering and site templates, the Site.ServePage method can be 278 | // called with a dynamically generated Page value, which will then 279 | // be rendered and served as the result of the request. 280 | // 281 | // Serving Errors 282 | // 283 | // If an error occurs while serving a request r, 284 | // the Site responds with the rendering of 285 | // 286 | // Page{ 287 | // "URL": r.URL.Path, 288 | // "status": 500, 289 | // "layout": "error", 290 | // "error": err, 291 | // } 292 | // 293 | // If that rendering itself fails, the Site responds with status 500 294 | // and the cryptic page text “error rendering error”. 295 | // 296 | // The Site.ServeError and Site.ServeErrorStatus methods provide a way 297 | // for dynamic servers to generate similar responses. 298 | // 299 | package web 300 | 301 | import ( 302 | "bytes" 303 | "errors" 304 | "fmt" 305 | "html" 306 | "html/template" 307 | "io/fs" 308 | "log" 309 | "net/http" 310 | "path" 311 | "regexp" 312 | "strconv" 313 | "strings" 314 | "sync" 315 | 316 | "rsc.io/web/internal/texthtml" 317 | ) 318 | 319 | // A Site is an http.Handler that serves requests from a file system. 320 | // See the package doc comment for details. 321 | type Site struct { 322 | fs fs.FS // from NewSite 323 | fileServer http.Handler // http.FileServer(http.FS(fs)) 324 | funcs template.FuncMap // accumulated from s.Funcs 325 | cache sync.Map // canonical file path -> *pageFile, for site.openPage 326 | } 327 | 328 | // NewSite returns a new Site for serving pages from the file system fsys. 329 | func NewSite(fsys fs.FS) *Site { 330 | return &Site{ 331 | fs: fsys, 332 | fileServer: http.FileServer(http.FS(fsys)), 333 | } 334 | } 335 | 336 | // Funcs adds the functions in m to the set of functions available to templates. 337 | // Funcs must not be called concurrently with any page rendering. 338 | func (s *Site) Funcs(m template.FuncMap) { 339 | if s.funcs == nil { 340 | s.funcs = make(template.FuncMap) 341 | } 342 | for k, v := range m { 343 | s.funcs[k] = v 344 | } 345 | } 346 | 347 | // readFile returns the content of the named file in the site's file system. 348 | // If file begins with a slash, it is interpreted relative to the root of the file system. 349 | // Otherwise, it is interpreted relative to dir. 350 | func (site *Site) readFile(dir, file string) ([]byte, error) { 351 | if strings.HasPrefix(file, "/") { 352 | file = path.Clean(file) 353 | } else { 354 | file = path.Join(dir, file) 355 | } 356 | file = strings.Trim(file, "/") 357 | if file == "" { 358 | file = "." 359 | } 360 | return fs.ReadFile(site.fs, file) 361 | } 362 | 363 | // ServeError is ServeErrorStatus with HTTP status code 500 (internal server error). 364 | func (s *Site) ServeError(w http.ResponseWriter, r *http.Request, err error) { 365 | s.ServeErrorStatus(w, r, err, http.StatusInternalServerError) 366 | } 367 | 368 | // ServeErrorStatus responds to the request 369 | // with the given error and HTTP status. 370 | // It is equivalent to calling ServePage(w, r, p) where p is: 371 | // 372 | // Page{ 373 | // "URL": r.URL.Path, 374 | // "status": status, 375 | // "layout": error, 376 | // "error": err, 377 | // } 378 | // 379 | func (s *Site) ServeErrorStatus(w http.ResponseWriter, r *http.Request, err error, status int) { 380 | s.serveErrorStatus(w, r, err, status, false) 381 | } 382 | 383 | func (s *Site) serveErrorStatus(w http.ResponseWriter, r *http.Request, err error, status int, renderingError bool) { 384 | 385 | if renderingError { 386 | log.Printf("error rendering error: %v", err) 387 | w.WriteHeader(status) 388 | w.Write([]byte("error rendering error")) 389 | return 390 | } 391 | 392 | p := Page{ 393 | "URL": r.URL.Path, 394 | "status": status, 395 | "layout": "error", 396 | "error": err, 397 | } 398 | s.servePage(w, r, p, true) 399 | } 400 | 401 | // ServePage renders the page p to HTML and writes that HTML to w. 402 | // See the package doc comment for details about page rendering. 403 | // 404 | // So that all templates can assume the presence of p["URL"], 405 | // if p["URL"] is unset or does not have type string, then ServePage 406 | // sets p["URL"] to r.URL.Path in a clone of p before rendering the page. 407 | func (s *Site) ServePage(w http.ResponseWriter, r *http.Request, p Page) { 408 | s.servePage(w, r, p, false) 409 | } 410 | 411 | func (s *Site) servePage(w http.ResponseWriter, r *http.Request, p Page, renderingError bool) { 412 | html, err := s.renderHTML(p, "site.tmpl", r) 413 | if err != nil { 414 | s.serveErrorStatus(w, r, fmt.Errorf("template execution: %v", err), http.StatusInternalServerError, renderingError) 415 | return 416 | } 417 | if code, ok := p["status"].(int); ok { 418 | w.WriteHeader(code) 419 | } 420 | w.Write(html) 421 | } 422 | 423 | // ServeHTTP implements http.Handler, serving from a file in the site. 424 | // See the Site type documentation for details about how requests are handled. 425 | func (s *Site) ServeHTTP(w http.ResponseWriter, r *http.Request) { 426 | abspath := r.URL.Path 427 | relpath := path.Clean(strings.TrimPrefix(abspath, "/")) 428 | 429 | // Is it a page we can generate? 430 | if p, err := s.openPage(relpath); err == nil { 431 | if p.url != abspath { 432 | // Redirect to canonical path. 433 | status := http.StatusMovedPermanently 434 | if i, ok := p.page["status"].(int); ok { 435 | status = i 436 | } 437 | http.Redirect(w, r, p.url, status) 438 | return 439 | } 440 | // Serve from the actual filesystem path. 441 | s.serveHTML(w, r, p) 442 | return 443 | } 444 | 445 | // Is it a directory or file we can serve? 446 | info, err := fs.Stat(s.fs, relpath) 447 | if err != nil { 448 | status := http.StatusInternalServerError 449 | if errors.Is(err, fs.ErrNotExist) { 450 | status = http.StatusNotFound 451 | } 452 | s.ServeErrorStatus(w, r, err, status) 453 | return 454 | } 455 | 456 | // Serve directory. 457 | if info != nil && info.IsDir() { 458 | if _, ok := s.findLayout(relpath, "dir"); ok { 459 | if !maybeRedirect(w, r) { 460 | s.serveDir(w, r, relpath) 461 | } 462 | return 463 | } 464 | } 465 | 466 | // Serve text file. 467 | if isTextFile(s.fs, relpath) { 468 | if _, ok := s.findLayout(path.Dir(relpath), "texthtml"); ok { 469 | if !maybeRedirectFile(w, r) { 470 | s.serveText(w, r, relpath) 471 | } 472 | return 473 | } 474 | } 475 | 476 | // Serve raw bytes. 477 | s.fileServer.ServeHTTP(w, r) 478 | } 479 | 480 | func maybeRedirect(w http.ResponseWriter, r *http.Request) (redirected bool) { 481 | canonical := path.Clean(r.URL.Path) 482 | if !strings.HasSuffix(canonical, "/") { 483 | canonical += "/" 484 | } 485 | if r.URL.Path != canonical { 486 | url := *r.URL 487 | url.Path = canonical 488 | http.Redirect(w, r, url.String(), http.StatusMovedPermanently) 489 | redirected = true 490 | } 491 | return 492 | } 493 | 494 | func maybeRedirectFile(w http.ResponseWriter, r *http.Request) (redirected bool) { 495 | c := path.Clean(r.URL.Path) 496 | c = strings.TrimRight(c, "/") 497 | if r.URL.Path != c { 498 | url := *r.URL 499 | url.Path = c 500 | http.Redirect(w, r, url.String(), http.StatusMovedPermanently) 501 | redirected = true 502 | } 503 | return 504 | } 505 | 506 | func (s *Site) serveHTML(w http.ResponseWriter, r *http.Request, p *pageFile) { 507 | src, _ := p.page["FileData"].(string) 508 | filePath, _ := p.page["File"].(string) 509 | isMarkdown := strings.HasSuffix(filePath, ".md") 510 | 511 | // if it begins with "") 576 | buf.Write(texthtml.Format(src, cfg)) 577 | buf.WriteString("") 578 | 579 | fmt.Fprintf(&buf, ``, html.EscapeString(relpath)) 580 | 581 | s.ServePage(w, r, Page{ 582 | "URL": r.URL.Path, 583 | "File": relpath, 584 | "layout": "texthtml", 585 | "texthtml": template.HTML(buf.String()), 586 | }) 587 | } 588 | 589 | var selRx = regexp.MustCompile(`^([0-9]+):([0-9]+)`) 590 | 591 | // rangeSelection computes the Selection for a text range described 592 | // by the argument str, of the form Start:End, where Start and End 593 | // are decimal byte offsets. 594 | func rangeSelection(str string) texthtml.Selection { 595 | m := selRx.FindStringSubmatch(str) 596 | if len(m) >= 2 { 597 | from, _ := strconv.Atoi(m[1]) 598 | to, _ := strconv.Atoi(m[2]) 599 | if from < to { 600 | return texthtml.Spans(texthtml.Span{Start: from, End: to}) 601 | } 602 | } 603 | return nil 604 | } 605 | 606 | func (s *Site) serveRawText(w http.ResponseWriter, text []byte) { 607 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 608 | w.Write(text) 609 | } 610 | --------------------------------------------------------------------------------