Repos / s4g / 3795bcef38
commit 3795bcef3804a456d93c3464b88c03a217a4e024
Author: Nhân <hi@imnhan.com>
Date:   Wed Jul 19 00:33:34 2023 +0700

    standardize recoverable user errors as UserFileErr

diff --git a/README.md b/README.md
index 1945f08..34f8944 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
 
 - [x] Livereload with no browser plugin (works but currently polls which is
   noisy, should probably upgrade to websockets)
-- [ ] Shows user error messages on the livereloaded web page
+- [x] Shows user error messages on the livereloaded web page
 - [ ] Just enough GUI so user doesn't have to touch a terminal
 - [ ] 1-click deploy to popular static hosting targets (git push, rsync, etc.)
 
diff --git a/errors.go b/errors.go
deleted file mode 100644
index a698240..0000000
--- a/errors.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"html/template"
-)
-
-type UserFileErr struct {
-	File  string
-	Field string
-	Msg   string
-}
-
-func (e *UserFileErr) Error() string {
-	return fmt.Sprintf("UserFileErr - %s - %s: %s", e.File, e.Field, e.Msg)
-}
-
-func (e *UserFileErr) Html() template.HTML {
-	return template.HTML(fmt.Sprintf(
-		"<p>In file <b>%s</b>, field <b>%s</b>: %s </p>",
-		e.File, e.Field, e.Msg,
-	))
-}
diff --git a/errs/errs.go b/errs/errs.go
new file mode 100644
index 0000000..6ad5572
--- /dev/null
+++ b/errs/errs.go
@@ -0,0 +1,45 @@
+package errs
+
+import (
+	"fmt"
+	"html/template"
+)
+
+// Represents a user input error, which in webmaker2000's case is almost
+// always some malformed file.
+type UserFileErr struct {
+	File string
+	Msg  string
+
+	// Optional. Zero value means unavailable.
+	Line int
+
+	// Optional. Zero value means unavailable.
+	Column int
+
+	// Optional. Zero value means unavailable.
+	Field string
+}
+
+func (e *UserFileErr) Error() string {
+	return fmt.Sprintf(
+		"UserFileErr: %s - %d:%d:%s %s",
+		e.File, e.Line, e.Column, e.Field, e.Msg,
+	)
+}
+
+func (e *UserFileErr) Html() template.HTML {
+	content := fmt.Sprintf("In file <b>%s</b>", e.File)
+	if e.Line != 0 {
+		content += fmt.Sprintf(", line %d", e.Line)
+	}
+	if e.Column != 0 {
+		content += fmt.Sprintf(", column %d", e.Column)
+	}
+	if e.Field != "" {
+		content += fmt.Sprintf(", field <b>%s</b>", e.Field)
+	}
+
+	content = fmt.Sprintf("<p>%s: %s</p>", content, e.Msg)
+	return template.HTML(content)
+}
diff --git a/livereload/error.go b/livereload/error.go
index ac650db..ac929ec 100644
--- a/livereload/error.go
+++ b/livereload/error.go
@@ -3,8 +3,11 @@
 import (
 	"bytes"
 	_ "embed"
+	"errors"
 	"html/template"
 	"net/http"
+
+	"go.imnhan.com/webmaker2000/errs"
 )
 
 //go:embed error.html
@@ -12,26 +15,27 @@
 
 var errTmpl = template.Must(template.New("error").Parse(errorTmpl))
 
