Skip to content

Commit 0d3a10c

Browse files
committed
Add url pattern and url constrains to page layout
Add a possibility to create a url pattern on a page layout. This allows the creation of wildcard slugs (e.g. /products/:id) and combine this with constrains (e.g. id: "integer"). During the page load the exact urlname match is still preferable, but if this misses a new UrlPatternMatcher service object will try to figure out if a page with an url pattern will match the route.
1 parent 9f10ae5 commit 0d3a10c

10 files changed

Lines changed: 493 additions & 5 deletions

File tree

app/controllers/alchemy/pages_controller.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,22 @@ def load_index_page
126126
def load_page
127127
page_not_found! unless Current.language
128128

129+
urlname = params[:urlname]
130+
language_code = params[:locale] || Current.language.code
129131
@page ||= Current.language.pages.contentpages.find_by(
130-
urlname: params[:urlname],
131-
language_code: params[:locale] || Current.language.code
132+
urlname: urlname,
133+
language_code: language_code
132134
)
135+
136+
if @page.nil?
137+
matcher = UrlPatternMatcher.new(urlname, language_code: language_code)
138+
139+
if matcher.page.present?
140+
@page = matcher.page
141+
params.merge!(matcher.params)
142+
end
143+
end
144+
133145
Current.page = @page
134146
end
135147

app/models/alchemy/page_definition.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class PageDefinition
2020
attribute :hide, :boolean, default: false
2121
attribute :editable_by
2222
attribute :hint
23+
attribute :url_pattern, :string
24+
attribute :url_constraints, default: {}
2325

2426
# Needs to be down here in order to have the attribute reader
2527
# available after the attribute is defined.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
class UrlPatternMatcher
5+
attr_reader :urlname, :language_code, :page, :params
6+
7+
def initialize(urlname, language_code: Current.language.code)
8+
@urlname = urlname
9+
@language_code = language_code
10+
@params = {}
11+
@page = find_page
12+
end
13+
14+
private
15+
16+
# Walks the page tree level by level, matching URL segments against page slugs or url_patterns.
17+
#
18+
# @return [Alchemy::Page, nil] the matched page or nil if no match
19+
def find_page
20+
return if urlname.blank? || pattern_layout_names.empty?
21+
22+
segments = urlname.split("/")
23+
root = Page.language_roots.find_by(language_code: language_code)
24+
return unless root
25+
26+
parent_id = root.id
27+
path_prefix = nil
28+
page = nil
29+
30+
until segments.empty?
31+
children = Page.contentpages.where(language_code: language_code, parent_id: parent_id)
32+
33+
# Try an exact slug match first
34+
expected_urlname = [path_prefix, segments.first].compact.join("/")
35+
page = children.find_by(urlname: expected_urlname)
36+
if page
37+
segments = segments.drop(1)
38+
parent_id = page.id
39+
path_prefix = expected_urlname
40+
next
41+
end
42+
43+
# Try children with url_patterns attributes
44+
children.where(page_layout: pattern_layout_names).each do |child|
45+
page_definition = PageDefinition.get(child.page_layout)
46+
url_pattern = page_definition.url_pattern
47+
48+
# a url pattern can have multiple segments, e.g. ":year/:slug"
49+
segment_count = url_pattern.count("/") + 1
50+
candidate_url = segments.first(segment_count).join("/")
51+
extracted_parameters = match_url_pattern(candidate_url, url_pattern)
52+
53+
# skip if the url does not match the pattern or constraints
54+
next unless extracted_parameters
55+
next unless satisfies_constraints?(extracted_parameters, page_definition.url_constraints)
56+
57+
@params.merge!(extracted_parameters)
58+
59+
# prepare the data for the next segment loop
60+
path_prefix = [path_prefix, child.slug].compact.join("/")
61+
segments = segments.drop(segment_count)
62+
parent_id = child.id
63+
page = child
64+
break
65+
end
66+
return unless page
67+
end
68+
69+
page
70+
end
71+
72+
# Matches a URL segment against a pattern like ":year/:slug" and returns
73+
# the extracted named captures as a hash, e.g. { year: "2024", slug: "my-post" }.
74+
#
75+
# @param url [String] the URL segment(s) to match, e.g. "2024/my-post"
76+
# @param pattern [String] the url_pattern from the page definition, e.g. ":year/:slug"
77+
# @return [Hash<Symbol, String>, nil] extracted params or nil if no match
78+
def match_url_pattern(url, pattern)
79+
regex_str = Regexp.escape(pattern).gsub(/:([a-zA-Z_][a-zA-Z0-9_]*)/) do
80+
"(?<#{$1}>[^/]+)"
81+
end
82+
83+
regex = Regexp.new("\\A#{regex_str}\\z")
84+
match_data = regex.match(url)
85+
return nil unless match_data
86+
87+
match_data.named_captures.transform_keys(&:to_sym)
88+
end
89+
90+
# Validates extracted params against url_constraints from the page definition.
91+
# Constraints can be a simple string or a hash mapping param names to types.
92+
#
93+
# @param params [Hash<Symbol, String>] extracted params
94+
# @param constraints [String, Hash, nil] constraint definitions
95+
# @return [Boolean]
96+
def satisfies_constraints?(params, constraints)
97+
return true if constraints.blank?
98+
99+
format_matchers = Alchemy.config.format_matchers
100+
normalize_constraints(constraints, params).all? do |key, type|
101+
if type.is_a?(Regexp)
102+
params[key]&.match?(type)
103+
else
104+
next true unless format_matchers.respond_to?(type.to_sym)
105+
106+
params[key]&.match?(format_matchers.public_send(type.to_sym))
107+
end
108+
end
109+
end
110+
111+
# Normalizes constraints into a hash mapping param names to type strings.
112+
# A simple string constraint applies to all params, e.g. url_constraints: "integer"
113+
#
114+
# @param constraints [String, Hash] constraint definitions
115+
# @param params [Hash<Symbol, String>] extracted params
116+
# @return [Hash<Symbol, String>]
117+
def normalize_constraints(constraints, params)
118+
if constraints.is_a?(String)
119+
params.keys.to_h { |key| [key, constraints] }
120+
else
121+
constraints.transform_keys(&:to_sym)
122+
end
123+
end
124+
125+
# Returns the names of all page layouts that have a url_pattern defined.
126+
#
127+
# @return [Array<String>] layout names with url_patterns
128+
def pattern_layout_names
129+
@_pattern_layout_names ||= PageDefinition.all.select { |d| d.url_pattern.present? }.map(&:name)
130+
end
131+
end
132+
end

