- load, render, emit, snippet, highlight, serve and checker branch tests; docs:build --check, docs:sync and docs:serve output tests in cmd/summer - fix: a CRLF or BOM page is reported as such, not as a page without a frontmatter block (TestFrontmatterEncodingProblems) - fix: src=#Type.Method finds a method with a parenthesised receiver (TestSnippetGenericsAndGroups, Box.Paren) - fix: docs:serve adds its watches before announcing the server, so an edit made right after the serving line rebuilds (TestServeWatchRebuilds)
290 lines
7.4 KiB
Go
290 lines
7.4 KiB
Go
package docsite
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
)
|
|
|
|
// DefaultServeAddr is the loopback address docs:serve listens on.
|
|
const DefaultServeAddr = "127.0.0.1:8088"
|
|
|
|
const serveDebounce = 200 * time.Millisecond
|
|
|
|
// Serve builds the site into a fresh temporary directory (removed on
|
|
// return), serves it on addr and rebuilds when the docs, a module or a
|
|
// src= source directory changes. A failed rebuild prints its problems and
|
|
// keeps serving the last good build. addr must be a loopback address
|
|
// unless allowRemote is set (the flag behind docs:serve --allow-remote).
|
|
// Serve returns when ctx is done.
|
|
func Serve(ctx context.Context, opts Options, addr string, allowRemote bool, out io.Writer) error {
|
|
if err := checkServeAddr(addr, allowRemote); err != nil {
|
|
return err
|
|
}
|
|
opts, err := opts.normalize()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.MkdirTemp("", "summer-docs-")
|
|
if err != nil {
|
|
return fmt.Errorf("docs:serve: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
s := &server{opts: opts, tmp: tmp, out: out}
|
|
if !s.rebuild() {
|
|
return errors.New("docs:serve: build failed")
|
|
}
|
|
|
|
// The watches are in place before the serving line is printed, so an
|
|
// edit made as soon as the server is announced triggers a rebuild.
|
|
w, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return fmt.Errorf("docs:serve: watcher: %w", err)
|
|
}
|
|
defer w.Close()
|
|
s.addWatches(w)
|
|
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("docs:serve: %w", err)
|
|
}
|
|
srv := &http.Server{Handler: dirHandler(s.dir), ReadHeaderTimeout: 10 * time.Second}
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- srv.Serve(ln) }()
|
|
fmt.Fprintf(out, "Serving docs at http://%s (press Ctrl+C to stop)\n", ln.Addr())
|
|
|
|
watchErr := make(chan error, 1)
|
|
watchCtx, stopWatch := context.WithCancel(ctx)
|
|
defer stopWatch()
|
|
go func() { watchErr <- s.watch(watchCtx, w) }()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
case err := <-serveErr:
|
|
return fmt.Errorf("docs:serve: %w", err)
|
|
case err := <-watchErr:
|
|
if err != nil {
|
|
_ = srv.Close()
|
|
return err
|
|
}
|
|
<-ctx.Done()
|
|
}
|
|
stopWatch()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdownCtx)
|
|
return nil
|
|
}
|
|
|
|
// checkServeAddr refuses a listen address whose host is not localhost or
|
|
// a loopback IP, unless allowRemote is set.
|
|
func checkServeAddr(addr string, allowRemote bool) error {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return fmt.Errorf("docs:serve: invalid --addr %q: %w", addr, err)
|
|
}
|
|
if allowRemote || host == "localhost" {
|
|
return nil
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("docs:serve: refusing to listen on %s: not a loopback address. Pass --allow-remote to serve on the network.", addr)
|
|
}
|
|
|
|
// server owns the served build directory and swaps it after each good
|
|
// rebuild.
|
|
type server struct {
|
|
opts Options
|
|
tmp string
|
|
out io.Writer
|
|
|
|
mu sync.RWMutex
|
|
current string
|
|
stale string
|
|
builds int
|
|
}
|
|
|
|
func (s *server) dir() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.current
|
|
}
|
|
|
|
// rebuild builds into a new directory and swaps it in only when the build
|
|
// has no problems. The build before the previous one is removed, so an
|
|
// in-flight request never loses its directory.
|
|
func (s *server) rebuild() bool {
|
|
s.builds++
|
|
dir := filepath.Join(s.tmp, fmt.Sprintf("build-%d", s.builds))
|
|
opts := s.opts
|
|
opts.Out = dir
|
|
_, problems, err := Build(opts)
|
|
if err != nil || len(problems) > 0 {
|
|
for _, p := range problems {
|
|
fmt.Fprintln(s.out, p)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(s.out, err)
|
|
}
|
|
_ = os.RemoveAll(dir)
|
|
if s.dir() != "" {
|
|
fmt.Fprintln(s.out, "docs:serve: build failed, still serving the previous version")
|
|
}
|
|
return false
|
|
}
|
|
s.mu.Lock()
|
|
old := s.stale
|
|
s.stale, s.current = s.current, dir
|
|
s.mu.Unlock()
|
|
if old != "" {
|
|
_ = os.RemoveAll(old)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// watch rebuilds on changes under Src, modules/ and every src= source
|
|
// directory, debounced. The caller owns w and has added the first watches.
|
|
func (s *server) watch(ctx context.Context, w *fsnotify.Watcher) error {
|
|
var timer *time.Timer
|
|
fire := make(chan struct{}, 1)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
if timer != nil {
|
|
timer.Stop()
|
|
}
|
|
return nil
|
|
case ev, ok := <-w.Events:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(filepath.Base(ev.Name), ".") || ev.Op == fsnotify.Chmod {
|
|
continue
|
|
}
|
|
if timer != nil {
|
|
timer.Stop()
|
|
}
|
|
timer = time.AfterFunc(serveDebounce, func() {
|
|
select {
|
|
case fire <- struct{}{}:
|
|
default:
|
|
}
|
|
})
|
|
case err, ok := <-w.Errors:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
fmt.Fprintf(s.out, "docs:serve: watch: %v\n", err)
|
|
case <-fire:
|
|
if s.rebuild() {
|
|
fmt.Fprintln(s.out, "docs:serve: rebuilt")
|
|
}
|
|
s.addWatches(w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// addWatches watches every directory of Src and modules/ and the
|
|
// directory of every src= reference. Adding a watched path again is a
|
|
// no-op.
|
|
func (s *server) addWatches(w *fsnotify.Watcher) {
|
|
dirs := map[string]bool{}
|
|
for _, base := range []string{s.opts.Src, filepath.Join(s.opts.Root, "modules")} {
|
|
_ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || !d.IsDir() {
|
|
return nil
|
|
}
|
|
if p != base && (strings.HasPrefix(d.Name(), ".") || d.Name() == "testdata" || d.Name() == "node_modules") {
|
|
return filepath.SkipDir
|
|
}
|
|
dirs[p] = true
|
|
return nil
|
|
})
|
|
}
|
|
pages, _, err := Pages(s.opts)
|
|
if err == nil {
|
|
for _, p := range pages {
|
|
lines := strings.Split(string(p.Body), "\n")
|
|
for _, f := range scanFences(lines) {
|
|
if ref, ok := ParseSrc(f.info); ok {
|
|
dirs[filepath.Join(s.opts.Root, filepath.FromSlash(path.Dir(ref.Path)))] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for d := range dirs {
|
|
if within(d, s.opts.Root) {
|
|
_ = w.Add(d)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handler serves the built site in dir: files by path, a directory's
|
|
// index.html, 404.html with status 404 for anything missing, and never a
|
|
// dot-file.
|
|
func Handler(dir string) http.Handler {
|
|
return dirHandler(func() string { return dir })
|
|
}
|
|
|
|
func dirHandler(dir func() string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
root := dir()
|
|
clean := path.Clean("/" + r.URL.Path)
|
|
for _, seg := range strings.Split(clean, "/") {
|
|
if strings.HasPrefix(seg, ".") {
|
|
notFound(w, root)
|
|
return
|
|
}
|
|
}
|
|
name := filepath.Join(root, filepath.FromSlash(clean))
|
|
info, err := os.Stat(name)
|
|
if err == nil && info.IsDir() {
|
|
name = filepath.Join(name, "index.html")
|
|
info, err = os.Stat(name)
|
|
}
|
|
if err != nil || !info.Mode().IsRegular() || !within(name, root) {
|
|
notFound(w, root)
|
|
return
|
|
}
|
|
f, err := os.Open(name)
|
|
if err != nil {
|
|
notFound(w, root)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
http.ServeContent(w, r, filepath.Base(name), info.ModTime(), f)
|
|
})
|
|
}
|
|
|
|
// notFound writes the site's 404.html with status 404.
|
|
func notFound(w http.ResponseWriter, root string) {
|
|
body, err := os.ReadFile(filepath.Join(root, "404.html"))
|
|
if err != nil {
|
|
http.NotFound(w, nil)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = io.Copy(w, bytes.NewReader(body))
|
|
}
|