Skip to content

Commit

Permalink
Merge pull request #2059 from tf/href-lang
Browse files Browse the repository at this point in the history
Entry translations/hreflang alternate links
  • Loading branch information
tf authored Jan 24, 2024
2 parents 8c136c4 + 0ab629f commit c7863c8
Show file tree
Hide file tree
Showing 40 changed files with 1,421 additions and 18 deletions.
1 change: 1 addition & 0 deletions .hound.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ruby:
config_file: .rubocop.yml
enabled: false
javascript:
config_file: .jshintrc
ignore_file: .jshintignore
Expand Down
12 changes: 5 additions & 7 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
AllCops:
TargetRubyVersion: 2.5
TargetRubyVersion: 3.2

# Use double quotes only for interpolation.
Style/StringLiterals:
Expand All @@ -14,7 +14,7 @@ Style/BlockDelimiters:
EnforcedStyle: braces_for_chaining

# The default of 80 characters is a little to narrow.
Metrics/LineLength:
Layout/LineLength:
Max: 100

# Only place spaces inside blocks written with braces.
Expand Down Expand Up @@ -48,19 +48,21 @@ Metrics/BlockLength:
# In specs methods using RSpec DSL often become long
Metrics/MethodLength:
Exclude:
- '**/app/views/components/**/*'
- '**/spec/**/*'

# Long spec files are ok
Metrics/ModuleLength:
Exclude:
- '**/spec/**/*'

Metricts/ParameterLists:
Metrics/ParameterLists:
CountKeywordArgs: false