lib/alchemy/configurations/format_matchers.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ class FormatMatchers < Alchemy::Configuration
66
option :email, :regexp, default: /\A[^@\s]+@([^@\s]+\.)+[^@\s]+\z/
77
option :url, :regexp, default: /\A[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?\z/ix
88
option :link_url, :regexp, default: /^(tel:|mailto:|\/|[a-z]+:\/\/)/
9+
option :integer, :regexp, default: /\A\d+\z/
10+
option :uuid, :regexp, default: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
911
end
1012
end
1113
end

spec/controllers/alchemy/pages_controller_spec.rb

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,5 +257,128 @@ module Alchemy
257257
end
258258
end
259259
end
260+
261+
describe "URL pattern matching" do
262+
before do
263+
PageDefinition.reset!
264+
end
265+
266+
context "with a child page that has url_pattern (replaces slug)" do
267+
let!(:products_page) do
268+
create(
269+
:alchemy_page,
270+
:public,
271+
name: "Products",
272+
page_layout: "standard",
273+
parent: default_language_root,
274+
language: default_language
275+
)
276+
end
277+
278+
let!(:product_detail_page) do
279+
create(
280+
:alchemy_page,
281+
:public,
282+
name: "Product Details",
283+
page_layout: "product_detail",
284+
parent: products_page,
285+
language: default_language
286+
)
287+
end
288+
289+
it "matches a dynamic path and sets params" do
290+
get :show, params: {urlname: "products/42"}
291+
expect(assigns(:page)).to eq(product_detail_page)
292+
expect(controller.params[:id]).to eq("42")
293+
end
294+
295+
it "renders 404 when constraint does not match" do
296+
expect {
297+
get :show, params: {urlname: "products/not-a-number"}
298+
}.to raise_error(ActionController::RoutingError)
299+
end
300+
end
301+
302+
context "exact page match takes priority over pattern" do
303+
let!(:products_page) do
304+
create(
305+
:alchemy_page,
306+
:public,
307+
name: "Products",
308+
page_layout: "standard",
309+
parent: default_language_root,
310+
language: default_language
311+
)
312+
end
313+
314+
let!(:product_detail_page) do
315+
create(
316+
:alchemy_page,
317+
:public,
318+
name: "Product Details",
319+
page_layout: "product_detail",
320+
parent: products_page,
321+
language: default_language
322+
)
323+
end
324+
325+
let!(:child_page) do
326+
create(
327+
:alchemy_page,
328+
:public,
329+
name: "Featured",
330+
page_layout: "standard",
331+
parent: product_detail_page,
332+
language: default_language
333+
)
334+
end
335+
336+
it "loads the exact page match, not the pattern match" do
337+
get :show, params: {urlname: child_page.urlname}
338+
expect(assigns(:page)).to eq(child_page)
339+
end
340+
end
341+
342+
context "with hierarchical patterns (grandchild under pattern page)" do
343+
let!(:products_page) do
344+
create(
345+
:alchemy_page,
346+
:public,
347+
name: "Products",
348+
page_layout: "standard",
349+
parent: default_language_root,
350+
language: default_language
351+
)
352+
end
353+
354+
let!(:product_detail_page) do
355+
create(
356+
:alchemy_page,
357+
:public,
358+
name: "Product Details",
359+
page_layout: "product_detail",
360+
parent: products_page,
361+
language: default_language
362+
)
363+
end
364+
365+
let!(:comments_page) do
366+
create(
367+
:alchemy_page,
368+
:public,
369+
name: "Comments",
370+
page_layout: "standard",
371+
parent: product_detail_page,
372+
language: default_language
373+
)
374+
end
375+
376+
it "matches a grandchild page with parent's pattern segment" do
377+
get :show, params: {urlname: "products/42/comments"}
378+
expect(assigns(:page)).to eq(comments_page)
379+
expect(controller.params[:id]).to eq("42")
380+
end
381+
end
382+
end
260383
end
261384
end

spec/dummy/config/alchemy/page_layouts.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,33 @@
5959
- menu
6060
layoutpage: true
6161

62+
- name: product_detail
63+
url_pattern: ":id"
64+
url_constraints: integer
65+
elements: [article]
66+
67+
- name: user_profile
68+
url_pattern: ":uuid/profile"
69+
url_constraints:
70+
uuid: "uuid"
71+
elements: [article]
72+
73+
- name: blog_post
74+
url_pattern: ":year/:slug"
75+
url_constraints:
76+
year: "integer"
77+
slug: "string"
78+
elements: [article]
79+
80+
- name: product_by_slug
81+
url_pattern: ":slug"
82+
elements: [article]
83+
84+
- name: product_by_sku
85+
url_pattern: ":sku"
86+
url_constraints:
87+
sku: !ruby/regexp /\A[A-Z]{2}-\d{4}\z/
88+
elements: [article]
89+
6290
- name: <%= 'erb_' + 'layout' %>
6391
unique: true

spec/libraries/alchemy/tasks/usage_spec.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,18 @@
5555
{"page_layout" => "standard", "count" => 2},
5656
{"page_layout" => "home", "count" => 1},
5757
{"page_layout" => "index", "count" => 1},
58+
{"page_layout" => "blog_post", "count" => 0},
5859
{"page_layout" => "contact", "count" => 0},
5960
{"page_layout" => "erb_layout", "count" => 0},
6061
{"page_layout" => "everything", "count" => 0},
6162
{"page_layout" => "footer", "count" => 0},
6263
{"page_layout" => "news", "count" => 0},
64+
{"page_layout" => "product_by_sku", "count" => 0},
65+
{"page_layout" => "product_by_slug", "count" => 0},
66+
{"page_layout" => "product_detail", "count" => 0},
6367
{"page_layout" => "readonly", "count" => 0},
64-
{"page_layout" => "search", "count" => 0}
68+
{"page_layout" => "search", "count" => 0},
69+
{"page_layout" => "user_profile", "count" => 0}
6570
]
6671
end
6772
end

0 commit comments

Comments
 (0)