diff --git a/common/herrors/errors.go b/common/herrors/errors.go index 98d2f41f830..700d1dd67d7 100644 --- a/common/herrors/errors.go +++ b/common/herrors/errors.go @@ -68,6 +68,20 @@ func (e *TimeoutError) Is(target error) bool { return ok } +// errMessage wraps an error with a message. +type errMessage struct { + msg string + err error +} + +func (e *errMessage) Error() string { + return e.msg +} + +func (e *errMessage) Unwrap() error { + return e.err +} + // IsFeatureNotAvailableError returns true if the given error is or contains a FeatureNotAvailableError. func IsFeatureNotAvailableError(err error) bool { return errors.Is(err, &FeatureNotAvailableError{}) @@ -121,22 +135,38 @@ func IsNotExist(err error) bool { var nilPointerErrRe = regexp.MustCompile(`at <(.*)>: error calling (.*?): runtime error: invalid memory address or nil pointer dereference`) -func ImproveIfNilPointer(inErr error) (outErr error) { +const deferredPrefix = "___hdeferred/" + +var deferredStringToRemove = regexp.MustCompile(`executing "___hdeferred/.*" `) + +// ImproveRenderErr improves the error message for rendering errors. +func ImproveRenderErr(inErr error) (outErr error) { outErr = inErr + msg := improveIfNilPointerMsg(inErr) + if msg != "" { + outErr = &errMessage{msg: msg, err: outErr} + } + if strings.Contains(inErr.Error(), deferredPrefix) { + msg := deferredStringToRemove.ReplaceAllString(inErr.Error(), "executing ") + outErr = &errMessage{msg: msg, err: outErr} + } + return +} + +func improveIfNilPointerMsg(inErr error) string { m := nilPointerErrRe.FindStringSubmatch(inErr.Error()) if len(m) == 0 { - return + return "" } call := m[1] field := m[2] parts := strings.Split(call, ".") if len(parts) < 2 { - return + return "" } receiverName := parts[len(parts)-2] receiver := strings.Join(parts[:len(parts)-1], ".") s := fmt.Sprintf("– %s is nil; wrap it in if or with: {{ with %s }}{{ .%s }}{{ end }}", receiverName, receiver, field) - outErr = errors.New(nilPointerErrRe.ReplaceAllString(inErr.Error(), s)) - return + return nilPointerErrRe.ReplaceAllString(inErr.Error(), s) } diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go index 8e2962712c7..6434674ff77 100644 --- a/hugolib/hugo_sites_build.go +++ b/hugolib/hugo_sites_build.go @@ -431,7 +431,7 @@ func (h *HugoSites) renderDeferred(l logg.LevelLogger) error { } } if err := s.executeDeferredTemplates(de); err != nil { - return err + return herrors.ImproveRenderErr(err) } } diff --git a/hugolib/hugo_sites_build_errors_test.go b/hugolib/hugo_sites_build_errors_test.go index 5a8b9f76fee..73e46501c36 100644 --- a/hugolib/hugo_sites_build_errors_test.go +++ b/hugolib/hugo_sites_build_errors_test.go @@ -628,3 +628,20 @@ title: "A page" b.CreateSites().BuildFail(BuildCfg{}) } + +func TestErrorTemplateRuntime(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/index.html -- +Home. +{{ .ThisDoesNotExist }} + ` + + b, err := TestE(t, files) + + b.Assert(err, qt.Not(qt.IsNil)) + b.Assert(err.Error(), qt.Contains, `/layouts/index.html:2:3`) + b.Assert(err.Error(), qt.Contains, `can't evaluate field ThisDoesNotExist`) +} diff --git a/hugolib/site_render.go b/hugolib/site_render.go index 47ee8658c76..83f2fce8971 100644 --- a/hugolib/site_render.go +++ b/hugolib/site_render.go @@ -111,7 +111,7 @@ func (s *Site) renderPages(ctx *siteRenderContext) error { err := <-errs if err != nil { - return fmt.Errorf("failed to render pages: %w", herrors.ImproveIfNilPointer(err)) + return fmt.Errorf("failed to render pages: %w", herrors.ImproveRenderErr(err)) } return nil } diff --git a/tpl/templates/defer_integration_test.go b/tpl/templates/defer_integration_test.go index a3c34d718ef..4bbc11b79b7 100644 --- a/tpl/templates/defer_integration_test.go +++ b/tpl/templates/defer_integration_test.go @@ -14,13 +14,18 @@ package templates_test import ( + "strings" "testing" + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" ) const deferFilesCommon = ` -- hugo.toml -- +disableLiveReload = true +disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404", "section"] [languages] [languages.en] weight = 1 @@ -42,23 +47,105 @@ outputs: ["html", "amp"] title: "Heim" outputs: ["html", "amp"] --- +-- assets/mytext.txt -- +Hello. -- layouts/index.html -- HTML. +{{ .Store.Set "hello" "Hello" }} {{ $data := dict "page" . }} -{{ with (defer (dict "data" $data) ) }}Title: {{ .page.Title }}|{{ .page.RelPermalink }}|Hello: {{ T "hello" }}{{ end }}$ + +{{ with (defer (dict "data" $data) ) }} +{{ $mytext := resources.Get "mytext.txt" }} +REPLACE_ME|Title: {{ .page.Title }}|{{ .page.RelPermalink }}|Hello: {{ T "hello" }}|Hello Store: {{ .page.Store.Get "hello" }}|Mytext: {{ $mytext.Content }}| +{{ end }}$ -- layouts/index.amp.html -- AMP. {{ $data := dict "page" . }} -{{ with (defer (dict "data" $data) ) }}Title: {{ .page.Title }}|{{ .page.RelPermalink }}|Hello: {{ T "hello" }}{{ end }}$ +{{ with (defer (dict "data" $data) ) }}Title AMP: {{ .page.Title }}|{{ .page.RelPermalink }}|Hello: {{ T "hello" }}{{ end }}$ ` -func TestDefer(t *testing.T) { +func TestDeferBasic(t *testing.T) { t.Parallel() b := hugolib.Test(t, deferFilesCommon) - b.AssertFileContent("public/index.html", "Title: Home|/|Hello: Hello") - b.AssertFileContent("public/amp/index.html", "Title: Home|/amp/|Hello: Hello") + b.AssertFileContent("public/index.html", "Title: Home|/|Hello: Hello|Hello Store: Hello|Mytext: Hello.|") + b.AssertFileContent("public/amp/index.html", "Title AMP: Home|/amp/|Hello: Hello") b.AssertFileContent("public/nn/index.html", "Title: Heim|/nn/|Hello: Hei") - b.AssertFileContent("public/nn/amp/index.html", "Title: Heim|/nn/amp/|Hello: Hei") + b.AssertFileContent("public/nn/amp/index.html", "Title AMP: Heim|/nn/amp/|Hello: Hei") +} + +func TestDeferErrorParse(t *testing.T) { + t.Parallel() + + b, err := hugolib.TestE(t, strings.ReplaceAll(deferFilesCommon, "Title AMP: {{ .page.Title }}", "{{ .page.Title }")) + + b.Assert(err, qt.Not(qt.IsNil)) + b.Assert(err.Error(), qt.Contains, `index.amp.html:3: unexpected "}" in operand`) +} + +func TestDeferErrorRuntime(t *testing.T) { + t.Parallel() + + b, err := hugolib.TestE(t, strings.ReplaceAll(deferFilesCommon, "Title AMP: {{ .page.Title }}", "{{ .page.Titles }}")) + + b.Assert(err, qt.Not(qt.IsNil)) + b.Assert(err.Error(), qt.Contains, `/layouts/index.amp.html:3:47`) + b.Assert(err.Error(), qt.Contains, `execute of template failed: template: index.amp.html:3:47: executing at <.page.Titles>: can't evaluate field Titles`) +} + +func TestDeferEditDeferBlock(t *testing.T) { + t.Parallel() + + b := hugolib.TestRunning(t, deferFilesCommon) + b.AssertRenderCountPage(4) + b.EditFileReplaceAll("layouts/index.html", "REPLACE_ME", "Edited.").Build() + b.AssertFileContent("public/index.html", "Edited.") + b.AssertRenderCountPage(2) +} + +func TestDeferEditResourceUsedInDeferBlock(t *testing.T) { + t.Parallel() + + b := hugolib.TestRunning(t, deferFilesCommon) + b.AssertRenderCountPage(4) + b.EditFiles("assets/mytext.txt", "Mytext Hello Edited.").Build() + b.AssertFileContent("public/index.html", "Mytext Hello Edited.") + b.AssertRenderCountPage(2) +} + +// This is currently not recommended, but see https://github.com/gohugoio/hugo/issues/12589 +func TestDeferMountPublic(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[module] +[[module.mounts]] +source = "content" +target = "content" +[[module.mounts]] +source = "layouts" +target = "layouts" +[[module.mounts]] +source = 'public' +target = 'assets/public' +-- public/.gitkeep -- +-- layouts/index.html -- +Home. +{{ $mydata := dict "v1" "v1value" }} +{{ $json := resources.FromString "mydata/data.json" ($mydata | jsonify ) }} +{{ $nop := $json.RelPermalink }} +{{ with (defer (dict "key" "foo")) }} + {{ $jsonFilePublic := resources.Get "public/mydata/data.json" }} + {{ with $jsonFilePublic }} + {{ $m := $jsonFilePublic | transform.Unmarshal }} + v1: {{ $m.v1 }} + {{ end }} +{{ end }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "v1: v1value") } diff --git a/tpl/tplimpl/template.go b/tpl/tplimpl/template.go index b74a6bf1536..b942c2e75be 100644 --- a/tpl/tplimpl/template.go +++ b/tpl/tplimpl/template.go @@ -195,11 +195,12 @@ func newTemplateNamespace(funcs map[string]any) *templateNamespace { } } -func newTemplateState(templ tpl.Template, info templateInfo, id identity.Identity) *templateState { +func newTemplateState(owner *templateState, templ tpl.Template, info templateInfo, id identity.Identity) *templateState { if id == nil { id = info } return &templateState{ + owner: owner, info: info, typ: info.resolveType(), Template: templ, @@ -261,7 +262,11 @@ func (t *templateExec) ExecuteWithContext(ctx context.Context, templ tpl.Templat execErr := t.executor.ExecuteWithContext(ctx, templ, wr, data) if execErr != nil { - execErr = t.addFileContext(templ, execErr) + owner := templ + if ts, ok := templ.(*templateState); ok && ts.owner != nil { + owner = ts.owner + } + execErr = t.addFileContext(owner, execErr) } return execErr } @@ -498,7 +503,7 @@ func (t *templateHandler) findLayout(d layouts.LayoutDescriptor, f output.Format return nil, false, err } - ts := newTemplateState(templ, overlay, identity.Or(base, overlay)) + ts := newTemplateState(nil, templ, overlay, identity.Or(base, overlay)) if found { ts.baseInfo = base @@ -747,7 +752,7 @@ func (t *templateHandler) applyTemplateTransformers(ns *templateNamespace, ts *t } for k, v := range c.deferNodes { - if err := t.main.fromListNode(k, ts.isText(), v); err != nil { + if err := t.main.addDeferredTemplate(ts, k, v); err != nil { return nil, err } } @@ -864,7 +869,7 @@ func (t *templateHandler) extractPartials(templ tpl.Template) error { continue } - ts := newTemplateState(templ, templateInfo{name: templ.Name()}, nil) + ts := newTemplateState(nil, templ, templateInfo{name: templ.Name()}, nil) ts.typ = templatePartial t.main.mu.RLock() @@ -1002,19 +1007,19 @@ func (t *templateNamespace) newTemplateLookup(in *templateState) func(name strin return templ } if templ, found := findTemplateIn(name, in); found { - return newTemplateState(templ, templateInfo{name: templ.Name()}, nil) + return newTemplateState(nil, templ, templateInfo{name: templ.Name()}, nil) } return nil } } -func (t *templateNamespace) fromListNode(name string, isText bool, n *parse.ListNode) error { +func (t *templateNamespace) addDeferredTemplate(owner *templateState, name string, n *parse.ListNode) error { t.mu.Lock() defer t.mu.Unlock() var templ tpl.Template - if isText { + if owner.isText() { prototype := t.prototypeText tt, err := prototype.New(name).Parse("") if err != nil { @@ -1032,7 +1037,7 @@ func (t *templateNamespace) fromListNode(name string, isText bool, n *parse.List templ = tt } - t.templates[name] = newTemplateState(templ, templateInfo{name: name}, nil) + t.templates[name] = newTemplateState(owner, templ, templateInfo{name: name}, nil) return nil } @@ -1049,7 +1054,7 @@ func (t *templateNamespace) parse(info templateInfo) (*templateState, error) { return nil, err } - ts := newTemplateState(templ, info, nil) + ts := newTemplateState(nil, templ, info, nil) t.templates[info.name] = ts @@ -1063,7 +1068,7 @@ func (t *templateNamespace) parse(info templateInfo) (*templateState, error) { return nil, err } - ts := newTemplateState(templ, info, nil) + ts := newTemplateState(nil, templ, info, nil) t.templates[info.name] = ts @@ -1075,6 +1080,9 @@ var _ tpl.IsInternalTemplateProvider = (*templateState)(nil) type templateState struct { tpl.Template + // Set for deferred templates. + owner *templateState + typ templateType parseInfo tpl.ParseInfo id identity.Identity diff --git a/tpl/tplimpl/template_ast_transformers_test.go b/tpl/tplimpl/template_ast_transformers_test.go index bd889b8320f..630415dac03 100644 --- a/tpl/tplimpl/template_ast_transformers_test.go +++ b/tpl/tplimpl/template_ast_transformers_test.go @@ -47,7 +47,7 @@ func TestTransformRecursiveTemplate(t *testing.T) { } func newTestTemplate(templ tpl.Template) *templateState { - return newTemplateState( + return newTemplateState(nil, templ, templateInfo{ name: templ.Name(),