Repository: gohugoio/hugo (language: go)

Issue:
Deprecate twitter shortcode in favor of x shortcode
The need for this became obvious (at least to me) while working to elevate our embedded shortcode documentation to its own section. They re-branded a while ago, and while the public's transition has been slow, the new brand has been stable for quite a while.

- Twitter => "X"
- tweet => "post" or "X post"

This change touches a few things, including:

- Internal shortcode aliasing (maybe)
- Replacement and deprecation of `privacy` config
- Replacement and deprecation of `services` config
- Renaming (or making copies of) the internal templates (`twitter` and `twitter_simple`)
- Updating tests

We'll leave the twitter_cards template alone because, at least for now, the term "Twitter Cards" seems to still be the recognized phrase.

Keep in mind that site and theme authors may have created their own `tweet`, `twitter`, or `twitter_simple` shortcodes, so calling shortcodes by these names should not trigger a warning. We need to handle this on the receiving end (i.e., after we've determined that we're using an embedded shortcode). 

I've got a working branch, but will refrain from submitting unless this proposal is approved.



Discussion / hints:

Fix patch:
diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
index ceb63048026..96d10b3bdd7 100644
--- a/config/allconfig/allconfig.go
+++ b/config/allconfig/allconfig.go
@@ -402,6 +402,28 @@ func (c *Config) CompileConfig(logger loggers.Logger) error {
 		c.Pagination.Path = c.PaginatePath
 	}
 
