Skip to content

Commit 73f8a2a

Browse files
committed
Add page finder service
Add a new service which find the page by urlname or should try to find the correct wildcard url for a given parent_page. It will traverse the page tree and will try to match all page_layouts with a wildcard_url. The first match wins and will be returned. The page and params are stored in the service itself and can be received later in the controller.
1 parent 77af972 commit 73f8a2a

2 files changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
class PageFinder
5+
attr_reader :parent_page, :params
6+
7+
def initialize(parent_page = Current.language.root_page, params: ActionController::Parameters.new)
8+
@parent_page = parent_page
9+
@params = params
10+
@extracted_params = ActionController::Parameters.new
11+
end
12+
13+
def call(urlname)
14+
return if parent_page.nil? || urlname.blank?
15+
16+
find_by_urlname(urlname) || find_by_wildcard(urlname)
17+
end
18+
19+
private
20+
21+
# Finds a page by exact urlname match within the current language.
22+
#
23+
# @return [Alchemy::Page]
24+
def find_by_urlname(urlname)
25+
parent_page.children.contentpages.find_by(urlname: urlname)
26+
end
27+
28+
# Walks the page tree level by level, matching URL segments against page slugs or wildcard URLs.
29+
#
30+
# @return [Alchemy::Page] the matched page or nil if no match
31+
def find_by_wildcard(urlname)
32+
return if wildcard_layout_names.empty?
33+
34+
segments = urlname.split("/")
35+
current_parent = parent_page
36+
path_prefix = nil
37+
page = nil
38+
39+
until segments.empty?
40+
children = current_parent.children.contentpages
41+
expected_urlname = [path_prefix, segments.first].compact.join("/")
42+
43+
# use only one database query to extract the exact child or children with wildcard_url
44+
possible_pages = children.where(urlname: expected_urlname)
45+
.or(children.where(page_layout: wildcard_layout_names))
46+
.to_a
47+
48+
# Try an exact slug match first
49+
page = possible_pages.find { _1.urlname == expected_urlname }
50+
if page
51+
segments = segments.drop(1)
52+
current_parent = page
53+
path_prefix = expected_urlname
54+
next
55+
end
56+
57+
# Try children with wildcard_url attributes
58+
possible_pages.select { wildcard_layout_names.include?(_1.page_layout) }.each do |child|
59+
pattern = child.wildcard_url.pattern
60+
61+
# a wildcard url pattern can have multiple segments, e.g. ":year/:slug"
62+
segment_count = pattern.count("/") + 1
63+
candidate_url = segments.first(segment_count).join("/")
64+
65+
next unless url_matches_pattern?(candidate_url, pattern) && params_match_constraints?(child.wildcard_url.params)
66+
67+
@params.merge!(@extracted_params.permit!)
68+
69+
# prepare the data for the next segment loop
70+
path_prefix = [path_prefix, child.slug].compact.join("/")
71+
segments = segments.drop(segment_count)
72+
current_parent = child
73+
page = child
74+
break
75+
end
76+
return unless page
77+
end
78+
79+
page
80+
end
81+
82+
# Checks if a URL matches a pattern's structure.
83+
# Verifies segment count and static segment equality.
84+
#
85+
# @param url [String] the URL segment(s) to check, e.g. "2024/my-post"
86+
# @param pattern [String] the wildcard pattern, e.g. ":year/:slug"
87+
# @return [Boolean]
88+
def url_matches_pattern?(url, pattern)
89+
url_segments = url.split("/")
90+
pattern_segments = pattern.split("/")
91+
return false unless url_segments.size == pattern_segments.size
92+
93+
pattern_segments.each_with_index.all? do |pattern_segment, index|
94+
has_param = pattern_segment.start_with?(":")
95+
@extracted_params[pattern_segment[1..].to_sym] = url_segments[index] if has_param
96+
97+
has_param || pattern_segment == url_segments[index]
98+
end
99+
end
100+
101+
# Validates extracted params against wildcard_url params from the page definition.
102+
# Params can be a simple string or a hash mapping param names to types.
103+
#
104+
# @param constraints [String, Hash, nil] param definitions
105+
# @return [Boolean]
106+
def params_match_constraints?(constraints)
107+
return true if constraints.blank?
108+
109+
format_matchers = Alchemy.config.format_matchers
110+
normalize_constraints(constraints).all? do |key, type|
111+
if type.is_a?(Regexp)
112+
@extracted_params[key]&.match?(type)
113+
else
114+
next true unless format_matchers.respond_to?(type.to_sym)
115+
116+
@extracted_params[key]&.match?(format_matchers.public_send(type.to_sym))
117+
end
118+
end
119+
end
120+
121+
# Normalizes constraints into a hash mapping param names to type strings.
122+
# A simple string applies to all params, e.g. params: "integer"
123+
#
124+
# @param constraints [String, Hash] param definitions
125+
# @return [Hash<Symbol, String>]
126+
def normalize_constraints(constraints)
127+
if constraints.is_a?(String)
128+
@extracted_params.keys.to_h { |key| [key, constraints] }
129+
else
130+
constraints.transform_keys(&:to_sym)
131+
end
132+
end
133+
134+
# Returns the names of all page layouts that have a wildcard_url defined.
135+
#
136+
# @return [Array<String>] layout names with wildcard URLs
137+
def wildcard_layout_names
138+
@_wildcard_layout_names ||= PageDefinition.all.select { |d| d.wildcard_url&.present? }.map(&:name)
139+
end
140+
end
141+
end
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
module Alchemy
6+
RSpec.describe PageFinder do
7+
let(:language) { create(:alchemy_language) }
8+
let!(:language_root) do
9+
create(:alchemy_page, :language_root, language: language)
10+
end
11+
12+
before do
13+
PageDefinition.reset!
14+
Current.language = language
15+
end
16+
17+
def create_page(name:, layout: "standard", parent: language_root)
18+
create(
19+
:alchemy_page, :public,
20+
name: name,
21+
page_layout: layout,
22+
parent: parent,
23+
language: language
24+
)
25+
end
26+
27+
# Page tree used by most tests:
28+
#
29+
# Products (standard) -> /products
30+
# +-- Product Details (product_detail) -> /products/:id (integer constraint)
31+
# | +-- Comments (standard) -> /products/:id/comments
32+
# +-- Product By Slug (product_by_slug) -> /products/:slug (no constraint)
33+
#
34+
# Blog (standard) -> /blog
35+
# +-- Blog Post (blog_post) -> /blog/:year/:slug (integer + string)
36+
#
37+
# Users (standard) -> /users
38+
# +-- User Profile (user_profile) -> /users/:uuid/profile (uuid constraint)
39+
40+
let!(:products_page) { create_page(name: "Products") }
41+
let!(:product_detail_page) { create_page(name: "Product Details", layout: "product_detail", parent: products_page) }
42+
let!(:comments_page) { create_page(name: "Comments", parent: product_detail_page) }
43+
let!(:product_by_slug_page) { create_page(name: "Product By Slug", layout: "product_by_slug", parent: products_page) }
44+
45+
let!(:blog_page) { create_page(name: "Blog") }
46+
let!(:blog_post_page) { create_page(name: "Blog Post", layout: "blog_post", parent: blog_page) }
47+
48+
let!(:users_page) { create_page(name: "Users") }
49+
let!(:user_profile_page) { create_page(name: "User Profile", layout: "user_profile", parent: users_page) }
50+
51+
let(:params) { ActionController::Parameters.new }
52+
53+
it "defaults to Current.language.root_page" do
54+
page = described_class.new(params: params).call("products/123")
55+
expect(page).to eq(product_detail_page)
56+
expect(params[:id]).to eq("123")
57+
end
58+
59+
it "finds a page by exact urlname" do
60+
page = described_class.new(params: params).call("products")
61+
expect(page).to eq(products_page)
62+
end
63+
64+
it "returns nil when the parent page is nil" do
65+
expect(described_class.new(nil, params: params).call("products/123")).to be_nil
66+
end
67+
68+
it "returns nil for a blank path" do
69+
expect(described_class.new(language_root, params: params).call("")).to be_nil
70+
end
71+
72+
it "returns nil for a path with no matching prefix" do
73+
expect(described_class.new(language_root, params: params).call("other/123")).to be_nil
74+
end
75+
76+
context "single-segment pattern with integer constraint" do
77+
it "matches /products/123" do
78+
page = described_class.new(language_root, params: params).call("products/123")
79+
expect(page).to eq(product_detail_page)
80+
expect(params[:id]).to eq("123")
81+
end
82+
83+
it "rejects non-integer and falls through to unconstrained sibling" do
84+
page = described_class.new(language_root, params: params).call("products/some-slug")
85+
expect(page).to eq(product_by_slug_page)
86+
expect(params[:slug]).to eq("some-slug")
87+
end
88+
end
89+
90+
context "multi-segment pattern" do
91+
it "matches /blog/2024/my-post and extracts both params" do
92+
page = described_class.new(language_root, params: params).call("blog/2024/my-post")
93+
expect(page).to eq(blog_post_page)
94+
expect(params[:year]).to eq("2024")
95+
expect(params[:slug]).to eq("my-post")
96+
end
97+
98+
it "does not match with wrong segment count" do
99+
expect(described_class.new(language_root, params: params).call("blog/2024")).to be_nil
100+
end
101+
end
102+
103+
context "pattern with static segments" do
104+
let(:uuid) { "550e8400-e29b-41d4-a716-446655440000" }
105+
106+
it "matches /users/:uuid/profile" do
107+
page = described_class.new(language_root, params: params).call("users/#{uuid}/profile")
108+
expect(page).to eq(user_profile_page)
109+
expect(params[:uuid]).to eq(uuid)
110+
end
111+
112+
it "does not match without the trailing static segment" do
113+
expect(described_class.new(language_root, params: params).call("users/#{uuid}")).to be_nil
114+
end
115+
end
116+
117+
context "hierarchical patterns (grandchild under pattern page)" do
118+
it "matches /products/42/comments through the pattern parent" do
119+
page = described_class.new(language_root, params: params).call("products/42/comments")
120+
expect(page).to eq(comments_page)
121+
expect(params[:id]).to eq("42")
122+
end
123+
end
124+
125+
context "with competing sibling patterns of different segment counts" do
126+
let!(:shared_page) { create_page(name: "Shared") }
127+
let!(:multi_segment_page) { create_page(name: "Multi Segment", layout: "blog_post", parent: shared_page) }
128+
let!(:single_segment_page) { create_page(name: "Single Segment", layout: "product_by_slug", parent: shared_page) }
129+
let!(:child_of_single) { create_page(name: "My Post", parent: single_segment_page) }
130+
131+
it "matches the first pattern sibling even when the other could also match via its child" do
132+
page = described_class.new(language_root, params: params).call("shared/2024/my-post")
133+
expect(page).to eq(multi_segment_page)
134+
expect(params[:year]).to eq("2024")
135+
expect(params[:slug]).to eq("my-post")
136+
end
137+
end
138+
139+
context "with a regex constraint" do
140+
let!(:warehouse_page) { create_page(name: "Warehouse") }
141+
let!(:sku_page) { create_page(name: "SKU Lookup", layout: "product_by_sku", parent: warehouse_page) }
142+
143+
it "matches when the value satisfies the regex" do
144+
page = described_class.new(language_root, params: params).call("warehouse/AB-1234")
145+
expect(page).to eq(sku_page)
146+
expect(params[:sku]).to eq("AB-1234")
147+
end
148+
149+
it "does not match when the value violates the regex" do
150+
expect(described_class.new(language_root, params: params).call("warehouse/invalid")).to be_nil
151+
end
152+
end
153+
end
154+
end

0 commit comments

Comments
 (0)