Skip to content

Add wildcard urls - a proposal#3776

Open
kulturbande wants to merge 10 commits intoAlchemyCMS:mainfrom
kulturbande:pages-with-wildcard-slugs
Open

Add wildcard urls - a proposal#3776
kulturbande wants to merge 10 commits intoAlchemyCMS:mainfrom
kulturbande:pages-with-wildcard-slugs

Conversation

@kulturbande
Copy link
Copy Markdown
Contributor

@kulturbande kulturbande commented Mar 23, 2026

What is this pull request for?

Add a possibility to create wildcard pages. A page layout can now have a wildcard_url (e.g. ":awesome_id"). With that in place it is possible to create dynamic pages.
This PR is only the technical groundwork. It does not handle admin view changes. It would be preferable to prevent changing the slug in the page information modal and it would be helpful for the user to see a hint in the page tree, if a page contains wildcards.

Screenshots

Page Layout Configuration

Aufnahme 2026-04-03 at 14 43 30@2x

A resolved URL

Aufnahme 2026-03-23 at 08 38 09@2x

View Template

Aufnahme 2026-03-23 at 08 43 57@2x

Page Sitemap

Aufnahme 2026-03-24 at 08 32 03@2x

Page Properties

Aufnahme 2026-03-24 at 08 32 24@2x

Checklist

  • I have followed Pull Request guidelines
  • I have added a detailed description into each commit message
  • I have added tests to cover this change

@kulturbande kulturbande requested a review from a team as a code owner March 23, 2026 07:48
@tvdeyen tvdeyen marked this pull request as draft March 23, 2026 08:39
@kulturbande kulturbande force-pushed the pages-with-wildcard-slugs branch 3 times, most recently from e835077 to 7b1fdcb Compare March 23, 2026 17:38
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.08%. Comparing base (d187dd2) to head (eb95492).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3776      +/-   ##
==========================================
+ Coverage   98.06%   98.08%   +0.02%     
==========================================
  Files         322      324       +2     
  Lines        8526     8630     +104     
==========================================
+ Hits         8361     8465     +104     
  Misses        165      165              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kulturbande kulturbande force-pushed the pages-with-wildcard-slugs branch 2 times, most recently from f7b0ca1 to 1846784 Compare March 24, 2026 07:44
@kulturbande kulturbande force-pushed the pages-with-wildcard-slugs branch from 1846784 to 67f57ac Compare April 3, 2026 12:42
@kulturbande kulturbande marked this pull request as ready for review April 3, 2026 12:44
@kulturbande kulturbande force-pushed the pages-with-wildcard-slugs branch 2 times, most recently from 3039bfa to dfb07d3 Compare April 3, 2026 12:48
@kulturbande
Copy link
Copy Markdown
Contributor Author

I adjusted the whole structure on this PR and updated the page defintion to use wildcard_url as parameter.

@kulturbande kulturbande force-pushed the pages-with-wildcard-slugs branch 3 times, most recently from 060ea55 to c0a7f60 Compare April 8, 2026 20:26
@tvdeyen tvdeyen added this to the 8.3 milestone Apr 9, 2026
@kulturbande kulturbande force-pushed the pages-with-wildcard-slugs branch from 644b03a to ef93e91 Compare April 11, 2026 15:27
There are already format for email, url, and link_url, but the spec was missing.
Add the configuration for integer and uuid to format matchers. These option will be used for the wildcard url validation.
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.
@kulturbande kulturbande force-pushed the pages-with-wildcard-slugs branch from ef93e91 to dbb9ae9 Compare April 11, 2026 15:28
@kulturbande
Copy link
Copy Markdown
Contributor Author

I renamed the service to page_finder and the signature is way leaner.

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.
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.
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.
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.
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.
@kulturbande kulturbande force-pushed the pages-with-wildcard-slugs branch from dbb9ae9 to 22ebb5c Compare April 11, 2026 19:20
The urlname getter is resolving the urlname attribute as before, but it has now the ability to substitute wildcard parameter if the urlname has those.
Allow the setting of wildcard parameter in url_path class. It is using the already given optional_params attribute and the previous addition to the urlname method to add parameter to the wildcard url.
urlname: params[:urlname],
language_code: params[:locale] || Current.language.code
)
@page ||= PageFinder.new(params: params).call(params[:urlname])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think there is advantage in passing params in two ways to this service

Suggested change
@page ||= PageFinder.new(params: params).call(params[:urlname])
@page ||= PageFinder.new(params:).call

@params = params
end

def call(urlname)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have the params, we should use the instead of passing a subset into this method.

Suggested change
def call(urlname)
def call

extracted_params = extract_matching_params(candidate_url, pattern, child.wildcard_url.params)
next unless extracted_params

@params.merge!(ActionController::Parameters.new(extracted_params).permit(*extracted_params.keys))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should never merge params like this. We should return new params object instead.

params = ActionController::Parameters.new(extracted_params).permit(*extracted_params.keys)

Then we should introduce a PageFinder::Result object which holds the page and the params.

Then the controller will use it

result = PageFinder.new.call(params[:urlname])
@page = result.page
params.merge!(result.params) if result.params.present?

class PageFinder
attr_reader :params

def initialize(params: ActionController::Parameters.new)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def initialize(params: ActionController::Parameters.new)
def initialize(urlname:)

@tvdeyen
Copy link
Copy Markdown
Member

tvdeyen commented Apr 13, 2026

Two suggestions for PageFinder:


Single-query optimization for find_by_wildcard_url

Instead of walking the tree level by level (one query per depth level), we could avoid the N+1 with a single query approach:

  1. Try exact match first (already done, single query)
  2. If no match, load all pages whose urlname contains wildcard markers in one query:
candidates = Current.language.pages.contentpages.where("urlname LIKE ?", "%:%")
  1. Then match in Ruby:
input_segments = urlname.split("/")  # ["products", "42", "comments"]

candidates.find do |candidate|
  pattern_segments = candidate.urlname.split("/")  # ["products", ":id", "comments"]
  next unless input_segments.size == pattern_segments.size

  # match static segments exactly, extract params from wildcard segments
end

Since urlnames are stored with wildcard patterns literally (e.g., products/:id/comments), this works without tree traversal. The set of wildcard-path pages should be small.

For ambiguous matches (multiple candidates with same segment count), we'd need explicit priority — e.g., prefer the most specific match (most static segments). But that's solvable.


Validate constraint types in WildcardUrlType

Currently matches_constraint? silently passes when a constraint type is unknown (e.g., a typo like integre instead of integer). This should be caught early — WildcardUrlType#assert_valid_value could validate that each param constraint references a known format matcher:

if value.is_a?(Hash) && value[:params].is_a?(Hash)
  value[:params].each_value do |constraint|
    next unless constraint.is_a?(String)
    unless Alchemy.config.format_matchers.respond_to?(constraint.to_sym)
      raise ArgumentError, "Unknown format matcher: #{constraint.inspect}"
    end
  end
end

That way typos blow up at YAML load time, not silently at request time.

- name: product_detail
wildcard_url:
pattern: ":id"
params: integer
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do not need this

# Returns the urlname of the page.
# If the page has a wildcard_url, you can pass params to substitute
# the wildcards in the urlname: page.urlname(id: 42) # => "products/42"
def urlname(**params)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to handle this in url_path instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants