From 36a4194ee3013dfa834aa8d4d57b0bfacf724a1a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 30 Jul 2026 04:41:07 +0200 Subject: 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. --- app/helpers/social_helper.rb | 122 +++++++++++++++++++++ app/views/layouts/_social_meta.html.erb | 34 ++++++ app/views/layouts/application.html.erb | 4 +- config/locales/de.yml | 2 + config/locales/en.yml | 2 + public/images/social_card.png | Bin 0 -> 47670 bytes public/images/social_default.png | Bin 0 -> 48859 bytes test/controllers/content_controller_test.rb | 76 +++++++++++++ .../controllers/shared_previews_controller_test.rb | 15 +++ 9 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 app/helpers/social_helper.rb create mode 100644 app/views/layouts/_social_meta.html.erb create mode 100644 public/images/social_card.png create mode 100644 public/images/social_default.png 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 @@ +module SocialHelper + # The club's name is a proper noun and identical in both locales. + OG_SITE_NAME = "Chaos Computer Club".freeze + + # Facebook's og:locale wants a language_TERRITORY tag, not a bare + # language code. Anything not listed falls back to the default locale's + # tag rather than emitting something no consumer recognises. + OG_LOCALES = { :de => "de_DE", :en => "en_GB" }.freeze + + OG_DEFAULT_IMAGE = "/images/social_default.png".freeze + + # Previews render unpublished drafts behind nothing but a token in the + # URL, so these controllers emit no social metadata at all. + PREVIEW_CONTROLLERS = %w[shared_previews pages].freeze + + # Search result pages are the classic noindex case. Previews join them + # for the reason above. + NOINDEX_CONTROLLERS = (PREVIEW_CONTROLLERS + %w[search]).freeze + + # og:image must be an absolute URL with a scheme; a path is ignored. + # request.base_url rather than a routing helper, so default_url_options + # cannot inject a locale prefix into a static asset path. + def og_absolute_url(path) + "#{request.base_url}#{path}" + end + + def social_meta? + !PREVIEW_CONTROLLERS.include?(controller_name) + end + + def robots_directive + return nil unless NOINDEX_CONTROLLERS.include?(controller_name) + + # nofollow on previews as well, so a crawler that reaches one does not + # walk out of it into whatever the draft links to. Search pages omit it, + # since following result links is the one useful thing a crawler can do + # there. + PREVIEW_CONTROLLERS.include?(controller_name) ? "noindex, nofollow" : "noindex" + end + + # The headline asset's card if it has one, else the site default. + # has_variant? + # + # The query suffix defeats indefinite crawler caching: FileAttachment + # deliberately keeps public URLs stable across a file replacement, so + # without it Facebook would serve the superseded card forever. + def og_image_url + asset = @page&.persisted? ? @page.headline_asset : nil + + if asset&.has_variant?(:og) + og_absolute_url("#{asset.upload.url(:og)}?v=#{asset.upload_updated_at.to_i}") + else + og_absolute_url(OG_DEFAULT_IMAGE) + end + end + + # Both files are exactly 1200x630, so these are constants either way. + def og_image_width + FileAttachment::OG_WIDTH + end + + def og_image_height + FileAttachment::OG_HEIGHT + end + + def og_image_alt + asset = @page&.persisted? ? @page.headline_asset : nil + asset&.has_variant?(:og) ? asset.name.to_s : OG_SITE_NAME + end + + def og_title + @page&.title.presence || OG_SITE_NAME + end + + # Public controllers are deliberately not pinned to the default locale, + # so Globalize follows I18n.locale here and the abstract arrives in the + # language being served. Abstracts hold markup, hence strip_tags. + def og_description + text = strip_tags(@page&.abstract.to_s).squish + return truncate(text, :length => 200, :separator => " ") if text.present? + + t("layouts.social_meta.site_description") + end + + # A rendered page is a piece of content; the aggregate views (search, + # tags, gallery) have no @page and are the site itself. + def og_type + @page&.persisted? ? "article" : "website" + end + + def og_published_time + @page&.published_at&.iso8601 + end + + # request.path rather than a routing helper: it is already the locale's + # own canonical form -- unprefixed for German, /en/ for English -- and + # dropping the query string is what makes it canonical. + def og_canonical_url + og_absolute_url(request.path) + end + + def og_locale + OG_LOCALES.fetch((@page&.effective_lang || I18n.locale).to_sym, + OG_LOCALES[I18n.default_locale.to_sym]) + end + + # Only locales in which this page genuinely has a translation, so a + # crawler is not told about a variant that would fall back. + def og_locale_alternates + return [] unless @page + + (@page.translated_locales.map(&:to_sym) - [og_locale_key]).filter_map do |locale| + OG_LOCALES[locale] + end + end + + private + + def og_locale_key + (@page&.effective_lang || I18n.locale).to_sym + end +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 @@ +<% if (directive = robots_directive) %> + +<% end %> + +<% if social_meta? %> + + + + + + + + + + + + + + + + + <% og_locale_alternates.each do |alternate| %> + + <% end %> + + <% if og_published_time %> + + <% end %> + + <%# X ignores og:image unless the card type is declared, and falls back + to a small square thumbnail. twitter:image is deliberately absent: + with only twitter:card set, X reuses og:image. %> + +<% 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 @@ - - - + <%= render :partial => "layouts/social_meta" %> <%= page_title %> 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: switch_locale: "Sprache umschalten" scheme_override: "Hell/Dunkel umschalten" log_out: "Abmelden" + social_meta: + 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: switch_locale: "Toggle language" scheme_override: "Toggle light/dark" log_out: "Log out" + social_meta: + 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 Binary files /dev/null and b/public/images/social_card.png differ diff --git a/public/images/social_default.png b/public/images/social_default.png new file mode 100644 index 00000000..81b6f069 Binary files /dev/null and b/public/images/social_default.png 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 assert_response :success end + + test "a published page emits article social metadata" do + node = create_node_under_root "og_article_test" + draft = find_or_create_draft(node, @user1) + draft.title = "Offener Brief" + draft.abstract = "Wir veröffentlichen den Wortlaut eines Offenen Briefes." + draft.save + node.publish_draft! + + get :render_page, params: { :locale => "de", :page_path => ["og_article_test"] } + + assert_response :success + assert_select "meta[property='og:type'][content=?]", "article" + assert_select "meta[property='og:title'][content=?]", "Offener Brief" + assert_select "meta[property='og:site_name'][content=?]", "Chaos Computer Club" + assert_select "meta[property='og:locale'][content=?]", "de_DE" + assert_select "meta[property='article:published_time']" + + # og:title carries the bare title; page_title's "CCC | " prefix belongs + # to only, since platforms render og:site_name separately. + assert_select "title", :text => "CCC | Offener Brief" + + # A canonical URL must not carry a query string. + canonical = css_select("link[rel=canonical]").first["href"] + assert_match %r{/og_article_test\z}, canonical + + assert_select "meta[name=robots]", false, "a public page must be indexable" + end + + test "a page without a headline asset falls back to the default card" do + node = create_node_under_root "og_fallback_test" + find_or_create_draft(node, @user1).update!(:title => "Ohne Aufmacher") + node.publish_draft! + + get :render_page, params: { :locale => "de", :page_path => ["og_fallback_test"] } + + assert_response :success + assert_select "meta[property='og:image'][content=?]", + "http://test.host/images/social_default.png" + assert_select "meta[property='og:image:width'][content=?]", "1200" + assert_select "meta[property='og:image:height'][content=?]", "630" + end + + test "a page with a headline asset points at its social card" do + node = create_node_under_root "og_variant_test" + draft = find_or_create_draft(node, @user1) + draft.title = "Mit Aufmacher" + draft.save + node.publish_draft! + node.reload + + asset = Asset.create!(:name => "aufmacher", + :upload_file_name => "aufmacher.png", + :upload_content_type => "image/png", + :upload_updated_at => Time.at(1_700_000_000)) + node.attach_asset!(asset, :user => @user1, :headline => true) + + # has_variant? only tests File.exist?, so touching the path is enough + # and no ImageMagick runs in the suite. image/png takes .jpg for the + # card, per variant_filename's per-style rule. + card = Rails.root.join("tmp", "test_uploads", asset.id.to_s, "og", "aufmacher.jpg") + + begin + FileUtils.mkdir_p(File.dirname(card)) + FileUtils.touch(card) + + get :render_page, params: { :locale => "de", :page_path => ["og_variant_test"] } + + assert_response :success + assert_select "meta[property='og:image'][content=?]", + "http://test.host/system/uploads/#{asset.id}/og/aufmacher.jpg?v=1700000000" + assert_select "meta[property='og:image:alt'][content=?]", "aufmacher" + ensure + FileUtils.rm_rf(Rails.root.join("tmp", "test_uploads", asset.id.to_s)) + end + end protected 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 assert_redirected_to node.head.public_link end + + test "a shared preview emits no social metadata and is not indexable" do + node = Node.root.children.create!(:slug => "shared_preview_no_og_test") + node.draft.update!(:title => "Unveröffentlichter Entwurf") + node.draft.ensure_preview_token! + + get :show, params: { :token => node.draft.preview_token } + + assert_response :success + + # An unfurled preview link would otherwise hand the draft's title, + # abstract and headline image to everyone in the chat room. + assert_select "meta[property^='og:']", false, "a preview must emit no og tags" + assert_select "meta[name=robots][content=?]", "noindex, nofollow" + end end -- cgit v1.3