Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions app/controllers/alchemy/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions app/models/alchemy/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 28 additions & 5 deletions app/models/alchemy/page/page_naming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand All @@ -18,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?
Expand All @@ -42,13 +45,32 @@ 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 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!)
Expand All @@ -67,13 +89,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
Expand Down
9 changes: 7 additions & 2 deletions app/models/alchemy/page/url_path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/alchemy/page_definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
84 changes: 84 additions & 0 deletions app/services/alchemy/page_finder.rb
Original file line number Diff line number Diff line change
@@ -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<Symbol, String>, 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
48 changes: 48 additions & 0 deletions app/types/alchemy/wildcard_url_type.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion app/views/alchemy/admin/pages/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
</div>

<%= 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| %>
<alchemy-char-counter max-chars="60">
Expand Down
2 changes: 2 additions & 0 deletions config/locales/alchemy.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading