Repos / s4g / 160382f09d
commit 160382f09dfbfb5fd4d6976250759c0b3915fa5a
Author: Nhân <hi@imnhan.com>
Date:   Sat Jul 15 13:33:07 2023 +0700

    show error page; restart server when root changes
    
    TODO: when Root changes from a subpath to "/", the server will return
    a 404 instead of redirecting user to new path. Should fix that.

diff --git a/feed.go b/feed.go
index 7ecd3dd..c49dff7 100644
--- a/feed.go
+++ b/feed.go
@@ -9,7 +9,7 @@
 
 // TODO: Use Article's updated date instead of PostedAt.
 // I need to implement Article.UpdatedAt first though.
-func generateFeed(site SiteMetadata, posts []Article, path string) []byte {
+func generateFeed(site *SiteMetadata, posts []Article, path string) []byte {
 	siteAddr := site.Address
 	if !strings.HasSuffix(siteAddr, "/") {
 		siteAddr += "/"
diff --git a/livereload/error.html b/livereload/error.html
new file mode 100644
index 0000000..c2d392e
--- /dev/null
+++ b/livereload/error.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>Error</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  </head>
+  <body>
+    <b>Error:</b> {{.}}
+    <style>
+      body {
+        background-color: pink;
+        color: red;
+      }
+    </style>
+  </body>
+</html>
diff --git a/livereload/livereload.go b/livereload/livereload.go
index 6bd7e02..898e6dd 100644
--- a/livereload/livereload.go
+++ b/livereload/livereload.go
@@ -7,6 +7,7 @@
 	"net/http"
 	"strings"
 	"sync"
+	"text/template"
 
 	"go.imnhan.com/webmaker2000/writablefs"
 )
@@ -17,6 +18,9 @@
 //go:embed livereload.html
 var lrScript []byte
 
+//go:embed error.html
+var errorTmpl string
+
 var pleaseReload = []byte("1")
 var dontReload = []byte("0")
 
@@ -25,8 +29,10 @@
 	//
 	// Client IDs are generated on client side so that an open tab's
 	// livereload feature keeps working even when the server is restarted.
-	clients map[string]bool
-	mut     sync.RWMutex
+	clients    map[string]bool
+	clientsMut sync.RWMutex
+	err        error
+	errMut     sync.RWMutex
 }{
 	clients: make(map[string]bool),
 }
@@ -45,16 +51,16 @@ func init() {
 
 func handleFunc(w http.ResponseWriter, r *http.Request) {
 	clientId := r.Header.Get(clientIdHeader)
-	state.mut.RLock()
+	state.clientsMut.RLock()
 	shouldReload, ok := state.clients[clientId]
-	state.mut.RUnlock()
+	state.clientsMut.RUnlock()
 
 	// New client: add client to state, don't reload
 	if !ok {
 		//fmt.Println("New livereload client:", clientId)
-		state.mut.Lock()
+		state.clientsMut.Lock()
 		state.clients[clientId] = false
-		state.mut.Unlock()
+		state.clientsMut.Unlock()
 		w.Write(dontReload)
 		return
 	}
@@ -64,21 +70,30 @@ func handleFunc(w http.ResponseWriter, r *http.Request) {
 		w.Write(pleaseReload)
 		// On reload, the browser tab will generate another client ID,
 		// so we can safely delete the old client ID now:
-		state.mut.Lock()
+		state.clientsMut.Lock()
 		delete(state.clients, clientId)
-		state.mut.Unlock()
+		state.clientsMut.Unlock()
 	} else {
 		w.Write(dontReload)
 	}
 }
 
 // For html pages, insert a script tag to enable livereload
-func Middleware(root string, fsys writablefs.FS, f http.Handler) http.Handler {
+func Middleware(mux *http.ServeMux, root string, fsys writablefs.FS, f http.Handler) http.Handler {
 
 	// Handle AJAX endpoint
-	http.HandleFunc(endpoint, handleFunc)
+	mux.HandleFunc(endpoint, handleFunc)
 
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var err error
+		state.errMut.RLock()
+		err = state.err
+		state.errMut.RUnlock()
+		if err != nil {
+			serveError(w, r, err)
+			return
+		}
+
 		path := r.URL.Path
 
 		// For non-html requests, fall through to default FileServer handler
@@ -107,13 +122,22 @@ func Middleware(root string, fsys writablefs.FS, f http.Handler) http.Handler {
 
 // Tell current browser tabs to reload
 func Trigger() {
-	state.mut.Lock()
-	defer state.mut.Unlock()
+	state.clientsMut.Lock()
+	defer state.clientsMut.Unlock()
 	for k := range state.clients {
 		state.clients[k] = true
 	}
 }
 
+// When a non-nil error is set, the local webserver returns
+// the error page for every path (except livereload duh).
+func SetError(err error) {
+	state.errMut.Lock()
+	state.err = err
+	state.errMut.Unlock()
+	Trigger()
+}
+
 func withLiveReload(original []byte) []byte {
 	bodyEndPos := bytes.LastIndex(original, []byte("</body>"))
 	if bodyEndPos == -1 {
@@ -128,3 +152,12 @@ func withLiveReload(original []byte) []byte {
 	copy(result[bodyEndPos+len(lrScript):], original[bodyEndPos:])
 	return result
 }
+
+var errTmpl = template.Must(template.New("error").Parse(errorTmpl))
+
+func serveError(w http.ResponseWriter, r *http.Request, err error) {
+	var buf bytes.Buffer
+	errTmpl.Execute(&buf, err.Error())
+	body := withLiveReload(buf.Bytes())
+	w.Write(body)
+}
diff --git a/main.go b/main.go
index 7e7b6f7..7eb15a6 100644
--- a/main.go
+++ b/main.go
@@ -2,6 +2,7 @@
 
 import (
 	"bytes"
+	"context"
 	"flag"
 	"fmt"
 	"html/template"
@@ -13,6 +14,7 @@
 	"path/filepath"
 	"sort"
 	"strings"
+	"sync"
 	"time"
 
 	"go.imnhan.com/webmaker2000/djot"
@@ -81,8 +83,34 @@ func handleServeCmd(folder, port string) {
 	}
 
 	fsys := writablefs.WriteDirFS(absolutePath)
+	site, err := ReadSiteMetadata(fsys)
+	if err != nil {
+		panic(err)
+	}
 
-	site := regenerate(fsys)
+	webRootUpdates := make(chan string)
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func(webRoot string) {
+		defer wg.Done()
+
+		srv := runServer(fsys, webRoot, port)
+
+		for {
+			newRoot := <-webRootUpdates
+			if newRoot == webRoot {
+				continue
+			}
+			fmt.Println("Root changed => restarting server")
+			webRoot = newRoot
+			err := srv.Shutdown(context.TODO())
+			if err != nil {
+				panic(err)
+			}
+			srv = runServer(fsys, webRoot, port)
+		}
+	}(site.Root)
 
 	// TODO: only rebuild necessary bits instead of regenerating
 	// the whole thing. To do that I'll probably need to:
@@ -93,35 +121,61 @@ func handleServeCmd(folder, port string) {
 	// directory.
 	closeWatcher := WatchLocalFS(fsys, func() {
 		fmt.Println("Change detected. Regenerating...")
-		regenerate(fsys)
-		livereload.Trigger()
+		newSite, err := regenerate(fsys)
+		livereload.SetError(err)
+		if err == nil {
+			fmt.Println("Sending", newSite.Root)
+			webRootUpdates <- newSite.Root
+			fmt.Println("Done", newSite.Root)
+		}
 	})
 	defer closeWatcher()
 
-	println("Serving local website at http://localhost:" + port + site.Root)
-	http.Handle(
-		site.Root,
+	site, err = regenerate(fsys)
+	livereload.SetError(err)
+
+	wg.Wait()
+}
+
+// Non-blocking. Returns srv handle to allow calling Shutdown() later.
+func runServer(fsys writablefs.FS, webRoot string, port string) *http.Server {
+	println("Serving local website at http://localhost:" + port + webRoot)
+	mux := http.NewServeMux()
+	mux.Handle(
+		webRoot,
 		livereload.Middleware(
-			site.Root,
+			mux,
+			webRoot,
 			fsys,
-			http.StripPrefix(site.Root, http.FileServer(http.FS(fsys))),
+			http.StripPrefix(webRoot, http.FileServer(http.FS(fsys))),
 		),
 	)
 
-	if site.Root != "/" {
-		http.Handle("/", http.RedirectHandler(site.Root, http.StatusTemporaryRedirect))
+	if webRoot != "/" {
+		mux.Handle("/", http.RedirectHandler(webRoot, http.StatusTemporaryRedirect))
 	}
 
-	err = http.ListenAndServe("127.0.0.1:"+port, nil)
-	if err != nil {
-		panic(err)
+	srv := &http.Server{
+		Addr:    "127.0.0.1:" + port,
+		Handler: mux,
 	}
+
+	go func() {
+		srv.ListenAndServe()
+	}()
+
+	return srv
 }
 
-func regenerate(fsys writablefs.FS) (site SiteMetadata) {
+func regenerate(fsys writablefs.FS) (site *SiteMetadata, err error) {
 	defer timer("Took %s")()
 
-	site = ReadSiteMetadata(fsys)
+	site, err = ReadSiteMetadata(fsys)
+	if err != nil {
+		livereload.SetError(err)
+		return nil, err
+	}
+
 	articles := findArticles(fsys, site)
 
 	if len(articles) == 0 {
@@ -136,8 +190,8 @@ func regenerate(fsys writablefs.FS) (site SiteMetadata) {
 	for _, link := range site.NavbarLinks {
 		a, ok := articles[link]
 		if !ok {
-			fmt.Printf("NavbarLinks: %s not found\n", link)
-			continue
+			return nil,
+				fmt.Errorf("%s: NavbarLinks: %s not found", FeedPath, link)
 		}
 		articlesInNav = append(articlesInNav, a)
 	}
@@ -160,7 +214,7 @@ func regenerate(fsys writablefs.FS) (site SiteMetadata) {
 
 	for _, a := range articles {
 		fmt.Println(">", a.Path, "-", a.Title)
-		a.WriteHtmlFile(&site, articlesInNav, articlesInFeed, startYear)
+		a.WriteHtmlFile(site, articlesInNav, articlesInFeed, startYear)
 		generatedFiles[a.OutputPath] = true
 	}
 	fmt.Printf("Processed %d articles\n", len(articles))
@@ -270,7 +324,7 @@ func (a *Article) WriteHtmlFile(
 	}
 }
 
-func findArticles(fsys writablefs.FS, site SiteMetadata) map[string]Article {
+func findArticles(fsys writablefs.FS, site *SiteMetadata) map[string]Article {
 	result := make(map[string]Article)
 
 	fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
diff --git a/metadata.go b/metadata.go
index a047d37..d32badb 100644
--- a/metadata.go
+++ b/metadata.go
@@ -42,12 +42,12 @@ func NewSiteMetadata() SiteMetadata {
 	}
 }
 
-func ReadSiteMetadata(fsys writablefs.FS) SiteMetadata {
+func ReadSiteMetadata(fsys writablefs.FS) (*SiteMetadata, error) {
 	sm := NewSiteMetadata()
 
 	data, err := fs.ReadFile(fsys, SiteFileName)
 	if err != nil {
-		panic(err)
+		return nil, fmt.Errorf("ReadSiteMetadata: %w", err)
 	}
 
 	UnmarshalMetadata(data, &sm)
@@ -60,7 +60,7 @@ func ReadSiteMetadata(fsys writablefs.FS) SiteMetadata {
 		sm.Root = fmt.Sprintf("/%s/", trimmed)
 	}
 
-	return sm
+	return &sm, nil
 }
 
 // Similar API to json.Unmarshal but supports neither struct tags nor nesting.