Skip to content

Commit 34fbff5

Browse files
committed
Add localized page metadata endpoint with comprehensive SEO support
1 parent 10c69e5 commit 34fbff5

File tree

6 files changed

+1105
-4
lines changed

6 files changed

+1105
-4
lines changed
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
# Returns comprehensive localized page metadata for SEO and rendering
6+
#
7+
# This endpoint provides all data needed to render a page in a specific locale,
8+
# including SEO meta tags, Open Graph, Twitter Cards, JSON-LD structured data,
9+
# navigation info, and rendered page content.
10+
#
11+
# All text fields (title, meta_description, meta_keywords) are automatically
12+
# returned in the requested locale via Mobility translations.
13+
#
14+
class LocalizedPagesController < BaseController
15+
include ApiPublic::Cacheable
16+
include UrlLocalizationHelper
17+
18+
def show
19+
setup_locale
20+
21+
unless website_provisioned?
22+
render json: website_not_provisioned_error, status: :not_found
23+
return
24+
end
25+
26+
page = Pwb::Current.website.pages.find_by(slug: params[:page_slug])
27+
28+
unless page
29+
render json: page_not_found_error, status: :not_found
30+
return
31+
end
32+
33+
set_short_cache(max_age: 1.hour, etag_data: [page.id, page.updated_at, I18n.locale])
34+
return if performed?
35+
36+
render json: build_localized_page_response(page)
37+
end
38+
39+
private
40+
41+
def setup_locale
42+
locale = params[:locale] || I18n.default_locale
43+
I18n.locale = locale.to_sym
44+
end
45+
46+
def website_provisioned?
47+
Pwb::Current.website.present? && Pwb::Current.website.pages.exists?
48+
end
49+
50+
def website_not_provisioned_error
51+
{
52+
error: "Website not provisioned",
53+
message: "The website has not been provisioned with any pages.",
54+
code: "WEBSITE_NOT_PROVISIONED"
55+
}
56+
end
57+
58+
def page_not_found_error
59+
{
60+
error: "Page not found",
61+
code: "PAGE_NOT_FOUND"
62+
}
63+
end
64+
65+
def build_localized_page_response(page)
66+
website = Pwb::Current.website
67+
68+
{
69+
id: page.id,
70+
slug: page.slug,
71+
# Mobility-translated fields - automatically return current locale's value
72+
title: page.seo_title.presence || page.page_title.presence || page.slug.titleize,
73+
meta_description: page.meta_description,
74+
meta_keywords: page.meta_keywords,
75+
# Canonical URL is generated dynamically based on locale
76+
canonical_url: build_canonical_url(page),
77+
last_modified: page.updated_at.iso8601,
78+
etag: generate_page_etag(page),
79+
cache_control: "public, max-age=3600",
80+
og: build_open_graph(page, website),
81+
twitter: build_twitter_card(page, website),
82+
json_ld: build_json_ld(page, website),
83+
breadcrumbs: build_breadcrumbs(page),
84+
alternate_locales: build_alternate_locales(page),
85+
html_elements: build_html_elements(page),
86+
sort_order_top_nav: page.sort_order_top_nav,
87+
show_in_top_nav: page.show_in_top_nav,
88+
sort_order_footer: page.sort_order_footer,
89+
show_in_footer: page.show_in_footer,
90+
visible: page.visible,
91+
page_contents: build_page_contents(page)
92+
}
93+
end
94+
95+
def generate_page_etag(page)
96+
"\"#{Digest::MD5.hexdigest("#{page.id}-#{page.updated_at}-#{I18n.locale}")}\""
97+
end
98+
99+
def build_canonical_url(page)
100+
host = request.host_with_port
101+
protocol = request.protocol
102+
path = build_page_path(page, I18n.locale)
103+
104+
"#{protocol}#{host}#{path}"
105+
end
106+
107+
def build_page_path(page, locale)
108+
default_locale = default_website_locale
109+
110+
if locale.to_s == default_locale.to_s
111+
"/p/#{page.slug}"
112+
else
113+
"/#{locale}/p/#{page.slug}"
114+
end
115+
end
116+
117+
def default_website_locale
118+
Pwb::Current.website&.default_client_locale&.to_s || "en"
119+
end
120+
121+
def build_open_graph(page, website)
122+
site_name = website.company_display_name.presence ||
123+
website.agency&.display_name.presence ||
124+
"Property Website"
125+
126+
og = {
127+
"og:title" => page.seo_title.presence || page.page_title.presence || page.slug.titleize,
128+
"og:description" => page.meta_description.presence || website_default_description(website),
129+
"og:type" => "website",
130+
"og:url" => build_canonical_url(page),
131+
"og:site_name" => site_name
132+
}
133+
134+
# Add og:image if available from page or website
135+
og_image = page_og_image(page) || website_logo(website)
136+
og["og:image"] = og_image if og_image.present?
137+
138+
og.compact
139+
end
140+
141+
def build_twitter_card(page, website)
142+
{
143+
"twitter:card" => "summary_large_image",
144+
"twitter:title" => page.seo_title.presence || page.page_title.presence || page.slug.titleize,
145+
"twitter:description" => page.meta_description.presence || website_default_description(website),
146+
"twitter:image" => page_og_image(page) || website_logo(website)
147+
}.compact
148+
end
149+
150+
def build_json_ld(page, website)
151+
site_name = website.company_display_name.presence ||
152+
website.agency&.display_name.presence ||
153+
"Property Website"
154+
155+
json_ld = {
156+
"@context" => "https://schema.org",
157+
"@type" => "WebPage",
158+
"name" => page.seo_title.presence || page.page_title.presence || page.slug.titleize,
159+
"description" => page.meta_description,
160+
"url" => build_canonical_url(page),
161+
"inLanguage" => I18n.locale.to_s,
162+
"datePublished" => page.created_at&.to_date&.iso8601,
163+
"dateModified" => page.updated_at&.to_date&.iso8601,
164+
"publisher" => build_publisher_json_ld(site_name, website)
165+
}
166+
167+
json_ld.compact
168+
end
169+
170+
def build_publisher_json_ld(site_name, website)
171+
publisher = {
172+
"@type" => "Organization",
173+
"name" => site_name
174+
}
175+
176+
logo_url = website_logo(website)
177+
if logo_url.present?
178+
publisher["logo"] = {
179+
"@type" => "ImageObject",
180+
"url" => logo_url
181+
}
182+
end
183+
184+
publisher
185+
end
186+
187+
def build_breadcrumbs(page)
188+
locale = I18n.locale
189+
default_locale = default_website_locale
190+
191+
home_url = locale.to_s == default_locale ? "/" : "/#{locale}/"
192+
page_url = build_page_path(page, locale)
193+
194+
[
195+
{ "name" => I18n.t("breadcrumbs.home", default: "Home"), "url" => home_url },
196+
{ "name" => page.page_title.presence || page.slug.titleize, "url" => page_url }
197+
]
198+
end
199+
200+
def build_alternate_locales(page)
201+
website = Pwb::Current.website
202+
supported_locales = website.supported_locales || [default_website_locale]
203+
current_base_locale = normalize_locale_for_mobility(I18n.locale)&.to_s
204+
205+
supported_locales.filter_map do |locale|
206+
# Normalize for comparison to handle "es" vs "es-MX"
207+
locale_base = normalize_locale_for_mobility(locale)&.to_s
208+
next if locale_base == current_base_locale
209+
210+
# Use the base locale for the URL path
211+
path = build_page_path(page, locale_base || locale)
212+
url = "#{request.protocol}#{request.host_with_port}#{path}"
213+
214+
{ "locale" => locale_base || locale.to_s, "url" => url }
215+
end
216+
end
217+
218+
def build_html_elements(page)
219+
# Return localized UI element labels for the page
220+
# These can be used by frontend frameworks that need pre-translated strings
221+
elements = []
222+
223+
# Page title element
224+
elements << {
225+
"element_class_id" => "page_title",
226+
"element_label" => build_element_translations(:page_title, page)
227+
}
228+
229+
# Common form elements (if page has forms)
230+
elements << {
231+
"element_class_id" => "submit_button",
232+
"element_label" => build_i18n_translations("buttons.submit", "Submit")
233+
}
234+
235+
elements << {
236+
"element_class_id" => "back_button",
237+
"element_label" => build_i18n_translations("buttons.back", "Back")
238+
}
239+
240+
elements
241+
end
242+
243+
def build_element_translations(attribute, page)
244+
translations = {}
245+
supported_locales = website_supported_locales
246+
247+
supported_locales.each do |locale|
248+
# Normalize locale for Mobility (e.g., "en-US" → "en")
249+
mobility_locale = normalize_locale_for_mobility(locale)
250+
next unless mobility_locale
251+
252+
Mobility.with_locale(mobility_locale) do
253+
value = page.send(attribute)
254+
translations[locale.to_s] = value if value.present?
255+
end
256+
end
257+
258+
# Fallback to slug if no translations
259+
translations[I18n.default_locale.to_s] ||= page.slug.titleize if translations.empty?
260+
261+
translations
262+
end
263+
264+
def build_i18n_translations(key, default)
265+
translations = {}
266+
supported_locales = website_supported_locales
267+
268+
supported_locales.each do |locale|
269+
# Use the full locale for I18n if available, otherwise use normalized
270+
i18n_locale = I18n.available_locales.map(&:to_s).include?(locale.to_s) ? locale : normalize_locale_for_mobility(locale)
271+
next unless i18n_locale
272+
273+
translations[locale.to_s] = I18n.t(key, locale: i18n_locale, default: default)
274+
end
275+
276+
translations
277+
end
278+
279+
# Get website's supported locales
280+
def website_supported_locales
281+
Pwb::Current.website&.supported_locales || I18n.available_locales.map(&:to_s)
282+
end
283+
284+
# Normalize regional locale codes to base codes for Mobility
285+
# e.g., "en-US" → :en, "es-MX" → :es
286+
def normalize_locale_for_mobility(locale)
287+
return nil if locale.blank?
288+
289+
base_locale = locale.to_s.split('-').first.to_sym
290+
# Only return if it's a valid Mobility/I18n locale
291+
I18n.available_locales.include?(base_locale) ? base_locale : nil
292+
end
293+
294+
def build_page_contents(page)
295+
return [] unless page.respond_to?(:ordered_visible_page_contents)
296+
297+
page.ordered_visible_page_contents.map do |page_content|
298+
raw_html = page_content.is_rails_part ? nil : page_content.content&.raw
299+
localized_html = raw_html.present? ? localize_html_urls(raw_html) : nil
300+
301+
{
302+
"page_part_key" => page_content.page_part_key,
303+
"sort_order" => page_content.sort_order,
304+
"visible" => page_content.visible_on_page,
305+
"is_rails_part" => page_content.is_rails_part || false,
306+
"rendered_html" => localized_html,
307+
"label" => page_content.label
308+
}
309+
end
310+
end
311+
312+
# Helper methods for website data
313+
314+
def website_default_description(website)
315+
website.default_meta_description.presence || "Find your dream property"
316+
end
317+
318+
def website_logo(website)
319+
return nil unless website.respond_to?(:logo_url)
320+
website.logo_url.presence
321+
end
322+
323+
def page_og_image(page)
324+
# Check if page has a custom OG image in its details or translations
325+
return nil unless page.respond_to?(:details) && page.details.is_a?(Hash)
326+
page.details["og_image_url"].presence
327+
end
328+
end
329+
end
330+
end

app/models/pwb/page.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ class Page < ApplicationRecord
4747
belongs_to :website, class_name: 'Pwb::Website', optional: true
4848

4949
# Mobility translations with container backend (single JSONB column)
50-
translates :raw_html, :page_title, :link_title
50+
# SEO fields are localized for proper multilingual SEO support
51+
translates :raw_html, :page_title, :link_title,
52+
:seo_title, :meta_description, :meta_keywords
5153

5254
has_many :links, class_name: 'Pwb::Link', foreign_key: 'page_slug', primary_key: 'slug'
5355
has_one :main_link, -> { where(placement: :top_nav) }, foreign_key: 'page_slug', primary_key: 'slug', class_name: 'Pwb::Link'

config/routes.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,10 @@
752752
get "/pages/:id" => "pages#show"
753753
get "/pages/by_slug/:slug" => "pages#show_by_slug"
754754

755+
# Localized page with comprehensive SEO metadata
756+
# Returns full page data including OG tags, JSON-LD, translations, etc.
757+
get "/localized_page/by_slug/:page_slug" => "localized_pages#show"
758+
755759
# Translations
756760
get "/translations" => "translations#index"
757761

0 commit comments

Comments
 (0)