+	// Legacy privacy values.
+	if c.Privacy.Twitter.Disable {
+		hugo.Deprecate("site config key privacy.twitter.disable", "Use privacy.x.disable instead.", "v0.141.0")
+		c.Privacy.X.Disable = c.Privacy.Twitter.Disable
+	}
+
+	if c.Privacy.Twitter.EnableDNT {
+		hugo.Deprecate("site config key privacy.twitter.enableDNT", "Use privacy.x.enableDNT instead.", "v0.141.0")
+		c.Privacy.X.EnableDNT = c.Privacy.Twitter.EnableDNT
+	}
+
+	if c.Privacy.Twitter.Simple {
+		hugo.Deprecate("site config key privacy.twitter.simple", "Use privacy.x.simple instead.", "v0.141.0")
+		c.Privacy.X.Simple = c.Privacy.Twitter.Simple
+	}
+
+	// Legacy services values.
+	if c.Services.Twitter.DisableInlineCSS {
+		hugo.Deprecate("site config key services.twitter.disableInlineCSS", "Use services.x.disableInlineCSS instead.", "v0.141.0")
+		c.Services.X.DisableInlineCSS = c.Services.Twitter.DisableInlineCSS
+	}
+
 	c.C = &ConfigCompiled{
 		Timeout:             timeout,
 		BaseURL:             baseURL,
diff --git a/config/privacy/privacyConfig.go b/config/privacy/privacyConfig.go
index 8880b1036d6..900f73540b6 100644
--- a/config/privacy/privacyConfig.go
+++ b/config/privacy/privacyConfig.go
@@ -30,9 +30,10 @@ type Config struct {
 	Disqus          Disqus
 	GoogleAnalytics GoogleAnalytics
 	Instagram       Instagram
-	Twitter         Twitter
+	Twitter         Twitter // deprecated in favor of X in v0.141.0
 	Vimeo           Vimeo
 	YouTube         YouTube
+	X               X
 }
 
 // Disqus holds the privacy configuration settings related to the Disqus template.
@@ -58,7 +59,8 @@ type Instagram struct {
 	Simple bool
 }
 
-// Twitter holds the privacy configuration settingsrelated to the Twitter shortcode.
+// Twitter holds the privacy configuration settings related to the Twitter shortcode.
+// Deprecated in favor of X in v0.141.0.
 type Twitter struct {
 	Service `mapstructure:",squash"`
 
@@ -70,7 +72,7 @@ type Twitter struct {
 	Simple bool
 }
 
-// Vimeo holds the privacy configuration settingsrelated to the Vimeo shortcode.
+// Vimeo holds the privacy configuration settings related to the Vimeo shortcode.
 type Vimeo struct {
 	Service `mapstructure:",squash"`
 
@@ -84,7 +86,7 @@ type Vimeo struct {
 	Simple bool
 }
 
-// YouTube holds the privacy configuration settingsrelated to the YouTube shortcode.
+// YouTube holds the privacy configuration settings related to the YouTube shortcode.
 type YouTube struct {
 	Service `mapstructure:",squash"`
 
@@ -94,6 +96,20 @@ type YouTube struct {
 	PrivacyEnhanced bool
 }
 
+// X holds the privacy configuration settings related to the X shortcode.
+type X struct {
+	Service `mapstructure:",squash"`
+
+	// When set to true, the X post and its embedded page on your site are not
+	// used for purposes that include personalized suggestions and personalized
+	// ads.
+	EnableDNT bool
+
+	// If simple mode is enabled, a static and no-JS version of the X post will
+	// be built.
+	Simple bool
+}
+
 // DecodeConfig creates a privacy Config from a given Hugo configuration.
 func DecodeConfig(cfg config.Provider) (pc Config, err error) {
 	if !cfg.IsSet(privacyConfigKey) {
diff --git a/config/services/servicesConfig.go b/config/services/servicesConfig.go
index 1b4317e92f5..f302244d4aa 100644
--- a/config/services/servicesConfig.go
+++ b/config/services/servicesConfig.go
@@ -31,7 +31,8 @@ type Config struct {
 	Disqus          Disqus
 	GoogleAnalytics GoogleAnalytics
 	Instagram       Instagram
-	Twitter         Twitter
+	Twitter         Twitter // deprecated in favor of X in v0.141.0
+	X               X
 	RSS             RSS
 }
 
@@ -61,6 +62,7 @@ type Instagram struct {
 }
 
 // Twitter holds the functional configuration settings related to the Twitter shortcodes.
+// Deprecated in favor of X in v0.141.0.
 type Twitter struct {
 	// The Simple variant of Twitter is decorated with a basic set of inline styles.
 	// This means that if you want to provide your own CSS, you want
@@ -68,6 +70,14 @@ type Twitter struct {
 	DisableInlineCSS bool
 }
 
+// X holds the functional configuration settings related to the X shortcodes.
+type X struct {
+	// The Simple variant of X is decorated with a basic set of inline styles.
+	// This means that if you want to provide your own CSS, you want
+	// to disable the inline CSS provided by Hugo.
+	DisableInlineCSS bool
+}
+
 // RSS holds the functional configuration settings related to the RSS feeds.
 type RSS struct {
 	// Limit the number of pages.
diff --git a/tpl/tplimpl/embedded/templates/shortcodes/twitter.html b/tpl/tplimpl/embedded/templates/shortcodes/twitter.html
index b88cf7ce0ea..7a4adea5d34 100644
--- a/tpl/tplimpl/embedded/templates/shortcodes/twitter.html
+++ b/tpl/tplimpl/embedded/templates/shortcodes/twitter.html
@@ -1,3 +1,4 @@
+{{- warnf "The \"twitter\", \"tweet\", and \"twitter_simple\" shortcodes were deprecated in v0.142.0 and will be removed in a future release. Please use the \"x\" shortcode instead." }}
 {{- $pc := .Page.Site.Config.Privacy.Twitter -}}
 {{- if not $pc.Disable -}}
   {{- if $pc.Simple -}}
diff --git a/tpl/tplimpl/embedded/templates/shortcodes/twitter_simple.html b/tpl/tplimpl/embedded/templates/shortcodes/twitter_simple.html
index 0fc8613b938..7251f64e31f 100644
--- a/tpl/tplimpl/embedded/templates/shortcodes/twitter_simple.html
+++ b/tpl/tplimpl/embedded/templates/shortcodes/twitter_simple.html
@@ -1,34 +1,37 @@
-{{- $pc := .Page.Site.Config.Privacy.Twitter -}}
-{{- $sc := .Page.Site.Config.Services.Twitter -}}
-{{- if not $pc.Disable -}}
+{{- warnf "The \"twitter\", \"tweet\", and \"twitter_simple\" shortcodes were deprecated in v0.142.0 and will be removed in a future release. Please use the \"x\" shortcode instead." }}
+{{- if not site.Config.Privacy.Twitter.Disable -}}
   {{- $id := or (.Get "id") "" -}}
   {{- $user := or (.Get "user") "" -}}
   {{- if and $id $user -}}
-    {{- template "render-simple-tweet" (dict "id" $id "user" $user "dnt" $pc.EnableDNT "name" .Name "position" .Position) -}}
+    {{- template "render-simple-tweet" (dict "id" $id "user" $user "ctx" .) -}}
   {{- else -}}
     {{- errorf "The %q shortcode requires two named parameters: user and id. See %s" .Name .Position -}}
   {{- end -}}
 {{- end -}}
 
 {{- define "render-simple-tweet" -}}
+  {{- $dnt := site.Config.Privacy.Twitter.EnableDNT }}
   {{- $url := printf "https://twitter.com/%v/status/%v" .user .id -}}
-  {{- $query := querify "url" $url "dnt" .dnt "omit_script" true -}}
+  {{- $query := querify "url" $url "dnt" $dnt "omit_script" true -}}
   {{- $request := printf "https://publish.twitter.com/oembed?%s" $query -}}
   {{- with try (resources.GetRemote $request) -}}
     {{- with .Err -}}
       {{- errorf "%s" . -}}
     {{- else with .Value -}}
+      {{- if not site.Config.Services.Twitter.DisableInlineCSS }}
+        {{- template "__h_simple_twitter_css" (dict "ctx" $.ctx) }}
+      {{- end }}
       {{- (. | transform.Unmarshal).html | safeHTML -}}
     {{- else -}}
-      {{- warnidf "shortcode-twitter-simple-getremote" "The %q shortcode was unable to retrieve the remote data. See %s" .name .position -}}
+      {{- warnidf "shortcode-twitter-simple-getremote" "The %q shortcode was unable to retrieve the remote data. See %s" .ctx.Name .ctx.Position -}}
     {{- end -}}
   {{- end -}}
 {{- end -}}
 
 {{- define "__h_simple_twitter_css" -}}
-  {{- if not (.Page.Scratch.Get "__h_simple_twitter_css") -}}
+  {{- if not (.ctx.Page.Store.Get "__h_simple_twitter_css") -}}
     {{/* Only include once */}}
-    {{- .Page.Scratch.Set "__h_simple_twitter_css" true -}}
+    {{- .ctx.Page.Store.Set "__h_simple_twitter_css" true -}}
     <style type="text/css">
       .twitter-tweet {
         font:
diff --git a/tpl/tplimpl/embedded/templates/shortcodes/x.html b/tpl/tplimpl/embedded/templates/shortcodes/x.html
new file mode 100644
index 00000000000..78c0ac08bca
--- /dev/null
+++ b/tpl/tplimpl/embedded/templates/shortcodes/x.html
@@ -0,0 +1,29 @@
+{{- $pc := .Page.Site.Config.Privacy.X -}}
+{{- if not $pc.Disable -}}
+  {{- if $pc.Simple -}}
+    {{- template "_internal/shortcodes/x_simple.html" . -}}
+  {{- else -}}
+    {{- $id := or (.Get "id") "" -}}
+    {{- $user := or (.Get "user") "" -}}
+    {{- if and $id $user -}}
+      {{- template "render-x" (dict "id" $id "user" $user "dnt" $pc.EnableDNT "name" .Name "position" .Position) -}}
+    {{- else -}}
+      {{- errorf "The %q shortcode requires two named parameters: user and id. See %s" .Name .Position -}}
+    {{- end -}}
+  {{- end -}}
+{{- end -}}
+
+{{- define "render-x" -}}
+  {{- $url := printf "https://x.com/%v/status/%v" .user .id -}}
+  {{- $query := querify "url" $url "dnt" .dnt -}}
+  {{- $request := printf "https://publish.x.com/oembed?%s" $query -}}
+  {{- with try (resources.GetRemote $request) -}}
+    {{- with .Err -}}
+      {{- errorf "%s" . -}}
+    {{- else with .Value -}}
+      {{- (. | transform.Unmarshal).html | safeHTML -}}
+    {{- else -}}
+    {{- warnidf "shortcode-x-getremote" "The %q shortcode was unable to retrieve the remote data. See %s" .name .position -}}
+    {{- end -}}
+  {{- end -}}
+{{- end -}}
diff --git a/tpl/tplimpl/embedded/templates/shortcodes/x_simple.html b/tpl/tplimpl/embedded/templates/shortcodes/x_simple.html
new file mode 100644
index 00000000000..661ed77560b
--- /dev/null
+++ b/tpl/tplimpl/embedded/templates/shortcodes/x_simple.html
@@ -0,0 +1,60 @@
+{{- if not site.Config.Privacy.X.Disable -}}
+  {{- $id := or (.Get "id") "" -}}
+  {{- $user := or (.Get "user") "" -}}
+  {{- if and $id $user -}}
+    {{- template "render-simple-x" (dict "id" $id "user" $user "ctx" .) -}}
+  {{- else -}}
+    {{- errorf "The %q shortcode requires two named parameters: user and id. See %s" .Name .Position -}}
+  {{- end -}}
+{{- end -}}
+
+{{- define "render-simple-x" -}}
+  {{- $dnt := site.Config.Privacy.X.EnableDNT }}
+  {{- $url := printf "https://x.com/%v/status/%v" .user .id -}}
+  {{- $query := querify "url" $url "dnt" $dnt "omit_script" true -}}
+  {{- $request := printf "https://publish.x.com/oembed?%s" $query -}}
+  {{- with try (resources.GetRemote $request) -}}
+    {{- with .Err -}}
+      {{- errorf "%s" . -}}
+    {{- else with .Value -}}
+      {{- if not site.Config.Services.X.DisableInlineCSS }}
+        {{- template "__h_simple_x_css" (dict "ctx" $.ctx) }}
+      {{- end }}
+      {{- (. | transform.Unmarshal).html | safeHTML -}}
+    {{- else -}}
+      {{- warnidf "shortcode-x-simple-getremote" "The %q shortcode was unable to retrieve the remote data. See %s" .ctx.Name .ctx.Position -}}
+    {{- end -}}
+  {{- end -}}
+{{- end -}}
+
+{{- define "__h_simple_x_css" -}}
+  {{- if not (.ctx.Page.Store.Get "__h_simple_x_css") -}}
+    {{/* Only include once */}}
+    {{- .ctx.Page.Store.Set "__h_simple_x_css" true -}}
+    <style type="text/css">
+      .twitter-tweet {
+        font:
+          14px/1.45 -apple-system,
+          BlinkMacSystemFont,
+          "Segoe UI",
+          Roboto,
+          Oxygen-Sans,
+          Ubuntu,
+          Cantarell,
+          "Helvetica Neue",
+          sans-serif;
+        border-left: 4px solid #2b7bb9;
+        padding-left: 1.5em;
+        color: #555;
+      }
+      .twitter-tweet a {
+        color: #2b7bb9;
+        text-decoration: none;
+      }
+      blockquote.twitter-tweet a:hover,
+      blockquote.twitter-tweet a:focus {
+        text-decoration: underline;
+      }
+    </style>
+  {{- end -}}
+{{- end -}}
diff --git a/tpl/tplimpl/template.go b/tpl/tplimpl/template.go
index 0a593593b88..0ea7117a3ac 100644
--- a/tpl/tplimpl/template.go
+++ b/tpl/tplimpl/template.go
@@ -66,6 +66,8 @@ const (
 // We need this to identify position in templates with base templates applied.
 var identifiersRe = regexp.MustCompile(`at \<(.*?)(\.{3})?\>:`)
 
+// The tweet and twitter shortcodes were deprecated in favor of the x shortcode
+// in v0.141.0. We can remove these aliases in v0.155.0 or later.
 var embeddedTemplatesAliases = map[string][]string{
 	"shortcodes/twitter.html": {"shortcodes/tweet.html"},
 }


Test patch:
diff --git a/config/privacy/privacyConfig_test.go b/config/privacy/privacyConfig_test.go
index bff627f48e1..6cde91165ef 100644
--- a/config/privacy/privacyConfig_test.go
+++ b/config/privacy/privacyConfig_test.go
@@ -40,6 +40,10 @@ simple = true
 disable = true
 enableDNT = true
 simple = true
+[privacy.x]
+disable = true
+enableDNT = true
+simple = true
 [privacy.vimeo]
 disable = true
 enableDNT = true
@@ -61,7 +65,8 @@ simple = true
 		pc.GoogleAnalytics.RespectDoNotTrack, pc.Instagram.Disable,
 		pc.Instagram.Simple, pc.Twitter.Disable, pc.Twitter.EnableDNT,
 		pc.Twitter.Simple, pc.Vimeo.Disable, pc.Vimeo.EnableDNT, pc.Vimeo.Simple,
-		pc.YouTube.PrivacyEnhanced, pc.YouTube.Disable,
+		pc.YouTube.PrivacyEnhanced, pc.YouTube.Disable, pc.X.Disable, pc.X.EnableDNT,
+		pc.X.Simple,
 	}
 
 	c.Assert(got, qt.All(qt.Equals), true)
diff --git a/config/services/servicesConfig_test.go b/config/services/servicesConfig_test.go
index 12b042a5a97..952a7fe1c7b 100644
--- a/config/services/servicesConfig_test.go
+++ b/config/services/servicesConfig_test.go
@@ -36,6 +36,8 @@ id = "ga_id"
 disableInlineCSS = true
 [services.twitter]
 disableInlineCSS = true
+[services.x]
+disableInlineCSS = true
 `
 	cfg, err := config.FromConfigString(tomlConfig, "toml")
 	c.Assert(err, qt.IsNil)
diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go
index 5c4e07498ea..9fdb6323848 100644
--- a/hugolib/testhelpers_test.go
+++ b/hugolib/testhelpers_test.go
@@ -262,6 +262,8 @@ respectDoNotTrack = true
 simple = true
 [privacy.twitter]
 enableDNT = true
+[privacy.x]
+enableDNT = true
 [privacy.vimeo]
 disable = false
 [privacy.youtube]
diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go
index 8088a0551c1..6ccf27f80e9 100644
--- a/tpl/tplimpl/tplimpl_integration_test.go
+++ b/tpl/tplimpl/tplimpl_integration_test.go
@@ -734,3 +734,154 @@ https://gohugo.io"
 		`<img src="/qr_472aab57ec7a6e3d.png" width="132" height="132">`,
 	)
 }
+
+// Issue 13214
+// We deprecated the twitter, tweet (alias of twitter), and twitter_simple
+// shortcodes in v0.141.0, replacing them with x and x_simple.
+func TestXShortcodes(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+disableKinds = ['home','rss','section','sitemap','taxonomy','term']
+#CONFIG
+-- content/p1.md --
+---
+title: p1
+---
+{{< x user="SanDiegoZoo" id="1453110110599868418" >}}
+-- content/p2.md --
+---
+title: p2
+---
+{{< twitter user="SanDiegoZoo" id="1453110110599868418" >}}
+-- content/p3.md --
+---
+title: p3
+---
+{{< tweet user="SanDiegoZoo" id="1453110110599868418" >}}
+-- content/p4.md --
+---
+title: p4
+---
+{{< x_simple user="SanDiegoZoo" id="1453110110599868418" >}}
+-- content/p5.md --
+---
+title: p5
+---
+{{< twitter_simple user="SanDiegoZoo" id="1453110110599868418" >}}
+-- layouts/_default/single.html --
+{{ .Content | strings.TrimSpace | safeHTML }}
+--
+`
+
+	b := hugolib.Test(t, files)
+
+	// Test x, twitter, and tweet shortcodes
+	want := `<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Owl bet you&#39;ll lose this staring contest 🦉 <a href="https://t.co/eJh4f2zncC">pic.twitter.com/eJh4f2zncC</a></p>&mdash; San Diego Zoo Wildlife Alliance (@sandiegozoo) <a href="https://twitter.com/sandiegozoo/status/1453110110599868418?ref_src=twsrc%5Etfw">October 26, 2021</a></blockquote>
+	<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`
+	b.AssertFileContent("public/p1/index.html", want)
+
+	htmlFiles := []string{
+		b.FileContent("public/p1/index.html"),
+		b.FileContent("public/p2/index.html"),
+		b.FileContent("public/p3/index.html"),
+	}
+	if !allElementsEqual(htmlFiles) {
+		t.Error("A: expected all files to be equal")
+	}
+
+	// Test x_simple and twitter_simple shortcodes
+	wantSimple := "<style type=\"text/css\">\n      .twitter-tweet {\n        font:\n          14px/1.45 -apple-system,\n          BlinkMacSystemFont,\n          \"Segoe UI\",\n          Roboto,\n          Oxygen-Sans,\n          Ubuntu,\n          Cantarell,\n          \"Helvetica Neue\",\n          sans-serif;\n        border-left: 4px solid #2b7bb9;\n        padding-left: 1.5em;\n        color: #555;\n      }\n      .twitter-tweet a {\n        color: #2b7bb9;\n        text-decoration: none;\n      }\n      blockquote.twitter-tweet a:hover,\n      blockquote.twitter-tweet a:focus {\n        text-decoration: underline;\n      }\n    </style><blockquote class=\"twitter-tweet\"><p lang=\"en\" dir=\"ltr\">Owl bet you&#39;ll lose this staring contest 🦉 <a href=\"https://t.co/eJh4f2zncC\">pic.twitter.com/eJh4f2zncC</a></p>&mdash; San Diego Zoo Wildlife Alliance (@sandiegozoo) <a href=\"https://twitter.com/sandiegozoo/status/1453110110599868418?ref_src=twsrc%5Etfw\">October 26, 2021</a></blockquote>\n--"
+	b.AssertFileContent("public/p4/index.html", wantSimple)
+
+	htmlFiles = []string{
+		b.FileContent("public/p4/index.html"),
+		b.FileContent("public/p5/index.html"),
+	}
+	if !allElementsEqual(htmlFiles) {
+		t.Error("B: expected all files to be equal")
+	}
+
+	filesOriginal := files
+
+	// Test privacy.twitter.simple
+	files = strings.ReplaceAll(filesOriginal, "#CONFIG", "privacy.twitter.simple=true")
+	b = hugolib.Test(t, files)
+	htmlFiles = []string{
+		b.FileContent("public/p2/index.html"),
+		b.FileContent("public/p3/index.html"),
+		b.FileContent("public/p5/index.html"),
+	}
+	if !allElementsEqual(htmlFiles) {
+		t.Error("C: expected all files to be equal")
+	}
+
+	// Test privacy.x.simple
+	files = strings.ReplaceAll(filesOriginal, "#CONFIG", "privacy.x.simple=true")
+	b = hugolib.Test(t, files)
+	htmlFiles = []string{
+		b.FileContent("public/p1/index.html"),
+		b.FileContent("public/p4/index.html"),
+		b.FileContent("public/p4/index.html"),
+	}
+	if !allElementsEqual(htmlFiles) {
+		t.Error("D: expected all files to be equal")
+	}
+	htmlFiles = []string{
+		b.FileContent("public/p2/index.html"),
+		b.FileContent("public/p3/index.html"),
+	}
+	if !allElementsEqual(htmlFiles) {
+		t.Error("E: expected all files to be equal")
+	}
+
+	// Test privacy.twitter.disable
+	files = strings.ReplaceAll(filesOriginal, "#CONFIG", "privacy.twitter.disable = true")
+	b = hugolib.Test(t, files)
+	b.AssertFileContent("public/p1/index.html", "")
+	htmlFiles = []string{
+		b.FileContent("public/p1/index.html"),
+		b.FileContent("public/p2/index.html"),
+		b.FileContent("public/p3/index.html"),
+		b.FileContent("public/p4/index.html"),
+		b.FileContent("public/p4/index.html"),
+	}
+	if !allElementsEqual(htmlFiles) {
+		t.Error("F: expected all files to be equal")
+	}
+
+	// Test privacy.x.disable
+	files = strings.ReplaceAll(filesOriginal, "#CONFIG", "privacy.x.disable = true")
+	b = hugolib.Test(t, files)
+	b.AssertFileContent("public/p1/index.html", "")
+	htmlFiles = []string{
+		b.FileContent("public/p1/index.html"),
+		b.FileContent("public/p4/index.html"),
+	}
+	if !allElementsEqual(htmlFiles) {
+		t.Error("G: expected all files to be equal")
+	}
+	htmlFiles = []string{
+		b.FileContent("public/p2/index.html"),
+		b.FileContent("public/p3/index.html"),
+	}
+	if !allElementsEqual(htmlFiles) {
+		t.Error("F: expected all files to be equal")
+	}
+}
+
+// allElementsEqual reports whether all elements in the given string slice are
+// equal.
+func allElementsEqual(slice []string) bool {
+	if len(slice) == 0 {
+		return true
+	}
+	first := slice[0]
+	for _, v := range slice[1:] {
+		if v != first {
+			return false
+		}
+	}
+	return true
+}