From 2ed7d6b5f43b8267bfbbdf70c2520968b884bf83 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Sat, 17 Oct 2026 02:54:01 -0700 Subject: [PATCH] Trim blank lines from the rendered Template actions that print nothing (assignments, false ifs) left their line's newline and indent behind, so view-source showed a run of empty lines between the robots meta and . Line-leading actions now trim the whitespace before them and conditional tags carry their own newline. Fixes #649 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- blog/blog_test.go | 7 ++ templates/shared/_head.html | 130 ++++++++++++++++++++++-------------- 2 files changed, 88 insertions(+), 49 deletions(-) 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) { `<link rel="canonical" href="https://www.example.test/dir/hello">`, `<meta property="og:title" content="GoBlog: Hello plugin">`) + // 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", `<meta name="robots" content="noindex, follow"`, `<link rel="canonical" href="https://www.example.test/search">`) 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" -}} <!DOCTYPE html> <html lang="en"> <head> @@ -7,43 +7,55 @@ <meta name="author" content="{{ .settings.site_title.Value }}"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> {{- /* Search result pages and the like ask not to be indexed; otherwise the robots_tag setting. */}} - {{ if .noindex }} + {{- if .noindex }} <meta name="robots" content="noindex, follow" /> - {{ else }} + {{- else }} <meta name="robots" content="{{ with index .settings "robots_tag" }}{{ if .Value }}{{ .Value }}{{ else }}index, follow{{ end }}{{ else }}index, follow{{ end }}" /> - {{ 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: "<site>: <page>" — a page that sets no title (the admin pages) gets the site title alone; a missing value must never print as %!s(<nil>). */}} - {{ $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>{{ $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 }}