-// Error that has a user-friendly HTML representation.
-type htmlErr interface {
-	error
-	Html() template.HTML
+type errTmplInput struct {
+	Text string
+	Html template.HTML
 }
 
-func serveError(w http.ResponseWriter, r *http.Request, err error) {
+func serveError(w http.ResponseWriter, r *http.Request, e error) {
 	var buf bytes.Buffer
-	_, ok := err.(htmlErr)
+	var uerr *errs.UserFileErr
+	ok := errors.As(e, &uerr)
+
+	var tmplInput errTmplInput
 	if ok {
-		errTmpl.Execute(&buf, err)
+		tmplInput.Text = uerr.Error()
+		tmplInput.Html = uerr.Html()
 	} else {
-		// Shim for errors that don't support HTML output
-		errTmpl.Execute(&buf, struct {
-			Error string
-			Html  template.HTML
-		}{
-			Error: err.Error(),
-			Html:  template.HTML(err.Error()),
-		})
+		tmplInput.Text = e.Error()
+		tmplInput.Html = template.HTML(e.Error())
+	}
+	err := errTmpl.Execute(&buf, tmplInput)
+	if err != nil {
+		panic(err)
 	}
 	body := withLiveReload(buf.Bytes())
 	w.Write(body)
diff --git a/livereload/error.html b/livereload/error.html
index e59eb99..ab65a2d 100644
--- a/livereload/error.html
+++ b/livereload/error.html
@@ -2,7 +2,7 @@
 <html lang="en">
   <head>
     <meta charset="utf-8" />
-    <title>{{.Error}}</title>
+    <title>{{.Text}}</title>
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   </head>
   <body>
diff --git a/main.go b/main.go
index 05cbe31..95f00f1 100644
--- a/main.go
+++ b/main.go
@@ -18,6 +18,7 @@
 	"time"
 
 	"go.imnhan.com/webmaker2000/djot"
+	"go.imnhan.com/webmaker2000/errs"
 	"go.imnhan.com/webmaker2000/gui"
 	"go.imnhan.com/webmaker2000/livereload"
 	"go.imnhan.com/webmaker2000/writablefs"
@@ -30,7 +31,7 @@
 
 func main() {
 	invalidCommand := func() {
-		fmt.Println("Usage: webfolder2000 new|serve [...]")
+		fmt.Println("Usage: webfolder2000 new|serve|gui [...]")
 		os.Exit(1)
 	}
 
@@ -210,7 +211,7 @@ func regenerate(fsys writablefs.FS) (site *SiteMetadata, err error) {
 	for _, link := range site.NavbarLinks {
 		a, ok := articles[link]
 		if !ok {
-			return nil, &UserFileErr{
+			return nil, &errs.UserFileErr{
 				File:  SiteFileName,
 				Field: "NavbarLinks",
 				Msg:   fmt.Sprintf(`"%s" does not exist`, link),
@@ -383,9 +384,10 @@ func findArticles(fsys writablefs.FS, site *SiteMetadata) (map[string]Article, e
 			},
 			ShowInFeed: true,
 		}
-		err = UnmarshalMetadata(metaText, &meta)
-		if err != nil {
-			return err
+		userErr := UnmarshalMetadata(metaText, &meta)
+		if userErr != nil {
+			userErr.File = path
+			return fmt.Errorf("findArticles failed to unmarshall metadata: %w", userErr)
 		}
 
 		article := Article{
diff --git a/metadata.go b/metadata.go
index 6050bc7..2952388 100644
--- a/metadata.go
+++ b/metadata.go
@@ -11,6 +11,7 @@
 	"strings"
 	"time"
 
+	"go.imnhan.com/webmaker2000/errs"
 	"go.imnhan.com/webmaker2000/writablefs"
 )
 
@@ -64,7 +65,7 @@ func ReadSiteMetadata(fsys writablefs.FS) (*SiteMetadata, error) {
 }
 
 // Similar API to json.Unmarshal but supports neither struct tags nor nesting.
-func UnmarshalMetadata(data []byte, dest any) error {
+func UnmarshalMetadata(data []byte, dest any) *errs.UserFileErr {
 	m := metaTextToMap(data)
 
 	s := reflect.ValueOf(dest).Elem()
@@ -81,7 +82,7 @@ func UnmarshalMetadata(data []byte, dest any) error {
 			case "int":
 				intVal, err := strconv.Atoi(val)
 				if err != nil {
-					return &UserFileErr{
+					return &errs.UserFileErr{
 						Field: fieldName,
 						Msg:   fmt.Sprintf(`invalid int: "%s"`, err),
 					}
@@ -90,7 +91,7 @@ func UnmarshalMetadata(data []byte, dest any) error {
 
 			case "bool":
 				if val != "true" && val != "false" {
-					return &UserFileErr{
+					return &errs.UserFileErr{
 						Field: fieldName,
 						Msg: fmt.Sprintf(
 							`invalid boolean: expected true/false, got "%s"`,
@@ -104,7 +105,7 @@ func UnmarshalMetadata(data []byte, dest any) error {
 				tVal, err := time.ParseInLocation("2006-01-02", val, time.Local)
 				tVal = tVal.Local()
 				if err != nil {
-					return &UserFileErr{
+					return &errs.UserFileErr{
 						Field: fieldName,
 						Msg: fmt.Sprintf(
 							`invalid date: expected YYYY-MM-DD, got "%s"`, val,