Introduction#

I configured a hero button in front matter with a phone number (url: "tel:+48123123123"). In the rendered HTML, instead of href="tel:+48123123123", I got href="#ZgotmplZ". Clicking did nothing, and hover showed an anchor to the current page.

The diagnosis wasn’t obvious, because a hardcoded tel: link in the footer worked fine. The culprit sat at the intersection of two mechanisms: Hugo’s relURL inside the URL partial and contextual auto-escaping in Go’s html/template package. Below I break down why #ZgotmplZ shows up at all and why a proper fix requires changes both in the partial and at every call site.

The symptom#

A hero button configured in front matter:

cta_primary:
  text: "Book appointment"
  url: "tel:+48123123123"

The button renders, but hovering shows https://localcraft.com/#ZgotmplZ instead of tel:+48123123123. Clicking does nothing.

Where #ZgotmplZ comes from#

Go’s html/template package uses contextual auto-escaping. When a dynamic value lands in an href attribute, the template engine applies its URL filter. If the value looks suspicious or comes from an unexpected source, it gets replaced with #ZgotmplZ. That’s intentional: instead of quietly opening an XSS vector, the page visibly breaks.

The cause: relURL doesn’t understand tel:#

The theme had a url.html partial responsible for generating correct relative URLs:

{{- strings.TrimPrefix "/" . | relURL -}}

Hugo’s relURL function is designed for paths like /about/ or /blog/. When it receives tel:+48123123123, it doesn’t recognize tel: as a scheme and treats the entire string as a relative path, producing something like /tel:+48123123123. That isn’t a valid URL, so html/template swaps it for #ZgotmplZ.

The phone-number link in the footer was hardcoded directly into the template:

<a href="tel:{{ $phoneClean }}">

Here the tel: prefix is a literal in the template — only the number is dynamic. The contextual escaper sees tel: statically and lets it through unmodified.

The fix#

Step 1. url.html: skip relURL for special schemes#

In the URL partial we split two cases. For values carrying tel:, mailto:, or full http(s):// schemes we return the string unchanged. For paths without a scheme we still fall through to relURL:

{{- if or (hasPrefix . "tel:") (hasPrefix . "mailto:") (hasPrefix . "http://") (hasPrefix . "https://") -}}
  {{- . -}}
{{- else -}}
  {{- strings.TrimPrefix "/" . | relURL -}}
{{- end -}}

Step 2. Call sites: pipe through safeURL#

A Hugo partial returns template.HTML. When that value lands in an href attribute, Go’s contextual escaper still applies the URL filter. The fix is to pipe the partial’s output through safeURL at every call site:

<a href="{{ partial "url.html" .url | safeURL }}" class="btn btn-primary">

safeURL marks the value as template.URL, which completely skips the URL filter. The contextual escaper walks past, and the href attribute receives exactly what the partial returned.

Why safeURL inside the partial isn’t enough#

The natural instinct is to add safeURL directly inside the partial, right at the returned value:

{{- . | safeURL -}}  {{/* this WON'T work */}}

It isn’t enough because the partial’s output buffer collects everything as template.HTML. The template.URL type returned by safeURL is lost the moment the partial finishes. The calling template sees only a template.HTML string and still pushes it through the URL filter, landing on #ZgotmplZ again.

Piping through safeURL at the call site sidesteps this. The value flows straight into the escaper as template.URL, the URL filter is skipped, and the href attribute gets exactly the string the partial produced.

Files changed#

FileChange
layouts/partials/url.htmlSkips relURL for tel:, mailto:, http://, https://
layouts/partials/sections/hero.html| safeURL on both CTA links
layouts/partials/sections/blog-preview.html| safeURL on the CTA link
layouts/partials/sections/faq.html| safeURL on the CTA link
layouts/partials/sections/before-after.html| safeURL on the CTA link

Summary#

#ZgotmplZ inside an href attribute is a direct signal that Go’s contextual escaper flagged the value as unsafe. A full fix requires skipping relURL for schemes like tel: or mailto: in the URL partial and piping the result through safeURL at every call site.

Key takeaways from this debugging session:

  1. #ZgotmplZ means Go’s html/template blocked a URL it deemed unsafe. It’s always a type or context error, never a content one.
  2. relURL is for paths only. Don’t pipe tel: or mailto: through it.
  3. A Hugo partial’s output is template.HTML. safeURL inside a partial gets swallowed, so apply it at the call site.
  4. Hardcoded scheme prefixes in templates are treated as static and pass the URL filter without safeURL.

References and further reading#

  • html/template (Go) – official package documentation covering contextual auto-escaping and the ZgotmplZ value.
  • Hugo relURL – what the function does and how Hugo treats relative paths.
  • Hugo safeURL – how to mark a value as a safe URL and bypass the contextual escaper’s filter.