# Do not require class documentation for specs and migrations
Style/Documentation:
Exclude:
- admins/**/*
- '**/spec/**/*'
- db/migrate/*

Expand All @@ -79,7 +81,3 @@ Style/ModuleFunction:
# Allow method names like has_text?
Naming/PredicateName:
Enabled: false

# Braces can have meaning in Ruby 3
Style/BracesAroundHashParameters:
Enabled: false
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ gem 'shakapacker', '~> 7.0'

# Make tests fail on JS errors
gem 'capybara-chromedriver-logger', git: 'https://github.com/codevise/capybara-chromedriver-logger', branch: 'fix-selenium-4-deprecation', require: false

# See https://github.com/charkost/prosopite/pull/79
gem 'prosopite', git: 'https://github.com/tf/prosopite', branch: 'location-backtrace-cleaner'
1 change: 1 addition & 0 deletions admins/pageflow/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ module Pageflow
helper Admin::FormHelper
helper Admin::MembershipsHelper
helper Admin::RevisionsHelper
helper Admin::EntryTranslationsHelper

helper_method :account_policy_scope
helper_method :site_policy_scope
Expand Down
75 changes: 75 additions & 0 deletions admins/pageflow/translations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
module Pageflow
ActiveAdmin.register Entry, as: 'Translations' do
menu false
belongs_to :entry

actions :new, :create, :destroy

searchable_select_options(name: :potential_entry_translations,
scope: lambda do |_params|
authorize!(:manage_translations, parent)
PotentialEntryTranslations.for(parent).resolve
end,
text_attribute: :title,
display_text: lambda do |entry|
if entry.translation_group
entry.translation_group
.entries
.order('title ASC')
.map(&:title)
.join(' / ')
.presence
else
entry.title
end
end)

form partial: 'form'

member_action :default, method: :put do
entry = Entry.find(params[:id])

authorize!(:manage_translations, entry)
entry.mark_as_default_translation

redirect_to(admin_entry_path(parent, tab: 'translations'))
end

controller do
helper Pageflow::Admin::FormHelper

def index
redirect_to admin_entry_path(parent, tab: 'translations')
end

def create
entry = Entry.find(params.require(:entry)[:id])

authorize!(:manage_translations, parent)
authorize!(:manage_translations, entry)
parent.mark_as_translation_of(entry)

redirect_to(admin_entry_path(parent, tab: 'translations'))
end

def destroy
entry = Entry.find(params[:id])

authorize!(:manage_translations, entry)
entry.remove_from_translation_group

redirect_to(admin_entry_path(parent, tab: 'translations'))
end

protected

def authorized?(action, subject = nil)
if subject.is_a?(Entry) && subject.new_record?
super(:manage_translations, parent)
else
super
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.index_table {
.index_table,
.embedded_index_table {
.publication_state {
width: 16px;
padding: 5px;
Expand Down
19 changes: 19 additions & 0 deletions app/helpers/pageflow/admin/entry_translations_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module Pageflow
module Admin
# @api private
module EntryTranslationsHelper
def entry_translation_display_locale(entry)
display_locale = t(
'pageflow.public._language',
locale: (entry.published_revision || entry.draft).locale
)

if entry.default_translation?
t('pageflow.admin.entry_translations.default_translation', display_locale:)
else
display_locale
end
end
end
end
end
34 changes: 34 additions & 0 deletions app/helpers/pageflow/hreflang_links_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module Pageflow
# Helpers to render alternate links to translations of an entry.
#
# @since edge
module HreflangLinksHelper
include SocialShareHelper

# Render alternate links to all published entries that have been
# marked as translations of the given entry.
def hreflang_link_tags_for_entry(entry)
translations =
entry.translations(-> { preload(:site, :translation_group, permalink: :directory) })

safe_join(
translations.each_with_object([]) do |translation, links|
links << hreflang_link_tag(translation)

if translation.default_translation?
links << hreflang_link_tag(translation, hreflang: 'x-default')
end
end
)
end

private

def hreflang_link_tag(entry, hreflang: entry.locale)
tag('link',
rel: 'alternate',
hreflang:,
href: social_share_entry_url(entry))
end
end
end
62 changes: 62 additions & 0 deletions app/models/concerns/pageflow/translatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
module Pageflow
# @api private
module Translatable
extend ActiveSupport::Concern

included do
belongs_to(:translation_group,
optional: true,
class_name: 'EntryTranslationGroup')

has_many(:translations,
through: :translation_group,
source: :entries)

after_destroy do
if translation_group&.single_item_or_empty?
translation_group.destroy
elsif default_translation?
translation_group.update(default_translation: nil)
end
end
end

def mark_as_translation_of(entry)
transaction do
ensure_translation_group(entry)

if !entry.translation_group
entry.update!(translation_group:)
elsif entry.translation_group != translation_group
entry.translation_group.merge_into(translation_group)
end
end
end

def remove_from_translation_group
if translation_group.entries.count <= 2
translation_group.destroy
else
translation_group.update(default_translation: nil) if default_translation?
update!(translation_group: nil)
end
end

def mark_as_default_translation
translation_group.update!(default_translation: self)
end

def default_translation?
translation_group&.default_translation == self
end

private

def ensure_translation_group(other_entry)
return if translation_group

update!(translation_group: other_entry.translation_group ||
build_translation_group(default_translation: self))
end
end
end
1 change: 1 addition & 0 deletions app/models/pageflow/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class PasswordMissingError < StandardError
include EntryPublicationStates
include Permalinkable
include SerializationBlacklist
include Translatable

extend FriendlyId
friendly_id :slug_candidates, :use => [:finders, :slugged]
Expand Down
27 changes: 27 additions & 0 deletions app/models/pageflow/entry_translation_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module Pageflow
# @api private
class EntryTranslationGroup < ApplicationRecord
has_many :entries,
-> { order(title: :asc) },
foreign_key: 'translation_group_id',
dependent: :nullify

has_many :publicly_visible_entries,
-> { published_without_password_protection.published_without_noindex },
foreign_key: 'translation_group_id',
class_name: 'Entry'

belongs_to :default_translation,
class_name: 'Entry',
optional: true

def merge_into(translation_group)
entries.update_all(translation_group_id: translation_group.id)
destroy
end

def single_item_or_empty?
!entries.many?
end
end
end
55 changes: 55 additions & 0 deletions app/models/pageflow/potential_entry_translations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
module Pageflow
# @api private
class PotentialEntryTranslations
def initialize(entry)
@entry = entry
end

def self.for(entry)
new(entry)
end

def resolve
preload(
group_by_translation_group(
exclude_translations(
other_entries_of_account
)
)
)
end

private

def other_entries_of_account
@entry.account.entries.where.not(id: @entry.id)
end

def exclude_translations(scope)
return scope unless @entry.translation_group

scope
.where.not(translation_group_id: @entry.translation_group)
.or(scope.where(translation_group: nil))
end

def group_by_translation_group(scope)
scope
# Use MIN(id) to choose an arbitrary entry to represent its
# translation group. MIN(translation_group_id) is needed since
# technically translation_group_id is not part of the GROUP BY
# clause.
.select(<<-SQL)
MIN(id) as id,
GROUP_CONCAT(title) as title,
MIN(translation_group_id) as translation_group_id
SQL
.group('IFNULL(translation_group_id, id)')
.order('title ASC')
end

def preload(scope)
scope.includes(translation_group: :entries)
end
end
end
Loading

0 comments on commit c7863c8

Please sign in to comment.