blob: fd4f256fd3312af1c21a568efc8c199970fa8790 [file] [log] [blame]
// Copyright 2023 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 content implements a basic web serving framework.
//
// # Content Server
//
// A content server is an http.Handler that serves requests from a file system.
// Use Server(fsys) to create a new content server.
//
// The server is defined primarily by the content of its file system fsys,
// which holds files to be served. It renders markdown files and golang
// templates into HTML.
//
// # Page Rendering
//
// A request for a path like "/page" will search the file system for
// "page.md", "page.html", "page/index.md", and "page/index.html" and
// render HTML output for the first file found.
//
// Partial templates with the extension ".tmpl" at the root of the file system
// and in the same directory as the requested page are included in the
// html/template execution step to allow for sharing and composing logic from
// multiple templates.
//
// Markdown templates must have an html layout template set in the frontmatter
// section. The markdown content is available to the layout template as the
// field `{{.Content}}`.
package content
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"net/http"
"path"
"strconv"
"strings"
"github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
// contentServer serves requests for a given file system and renders html
// templates.
type contentServer struct {
fsys fs.FS
fserv http.Handler
handlers map[string]HandlerFunc
}
type HandlerFunc func(http.ResponseWriter, *http.Request) error
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := f(w, r); err != nil {
handleErr(w, r, err, http.StatusInternalServerError)
}
}
// Server returns a handler that serves HTTP requests with the contents
// of the file system rooted at fsys. For requests to a path without an
// extension, the server will search fsys for markdown or html templates
// first by appending .md, .html, /index.md, and /index.html to the
// requested url path.
//
// The default behavior of looking for templates within fsys can be overridden
// by using an optional set of content handlers.
//
// For example, a server can be constructed for a file system with a single
// template, “index.html“, in a directory, “content“, and a handler:
//
// fsys := os.DirFS("content")
// s := content.Server(fsys,
// content.Handler("/", func(w http.ReponseWriter, _ *http.Request) error {
// return content.Template(w, fsys, "index.html", nil, http.StatusOK)
// }))
//
// or without a handler:
//
// content.Server(os.DirFS("content"))
//
// Both examples will render the template index.html for requests to "/".
func Server(fsys fs.FS, handlers ...*handler) http.Handler {
fserv := http.FileServer(http.FS(fsys))
hs := make(map[string]HandlerFunc)
for _, h := range handlers {
if _, ok := hs[h.path]; ok {
panic("multiple registrations for " + h.path)
}
hs[h.path] = h.fn
}
return &contentServer{fsys, fserv, hs}
}
type handler struct {
path string
fn HandlerFunc
}
func Handler(path string, h HandlerFunc) *handler {
return &handler{path, h}
}
func (c *contentServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if len(r.URL.Path) > 255 {
handleErr(w, r, errors.New("url too long"), http.StatusBadRequest)
return
}
if h, ok := c.handlers[r.URL.Path]; ok {
h.ServeHTTP(w, r)
return
}
ext := path.Ext(r.URL.Path)
if ext == ".md" || ext == ".html" {
http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, ext), http.StatusMovedPermanently)
return
}
filepath, err := stat(c.fsys, r.URL.Path)
if errors.Is(err, fs.ErrNotExist) {
handleErr(w, r, errors.New(http.StatusText(http.StatusNotFound)), http.StatusNotFound)
return
}
if err == nil {
if strings.HasSuffix(r.URL.Path, "/index") {
http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, "/index"), http.StatusMovedPermanently)
return
}
switch path.Ext(filepath) {
case ".html":
err = Template(w, c.fsys, filepath, nil, http.StatusOK)
case ".md":
err = markdown(w, c.fsys, filepath, http.StatusOK)
default:
c.fserv.ServeHTTP(w, r)
}
}
if err != nil {
handleErr(w, r, err, http.StatusInternalServerError)
}
}
// Template executes a template response.
func Template(w http.ResponseWriter, fsys fs.FS, tmplPath string, data any, code int) error {
patterns, err := tmplPatterns(fsys, tmplPath)
if err != nil {
return err
}
patterns = append(patterns, tmplPath)
tmpl, err := template.ParseFS(fsys, patterns...)
if err != nil {
return err
}
name := path.Base(tmplPath)
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return err
}
if code != 0 {
w.WriteHeader(code)
}
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
if _, err := w.Write(buf.Bytes()); err != nil {
return err
}
return nil
}
// JSON encodes data as JSON response with a status code.
func JSON(w http.ResponseWriter, data any, code int) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(data); err != nil {
return err
}
if code != 0 {
w.WriteHeader(code)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
if _, err := w.Write(buf.Bytes()); err != nil {
return err
}
return nil
}
// Text formats data as a text response with a status code.
func Text(w http.ResponseWriter, data any, code int) error {
var buf bytes.Buffer
if _, err := fmt.Fprint(&buf, data); err != nil {
return err
}
if code != 0 {
w.WriteHeader(code)
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
if _, err := w.Write(buf.Bytes()); err != nil {
return err
}
return nil
}
// Text renders an http status code as a text response.
func Status(w http.ResponseWriter, code int) error {
if code < http.StatusBadRequest {
return Text(w, http.StatusText(code), code)
}
return Error(errors.New(http.StatusText(code)), code)
}
// Error annotates an error with http status information.
func Error(err error, code int) error {
return &contentError{err, code}
}
type contentError struct {
err error
Code int
}
func (e *contentError) Error() string { return e.err.Error() }
// handleErr writes an error as an HTTP response with a status code.
func handleErr(w http.ResponseWriter, req *http.Request, err error, code int) {
if cerr, ok := err.(*contentError); ok {
code = cerr.Code
}
if code == http.StatusInternalServerError {
http.Error(w, http.StatusText(http.StatusInternalServerError), code)
} else {
http.Error(w, err.Error(), code)
}
}
// markdown renders a markdown template as html.
func markdown(w http.ResponseWriter, fsys fs.FS, tmplPath string, code int) error {
markdown, err := fs.ReadFile(fsys, tmplPath)
if err != nil {
return err
}
md := goldmark.New(
goldmark.WithParserOptions(
parser.WithHeadingAttribute(),
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithUnsafe(),
html.WithXHTML(),
),
goldmark.WithExtensions(
extension.GFM,
extension.NewTypographer(),
meta.Meta,
),
)
var content bytes.Buffer
ctx := parser.NewContext()
if err := md.Convert(markdown, &content, parser.WithContext(ctx)); err != nil {
return err
}
data := meta.Get(ctx)
if data == nil {
data = map[string]interface{}{}
}
data["Content"] = template.HTML(content.String())
layout, ok := data["Layout"]
if !ok {
return errors.New("missing layout for template " + tmplPath)
}
return Template(w, fsys, layout.(string), data, code)
}
// stat trys to coerce a urlPath into an openable file then returns the
// file path.
func stat(fsys fs.FS, urlPath string) (string, error) {
cleanPath := path.Clean(strings.TrimPrefix(urlPath, "/"))
ext := path.Ext(cleanPath)
filePaths := []string{cleanPath}
if ext == "" || ext == "." {
md := cleanPath + ".md"
html := cleanPath + ".html"
indexMD := path.Join(cleanPath, "index.md")
indexHTML := path.Join(cleanPath, "index.html")
filePaths = []string{md, html, indexMD, indexHTML, cleanPath}
}
var p string
var err error
for _, p = range filePaths {
if _, err = fs.Stat(fsys, p); err == nil {
break
}
}
return p, err
}
// tmplPatterns generates a slice of file patterns to use in template.ParseFS.
func tmplPatterns(fsys fs.FS, tmplPath string) ([]string, error) {
var patterns []string
globs := []string{"*.tmpl", path.Join(path.Dir(tmplPath), "*.tmpl")}
for _, g := range globs {
matches, err := fs.Glob(fsys, g)
if err != nil {
return nil, err
}
patterns = append(patterns, matches...)
}
return patterns, nil
}