summaryrefslogtreecommitdiff
path: root/app
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 /app
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.
Diffstat (limited to 'app')
-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
3 files changed, 157 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') %>">