diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-30 04:41:07 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-30 04:41:07 +0200 |
| commit | 36a4194ee3013dfa834aa8d4d57b0bfacf724a1a (patch) | |
| tree | 3f46fb04e6d6b38a7d3bef4d3ba1c98fb098a347 | |
| parent | 6c62d0c538e6b8baa416a7ed901a74a66de44348 (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.rb | 122 | ||||
| -rw-r--r-- | app/views/layouts/_social_meta.html.erb | 34 | ||||
| -rw-r--r-- | app/views/layouts/application.html.erb | 4 | ||||
| -rw-r--r-- | config/locales/de.yml | 2 | ||||
| -rw-r--r-- | config/locales/en.yml | 2 | ||||
| -rw-r--r-- | public/images/social_card.png | bin | 0 -> 47670 bytes | |||
| -rw-r--r-- | public/images/social_default.png | bin | 0 -> 48859 bytes | |||
| -rw-r--r-- | test/controllers/content_controller_test.rb | 76 | ||||
| -rw-r--r-- | test/controllers/shared_previews_controller_test.rb | 15 |
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 @@ | |||
| 1 | module 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 | ||
| 122 | end | ||
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 | ||
| 35 | end | 50 | end |
