Skip to content

Commit 6577e25

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 4d9a71d commit 6577e25

2 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
class PageFinder
5+
attr_reader :params
6+
7+
def initialize(params: ActionController::Parameters.new)
8+
@params = params
9+
end
10+
11+
def call(urlname)
12+
return if urlname.blank?
13+
14+
find_by_urlname(urlname) || find_by_wildcard_url(urlname)
15+
end
16+
17+
private
18+
19+
# Finds a page by exact urlname match within the current language.
20+
#
21+
# @return [Alchemy::Page]
22+
def find_by_urlname(urlname)
23+
Current.language.pages.contentpages.find_by(urlname: urlname)
24+
end
25+
26+
# Walks the page tree level by level, matching URL segments against page slugs or wildcard URLs.
27+
#
28+
# @return [Alchemy::Page] the matched page or nil if no match
29+
def find_by_wildcard_url(urlname)
30+
root_page = Current.language.root_page
31+
return if root_page.nil? || wildcard_layout_names.empty?
32+
33+
segments = urlname.split("/")
34+
current_parent = root_page
35+
path_prefix = nil
36+
page = nil
37+
38+
until segments.empty?
39+
children = current_parent.children.contentpages
40+
expected_urlname = [path_prefix, segments.first].compact.join("/")
41+
42+
# use only one database query to extract the exact child or children with wildcard_url
43+
possible_pages = children.where(urlname: expected_urlname)
44+
.or(children.where(page_layout: wildcard_layout_names))
45+
.to_a
46+
47+
# Try an exact slug match first
48+
page = possible_pages.find { _1.urlname == expected_urlname }
49+
if page
50+
segments = segments.drop(1)
51+
current_parent = page
52+
path_prefix = expected_urlname
53+
next
54+
end
55+
56+
# Try children with wildcard_url attributes (it is not necessary to select for wildcard urls
57+
# because the urlname did not match before)
58+
possible_pages.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+
extracted_params = extract_matching_params(candidate_url, pattern, child.wildcard_url.params)
66+
next unless extracted_params
67+
68+
@params.merge!(ActionController::Parameters.new(extracted_params).permit(*extracted_params.keys))
69+
70+
# prepare the data for the next segment loop
71+
path_prefix = [path_prefix, child.slug].compact.join("/")
72+
segments = segments.drop(segment_count)
73+
current_parent = child
74+
page = child
75+
break
76+
end
77+
return unless page
78+
end
79+
80+
page
81+
end
82+
83+
# Extracts named parameters from a URL matching a wildcard pattern.
84+
# Returns nil if the URL does not match or constraints are not satisfied.
85+
#
86+
# @param url [String] the URL segment(s) to check, e.g. "2024/my-post"
87+
# @param pattern [String] the wildcard pattern, e.g. ":year/:slug"
88+
# @param constraints [String, Hash, nil] param definitions
89+
# @return [Hash<Symbol, String>, nil] extracted params or nil
90+
def extract_matching_params(url, pattern, constraints)
91+
url_segments = url.split("/")
92+
pattern_segments = pattern.split("/")
93+
return unless url_segments.size == pattern_segments.size
94+
95+
extracted = {}
96+
matched = pattern_segments.each_with_index.all? do |pattern_segment, index|
97+
url_segment = url_segments[index]
98+
if pattern_segment.start_with?(":")
99+
key = pattern_segment[1..].to_sym
100+
extracted[key] = url_segment if matches_constraint?(url_segment, key, constraints)
101+
else
102+
pattern_segment == url_segment
103+
end
104+
end
105+
106+
extracted if matched
107+
end
108+
109+
# Checks if a param value satisfies its constraint.
110+
#
111+
# @param value [String] the param value
112+
# @param key [Symbol] the param name
113+
# @param constraints [String, Hash, nil] param definitions
114+
# @return [Boolean]
115+
def matches_constraint?(value, key, constraints)
116+
return true if constraints.blank?
117+
118+
type = constraints.is_a?(String) ? constraints : constraints[key.to_s] || constraints[key]
119+
return true if type.nil?
120+
return value.match?(type) if type.is_a?(Regexp)
121+
122+
format_matchers = Alchemy.config.format_matchers
123+
!format_matchers.respond_to?(type.to_sym) || value.match?(format_matchers.public_send(type.to_sym))
124+
end
125+
126+
# Returns the names of all page layouts that have a wildcard_url defined.
127+
#
128+
# @return [Array<String>] layout names with wildcard URLs
129+
def wildcard_layout_names
130+
@_wildcard_layout_names ||= PageDefinition.all.select { |d| d.wildcard_url&.present? }.map(&:name)
131+
end
132+
end
133+
end
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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 "finds a nested page by its full urlname" do
65+
page = described_class.new(params: params).call(comments_page.urlname)
66+
expect(page).to eq(comments_page)
67+
end
68+
69+
it "returns nil for a blank path" do
70+
expect(described_class.new(params: params).call("")).to be_nil
71+
end
72+
73+
it "returns nil for a path with no matching prefix" do
74+
expect(described_class.new(params: params).call("other/123")).to be_nil
75+
end
76+
77+
context "single-segment pattern with integer constraint" do
78+
it "matches /products/123" do
79+
page = described_class.new(params: params).call("products/123")
80+
expect(page).to eq(product_detail_page)
81+
expect(params[:id]).to eq("123")
82+
end
83+
84+
it "rejects non-integer and falls through to unconstrained sibling" do
85+
page = described_class.new(params: params).call("products/some-slug")
86+
expect(page).to eq(product_by_slug_page)
87+
expect(params[:slug]).to eq("some-slug")
88+
# ensures no stale params leak from the failed product_detail match
89+
expect(params).not_to have_key(:id)
90+
end
91+
end
92+
93+
context "multi-segment pattern" do
94+
it "matches /blog/2024/my-post and extracts both params" do
95+
page = described_class.new(params: params).call("blog/2024/my-post")
96+
expect(page).to eq(blog_post_page)
97+
expect(params[:year]).to eq("2024")
98+
expect(params[:slug]).to eq("my-post")
99+
end
100+
101+
it "does not match with wrong segment count" do
102+
expect(described_class.new(params: params).call("blog/2024")).to be_nil
103+
end
104+
end
105+
106+
context "pattern with static segments" do
107+
let(:uuid) { "550e8400-e29b-41d4-a716-446655440000" }
108+
109+
it "matches /users/:uuid/profile" do
110+
page = described_class.new(params: params).call("users/#{uuid}/profile")
111+
expect(page).to eq(user_profile_page)
112+
expect(params[:uuid]).to eq(uuid)
113+
end
114+
115+
it "does not match without the trailing static segment" do
116+
expect(described_class.new(params: params).call("users/#{uuid}")).to be_nil
117+
end
118+
end
119+
120+
context "hierarchical patterns (grandchild under pattern page)" do
121+
it "matches /products/42/comments through the pattern parent" do
122+
page = described_class.new(params: params).call("products/42/comments")
123+
expect(page).to eq(comments_page)
124+
expect(params[:id]).to eq("42")
125+
end
126+
end
127+
128+
context "nested wildcard patterns" do
129+
let!(:category_page) { create_page(name: "Category") }
130+
let!(:category_by_slug) { create_page(name: "Category By Slug", layout: "product_by_slug", parent: category_page) }
131+
let!(:category_detail) { create_page(name: "Category Detail", layout: "product_detail", parent: category_by_slug) }
132+
133+
it "matches /category/electronics/42 and extracts both slug and id" do
134+
page = described_class.new(params: params).call("category/electronics/42")
135+
expect(page).to eq(category_detail)
136+
expect(params[:slug]).to eq("electronics")
137+
expect(params[:id]).to eq("42")
138+
end
139+
end
140+
141+
context "with competing sibling patterns of different segment counts" do
142+
let!(:shared_page) { create_page(name: "Shared") }
143+
let!(:multi_segment_page) { create_page(name: "Multi Segment", layout: "blog_post", parent: shared_page) }
144+
let!(:single_segment_page) { create_page(name: "Single Segment", layout: "product_by_slug", parent: shared_page) }
145+
let!(:child_of_single) { create_page(name: "My Post", parent: single_segment_page) }
146+
147+
it "matches the first pattern sibling even when the other could also match via its child" do
148+
page = described_class.new(params: params).call("shared/2024/my-post")
149+
expect(page).to eq(multi_segment_page)
150+
expect(params[:year]).to eq("2024")
151+
expect(params[:slug]).to eq("my-post")
152+
end
153+
end
154+
155+
context "with a static wildcard_url pattern (no dynamic segments)" do
156+
let!(:static_parent) { create_page(name: "Static Parent") }
157+
let!(:static_page) { create_page(name: "Static Page", layout: "static_wildcard", parent: static_parent) }
158+
159+
it "matches /static-parent/foo/bar" do
160+
page = described_class.new(params: params).call("static-parent/foo/bar")
161+
expect(page).to eq(static_page)
162+
expect(params.keys).to be_empty
163+
end
164+
end
165+
166+
context "with a regex constraint" do
167+
let!(:warehouse_page) { create_page(name: "Warehouse") }
168+
let!(:sku_page) { create_page(name: "SKU Lookup", layout: "product_by_sku", parent: warehouse_page) }
169+
170+
it "matches when the value satisfies the regex" do
171+
page = described_class.new(params: params).call("warehouse/AB-1234")
172+
expect(page).to eq(sku_page)
173+
expect(params[:sku]).to eq("AB-1234")
174+
end
175+
176+
it "does not match when the value violates the regex" do
177+
expect(described_class.new(params: params).call("warehouse/invalid")).to be_nil
178+
end
179+
end
180+
end
181+
182+
RSpec.describe PageFinder, "when no content pages exist" do
183+
let(:language) { create(:alchemy_language) }
184+
let!(:language_root) do
185+
create(:alchemy_page, :language_root, language: language)
186+
end
187+
188+
before do
189+
PageDefinition.reset!
190+
Current.language = language
191+
end
192+
193+
it "returns nil" do
194+
expect(described_class.new.call("anything")).to be_nil
195+
end
196+
end
197+
end

0 commit comments

Comments
 (0)