summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-30 04:41:07 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-30 04:41:07 +0200
commit36a4194ee3013dfa834aa8d4d57b0bfacf724a1a (patch)
tree3f46fb04e6d6b38a7d3bef4d3ba1c98fb098a347
parent6c62d0c538e6b8baa416a7ed901a74a66de44348 (diff)
Emit per-page Open Graph metadata
Replaces one hardcoded German description and an unrenderable SVG with per-page title, description, canonical URL, locale and publication date, plus the card variant or a site-wide default.
-rw-r--r--app/helpers/social_helper.rb122
-rw-r--r--app/views/layouts/_social_meta.html.erb34
-rw-r--r--app/views/layouts/application.html.erb4
-rw-r--r--config/locales/de.yml2
-rw-r--r--config/locales/en.yml2
-rw-r--r--public/images/social_card.pngbin0 -> 47670 bytes
-rw-r--r--public/images/social_default.pngbin0 -> 48859 bytes
-rw-r--r--test/controllers/content_controller_test.rb76
-rw-r--r--test/controllers/shared_previews_controller_test.rb15
9 files changed, 252 insertions, 3 deletions
diff --git a/app/helpers/social_helper.rb b/app/helpers/social_helper.rb
new file mode 100644
index 00000000..e66884a4
--- /dev/null
+++ b/app/helpers/social_helper.rb
@@ -0,0 +1,122 @@
1module SocialHelper
2 # The club's name is a proper noun and identical in both locales.
3 OG_SITE_NAME = "Chaos Computer Club".freeze
4
5 # Facebook's og:locale wants a language_TERRITORY tag, not a bare
6 # language code. Anything not listed falls back to the default locale's
7 # tag rather than emitting something no consumer recognises.
8 OG_LOCALES = { :de => "de_DE", :en => "en_GB" }.freeze
9
10 OG_DEFAULT_IMAGE = "/images/social_default.png".freeze
11
12 # Previews render unpublished drafts behind nothing but a token in the
13 # URL, so these controllers emit no social metadata at all.
14 PREVIEW_CONTROLLERS = %w[shared_previews pages].freeze
15
16 # Search result pages are the classic noindex case. Previews join them
17 # for the reason above.
18 NOINDEX_CONTROLLERS = (PREVIEW_CONTROLLERS + %w[search]).freeze
19
20 # og:image must be an absolute URL with a scheme; a path is ignored.
21 # request.base_url rather than a routing helper, so default_url_options
22 # cannot inject a locale prefix into a static asset path.
23 def og_absolute_url(path)
24 "#{request.base_url}#{path}"
25 end
26
27 def social_meta?
28 !PREVIEW_CONTROLLERS.include?(controller_name)
29 end
30
31 def robots_directive
32 return nil unless NOINDEX_CONTROLLERS.include?(controller_name)
33
34 # nofollow on previews as well, so a crawler that reaches one does not
35 # walk out of it into whatever the draft links to. Search pages omit it,
36 # since following result links is the one useful thing a crawler can do
37 # there.
38 PREVIEW_CONTROLLERS.include?(controller_name) ? "noindex, nofollow" : "noindex"
39 end
40
41 # The headline asset's card if it has one, else the site default.
42 # has_variant?
43 #
44 # The query suffix defeats indefinite crawler caching: FileAttachment
45 # deliberately keeps public URLs stable across a file replacement, so
46 # without it Facebook would serve the superseded card forever.
47 def og_image_url
48 asset = @page&.persisted? ? @page.headline_asset : nil
49
50 if asset&.has_variant?(:og)
51 og_absolute_url("#{asset.upload.url(:og)}?v=#{asset.upload_updated_at.to_i}")
52 else
53 og_absolute_url(OG_DEFAULT_IMAGE)
54 end
55 end
56
57 # Both files are exactly 1200x630, so these are constants either way.
58 def og_image_width
59 FileAttachment::OG_WIDTH
60 end
61
62 def og_image_height
63 FileAttachment::OG_HEIGHT
64 end
65
66 def og_image_alt
67 asset = @page&.persisted? ? @page.headline_asset : nil
68 asset&.has_variant?(:og) ? asset.name.to_s : OG_SITE_NAME
69 end
70
71 def og_title
72 @page&.title.presence || OG_SITE_NAME
73 end
74
75 # Public controllers are deliberately not pinned to the default locale,
76 # so Globalize follows I18n.locale here and the abstract arrives in the
77 # language being served. Abstracts hold markup, hence strip_tags.
78 def og_description
79 text = strip_tags(@page&.abstract.to_s).squish
80 return truncate(text, :length => 200, :separator => " ") if text.present?
81
82 t("layouts.social_meta.site_description")
83 end
84
85 # A rendered page is a piece of content; the aggregate views (search,
86 # tags, gallery) have no @page and are the site itself.
87 def og_type
88 @page&.persisted? ? "article" : "website"
89 end
90
91 def og_published_time
92 @page&.published_at&.iso8601
93 end
94
95 # request.path rather than a routing helper: it is already the locale's
96 # own canonical form -- unprefixed for German, /en/ for English -- and
97 # dropping the query string is what makes it canonical.
98 def og_canonical_url
99 og_absolute_url(request.path)
100 end
101
102 def og_locale
103 OG_LOCALES.fetch((@page&.effective_lang || I18n.locale).to_sym,
104 OG_LOCALES[I18n.default_locale.to_sym])
105 end
106
107 # Only locales in which this page genuinely has a translation, so a
108 # crawler is not told about a variant that would fall back.
109 def og_locale_alternates
110 return [] unless @page
111
112 (@page.translated_locales.map(&:to_sym) - [og_locale_key]).filter_map do |locale|
113 OG_LOCALES[locale]
114 end
115 end
116
117 private
118
119 def og_locale_key
120 (@page&.effective_lang || I18n.locale).to_sym
121 end
122end
diff --git a/app/views/layouts/_social_meta.html.erb b/app/views/layouts/_social_meta.html.erb
new file mode 100644
index 00000000..9a86d26d
--- /dev/null
+++ b/app/views/layouts/_social_meta.html.erb
@@ -0,0 +1,34 @@
1<% if (directive = robots_directive) %>
2 <meta name="robots" content="<%= directive %>"/>
3<% end %>
4
5<% if social_meta? %>
6 <meta name="description" content="<%= og_description %>"/>
7
8 <link rel="canonical" href="<%= og_canonical_url %>"/>
9
10 <meta property="og:site_name" content="<%= SocialHelper::OG_SITE_NAME %>"/>
11 <meta property="og:type" content="<%= og_type %>"/>
12 <meta property="og:url" content="<%= og_canonical_url %>"/>
13 <meta property="og:title" content="<%= og_title %>"/>
14 <meta property="og:description" content="<%= og_description %>"/>
15
16 <meta property="og:image" content="<%= og_image_url %>"/>
17 <meta property="og:image:width" content="<%= og_image_width %>"/>
18 <meta property="og:image:height" content="<%= og_image_height %>"/>
19 <meta property="og:image:alt" content="<%= og_image_alt %>"/>
20
21 <meta property="og:locale" content="<%= og_locale %>"/>
22 <% og_locale_alternates.each do |alternate| %>
23 <meta property="og:locale:alternate" content="<%= alternate %>"/>
24 <% end %>
25
26 <% if og_published_time %>
27 <meta property="article:published_time" content="<%= og_published_time %>"/>
28 <% end %>
29
30 <%# X ignores og:image unless the card type is declared, and falls back
31 to a small square thumbnail. twitter:image is deliberately absent:
32 with only twitter:card set, X reuses og:image. %>
33 <meta name="twitter:card" content="summary_large_image"/>
34<% end %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 6a31104a..5717cebf 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -4,10 +4,8 @@
4 <head> 4 <head>
5 <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> 5 <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
6 <meta name="viewport" content="width=device-width,initial-scale=1"/> 6 <meta name="viewport" content="width=device-width,initial-scale=1"/>
7 <meta name="description" content="Der Chaos Computer Club ist eine galaktische Gemeinschaft von Lebewesen für Informationsfreiheit und Technikfolgenabschätzung."/>
8 7
9 <meta property="og:image" content="https://www.ccc.de/images/chaosknoten.svg" /> 8 <%= render :partial => "layouts/social_meta" %>
10 <meta property="og:description" content="Der Chaos Computer Club ist eine galaktische Gemeinschaft von Lebewesen für Informationsfreiheit und Technikfolgenabschätzung." />
11 9
12 <title><%= page_title %></title> 10 <title><%= page_title %></title>
13 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/ccc.css') %>"> 11 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/ccc.css') %>">
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 7253d1e4..4f0739ce 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -671,3 +671,5 @@ de:
671 switch_locale: "Sprache umschalten" 671 switch_locale: "Sprache umschalten"
672 scheme_override: "Hell/Dunkel umschalten" 672 scheme_override: "Hell/Dunkel umschalten"
673 log_out: "Abmelden" 673 log_out: "Abmelden"
674 social_meta:
675 site_description: "Der Chaos Computer Club ist eine galaktische Gemeinschaft von Lebewesen für Informationsfreiheit und Technikfolgenabschätzung."
diff --git a/config/locales/en.yml b/config/locales/en.yml
index b21008f7..703e14ec 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -623,3 +623,5 @@ en:
623 switch_locale: "Toggle language" 623 switch_locale: "Toggle language"
624 scheme_override: "Toggle light/dark" 624 scheme_override: "Toggle light/dark"
625 log_out: "Log out" 625 log_out: "Log out"
626 social_meta:
627 site_description: "The Chaos Computer Club is a galactic community of life forms campaigning for freedom of information and the assessment of the impact of technology."
diff --git a/public/images/social_card.png b/public/images/social_card.png
new file mode 100644
index 00000000..42a52f8e
--- /dev/null
+++ b/public/images/social_card.png
Binary files differ
diff --git a/public/images/social_default.png b/public/images/social_default.png
new file mode 100644
index 00000000..81b6f069
--- /dev/null
+++ b/public/images/social_default.png
Binary files differ
diff --git a/test/controllers/content_controller_test.rb b/test/controllers/content_controller_test.rb
index 9731d082..304fd921 100644
--- a/test/controllers/content_controller_test.rb
+++ b/test/controllers/content_controller_test.rb
@@ -114,6 +114,82 @@ class ContentControllerTest < ActionController::TestCase
114 114
115 assert_response :success 115 assert_response :success
116 end 116 end
117
118 test "a published page emits article social metadata" do
119 node = create_node_under_root "og_article_test"
120 draft = find_or_create_draft(node, @user1)
121 draft.title = "Offener Brief"
122 draft.abstract = "Wir veröffentlichen den Wortlaut eines Offenen Briefes."
123 draft.save
124 node.publish_draft!
125
126 get :render_page, params: { :locale => "de", :page_path => ["og_article_test"] }
127
128 assert_response :success
129 assert_select "meta[property='og:type'][content=?]", "article"
130 assert_select "meta[property='og:title'][content=?]", "Offener Brief"
131 assert_select "meta[property='og:site_name'][content=?]", "Chaos Computer Club"
132 assert_select "meta[property='og:locale'][content=?]", "de_DE"
133 assert_select "meta[property='article:published_time']"
134
135 # og:title carries the bare title; page_title's "CCC | " prefix belongs
136 # to <title> only, since platforms render og:site_name separately.
137 assert_select "title", :text => "CCC | Offener Brief"
138
139 # A canonical URL must not carry a query string.
140 canonical = css_select("link[rel=canonical]").first["href"]
141 assert_match %r{/og_article_test\z}, canonical
142
143 assert_select "meta[name=robots]", false, "a public page must be indexable"
144 end
145
146 test "a page without a headline asset falls back to the default card" do
147 node = create_node_under_root "og_fallback_test"
148 find_or_create_draft(node, @user1).update!(:title => "Ohne Aufmacher")
149 node.publish_draft!
150
151 get :render_page, params: { :locale => "de", :page_path => ["og_fallback_test"] }
152
153 assert_response :success
154 assert_select "meta[property='og:image'][content=?]",
155 "http://test.host/images/social_default.png"
156 assert_select "meta[property='og:image:width'][content=?]", "1200"
157 assert_select "meta[property='og:image:height'][content=?]", "630"
158 end
159
160 test "a page with a headline asset points at its social card" do
161 node = create_node_under_root "og_variant_test"
162 draft = find_or_create_draft(node, @user1)
163 draft.title = "Mit Aufmacher"
164 draft.save
165 node.publish_draft!
166 node.reload
167
168 asset = Asset.create!(:name => "aufmacher",
169 :upload_file_name => "aufmacher.png",
170 :upload_content_type => "image/png",
171 :upload_updated_at => Time.at(1_700_000_000))
172 node.attach_asset!(asset, :user => @user1, :headline => true)
173
174 # has_variant? only tests File.exist?, so touching the path is enough
175 # and no ImageMagick runs in the suite. image/png takes .jpg for the
176 # card, per variant_filename's per-style rule.
177 card = Rails.root.join("tmp", "test_uploads", asset.id.to_s, "og", "aufmacher.jpg")
178
179 begin
180 FileUtils.mkdir_p(File.dirname(card))
181 FileUtils.touch(card)
182
183 get :render_page, params: { :locale => "de", :page_path => ["og_variant_test"] }
184
185 assert_response :success
186 assert_select "meta[property='og:image'][content=?]",
187 "http://test.host/system/uploads/#{asset.id}/og/aufmacher.jpg?v=1700000000"
188 assert_select "meta[property='og:image:alt'][content=?]", "aufmacher"
189 ensure
190 FileUtils.rm_rf(Rails.root.join("tmp", "test_uploads", asset.id.to_s))
191 end
192 end
117 193
118 protected 194 protected
119 195
diff --git a/test/controllers/shared_previews_controller_test.rb b/test/controllers/shared_previews_controller_test.rb
index 4ebc785f..6d4588a8 100644
--- a/test/controllers/shared_previews_controller_test.rb
+++ b/test/controllers/shared_previews_controller_test.rb
@@ -32,4 +32,19 @@ class SharedPreviewsControllerTest < ActionController::TestCase
32 32
33 assert_redirected_to node.head.public_link 33 assert_redirected_to node.head.public_link
34 end 34 end
35
36 test "a shared preview emits no social metadata and is not indexable" do
37 node = Node.root.children.create!(:slug => "shared_preview_no_og_test")
38 node.draft.update!(:title => "Unveröffentlichter Entwurf")
39 node.draft.ensure_preview_token!
40
41 get :show, params: { :token => node.draft.preview_token }
42
43 assert_response :success
44
45 # An unfurled preview link would otherwise hand the draft's title,
46 # abstract and headline image to everyone in the chat room.
47 assert_select "meta[property^='og:']", false, "a preview must emit no og tags"
48 assert_select "meta[name=robots][content=?]", "noindex, nofollow"
49 end
35end 50end