From c2d95cb97f1bc6905d7892409c697a12398f09ad Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:36:28 +0100 Subject: [PATCH 01/10] Add format matchers spec There are already format for email, url, and link_url, but the spec was missing. --- .../configurations/format_matchers_spec.rb | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 spec/libraries/alchemy/configurations/format_matchers_spec.rb diff --git a/spec/libraries/alchemy/configurations/format_matchers_spec.rb b/spec/libraries/alchemy/configurations/format_matchers_spec.rb new file mode 100644 index 0000000000..a65d0aa25d --- /dev/null +++ b/spec/libraries/alchemy/configurations/format_matchers_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Configurations::FormatMatchers do + subject(:format_matchers) { described_class.new } + + describe "#email" do + it "matches valid emails" do + expect("user@example.com").to match(format_matchers.email) + end + + it "does not match invalid emails" do + expect("not-an-email").not_to match(format_matchers.email) + expect("user@").not_to match(format_matchers.email) + expect("@example.com").not_to match(format_matchers.email) + end + end + + describe "#url" do + it "matches valid URLs" do + expect("example.com").to match(format_matchers.url) + expect("sub.example.com").to match(format_matchers.url) + expect("example.com:8080").to match(format_matchers.url) + expect("example.com/path").to match(format_matchers.url) + end + + it "does not match invalid URLs" do + expect("not a url").not_to match(format_matchers.url) + end + end + + describe "#link_url" do + it "matches tel: links" do + expect("tel:+1234567890").to match(format_matchers.link_url) + end + + it "matches mailto: links" do + expect("mailto:user@example.com").to match(format_matchers.link_url) + end + + it "matches absolute paths" do + expect("/some/path").to match(format_matchers.link_url) + end + + it "matches protocol URLs" do + expect("https://example.com").to match(format_matchers.link_url) + expect("http://example.com").to match(format_matchers.link_url) + end + + it "does not match relative paths" do + expect("relative/path").not_to match(format_matchers.link_url) + end + end +end From 925e2f1e468d6b6972a474af47617c716c73ac51 Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:12:14 +0200 Subject: [PATCH 02/10] Add wildcard url type Add an active record type for wildcard urls. These types will be later used in the page definition and can a simple string or hash with a pattern attribute and an optional params attribute. This way the user can configure the wildcard for a page layout and it will be validated when Alchemy reads in the page layout. --- app/types/alchemy/wildcard_url_type.rb | 48 ++++++++++++++ spec/types/alchemy/wildcard_url_type_spec.rb | 66 ++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 app/types/alchemy/wildcard_url_type.rb create mode 100644 spec/types/alchemy/wildcard_url_type_spec.rb diff --git a/app/types/alchemy/wildcard_url_type.rb b/app/types/alchemy/wildcard_url_type.rb new file mode 100644 index 0000000000..51d946dd3a --- /dev/null +++ b/app/types/alchemy/wildcard_url_type.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Alchemy + class WildcardUrlType < ActiveModel::Type::Value + def cast(value) + case value + when nil then nil + when Symbol, String then normalize(value) + else value + end + end + + def assert_valid_value(value) + case value + when nil + nil + when Symbol, String + assert_valid_param!(normalize(value)) + else + raise ArgumentError, "#{value.inspect} is not a valid wildcard_url. Must be a Symbol or String." + end + end + + private + + # Normalizes a wildcard_url input to a String. Symbols are turned into a + # dynamic segment with a leading colon (`:slug` => `":slug"`). + # + # @param value [String, Symbol] + # @return [String] + def normalize(value) + value.is_a?(Symbol) ? ":#{value}" : value.to_s + end + + def assert_valid_param!(value) + if value.include?("/") + raise ArgumentError, + "wildcard_url #{value.inspect}: cannot contain \"/\". " \ + "Must be a single URL segment." + end + + unless value.match?(/:\w+/) + raise ArgumentError, + "wildcard_url #{value.inspect}: must contain a dynamic segment, e.g. \":id\"." + end + end + end +end diff --git a/spec/types/alchemy/wildcard_url_type_spec.rb b/spec/types/alchemy/wildcard_url_type_spec.rb new file mode 100644 index 0000000000..9576c9fd1d --- /dev/null +++ b/spec/types/alchemy/wildcard_url_type_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Alchemy + RSpec.describe WildcardUrlType do + subject(:type) { described_class.new } + + describe "#cast" do + it "returns nil for nil" do + expect(type.cast(nil)).to be_nil + end + + it "returns the String unchanged" do + expect(type.cast(":slug")).to eq(":slug") + end + + it "normalizes a Symbol to a leading-colon String" do + expect(type.cast(:slug)).to eq(":slug") + end + + it "returns unsupported values as-is" do + expect(type.cast(42)).to eq(42) + end + end + + describe "#assert_valid_value" do + it "accepts nil" do + expect { type.assert_valid_value(nil) }.not_to raise_error + end + + it "accepts a String" do + expect { type.assert_valid_value(":slug") }.not_to raise_error + end + + it "accepts a Symbol" do + expect { type.assert_valid_value(:slug) }.not_to raise_error + end + + it "raises for an unsupported value" do + expect { type.assert_valid_value(42) }.to raise_error( + ArgumentError, /is not a valid wildcard_url.*Symbol or String/ + ) + end + + it "raises for a Hash" do + expect { type.assert_valid_value({param: ":id"}) }.to raise_error( + ArgumentError, /is not a valid wildcard_url.*Symbol or String/ + ) + end + + it "raises for a value containing a slash" do + expect { type.assert_valid_value(":year/:slug") }.to raise_error( + ArgumentError, /cannot contain "\/".*single URL segment/ + ) + end + + it "raises for a value without a dynamic segment" do + expect { type.assert_valid_value("static") }.to raise_error( + ArgumentError, /must contain a dynamic segment/ + ) + end + end + + end +end From eae17c187876c99562e16b6d2a5f80b3f192da38 Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:16:27 +0200 Subject: [PATCH 03/10] Add wildcard_url to page definition Add a new attribute to page definition to allow the usage of wildcard_urls (e.g. :user_id/profile). They can have different configurations (simple string or a hash structure). Add also more page layouts to the dummy to test the different configuration later. --- app/models/alchemy/page_definition.rb | 1 + spec/dummy/config/alchemy/page_layouts.yml | 4 ++++ spec/libraries/alchemy/tasks/usage_spec.rb | 1 + spec/models/alchemy/page_definition_spec.rb | 1 + spec/models/alchemy/site_spec.rb | 5 +++-- 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/models/alchemy/page_definition.rb b/app/models/alchemy/page_definition.rb index b979bbe059..ed8aa5d60c 100644 --- a/app/models/alchemy/page_definition.rb +++ b/app/models/alchemy/page_definition.rb @@ -20,6 +20,7 @@ class PageDefinition attribute :hide, :boolean, default: false attribute :editable_by attribute :hint + attribute :wildcard_url, Alchemy::WildcardUrlType.new # Needs to be down here in order to have the attribute reader # available after the attribute is defined. diff --git a/spec/dummy/config/alchemy/page_layouts.yml b/spec/dummy/config/alchemy/page_layouts.yml index 51742ea56e..5ba9df7e3d 100644 --- a/spec/dummy/config/alchemy/page_layouts.yml +++ b/spec/dummy/config/alchemy/page_layouts.yml @@ -59,5 +59,9 @@ - menu layoutpage: true +- name: page_with_wildcard_url + wildcard_url: :slug + elements: [article] + - name: <%= 'erb_' + 'layout' %> unique: true diff --git a/spec/libraries/alchemy/tasks/usage_spec.rb b/spec/libraries/alchemy/tasks/usage_spec.rb index c8e525228e..0193b3f503 100644 --- a/spec/libraries/alchemy/tasks/usage_spec.rb +++ b/spec/libraries/alchemy/tasks/usage_spec.rb @@ -60,6 +60,7 @@ {"page_layout" => "everything", "count" => 0}, {"page_layout" => "footer", "count" => 0}, {"page_layout" => "news", "count" => 0}, + {"page_layout" => "page_with_wildcard_url", "count" => 0}, {"page_layout" => "readonly", "count" => 0}, {"page_layout" => "search", "count" => 0} ] diff --git a/spec/models/alchemy/page_definition_spec.rb b/spec/models/alchemy/page_definition_spec.rb index 1b034a761d..6e3da881fb 100644 --- a/spec/models/alchemy/page_definition_spec.rb +++ b/spec/models/alchemy/page_definition_spec.rb @@ -20,6 +20,7 @@ module Alchemy it { is_expected.to have_key(:hide) } it { is_expected.to have_key(:editable_by) } it { is_expected.to have_key(:hint) } + it { is_expected.to have_key(:wildcard_url) } end describe "#blank?" do diff --git a/spec/models/alchemy/site_spec.rb b/spec/models/alchemy/site_spec.rb index d453b5004a..865c9dc539 100644 --- a/spec/models/alchemy/site_spec.rb +++ b/spec/models/alchemy/site_spec.rb @@ -170,7 +170,7 @@ module Alchemy it "returns all non 'layoutpage' page layout names" do allow(Site).to receive(:definitions).and_return([]) - expect(site.page_layout_names).to eq(%w[index readonly standard everything news search contact erb_layout]) + expect(site.page_layout_names).to eq(%w[index readonly standard everything news search contact page_with_wildcard_url erb_layout]) end context "when layoutpages are requested" do @@ -211,7 +211,8 @@ module Alchemy "contact", "footer", "erb_layout", - "search" + "search", + "page_with_wildcard_url" ]) end end From f051b43fc742cdecdded29393a29275b641cbb71 Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:38:48 +0200 Subject: [PATCH 04/10] Use wildcard_url for url naming Prevent the creation of multiple pages with the same wildcard_url under the same parent page. Alchemy will validate the slug and will prevent creating the same slug twice. Also delegate the wildcard_url to the page delegation. --- app/models/alchemy/page/page_naming.rb | 11 +++++++---- spec/models/alchemy/page_spec.rb | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/models/alchemy/page/page_naming.rb b/app/models/alchemy/page/page_naming.rb index 0b2d9bc823..40811dd3d1 100644 --- a/app/models/alchemy/page/page_naming.rb +++ b/app/models/alchemy/page/page_naming.rb @@ -8,6 +8,8 @@ module PageNaming RESERVED_URLNAMES = %w[admin messages new] + delegate :wildcard_url, to: :definition + included do before_validation :set_urlname, if: :renamed?, @@ -67,13 +69,14 @@ def set_urlname end # Returns the full nested urlname. - # + # Uses the wildcard_url from the page definition if present, + # otherwise converts the slug or name to a url-friendly string. def nested_url_name - converted_url_name = convert_to_urlname(slug.blank? ? name : slug) + url_part = wildcard_url.presence || convert_to_urlname(slug.blank? ? name : slug) if parent&.language_root? - converted_url_name + url_part else - [parent&.urlname, converted_url_name].compact.join("/") + [parent&.urlname, url_part].compact.join("/") end end end diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index a5d13b4934..3efd6f0a7e 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -1876,6 +1876,27 @@ module Alchemy end end + context "with a page layout that has a wildcard_url" do + let(:parent) { create(:alchemy_page, name: "Products") } + let(:pattern_page) { create(:alchemy_page, parent: parent, name: "Product Details", page_layout: "page_with_wildcard_url") } + + it "uses the wildcard_url pattern instead of the page name" do + expect(pattern_page.urlname).to eq("products/:slug") + end + + it "uses the wildcard_url pattern as slug" do + expect(pattern_page.slug).to eq(":slug") + end + + context "with a child page under a wildcard_url page" do + let(:child) { create(:alchemy_page, parent: pattern_page, name: "Comments") } + + it "includes the parent's wildcard_url pattern in the path" do + expect(child.urlname).to eq("products/:slug/comments") + end + end + end + context "if new urlname exists as a legacy url" do it "will delete obsolete legacy_urls" do expect(page.urlname).to eq("parentparent/parent/page") From 03556315f7c60b26bbcb65b936310ad62a163b7a Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:26:28 +0200 Subject: [PATCH 05/10] 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. --- app/services/alchemy/page_finder.rb | 84 +++++++++++++++++++++++ spec/services/alchemy/page_finder_spec.rb | 69 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 app/services/alchemy/page_finder.rb create mode 100644 spec/services/alchemy/page_finder_spec.rb diff --git a/app/services/alchemy/page_finder.rb b/app/services/alchemy/page_finder.rb new file mode 100644 index 0000000000..8f91340ce5 --- /dev/null +++ b/app/services/alchemy/page_finder.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Alchemy + class PageFinder + attr_reader :urlname + + Result = Data.define(:page, :extracted_params) + + def initialize(urlname) + @urlname = urlname + end + + # @return [PageFinder::Result, nil] + def call + return if urlname.blank? + + find_by_urlname || find_by_wildcard_url + end + + private + + # Finds a page by exact urlname match within the current language. + def find_by_urlname + page = Current.language.pages.contentpages.find_by(urlname: urlname) + Result.new(page: page, extracted_params: ActionController::Parameters.new.permit!) if page + end + + # Finds a page whose urlname pattern matches the given URL. + # Loads all pages whose urlname contains a `:param` segment in a + # single SQL query, then matches each in Ruby. + # + # A urlname may contain more than one `:param` segment when a wildcard + # page is nested under another wildcard page. + def find_by_wildcard_url + return unless any_wildcard_definitions? + + url_depth = urlname.count("/") + + # find pages ordered by tree position so we can return the first match + wildcard_pages = Current.language.pages.contentpages + .where("urlname LIKE ? OR urlname LIKE ?", ":%", "%/:%") + .order(:lft) + + wildcard_pages.each do |wildcard_page| + next if wildcard_page.urlname.count("/") != url_depth + + matched_params = match_url_pattern(wildcard_page) + next unless matched_params + + # return the first match + return Result.new( + page: wildcard_page, + extracted_params: ActionController::Parameters.new(matched_params).permit! + ) + end + + nil + end + + # Matches the urlname against a page's urlname pattern. + # Static segments must match literally; each `:param` segment is captured + # as a single URL segment. + # + # @param wildcard_page [Alchemy::Page] a page whose urlname contains one or more `:param` segments + # @return [Hash, nil] matched params or nil + def match_url_pattern(wildcard_page) + regex_parts = wildcard_page.urlname.split("/").map do |segment| + next Regexp.escape(segment) unless segment.start_with?(":") + + "(?<#{segment[1..]}>[^/]+)" + end + + match = Regexp.new("\\A#{regex_parts.join("/")}\\z").match(urlname) + return unless match + + match.named_captures.transform_keys(&:to_sym) + end + + # @return [Boolean] whether any page definition declares a wildcard_url + def any_wildcard_definitions? + PageDefinition.all.any? { |d| d.wildcard_url.present? } + end + end +end diff --git a/spec/services/alchemy/page_finder_spec.rb b/spec/services/alchemy/page_finder_spec.rb new file mode 100644 index 0000000000..96c5346fcc --- /dev/null +++ b/spec/services/alchemy/page_finder_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Alchemy + RSpec.describe PageFinder do + let(:language) { create(:alchemy_language) } + let!(:language_root) do + create(:alchemy_page, :language_root, language: language) + end + + before do + PageDefinition.reset! + Current.language = language + end + + def create_page(name:, layout: "standard", parent: language_root) + create( + :alchemy_page, :public, + name: name, + page_layout: layout, + parent: parent, + language: language + ) + end + + # Page tree used by most tests: + # + # Products (standard) -> /products + # +-- Product Detail (page_with_wildcard_url) -> /products/:slug + # +-- Comments (standard) -> /products/:slug/comments + + let!(:products_page) { create_page(name: "Products") } + let!(:product_detail_page) { create_page(name: "Product Detail", layout: "page_with_wildcard_url", parent: products_page) } + let!(:comments_page) { create_page(name: "Comments", parent: product_detail_page) } + + it "finds a wildcard page and extracts params" do + result = described_class.new("products/123").call + expect(result.page).to eq(product_detail_page) + expect(result.extracted_params[:slug]).to eq("123") + end + + it "finds a page by exact urlname" do + result = described_class.new("products").call + expect(result.page).to eq(products_page) + end + + it "finds a nested page by its full urlname" do + result = described_class.new(comments_page.urlname).call + expect(result.page).to eq(comments_page) + end + + it "returns nil for a blank path" do + expect(described_class.new("").call).to be_nil + end + + it "returns nil for a path with no matching prefix" do + expect(described_class.new("other/123").call).to be_nil + end + + context "hierarchical patterns (grandchild under pattern page)" do + it "matches /products/42/comments through the pattern parent" do + result = described_class.new("products/42/comments").call + expect(result.page).to eq(comments_page) + expect(result.extracted_params[:slug]).to eq("42") + end + end + end +end From 6553607ce6760ac6a77833c02e60a21f2f8538d1 Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:33:38 +0200 Subject: [PATCH 06/10] Add page finder to pages controller Use the page finder service object to find the page per urlname or try to find a page with wildcard_url. The params will be extended by the page finder service, if a wildcard url is used. --- app/controllers/alchemy/pages_controller.rb | 9 +- .../alchemy/pages_controller_spec.rb | 117 ++++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/app/controllers/alchemy/pages_controller.rb b/app/controllers/alchemy/pages_controller.rb index 7b3f1fd8ee..a01fb8abd7 100644 --- a/app/controllers/alchemy/pages_controller.rb +++ b/app/controllers/alchemy/pages_controller.rb @@ -126,10 +126,11 @@ def load_index_page def load_page page_not_found! unless Current.language - @page ||= Current.language.pages.contentpages.find_by( - urlname: params[:urlname], - language_code: params[:locale] || Current.language.code - ) + result = PageFinder.new(params[:urlname]).call + if result + @page ||= result.page + params.merge!(result.extracted_params) + end Current.page = @page end diff --git a/spec/controllers/alchemy/pages_controller_spec.rb b/spec/controllers/alchemy/pages_controller_spec.rb index cdc00b12f2..98242e4e13 100644 --- a/spec/controllers/alchemy/pages_controller_spec.rb +++ b/spec/controllers/alchemy/pages_controller_spec.rb @@ -257,5 +257,122 @@ module Alchemy end end end + + describe "Wildcard URL matching" do + before do + PageDefinition.reset! + end + + context "with a child page that has wildcard_url (replaces slug)" do + let!(:products_page) do + create( + :alchemy_page, + :public, + name: "Products", + page_layout: "standard", + parent: default_language_root, + language: default_language + ) + end + + let!(:product_detail_page) do + create( + :alchemy_page, + :public, + name: "Product Details", + page_layout: "page_with_wildcard_url", + parent: products_page, + language: default_language + ) + end + + it "matches a dynamic path and sets params" do + get :show, params: {urlname: "products/42"} + expect(assigns(:page)).to eq(product_detail_page) + expect(controller.params[:slug]).to eq("42") + end + end + + context "exact page match takes priority over pattern" do + let!(:products_page) do + create( + :alchemy_page, + :public, + name: "Products", + page_layout: "standard", + parent: default_language_root, + language: default_language + ) + end + + let!(:product_detail_page) do + create( + :alchemy_page, + :public, + name: "Product Details", + page_layout: "page_with_wildcard_url", + parent: products_page, + language: default_language + ) + end + + let!(:child_page) do + create( + :alchemy_page, + :public, + name: "Featured", + page_layout: "standard", + parent: product_detail_page, + language: default_language + ) + end + + it "loads the exact page match, not the pattern match" do + get :show, params: {urlname: child_page.urlname} + expect(assigns(:page)).to eq(child_page) + end + end + + context "with hierarchical patterns (grandchild under pattern page)" do + let!(:products_page) do + create( + :alchemy_page, + :public, + name: "Products", + page_layout: "standard", + parent: default_language_root, + language: default_language + ) + end + + let!(:product_detail_page) do + create( + :alchemy_page, + :public, + name: "Product Details", + page_layout: "page_with_wildcard_url", + parent: products_page, + language: default_language + ) + end + + let!(:comments_page) do + create( + :alchemy_page, + :public, + name: "Comments", + page_layout: "standard", + parent: product_detail_page, + language: default_language + ) + end + + it "matches a grandchild page with parent's pattern segment" do + get :show, params: {urlname: "products/42/comments"} + expect(assigns(:page)).to eq(comments_page) + expect(controller.params[:slug]).to eq("42") + end + end + end end end From 7bd2d02403dd7619de3f9c8150b2f61f752996fc Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:42:15 +0200 Subject: [PATCH 07/10] Disable slug if a wildcard_url is set It wouldn't work to update the slug anyway, because the urlname mechanic is different for pages with a wildcard_url. The slug form field will be disabled and the slug can also contain slashes. --- app/models/alchemy/page/page_naming.rb | 6 +++++- app/views/alchemy/admin/pages/_form.html.erb | 5 ++++- spec/models/alchemy/page_spec.rb | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/models/alchemy/page/page_naming.rb b/app/models/alchemy/page/page_naming.rb index 40811dd3d1..62822e1b39 100644 --- a/app/models/alchemy/page/page_naming.rb +++ b/app/models/alchemy/page/page_naming.rb @@ -44,11 +44,15 @@ def update_urlname! end end - # Returns always the last part of a urlname path + # Returns wildcard url param or the last part of an urlname path def slug urlname.to_s.split("/").last end + def has_wildcard_url? + wildcard_url.present? + end + private def update_descendants_urlnames diff --git a/app/views/alchemy/admin/pages/_form.html.erb b/app/views/alchemy/admin/pages/_form.html.erb index ea8740d557..9b9ec101ae 100644 --- a/app/views/alchemy/admin/pages/_form.html.erb +++ b/app/views/alchemy/admin/pages/_form.html.erb @@ -18,7 +18,10 @@ <%= f.input :name, autofocus: true %> - <%= f.input :urlname, as: 'string', input_html: {value: @page.slug}, label: Alchemy::Page.human_attribute_name(:slug) %> + <%= f.input :urlname, as: 'string', input_html: { + value: @page.slug, + disabled: @page.has_wildcard_url? + }, label: Alchemy::Page.human_attribute_name(:slug) %> <%= f.fields_for :draft_version, @page.draft_version do |v| %> diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index 3efd6f0a7e..dc98c428d6 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -1994,6 +1994,14 @@ module Alchemy expect(page.slug).to be_nil end end + + context "with a page layout that has a wildcard_url" do + let(:page) { build(:alchemy_page, page_layout: "page_with_wildcard_url", urlname: "products/:slug") } + + it "returns the wildcard_url pattern" do + expect(page.slug).to eq(":slug") + end + end end context "page status methods" do From 97fe30cfb5ab59b1bd021136d3e7ee312a63d4f2 Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:36:30 +0200 Subject: [PATCH 08/10] Add page url_path spec The spec for the url_path was missing. --- spec/models/alchemy/page_spec.rb | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index dc98c428d6..46fdf7fb3e 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -330,6 +330,34 @@ module Alchemy end end + describe ".url_path" do + let(:page) { create(:alchemy_page, name: "Foo") } + + subject { page.url_path } + + it { is_expected.to eq("/foo") } + + context "with optional parameters" do + subject { page.url_path({page: 2}) } + + it { is_expected.to eq("/foo?page=2") } + end + + context "with a custom url_path_class" do + let(:url_path_class) { Struct.new(:page, :params) { def call = "/bar" } } + + before do + described_class.url_path_class = url_path_class + end + + after do + described_class.url_path_class = Alchemy::Page::UrlPath + end + + it { is_expected.to eq("/bar") } + end + end + describe ".all_from_clipboard_for_select" do context "with clipboard holding pages having non unique page layout" do it "should return the pages" do From 7c21a941e7e52329351e1353e8ccb3d9a7f4e1bb Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:48:55 +0200 Subject: [PATCH 09/10] Extend url path class to handle wildcard params Allow the setting of wildcard parameter in url_path class. It is introducing a new wildcard_params hash to not mix that set of params with the default optional_params hash. --- app/models/alchemy/page.rb | 4 +-- app/models/alchemy/page/url_path.rb | 9 +++++-- spec/models/alchemy/page/url_path_spec.rb | 33 +++++++++++++++++++++++ spec/models/alchemy/page_spec.rb | 10 ++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/app/models/alchemy/page.rb b/app/models/alchemy/page.rb index 28c399b121..e653eebed2 100644 --- a/app/models/alchemy/page.rb +++ b/app/models/alchemy/page.rb @@ -305,8 +305,8 @@ def find_elements(options = {}) # = The url_path for this page # # @see Alchemy::Page::UrlPath#call - def url_path(optional_params = {}) - self.class.url_path_class.new(self, optional_params).call + def url_path(optional_params = {}, wildcard_params: {}) + self.class.url_path_class.new(self, optional_params, wildcard_params: wildcard_params).call end # The page's view partial is dependent from its page layout diff --git a/app/models/alchemy/page/url_path.rb b/app/models/alchemy/page/url_path.rb index dc0ce4a886..cf0fa3001b 100644 --- a/app/models/alchemy/page/url_path.rb +++ b/app/models/alchemy/page/url_path.rb @@ -20,11 +20,12 @@ class Page # link_to page.url # class UrlPath - def initialize(page, optional_params = {}) + def initialize(page, optional_params = {}, wildcard_params: {}) @page = page @language = @page.language @site = @language.site @optional_params = optional_params + @wildcard_params = wildcard_params end def call @@ -64,7 +65,11 @@ def language_path end def page_path - "#{root_path}#{@page.urlname}" + urlname = @page.urlname + @wildcard_params.each do |key, val| + urlname = urlname.gsub(":#{key}", val.to_s) + end + "#{root_path}#{urlname}" end def root_path diff --git a/spec/models/alchemy/page/url_path_spec.rb b/spec/models/alchemy/page/url_path_spec.rb index 9a2e789c93..20bcecb1d2 100644 --- a/spec/models/alchemy/page/url_path_spec.rb +++ b/spec/models/alchemy/page/url_path_spec.rb @@ -76,6 +76,39 @@ end end + context "with wildcard params" do + let(:parent) { create(:alchemy_page, name: "Products") } + let(:page) { create(:alchemy_page, parent: parent, name: "Product Detail", page_layout: "page_with_wildcard_url") } + + subject(:url) { described_class.new(page, wildcard_params: {slug: 42}).call } + + it "substitutes wildcards in the urlname" do + is_expected.to eq("/products/42") + end + end + + context "with wildcard params as ActionController::Parameters" do + let(:parent) { create(:alchemy_page, name: "Products") } + let(:page) { create(:alchemy_page, parent: parent, name: "Product Detail", page_layout: "page_with_wildcard_url") } + + subject(:url) { described_class.new(page, wildcard_params: ActionController::Parameters.new({slug: 42})).call } + + it "substitutes wildcards in the urlname" do + is_expected.to eq("/products/42") + end + end + + context "with wildcard and query params mixed" do + let(:parent) { create(:alchemy_page, name: "Products") } + let(:page) { create(:alchemy_page, parent: parent, name: "Product Detail", page_layout: "page_with_wildcard_url") } + + subject(:url) { described_class.new(page, {page: 2}, wildcard_params: {slug: 42}).call } + + it "substitutes wildcards and appends remaining params as query string" do + is_expected.to eq("/products/42?page=2") + end + end + context "mounted on a non-root path" do let(:page) do create(:alchemy_page) diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index 46fdf7fb3e..1a8c3cbad5 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -344,7 +344,7 @@ module Alchemy end context "with a custom url_path_class" do - let(:url_path_class) { Struct.new(:page, :params) { def call = "/bar" } } + let(:url_path_class) { Struct.new(:page, :params, :wildcard_params) { def call = "/bar" } } before do described_class.url_path_class = url_path_class @@ -356,6 +356,14 @@ module Alchemy it { is_expected.to eq("/bar") } end + + context "with wildcard params" do + let(:page) { create(:alchemy_page, page_layout: "page_with_wildcard_url") } + + subject { page.url_path(wildcard_params: {slug: 42}) } + + it { is_expected.to eq("/42") } + end end describe ".all_from_clipboard_for_select" do From a3397b87682c18f4a0a3895d17c41effcd01920e Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <68833+kulturbande@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:18:23 +0200 Subject: [PATCH 10/10] Add wildcard_url validation to page model To ease the complexity of the page_finder it is required, that the parameter key is unique for all page layouts. This requirement is reducing the searching complexity and also prevent edge cases where different page_layouts in the same tree can have multiple times the same parameter name (e.g. :id). --- app/models/alchemy/page/page_naming.rb | 16 +++++++++++++ config/locales/alchemy.en.yml | 2 ++ spec/models/alchemy/page_spec.rb | 32 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/app/models/alchemy/page/page_naming.rb b/app/models/alchemy/page/page_naming.rb index 62822e1b39..2f5baaf6a3 100644 --- a/app/models/alchemy/page/page_naming.rb +++ b/app/models/alchemy/page/page_naming.rb @@ -20,6 +20,7 @@ module PageNaming validates :urlname, uniqueness: {scope: [:language_id, :layoutpage], if: -> { urlname.present? }, case_sensitive: false}, exclusion: {in: RESERVED_URLNAMES} + validate :unique_wildcard_param_keys, if: :has_wildcard_url? after_update :update_descendants_urlnames, if: :saved_change_to_urlname? @@ -55,6 +56,21 @@ def has_wildcard_url? private + def unique_wildcard_param_keys + conflicting = PageDefinition.all.find do |other| + other.name != page_layout && other.wildcard_url == wildcard_url + end + + if conflicting + errors.add( + :page_layout, + :conflicting_wildcard_param_key, + param: wildcard_url, + conflicting_layout: conflicting.name + ) + end + end + def update_descendants_urlnames reload descendants.each(&:update_urlname!) diff --git a/config/locales/alchemy.en.yml b/config/locales/alchemy.en.yml index cfadd57c76..cfaaabde7a 100644 --- a/config/locales/alchemy.en.yml +++ b/config/locales/alchemy.en.yml @@ -834,6 +834,8 @@ en: base: restrict_dependent_destroy: has_many: "There are still %{record} attached to this page. Please remove them first." + page_layout: + conflicting_wildcard_param_key: "has a conflicting wildcard param \"%{param}\" already used by the \"%{conflicting_layout}\" page layout" descendants: still_attached_to_nodes: "The following descendant pages are still attached to menu nodes: %{page_names}. Please remove them first." alchemy/element: diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index 1a8c3cbad5..a346e029e1 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -1933,6 +1933,38 @@ module Alchemy end end + context "with conflicting wildcard param keys across layouts" do + let(:parent) { create(:alchemy_page, name: "Items") } + + before do + PageDefinition.add( + name: "conflicting_layout", + wildcard_url: ":slug" + ) + end + + after { PageDefinition.reset! } + + it "is invalid when another layout already uses the same param key" do + page = build(:alchemy_page, parent: parent, name: "Item", page_layout: "conflicting_layout") + expect(page).not_to be_valid + expect(page.errors[:page_layout]).to include( + a_string_matching(/param ":slug".*"page_with_wildcard_url"/) + ) + end + end + + context "with the same wildcard layout under different parents" do + let(:parent_a) { create(:alchemy_page, name: "Section A") } + let(:parent_b) { create(:alchemy_page, name: "Section B") } + + it "allows creating pages with the same layout" do + create(:alchemy_page, parent: parent_a, name: "Detail A", page_layout: "page_with_wildcard_url") + page_b = build(:alchemy_page, parent: parent_b, name: "Detail B", page_layout: "page_with_wildcard_url") + expect(page_b).to be_valid + end + end + context "if new urlname exists as a legacy url" do it "will delete obsolete legacy_urls" do expect(page.urlname).to eq("parentparent/parent/page")