blob: 16c38221305e0f961d6b90f8fe4ffe3b1dd20319 [file] [log] [blame]
// 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"
"github.com/yuin/goldmark/parser"
"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 != "" {
// Load actual Markdown content (also 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
}
if strings.HasSuffix(file, ".md") {
html, err := markdownToHTML(buf.String())
if err != nil {
return nil, err
}
p["Content"] = html
} else {
p["Content"] = template.HTML(buf.String())
}
buf.Reset()
}
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,
// but Go templates have already been processed.
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,
),
)
var buf bytes.Buffer
if err := md.Convert(replaceTabs([]byte(markdown)), &buf); err != nil {
return "", err
}
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()
}