Improve error handling/messages in Hugo Pipes

Fixes #14257
Closes #14270
This commit is contained in:
Bjørn Erik Pedersen
2025-12-16 16:25:37 +01:00
parent b82e496c13
commit 0bf61353f6
15 changed files with 212 additions and 97 deletions
+44 -24
View File
@@ -288,6 +288,50 @@ func Unwrap(err error) error {
return err
}
// UnwrapFileErrors returns all FileError contained in err.
func UnwrapFileErrors(err error) []FileError {
if err == nil {
return nil
}
errs := Errors(err)
var fileErrors []FileError
for _, e := range errs {
if v, ok := e.(FileError); ok {
fileErrors = append(fileErrors, v)
}
fileErrors = append(fileErrors, UnwrapFileErrors(errors.Unwrap(e))...)
}
return fileErrors
}
// UnwrapFileErrorsWithErrorContext tries to unwrap all FileError in err that has an ErrorContext.
func UnwrapFileErrorsWithErrorContext(err error) []FileError {
errs := UnwrapFileErrors(err)
var n int
for _, e := range errs {
if e.ErrorContext() != nil {
errs[n] = e
n++
}
}
return errs[:n]
}
// Errors returns the list of errors contained in err.
func Errors(err error) []error {
if err == nil {
return nil
}
type unwrapper interface {
Unwrap() []error
}
if u, ok := err.(unwrapper); ok {
return u.Unwrap()
}
return []error{err}
}
func extractFileTypePos(err error) (string, text.Position) {
err = Unwrap(err)
@@ -350,30 +394,6 @@ func UnwrapFileError(err error) FileError {
return nil
}
// UnwrapFileErrors tries to unwrap all FileError.
func UnwrapFileErrors(err error) []FileError {
var errs []FileError
for err != nil {
if v, ok := err.(FileError); ok {
errs = append(errs, v)
}
err = errors.Unwrap(err)
}
return errs
}
// UnwrapFileErrorsWithErrorContext tries to unwrap all FileError in err that has an ErrorContext.
func UnwrapFileErrorsWithErrorContext(err error) []FileError {
var errs []FileError
for err != nil {
if v, ok := err.(FileError); ok && v.ErrorContext() != nil {
errs = append(errs, v)
}
err = errors.Unwrap(err)
}
return errs
}
func extractOffsetAndType(e error) (int, string) {
switch v := e.(type) {
case *json.UnmarshalTypeError:
+31 -23
View File
@@ -15,6 +15,7 @@ package hugolib
import (
"context"
"errors"
"fmt"
"io"
"iter"
@@ -254,6 +255,16 @@ func (f *fatalErrorHandler) FatalError(err error) {
f.err = err
}
// Stop stops the fatal error handler without setting an error.
func (f *fatalErrorHandler) Stop() {
f.mu.Lock()
defer f.mu.Unlock()
if !f.done {
f.done = true
close(f.donec)
}
}
func (f *fatalErrorHandler) getErr() error {
f.mu.Lock()
defer f.mu.Unlock()
@@ -397,38 +408,35 @@ func (h *HugoSites) onPageRender() {
}
}
func (h *HugoSites) pickOneAndLogTheRest(errors []error) error {
if len(errors) == 0 {
func (h *HugoSites) filterAndJoinErrors(errs []error) error {
if len(errs) == 0 {
return nil
}
var i int
for j, err := range errors {
// If this is in server mode, we want to return an error to the client
// with a file context, if possible.
if herrors.UnwrapFileError(err) != nil {
i = j
break
}
}
// Log the rest, but add a threshold to avoid flooding the log.
const errLogThreshold = 5
for j, err := range errors {
if j == i || err == nil {
seen := map[string]bool{}
var n int
for _, err := range errs {
if err == nil {
continue
}
errMsg := herrors.Cause(err).Error() // We don't need to see many "Could not resolve "@alpinejs/persists""
if !seen[errMsg] {
seen[errMsg] = true
errs[n] = err
n++
}
}
errs = errs[:n]
if j >= errLogThreshold {
break
for i, err := range errs {
errs[i] = herrors.ImproveRenderErr(err)
}
h.Log.Errorln(err)
const limit = 10
if len(errs) > limit {
errs = errs[:limit]
}
return errors[i]
return errors.Join(errs...)
}
func (h *HugoSites) isMultilingual() bool {
+1 -1
View File
@@ -117,7 +117,7 @@ func (h *HugoSites) Build(config BuildCfg, events ...fsnotify.Event) error {
}
errors = append(errors, e)
}
to <- h.pickOneAndLogTheRest(errors)
to <- h.filterAndJoinErrors(errors)
close(to)
}(errCollector, errs)
+6 -2
View File
@@ -319,7 +319,9 @@ minifyOutput = true
b, err := TestE(t, files)
b.Assert(err, qt.IsNotNil)
fe := herrors.UnwrapFileError(err)
fes := herrors.UnwrapFileErrors(err)
b.Assert(len(fes), qt.Equals, 1)
fe := fes[0]
b.Assert(fe, qt.IsNotNil)
b.Assert(fe.Position().LineNumber, qt.Equals, 2)
b.Assert(fe.Position().ColumnNumber, qt.Equals, 9)
@@ -626,7 +628,9 @@ line3: 'value3'
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, "[2:1] non-map value is specified")
fe := herrors.UnwrapFileError(err)
fes := herrors.UnwrapFileErrors(err)
b.Assert(len(fes), qt.Equals, 1)
fe := fes[0]
b.Assert(fe, qt.Not(qt.IsNil))
pos := fe.Position()
b.Assert(pos.Filename, qt.Contains, filepath.FromSlash("content/_index.md"))
-5
View File
@@ -593,11 +593,6 @@ func (s *IntegrationTestBuilder) AssertFileExists(filename string, b bool) {
s.Assert(err, checker)
}
func (s *IntegrationTestBuilder) AssertIsFileError(err error) herrors.FileError {
s.Assert(err, qt.ErrorAs, new(herrors.FileError))
return herrors.UnwrapFileError(err)
}
func (s *IntegrationTestBuilder) AssertRenderCountContent(count int) {
s.Helper()
s.Assert(s.counters.contentRenderCounter.Load(), qt.Equals, uint64(count))
+9 -4
View File
@@ -1546,13 +1546,18 @@ func (s *Site) resetBuildState(sourceChanged bool) {
func (s *Site) errorCollator(results <-chan error, errs chan<- error) {
var errors []error
defer func() {
errs <- s.h.filterAndJoinErrors(errors)
close(errs)
}()
const maxErrors = 10
for e := range results {
errors = append(errors, e)
if len(errors) >= maxErrors {
s.h.Stop()
break
}
}
errs <- s.h.pickOneAndLogTheRest(errors)
close(errs)
}
// GetPage looks up a page of a given type for the given ref.
+28 -6
View File
@@ -22,7 +22,6 @@ import (
"github.com/bep/logg"
"github.com/gohugoio/go-radix"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/hugolib/doctree"
"github.com/gohugoio/hugo/tpl/tplimpl"
@@ -115,7 +114,7 @@ func (s *Site) renderPages(ctx *siteRenderContext) error {
err := <-errs
if err != nil {
return fmt.Errorf("%v failed to render pages: %w", s.resolveDimensionNames(), herrors.ImproveRenderErr(err))
return fmt.Errorf("%v failed to render pages: %w", s.resolveDimensionNames(), err)
}
return nil
}
@@ -129,6 +128,15 @@ func pageRenderer(
) {
defer wg.Done()
sendErr := func(err error) bool {
select {
case results <- err:
return true
case <-s.h.Done():
return false
}
}
for p := range pages {
if p.m.isStandalone() && !ctx.shouldRenderStandalonePage(p.Kind()) {
@@ -137,8 +145,11 @@ func pageRenderer(
if p.m.pageConfig.Build.PublishResources {
if err := p.renderResources(); err != nil {
s.SendError(p.errorf(err, "failed to render page resources"))
if sendErr(p.errorf(err, "failed to render resources")) {
continue
} else {
return
}
}
}
@@ -149,8 +160,11 @@ func pageRenderer(
templ, found, err := p.resolveTemplate()
if err != nil {
s.SendError(p.errorf(err, "failed to resolve template"))
if sendErr(p.errorf(err, "failed to resolve template")) {
continue
} else {
return
}
}
if !found {
@@ -181,12 +195,20 @@ func pageRenderer(
}
if err := s.renderAndWritePage(&s.PathSpec.ProcessingStats.Pages, targetPath, p, d, templ); err != nil {
results <- err
if sendErr(err) {
continue
} else {
return
}
}
if p.paginator != nil && p.paginator.current != nil {
if err := s.renderPaginator(p, templ); err != nil {
results <- err
if sendErr(err) {
continue
} else {
return
}
}
}
}
+7 -1
View File
@@ -17,6 +17,9 @@ import (
"log"
"os"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/commands"
)
@@ -24,6 +27,9 @@ func main() {
log.SetFlags(0)
err := commands.Execute(os.Args[1:])
if err != nil {
log.Fatalf("Error: %s", err)
for _, e := range herrors.Errors(err) {
loggers.Log().Errorf("%s", e)
}
os.Exit(1)
}
}
@@ -17,6 +17,7 @@ import (
"strings"
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/hugolib"
)
@@ -118,8 +119,9 @@ CONTENT
files = strings.ReplaceAll(files, `2`, `"x"`)
b, _ = hugolib.TestE(t, files)
b.AssertLogMatches(`error calling ToHTML: startLevel: unable to cast "x" of type string`)
b, err := hugolib.TestE(t, files)
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, `error calling ToHTML: startLevel: unable to cast "x" of type string`)
}
func TestHeadingsNilpointerIssue11843(t *testing.T) {
+18
View File
@@ -65,6 +65,9 @@ type GoldenImageTestOpts struct {
// If not set, a temporary directory will be created.
WorkingDir string
// Set to true to print the temp dir used and keep it after the test.
PrintAndKeepTempDir bool
// Set to true to skip any assertions. Useful when adding new golden variants to a test.
DevMode bool
@@ -90,6 +93,7 @@ func RunGolden(opts GoldenImageTestOpts) *hugolib.IntegrationTestBuilder {
c := hugolib.Test(opts.T, opts.Files, hugolib.TestOptWithConfig(func(conf *hugolib.IntegrationTestConfig) {
conf.NeedsOsFS = true
conf.WorkingDir = opts.WorkingDir
conf.PrintAndKeepTempDir = opts.PrintAndKeepTempDir
}))
codec := c.H.ResourceSpec.Imaging.Codec
@@ -118,6 +122,17 @@ func RunGolden(opts GoldenImageTestOpts) *hugolib.IntegrationTestBuilder {
return c
}
shouldSkip := func(d fs.DirEntry) bool {
if runtime.GOARCH == "arm64" {
// TODO(bep) figure out why this fails on arm64. I have inspected the images, and they look identical.
if d.Name() == "giphy_hu_e4a5984f8835d617.webp" {
c.Logf("skipping %s on %s", d.Name(), runtime.GOARCH)
return true
}
}
return false
}
decodeAll := func(f *os.File) []image.Image {
c.Helper()
var images []image.Image
@@ -138,6 +153,9 @@ func RunGolden(opts GoldenImageTestOpts) *hugolib.IntegrationTestBuilder {
c.Assert(err, qt.IsNil)
c.Assert(len(entries1), qt.Equals, len(entries2))
for i, e1 := range entries1 {
if shouldSkip != nil && shouldSkip(e1) {
continue
}
c.Assert(filepath.Ext(e1.Name()), qt.Not(qt.Equals), "")
func() {
e2 := entries2[i]
@@ -22,6 +22,7 @@ import (
"github.com/bep/logg"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/htesting"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/hugolib"
@@ -154,7 +155,7 @@ func TestTransformPostCSSError(t *testing.T) {
c := qt.New(t)
s, err := hugolib.NewIntegrationTestBuilder(
b, err := hugolib.NewIntegrationTestBuilder(
hugolib.IntegrationTestConfig{
T: c,
NeedsOsFS: true,
@@ -162,8 +163,9 @@ func TestTransformPostCSSError(t *testing.T) {
TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, "color: blue;", "@apply foo;"), // Syntax error
}).BuildE()
s.AssertIsFileError(err)
c.Assert(err.Error(), qt.Contains, "a.css:4:2")
ferrs := herrors.UnwrapFileErrors(err)
b.Assert(len(ferrs), qt.Equals, 1)
b.Assert(err.Error(), qt.Contains, "a.css:4:2")
}
func TestTransformPostCSSNotInstalledError(t *testing.T) {
@@ -173,14 +175,15 @@ func TestTransformPostCSSNotInstalledError(t *testing.T) {
c := qt.New(t)
s, err := hugolib.NewIntegrationTestBuilder(
_, err := hugolib.NewIntegrationTestBuilder(
hugolib.IntegrationTestConfig{
T: c,
NeedsOsFS: true,
TxtarString: postCSSIntegrationTestFiles,
}).BuildE()
s.AssertIsFileError(err)
ferrs := herrors.UnwrapFileErrors(err)
c.Assert(len(ferrs), qt.Equals, 1)
c.Assert(err.Error(), qt.Contains, `binary with name "postcss" not found using npx`)
}
@@ -192,7 +195,7 @@ func TestTransformPostCSSImportError(t *testing.T) {
c := qt.New(t)
s, err := hugolib.NewIntegrationTestBuilder(
_, err := hugolib.NewIntegrationTestBuilder(
hugolib.IntegrationTestConfig{
T: c,
NeedsOsFS: true,
@@ -200,8 +203,8 @@ func TestTransformPostCSSImportError(t *testing.T) {
LogLevel: logg.LevelInfo,
TxtarString: strings.ReplaceAll(postCSSIntegrationTestFiles, `@import "components/all.css";`, `@import "components/doesnotexist.css";`),
}).BuildE()
s.AssertIsFileError(err)
ferrs := herrors.UnwrapFileErrors(err)
c.Assert(len(ferrs), qt.Equals, 2)
c.Assert(err.Error(), qt.Contains, "styles.css:4:3")
c.Assert(err.Error(), qt.Contains, filepath.FromSlash(`failed to resolve CSS @import "/css/components/doesnotexist.css"`))
}
@@ -420,3 +420,21 @@ Home.
b.AssertFileContent("public/js/main.js", "efgh")
b.AssertFileContent("public/js/main.js", "! abcd")
}
func TestBuildErrorStack(t *testing.T) {
files := `
-- hugo.toml --
disableKinds=["page", "section", "taxonomy", "term", "sitemap", "robotsTXT"]
-- assets/js/main.js --
import { hello1, hello2 } from './util1';
hello1();
-- layouts/home.html --
{{ $js := resources.Get "js/main.js" | js.Build }}
JS Content:{{ $js.Content }}:End:
`
b, err := hugolib.TestE(t, files, hugolib.TestOptOsFs())
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, `execute of template failed: template: home.html:2:17`)
b.Assert(err.Error(), qt.Contains, `main.js:1:31": Could not resolve "./util1"`)
}
@@ -19,6 +19,7 @@ import (
"github.com/bep/logg"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/htesting"
"github.com/gohugoio/hugo/hugolib"
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass"
@@ -343,10 +344,18 @@ T1: {{ $r.Content }}
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, `main.scss:8:13":`)
b.Assert(err.Error(), qt.Contains, `: expected ":".`)
fe := b.AssertIsFileError(err)
b.Assert(fe.ErrorContext(), qt.IsNotNil)
b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{" $maincolor #eee;", "", "body {", "\tcolor: $maincolor;", "}"})
b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss")
fileErrs := herrors.UnwrapFileErrors(err)
b.Assert(len(fileErrs), qt.Equals, 2) // template + scss.
templErr := fileErrs[0]
b.Assert(templErr.ErrorContext(), qt.IsNotNil)
b.Assert(templErr.ErrorContext().Lines, qt.DeepEquals, []string{"{{ $cssOpts := dict \"transpiler\" \"dartsass\" }}", "{{ $r := resources.Get \"scss/main.scss\" | toCSS $cssOpts | minify }}", "T1: {{ $r.Content }}", "", "\t"})
b.Assert(templErr.ErrorContext().ChromaLexer, qt.Equals, "go-html-template")
scssErr := fileErrs[1]
b.Assert(scssErr.ErrorContext(), qt.IsNotNil)
b.Assert(scssErr.ErrorContext().Lines, qt.DeepEquals, []string{" $maincolor #eee;", "", "body {", "\tcolor: $maincolor;", "}"})
b.Assert(scssErr.ErrorContext().ChromaLexer, qt.Equals, "scss")
})
c.Run("error in import", func(c *qt.C) {
@@ -360,10 +369,12 @@ T1: {{ $r.Content }}
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, `_foo.scss:2:10":`)
b.Assert(err.Error(), qt.Contains, `: expected ":".`)
fe := b.AssertIsFileError(err)
b.Assert(fe.ErrorContext(), qt.IsNotNil)
b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 1 */", "$foocolor #ccc;", "", "foo {"})
b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss")
fileErrs := herrors.UnwrapFileErrors(err)
b.Assert(len(fileErrs), qt.Equals, 2) // template + scss.
scssErr := fileErrs[1]
b.Assert(scssErr.ErrorContext(), qt.IsNotNil)
b.Assert(scssErr.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 1 */", "$foocolor #ccc;", "", "foo {"})
b.Assert(scssErr.ErrorContext().ChromaLexer, qt.Equals, "scss")
})
}
@@ -20,6 +20,7 @@ import (
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/htesting"
"github.com/gohugoio/hugo/hugolib"
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss"
@@ -225,7 +226,9 @@ T1: {{ $r.Content }}
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`themes/mytheme/assets/scss/main.scss:6:1": expected ':' after $maincolor in assignment statement`))
fe := b.AssertIsFileError(err)
ferrs := herrors.UnwrapFileErrors(err)
c.Assert(len(ferrs), qt.Equals, 2)
fe := ferrs[1]
b.Assert(fe.ErrorContext(), qt.IsNotNil)
b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 4 */", "", "$maincolor #eee;", "", "body {"})
b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss")
@@ -241,7 +244,9 @@ T1: {{ $r.Content }}
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, `assets/scss/components/_foo.scss:2:1": expected ':' after $foocolor in assignment statement`)
fe := b.AssertIsFileError(err)
ferrs := herrors.UnwrapFileErrors(err)
c.Assert(len(ferrs), qt.Equals, 2)
fe := ferrs[1]
b.Assert(fe.ErrorContext(), qt.IsNotNil)
b.Assert(fe.ErrorContext().Lines, qt.DeepEquals, []string{"/* comment line 1 */", "$foocolor #ccc;", "", "foo {"})
b.Assert(fe.ErrorContext().ChromaLexer, qt.Equals, "scss")
+7 -9
View File
@@ -639,10 +639,13 @@ func (r *resourceAdapter) transform(key string, publish, setContent bool) (*reso
}
func (r *resourceAdapter) init(publish, setContent bool) {
r.initTransform(publish, setContent)
if err := r.doInit(publish, setContent); err != nil {
// The panic will be handled and converted to an error by the template framework.
panic(err)
}
}
func (r *resourceAdapter) initTransform(publish, setContent bool) {
func (r *resourceAdapter) doInit(publish, setContent bool) error {
r.transformationsInit.Do(func() {
if len(r.transformations) == 0 {
// Nothing to do.
@@ -656,18 +659,13 @@ func (r *resourceAdapter) initTransform(publish, setContent bool) {
}
r.transformationsErr = r.getOrTransform(publish, setContent)
if r.transformationsErr != nil {
if r.spec.ErrorSender != nil {
r.spec.ErrorSender.SendError(r.transformationsErr)
} else {
r.spec.Logger.Errorf("Transformation failed: %s", r.transformationsErr)
}
}
})
if publish && r.publishOnce != nil {
r.publish()
}
return r.transformationsErr
}
type resourceAdapterInner struct {