diff --git a/blog/blog_test.go b/blog/blog_test.go
index 93729aa5..837fa7be 100644
--- a/blog/blog_test.go
+++ b/blog/blog_test.go
@@ -1790,6 +1790,13 @@ func TestHeadMetadata(t *testing.T) {
``,
``)
+ // Template actions that print nothing must not leave blank lines (#649).
+ for _, page := range []string{"/", "/posts/2026/08/15/hello", "/dir/hello", "/search?q=x"} {
+ if h := head(get(page)); regexp.MustCompile(`\n[ \t]*\n`).MatchString(h) {
+ t.Errorf("%s head has blank lines:\n%s", page, h)
+ }
+ }
+
// Search results are not for indexing.
check(t, "/search?q=x", ``)
diff --git a/templates/shared/_head.html b/templates/shared/_head.html
index 58b568d6..4dced4c1 100644
--- a/templates/shared/_head.html
+++ b/templates/shared/_head.html
@@ -1,4 +1,4 @@
-{{ define "_head" }}
+{{ define "_head" -}}
@@ -7,43 +7,55 @@
{{- /* Search result pages and the like ask not to be indexed; otherwise the robots_tag setting. */}}
- {{ if .noindex }}
+ {{- if .noindex }}
- {{ else }}
+ {{- else }}
- {{ end }}
+ {{- end }}
{{- /* Description: the page's own (a plugin page's meta_description, a post's first words), else the site_description setting. */}}
- {{ $description := "" }}
- {{ if .meta_description }}{{ $description = .meta_description }}
- {{ else if .post }}{{ $description = .post.PlainTextPreview 155 }}
- {{ else }}{{ with index .settings "site_description" }}{{ $description = .Value }}{{ end }}{{ end }}
+ {{- $description := "" }}
+ {{- if .meta_description }}{{ $description = .meta_description }}
+ {{- else if .post }}{{ $description = .post.PlainTextPreview 155 }}
+ {{- else }}{{ with index .settings "site_description" }}{{ $description = .Value }}{{ end }}{{ end }}
{{- /* Title: ": " — a page that sets no title (the admin pages) gets the site title alone; a missing value must never print as %!s(). */}}
- {{ $siteTitle := "" }}{{ with .settings }}{{ with .site_title }}{{ $siteTitle = .Value }}{{ end }}{{ end }}
- {{ $pageTitle := "" }}{{ if .post }}{{ $pageTitle = .post.Title }}{{ else }}{{ with .title }}{{ $pageTitle = . }}{{ end }}{{ end }}
- {{ $title := $siteTitle }}{{ if and $siteTitle $pageTitle }}{{ $title = printf "%s: %s" $siteTitle $pageTitle }}{{ else if $pageTitle }}{{ $title = $pageTitle }}{{ end }}
+ {{- $siteTitle := "" }}{{ with .settings }}{{ with .site_title }}{{ $siteTitle = .Value }}{{ end }}{{ end }}
+ {{- $pageTitle := "" }}{{ if .post }}{{ $pageTitle = .post.Title }}{{ else }}{{ with .title }}{{ $pageTitle = . }}{{ end }}{{ end }}
+ {{- $title := $siteTitle }}{{ if and $siteTitle $pageTitle }}{{ $title = printf "%s: %s" $siteTitle $pageTitle }}{{ else if $pageTitle }}{{ $title = $pageTitle }}{{ end }}
{{- /* site_url is set by blog.Render; pages rendered without it (admin) get "" and no absolute URLs. */}}
- {{ $site := "" }}{{ with .site_url }}{{ $site = . }}{{ end }}
- {{ $image := "" }}
- {{ with index .settings "site_image" }}{{ if .Value }}{{ $image = printf "%s%s" $site .Value }}{{ end }}{{ end }}
+ {{- $site := "" }}{{ with .site_url }}{{ $site = . }}{{ end }}
+ {{- $image := "" }}
+ {{- with index .settings "site_image" }}{{ if .Value }}{{ $image = printf "%s%s" $site .Value }}{{ end }}{{ end }}
{{ $title }}
- {{ if $description }}{{ end }}
- {{ if .canonical_url }}{{ end }}
+ {{- if $description }}
+
+ {{- end }}
+ {{- if .canonical_url }}
+
+ {{- end }}
- {{ if $description }}{{ end }}
- {{ if .canonical_url }}{{ end }}
+ {{- if $description }}
+
+ {{- end }}
+ {{- if .canonical_url }}
+
+ {{- end }}
- {{ if .post }}
+ {{- if .post }}
- {{ $first := "" }}{{ range .post.ImageURLs $site }}{{ if not $first }}{{ $first = . }}{{ end }}{{ end }}
- {{ if $first }}{{ else if $image }}{{ end }}
+ {{- $first := "" }}{{ range .post.ImageURLs $site }}{{ if not $first }}{{ $first = . }}{{ end }}{{ end }}
+ {{- if $first }}
+
+ {{- else if $image }}
+
+ {{- end }}
- {{ range .post.Tags }}
+ {{- range .post.Tags }}
- {{ end }}
+ {{- end }}
- {{ else }}
+ {{- else }}
- {{ if $image }}{{ end }}
- {{ if .is_home }}
+ {{- if $image }}
+
+ {{- end }}
+ {{- if .is_home }}
{{- /* A personal site says whose it is: the site title is the person's name, and sameAs lists the profiles the Social Icons plugin holds (.plugins.socialicons.links), which is how a search engine ties them to this site; the optional person_* settings add role, employer, school and expertise. Off unless site_is_person is set. */}}
- {{ with index .settings "site_is_person" }}{{ if eq .Value "true" }}
+ {{- with index .settings "site_is_person" }}{{ if eq .Value "true" }}
- {{ end }}{{ end }}
- {{ end }}
- {{ end }}
- {{ if $site }}{{ end }}
+ {{- end }}{{ end }}
+ {{- end }}
+ {{- end }}
+ {{- if $site }}
+
+ {{- end }}
- {{ if .admin_page }}
+ {{- if .admin_page }}
- {{ end }}
+ {{- end }}
{{- /* Only the login page uses .btn-social; it matched no rule anywhere else. */}}
- {{ if .login_page }}
+ {{- if .login_page }}
- {{ end }}
+ {{- end }}
{{- /* Icons are decorative, so Font Awesome does not have to block the first
paint: it loads against the print media type, which the browser
fetches at a low priority, and switches to all once it arrives. The
@@ -133,20 +163,22 @@
{{- /* Was Tachyons from cdnjs: 73 KB for the four rules in base.css (#630). */}}
- {{ if .admin_page }}
+ {{- if .admin_page }}
- {{ end }}
+ {{- end }}
- {{ if .admin_page }}
+ {{- if .admin_page }}
- {{ end }}
- {{ with index .settings "custom_header_code" }}{{ if .Value }}
+ {{- end }}
+ {{- with index .settings "custom_header_code" }}{{ if .Value }}
{{ .Value | rawHTML }}
- {{ end }}{{ end }}
- {{ with .plugin_head_html }}{{ . | rawHTML }}{{ end }}
+ {{- end }}{{ end }}
+ {{- with .plugin_head_html }}
+ {{ . | rawHTML }}
+ {{- end }}
{{ end }}