| // Copyright 2020 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| package web | |
| import ( | |
| "bytes" | |
| "fmt" | |
| "html/template" | |
| "io/fs" | |
| "net/http" | |
| "net/url" | |
| "path" | |
| "regexp" | |
| "strings" | |
| "unicode/utf8" | |
| "github.com/yuin/goldmark" | |
| "github.com/yuin/goldmark/ast" | |
| "github.com/yuin/goldmark/extension" | |
| extast "github.com/yuin/goldmark/extension/ast" | |
| "github.com/yuin/goldmark/parser" | |
| "github.com/yuin/goldmark/renderer" | |
| "github.com/yuin/goldmark/renderer/html" | |
| "github.com/yuin/goldmark/text" | |
| "github.com/yuin/goldmark/util" | |
| "golang.org/x/website/internal/tmplfunc" | |
| ) | |
| // RenderContent returns the HTML rendering for the page using the named base template | |
| // (the standard base template is "site.tmpl"). | |
| func (site *Site) RenderContent(p Page, tmpl string) (template.HTML, error) { | |
| html, err := site.renderHTML(p, tmpl, &http.Request{URL: &url.URL{Path: "/missingurl"}}) | |
| if err != nil { | |
| return "", err | |
| } | |
| return template.HTML(html), nil | |
| } | |
| // renderHTML renders and returns the Content and framed HTML for the page. | |
| func (site *Site) renderHTML(p Page, tmpl string, r *http.Request) ([]byte, error) { | |
| // Clone p, because we are going to set its Content key-value pair. | |
| p2 := make(Page) | |
| for k, v := range p { | |
| p2[k] = v | |
| } | |
| p = p2 | |
| url, ok := p["URL"].(string) | |
| if !ok { | |
| // Set URL - caller did not. | |
| p["URL"] = r.URL.Path | |
| } | |
| file, _ := p["File"].(string) | |
| data, _ := p["FileData"].(string) | |
| // Load base template. | |
| base, err := site.readFile(".", tmpl) | |
| if err != nil { | |
| return nil, err | |
| } | |
| dir := strings.Trim(path.Dir(url), "/") | |
| if dir == "" { | |
| dir = "." | |
| } | |
| sd := &siteDir{site, dir} | |
| t := template.New("site.tmpl").Funcs(template.FuncMap{ | |
| "add": func(a, b int) int { return a + b }, | |
| "sub": func(a, b int) int { return a - b }, | |
| "mul": func(a, b int) int { return a * b }, | |
| "div": func(a, b int) int { return a / b }, | |
| "code": sd.code, | |
| "data": sd.data, | |
| "page": sd.page, | |
| "pages": sd.pages, | |
| "play": sd.play, | |
| "request": func() *http.Request { return r }, | |
| "path": func() pkgPath { return pkgPath{} }, | |
| "strings": func() pkgStrings { return pkgStrings{} }, | |
| "file": sd.file, | |
| "first": first, | |
| "markdown": markdown, | |
| "raw": raw, | |
| "yaml": yamlFn, | |
| "presentStyle": presentStyle, | |
| }) | |
| t.Funcs(site.funcs) | |
| if err := tmplfunc.Parse(t, string(base)); err != nil { | |
| return nil, err | |
| } | |
| // Load page-specific layout template. | |
| layout, _ := p["layout"].(string) | |
| if layout == "" { | |
| l, ok := site.findLayout(dir, "default") | |
| if ok { | |
| layout = l | |
| } else { | |
| layout = "none" | |
| } | |
| } else if path.IsAbs(layout) { | |
| layout = strings.TrimLeft(path.Clean(layout+".tmpl"), "/") | |
| } else if strings.Contains(layout, "/") { | |
| layout = path.Join(dir, layout+".tmpl") | |
| } else if layout != "none" { | |
| l, ok := site.findLayout(dir, layout) | |
| if !ok { | |
| return nil, fmt.Errorf("cannot find layout %q", layout) | |
| } | |
| layout = l | |
| } | |
| if layout != "none" { | |
| ldata, err := site.readFile(".", layout) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if err := tmplfunc.Parse(t.New(layout), string(ldata)); err != nil { | |
| return nil, err | |
| } | |
| } | |
| var buf bytes.Buffer | |
| if _, ok := p["Content"]; !ok && data != "" { | |
| // The page must explicitly request templating with "template: true" | |
| // in its metadata. Markdown files are no longer treated as | |
| // templates by default. | |
| isTemplate, _ := p["template"].(bool) | |
| tdata := data | |
| if isTemplate { | |
| // Load content as a template. | |
| tf := t.New(file) | |
| if err := tmplfunc.Parse(tf, data); err != nil { | |
| return nil, err | |
| } | |
| if err := tf.Execute(&buf, p); err != nil { | |
| return nil, err | |
| } | |
| tdata = buf.String() | |
| buf.Reset() | |
| } | |
| if strings.HasSuffix(file, ".md") { | |
| html, err := markdownToHTML(tdata) | |
| if err != nil { | |
| return nil, err | |
| } | |
| p["Content"] = html | |
| } else { | |
| p["Content"] = template.HTML(tdata) | |
| } | |
| } | |
| if err := t.Execute(&buf, p); err != nil { | |
| return nil, err | |
| } | |
| return buf.Bytes(), nil | |
| } | |
| // findLayout searches the start directory and parent directories for a template with the given base name. | |
| func (site *Site) findLayout(dir, name string) (string, bool) { | |
| name += ".tmpl" | |
| for { | |
| abs := path.Join(dir, name) | |
| if _, err := fs.Stat(site.fs, abs); err == nil { | |
| return abs, true | |
| } | |
| if dir == "." { | |
| return "", false | |
| } | |
| dir = path.Dir(dir) | |
| } | |
| } | |
| // markdownToHTML converts Markdown to HTML. | |
| // The Markdown source may contain raw HTML, | |
| // and may contain Go template syntax if templating was not enabled. | |
| func markdownToHTML(markdown string) (template.HTML, error) { | |
| // parser.WithHeadingAttribute allows custom ids on headings. | |
| // html.WithUnsafe allows use of raw HTML, which we need for tables. | |
| md := goldmark.New( | |
| goldmark.WithParserOptions( | |
| parser.WithHeadingAttribute(), | |
| parser.WithAutoHeadingID(), | |
| parser.WithASTTransformers(util.Prioritized(mdTransformFunc(mdLink), 1)), | |
| ), | |
| goldmark.WithRendererOptions(html.WithUnsafe()), | |
| goldmark.WithExtensions( | |
| extension.NewTypographer(), | |
| extension.NewLinkify( | |
| extension.WithLinkifyAllowedProtocols([][]byte{[]byte("http"), []byte("https")}), | |
| extension.WithLinkifyEmailRegexp(regexp.MustCompile(`[^\x00-\x{10FFFF}]`)), // impossible | |
| ), | |
| extension.DefinitionList, | |
| extension.NewTable(), | |
| tableWrapper{}, | |
| ), | |
| ) | |
| var buf bytes.Buffer | |
| buf.WriteString("<div class='markdown'>\n") | |
| if err := md.Convert(replaceTabs([]byte(markdown)), &buf); err != nil { | |
| return "", err | |
| } | |
| buf.WriteString("</div>\n") | |
| return template.HTML(buf.Bytes()), nil | |
| } | |
| // mdTransformFunc is a func implementing parser.ASTTransformer. | |
| type mdTransformFunc func(*ast.Document, text.Reader, parser.Context) | |
| func (f mdTransformFunc) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { | |
| f(node, reader, pc) | |
| } | |
| // mdLink walks doc, adding rel=noreferrer target=_blank to non-relative links. | |
| func mdLink(doc *ast.Document, _ text.Reader, _ parser.Context) { | |
| mdLinkWalk(doc) | |
| } | |
| func mdLinkWalk(n ast.Node) { | |
| switch n := n.(type) { | |
| case *ast.Link: | |
| dest := string(n.Destination) | |
| if strings.HasPrefix(dest, "https://") || strings.HasPrefix(dest, "http://") { | |
| n.SetAttributeString("rel", []byte("noreferrer")) | |
| n.SetAttributeString("target", []byte("_blank")) | |
| } | |
| return | |
| case *ast.AutoLink: | |
| // All autolinks are non-relative. | |
| n.SetAttributeString("rel", []byte("noreferrer")) | |
| n.SetAttributeString("target", []byte("_blank")) | |
| return | |
| } | |
| for child := n.FirstChild(); child != nil; child = child.NextSibling() { | |
| mdLinkWalk(child) | |
| } | |
| } | |
| // replaceTabs replaces all tabs in text with spaces up to a 4-space tab stop. | |
| // | |
| // In Markdown, tabs used for indentation are required to be interpreted as | |
| // 4-space tab stops. See https://spec.commonmark.org/0.30/#tabs. | |
| // Go also renders nicely and more compactly on the screen with 4-space | |
| // tab stops, while browsers often use 8-space. | |
| // And Goldmark crashes in some inputs that mix spaces and tabs. | |
| // Fix the crashes and make the Go code consistently compact across browsers, | |
| // all while staying Markdown-compatible, by expanding to 4-space tab stops. | |
| // | |
| // This function does not handle multi-codepoint Unicode sequences correctly. | |
| func replaceTabs(text []byte) []byte { | |
| var buf bytes.Buffer | |
| col := 0 | |
| for len(text) > 0 { | |
| r, size := utf8.DecodeRune(text) | |
| text = text[size:] | |
| switch r { | |
| case '\n': | |
| buf.WriteByte('\n') | |
| col = 0 | |
| case '\t': | |
| buf.WriteByte(' ') | |
| col++ | |
| for col%4 != 0 { | |
| buf.WriteByte(' ') | |
| col++ | |
| } | |
| default: | |
| buf.WriteRune(r) | |
| col++ | |
| } | |
| } | |
| return buf.Bytes() | |
| } | |
| // This is a goldmark extension for wrapping tables written in markdown. | |
| // Tables overflow on narrow screens, so it's necessary to wrap them in a div | |
| // with overflow-x: auto to trigger scroll bars. | |
| type tableWrapper struct{} | |
| func (t tableWrapper) Extend(m goldmark.Markdown) { | |
| m.Renderer().AddOptions( | |
| renderer.WithNodeRenderers( | |
| util.Prioritized(t, 0), | |
| ), | |
| ) | |
| } | |
| func (t tableWrapper) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { | |
| reg.Register(extast.KindTable, t.renderTable) | |
| } | |
| func (tableWrapper) renderTable(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { | |
| if entering { | |
| _, _ = w.WriteString(`<div style="overflow-x:auto"><table`) | |
| if n.Attributes() != nil { | |
| html.RenderAttributes(w, n, extension.TableAttributeFilter) | |
| } | |
| _, _ = w.WriteString(">\n") | |
| } else { | |
| _, _ = w.WriteString("</table></div>\n") | |
| } | |
| return ast.WalkContinue, nil | |
| } |