diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 16b9d3fb53..9cf64d09b4 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -64,4 +64,16 @@ module StatusesHelper def prefers_autoplay? ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif end + + def render_seo_schema(status) + json = ActiveModelSerializers::SerializableResource.new( + status, + serializer: SEO::SocialMediaPostingSerializer, + adapter: SEO::Adapter + ).to_json + + # rubocop:disable Rails/OutputSafety + content_tag(:script, json_escape(json).html_safe, type: 'application/ld+json') + # rubocop:enable Rails/OutputSafety + end end diff --git a/app/lib/seo/adapter.rb b/app/lib/seo/adapter.rb new file mode 100644 index 0000000000..0d95f05807 --- /dev/null +++ b/app/lib/seo/adapter.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class SEO::Adapter < ActiveModelSerializers::Adapter::Base + def self.default_key_transform + :camel_lower + end + + def self.transform_key_casing!(value, _options) + SEO::CaseTransform.camel_lower(value) + end + + def serializable_hash(options = nil) + serialized_hash = serializer.serializable_hash(serialization_options(options)) + self.class.transform_key_casing!(serialized_hash, instance_options) + end +end diff --git a/app/lib/seo/case_transform.rb b/app/lib/seo/case_transform.rb new file mode 100644 index 0000000000..6c81fb0019 --- /dev/null +++ b/app/lib/seo/case_transform.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module SEO::CaseTransform + PREFIX_KEYS = %w( + context + id + type + ).freeze + + class << self + def camel_lower_cache + @camel_lower_cache ||= {} + end + + def camel_lower(value) + case value + when Array + value.map { |item| camel_lower(item) } + when Hash + value.deep_transform_keys! { |key| camel_lower(key) } + when Symbol + camel_lower(value.to_s).to_sym + when String + camel_lower_cache[value] ||= begin + if PREFIX_KEYS.include?(value.to_s) + "@#{value}" + else + value.underscore.camelize(:lower) + end + end + else + value + end + end + end +end diff --git a/app/serializers/seo/social_media_posting_serializer.rb b/app/serializers/seo/social_media_posting_serializer.rb new file mode 100644 index 0000000000..72030e4173 --- /dev/null +++ b/app/serializers/seo/social_media_posting_serializer.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +class SEO::SocialMediaPostingSerializer < ActiveModel::Serializer + include FormattingHelper + include RoutingHelper + + attributes :context, :type, :url, :date_published, :date_modified, + :author, :text, :interaction_statistic + + attribute :image, if: -> { object.ordered_media_attachments.any?(&:image?) } + attribute :video, if: -> { object.ordered_media_attachments.any? { |attachment| attachment.video? || attachment.gifv? } } + attribute :audio, if: -> { object.ordered_media_attachments.any?(&:audio?) } + attribute :shared_content, if: -> { object.with_preview_card? } + + def context + 'https://schema.org' + end + + def type + 'SocialMediaPosting' + end + + def url + ActivityPub::TagManager.instance.url_for(object) + end + + def date_published + object.created_at.iso8601 + end + + def date_modified + object.edited_at&.iso8601 + end + + def author + { + type: 'Person', + name: object.account.display_name.presence || object.account.username, + alternate_name: object.account.local_username_and_domain, + identifier: object.account.local_username_and_domain, + url: ActivityPub::TagManager.instance.url_for(object.account), + interaction_statistic: [ + { + type: 'InteractionCounter', + interaction_type: 'https://schema.org/FollowAction', + user_interaction_count: object.account.followers_count, + }, + ], + } + end + + def text + status_content_format(object) + end + + def interaction_statistic + [ + { + type: 'InteractionCounter', + interaction_type: 'https://schema.org/LikeAction', + user_interaction_count: object.favourites_count, + }, + + { + type: 'InteractionCounter', + interaction_type: 'https://schema.org/ShareAction', + user_interaction_count: object.reblogs_count, + }, + + { + type: 'InteractionCounter', + interaction_type: 'https://schema.org/ReplyAction', + user_interaction_count: object.replies_count, + }, + ] + end + + def image + object.ordered_media_attachments.filter_map do |attachment| + next unless attachment.image? + + { + type: 'ImageObject', + content_url: full_asset_url(attachment.file.url(:original, false)), + thumbnail_url: attachment.thumbnail.present? ? full_asset_url(attachment.thumbnail.url(:original)) : full_asset_url(attachment.file.url(:small)), + description: attachment.description, + } + end + end + + def video + object.ordered_media_attachments.filter_map do |attachment| + next unless attachment.video? || attachment.gifv? + + { + type: 'VideoObject', + upload_date: attachment.created_at.iso8601, + content_url: full_asset_url(attachment.file.url(:original, false)), + thumbnail_url: attachment.thumbnail.present? ? full_asset_url(attachment.thumbnail.url(:original)) : full_asset_url(attachment.file.url(:small)), + embed_url: medium_player_url(attachment), + description: attachment.description, + } + end + end + + def audio + object.ordered_media_attachments.filter_map do |attachment| + next unless attachment.audio? + + { + type: 'AudioObject', + upload_date: attachment.created_at.iso8601, + content_url: full_asset_url(attachment.file.url(:original, false)), + thumbnail_url: attachment.thumbnail.present? ? full_asset_url(attachment.thumbnail.url(:original)) : full_asset_url(attachment.file.url(:small)), + embed_url: medium_player_url(attachment), + description: attachment.description, + } + end + end + + def shared_content + { + type: 'WebPage', + url: object.preview_card.url, + } + end +end diff --git a/app/views/statuses/show.html.haml b/app/views/statuses/show.html.haml index cc779f4370..c530b77c33 100644 --- a/app/views/statuses/show.html.haml +++ b/app/views/statuses/show.html.haml @@ -3,6 +3,8 @@ - content_for :header_tags do - if @account.user_prefers_noindex? %meta{ name: 'robots', content: 'noindex, noarchive' }/ + - else + = render_seo_schema @status %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: short_account_status_url(@account, @status), format: 'json') }/ %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@status) }/ diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index d9a8f72663..1befe0a6ff 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -27,6 +27,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'REST' inflect.acronym 'RSS' inflect.acronym 'StatsD' + inflect.acronym 'SEO' inflect.acronym 'TOC' inflect.acronym 'URL'