aris 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,722 @@
1
+ module Aris
2
+ module Router
3
+ extend self
4
+ HTTP_METHODS = Set[:get, :post, :put, :patch, :delete, :options].freeze
5
+
6
+ @@default_domain = nil
7
+ @@default_404_handler = nil
8
+ @@default_500_handler = nil
9
+
10
+
11
+ def default_domain=(domain)
12
+ @@default_domain = domain
13
+ end
14
+
15
+ def default_domain
16
+ @@default_domain
17
+ end
18
+
19
+ def set_defaults(config)
20
+ @@default_domain = config[:default_host] if config.key?(:default_host)
21
+ Aris.class_variable_set(:@@default_not_found_handler, config[:not_found]) if config.key?(:not_found)
22
+ Aris.class_variable_set(:@@default_error_handler, config[:error]) if config.key?(:error)
23
+ end
24
+
25
+ def define(config)
26
+ reset!
27
+ compile!(config)
28
+ self
29
+ end
30
+ def domain_config(domain)
31
+ @domain_configs ||= {}
32
+ @domain_configs[domain.to_s]
33
+ end
34
+
35
+ def store_domain_config(domain, config)
36
+ @domain_configs ||= {}
37
+ @domain_configs[domain.to_s] = config
38
+ end
39
+
40
+ def build_named_path(route_name, domain:, **params)
41
+ route_name = route_name.to_sym
42
+ locale = params.delete(:locale)
43
+ domain_config = self.domain_config(domain)
44
+
45
+ # If no locale specified but domain has default locale, use it
46
+ if !locale && domain_config && domain_config[:default_locale]
47
+ locale = domain_config[:default_locale]
48
+ end
49
+
50
+ # For localized routes, always use the base route name and let build_path handle it
51
+ found_metadata = @metadata.values.find do |meta|
52
+ meta[:name] == route_name && meta[:domain] == domain
53
+ end
54
+
55
+ unless found_metadata
56
+ found_metadata = @metadata.values.find do |meta|
57
+ meta[:name] == route_name && meta[:domain] == '*'
58
+ end
59
+ end
60
+
61
+ unless found_metadata
62
+ raise RouteNotFoundError.new(route_name, domain)
63
+ end
64
+
65
+ # If this is a localized route and we have a locale, use locale-aware path building
66
+ if found_metadata[:localized] && locale && domain_config
67
+ build_path_with_locale(found_metadata, params, locale, domain_config)
68
+ else
69
+ build_path_without_locale(found_metadata, params)
70
+ end
71
+ end
72
+ def match(domain:, method:, path:)
73
+
74
+ return nil if path.start_with?('//') && path != '//'
75
+ normalized_domain = domain.to_s.downcase
76
+ normalized_path = normalize_path(path)
77
+ normalized_method = method.to_sym
78
+ segments = get_path_segments(normalized_path)
79
+
80
+ match_result = nil
81
+
82
+ # 1. Try exact domain match first
83
+ domain_trie = @tries[normalized_domain]
84
+ if domain_trie
85
+ match_result = traverse_trie(domain_trie, segments)
86
+ end
87
+
88
+ # 2. Try subdomain wildcard match
89
+ unless match_result
90
+ wildcard_domain = find_wildcard_domain_match(normalized_domain)
91
+
92
+ if wildcard_domain
93
+ domain_trie = @tries[wildcard_domain]
94
+ if domain_trie
95
+ match_result = traverse_trie(domain_trie, segments)
96
+
97
+ if match_result
98
+ subdomain = extract_subdomain_from_wildcard(normalized_domain, wildcard_domain)
99
+ # Store subdomain in match result
100
+ match_result = [match_result[0], match_result[1], subdomain]
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # 3. Try global wildcard match (existing functionality)
107
+ unless match_result
108
+ wildcard_trie = @tries['*']
109
+ if wildcard_trie
110
+ match_result = traverse_trie(wildcard_trie, segments)
111
+ end
112
+ end
113
+
114
+ return nil unless match_result
115
+
116
+ node, params, subdomain = match_result
117
+ metadata_key = node[:handlers][normalized_method]
118
+ return nil unless metadata_key
119
+
120
+ metadata = @metadata[metadata_key]
121
+
122
+ unless enforce_constraints(params, metadata[:constraints]); return nil; end
123
+
124
+ route = {
125
+ name: metadata[:name],
126
+ handler: metadata[:handler],
127
+ use: metadata[:use].dup,
128
+ params: params,
129
+ locale: metadata[:locale],
130
+ domain: normalized_domain
131
+ }
132
+
133
+ # Add subdomain to route if we matched a wildcard domain
134
+ route[:subdomain] = subdomain if subdomain
135
+
136
+ route
137
+ end
138
+
139
+ private
140
+
141
+ def find_wildcard_domain_match(domain)
142
+ # Look through all registered domains for wildcard patterns
143
+ @tries.keys.each do |registered_domain|
144
+ if registered_domain.start_with?('*.')
145
+ base_domain = registered_domain[2..] # Remove '*.'
146
+
147
+ # Check if the requested domain matches the wildcard pattern
148
+ # Examples:
149
+ # - domain: "acme.example.com" matches "*.example.com"
150
+ # - domain: "example.com" matches "*.example.com" (no subdomain case)
151
+ if domain == base_domain || domain.end_with?(".#{base_domain}")
152
+ return registered_domain
153
+ end
154
+ end
155
+ end
156
+ nil
157
+ end
158
+
159
+ def extract_subdomain_from_wildcard(full_domain, wildcard_domain)
160
+ base_domain = wildcard_domain[2..] # Remove '*.'
161
+
162
+ if full_domain == base_domain
163
+ # No subdomain (user visited example.com directly)
164
+ nil
165
+ else
166
+ # Extract subdomain by removing the base domain
167
+ # "acme.example.com" -> "acme"
168
+ # "app.staging.example.com" -> "app.staging"
169
+ full_domain.gsub(".#{base_domain}", '')
170
+ end
171
+ end
172
+ def self.find_domain_config(domain)
173
+ # Exact match first
174
+ exact_match = @domains[domain]
175
+ return exact_match if exact_match
176
+
177
+ # Then wildcard matches
178
+ @domains.each do |config_domain, config|
179
+ if config_domain.start_with?('*.')
180
+ base_domain = config_domain[2..] # Remove '*.'
181
+ if domain.end_with?(".#{base_domain}") || domain == base_domain
182
+ return config.merge(wildcard: true, base_domain: base_domain)
183
+ end
184
+ end
185
+ end
186
+
187
+ nil
188
+ end
189
+
190
+ def build_path(metadata, params)
191
+ build_path_without_locale(metadata, params)
192
+ end
193
+
194
+ def build_path_without_locale(metadata, params)
195
+ segments = metadata[:segments].dup
196
+ required_params = metadata[:params]
197
+ provided_params = params.dup
198
+ missing_params = required_params - provided_params.keys
199
+ unless missing_params.empty?
200
+ raise ArgumentError, "Missing required param(s) #{missing_params.map { |p| "'#{p}'" }.join(', ')} for route :#{metadata[:name]}"
201
+ end
202
+
203
+ path_parts = segments.map do |segment|
204
+ if segment.start_with?(':')
205
+ param_name = segment[1..].to_sym
206
+ value = provided_params.delete(param_name)
207
+ URI.encode_www_form_component(value.to_s)
208
+ elsif segment.start_with?('*')
209
+ param_name = segment[1..].to_s
210
+ param_name = 'path' if param_name.empty?
211
+ value = provided_params.delete(param_name.to_sym)
212
+ value.to_s
213
+ else
214
+ segment
215
+ end
216
+ end
217
+
218
+ path = path_parts.empty? ? '/' : '/' + path_parts.join('/')
219
+ unless provided_params.empty?
220
+ query_string = URI.encode_www_form(provided_params)
221
+ path += "?#{query_string}"
222
+ end
223
+ path
224
+ end
225
+
226
+ def build_path_with_locale(metadata, params, locale, domain_config)
227
+ # Validate locale is available for this domain
228
+ unless domain_config[:locales].include?(locale)
229
+ raise LocaleError, "Locale :#{locale} not available for domain '#{metadata[:domain]}'. Available locales: #{domain_config[:locales].inspect}"
230
+ end
231
+
232
+ # For localized routes, we need to find the actual locale-specific route metadata
233
+ localized_name = "#{metadata[:name]}_#{locale}".to_sym
234
+ # Find the locale-specific route
235
+ localized_metadata = @metadata.values.find do |meta|
236
+ meta[:name] == localized_name && meta[:domain] == metadata[:domain]
237
+ end
238
+
239
+ if localized_metadata
240
+ # Use the locale-specific route's segments
241
+ build_path_without_locale(localized_metadata, params)
242
+ else
243
+ # Fallback: build path manually with locale prefix
244
+ base_path = build_path_without_locale(metadata, params)
245
+ if base_path == '/'
246
+ "/#{locale}"
247
+ else
248
+ "/#{locale}#{base_path}"
249
+ end
250
+ end
251
+ end
252
+
253
+ def self.not_found(request, response = nil)
254
+ handler = @@default_not_found_handler
255
+ handler ? handler.call(request, {}) : [404, {'content-type' => 'text/plain'}, ['Not Found']]
256
+ end
257
+
258
+ def self.error(request, exception)
259
+ handler = @@default_error_handler
260
+ handler ? handler.call(request, exception) : [500, {'content-type' => 'text/plain'}, ['Internal Server Error']]
261
+ end
262
+ def self.error_response(request, exception)
263
+ handler = @@default_error_handler
264
+ return handler.call(request, exception) if handler
265
+ [500, {'content-type' => 'text/plain'}, ['Internal Server Error']]
266
+ end
267
+ private
268
+
269
+ def merge_use(parent_use, child_use)
270
+ parent = parent_use.is_a?(Array) ? parent_use : []
271
+ child = child_use.is_a?(Array) ? child_use : []
272
+ (parent + child).uniq
273
+ end
274
+
275
+ def reset!
276
+ @tries = {}
277
+ @metadata = {}
278
+ @named_routes = {}
279
+ @route_name_registry = Set.new
280
+ @segment_cache = {}
281
+ @cache_max_size = 1000
282
+ @@default_domain = nil
283
+ end
284
+ def compile!(config)
285
+ @domain_configs = {}
286
+
287
+ config.each do |domain_key, domain_config|
288
+ domain = domain_key.to_s.downcase
289
+
290
+ # Store domain configuration (locales, etc.)
291
+ if domain_config.is_a?(Hash) && (domain_config[:locales] || domain_config[:default_locale])
292
+ locales = Array(domain_config[:locales])
293
+ default_locale = domain_config[:default_locale]
294
+
295
+ # Validate that default_locale is in locales
296
+ if default_locale && !locales.include?(default_locale)
297
+ raise LocaleError, "Default locale '#{default_locale}' not found in locales #{locales.inspect} for domain '#{domain}'"
298
+ end
299
+
300
+ # Validate that all localized routes use valid locales
301
+ validate_localized_routes(domain_config, locales, domain)
302
+
303
+ store_domain_config(domain, {
304
+ locales: locales,
305
+ default_locale: default_locale || locales.first,
306
+ root_locale_redirect: domain_config.fetch(:root_locale_redirect, true)
307
+ })
308
+ end
309
+
310
+ # Handle wildcard domains - store them as-is in tries
311
+ # This preserves the "*.example.com" pattern for matching later
312
+ @tries[domain] = new_trie_node
313
+
314
+ # Compile routes for this domain (wildcard or regular)
315
+ compile_scope(config: domain_config, domain: domain, path_segments: [], inherited_use: [])
316
+ end
317
+ end
318
+
319
+ def validate_localized_routes(domain_config, valid_locales, domain)
320
+ check_config_for_locales(domain_config, valid_locales, domain, '')
321
+ end
322
+
323
+ def check_config_for_locales(config, valid_locales, domain, path = '')
324
+ return unless config.is_a?(Hash)
325
+
326
+ config.each do |key, value|
327
+ if value.is_a?(Hash) && value[:localized]
328
+ invalid_locales = value[:localized].keys - valid_locales
329
+ if invalid_locales.any?
330
+ raise LocaleError, "Handler at #{path}/#{key} uses invalid locales #{invalid_locales.inspect} but domain only supports #{valid_locales.inspect}"
331
+ end
332
+ elsif value.is_a?(Hash)
333
+ # Recursively check nested routes
334
+ check_config_for_locales(value, valid_locales, domain, "#{path}/#{key}")
335
+ end
336
+ end
337
+ end
338
+
339
+ def compile_scope(config:, domain:, path_segments:, inherited_use:)
340
+ return unless config.is_a?(Hash)
341
+
342
+ if config.key?(:use)
343
+ scope_use_value = config[:use]
344
+ if scope_use_value.nil?
345
+ current_use = []
346
+ else
347
+ resolved_scope_use = Array(scope_use_value).flat_map do |item|
348
+ if item.is_a?(Symbol)
349
+ Aris.resolve_plugin(item) # Returns array of classes
350
+ else
351
+ item
352
+ end
353
+ end
354
+ current_use = merge_use(inherited_use, resolved_scope_use)
355
+ end
356
+ else
357
+ current_use = inherited_use.is_a?(Array) ? inherited_use : []
358
+ end
359
+ config.each do |key, value|
360
+ next if key == :use
361
+ if HTTP_METHODS.include?(key)
362
+ register_route(domain: domain, method: key, path_segments: path_segments, route_config: value, inherited_use: current_use)
363
+ elsif key.is_a?(String) || key.is_a?(Symbol)
364
+ new_segments = parse_path_key(key.to_s)
365
+ compile_scope(config: value, domain: domain, path_segments: path_segments + new_segments, inherited_use: current_use)
366
+ end
367
+ end
368
+ end
369
+
370
+ def register_route(domain:, method:, path_segments:, route_config:, inherited_use:)
371
+ name = route_config[:as]
372
+ handler = route_config[:to]
373
+ constraints = route_config[:constraints] || {}
374
+ route_use_unresolved = route_config[:use]
375
+ localized_paths = route_config[:localized]
376
+
377
+ resolved_inherited = Array(inherited_use).flat_map do |item|
378
+ if item.is_a?(Symbol)
379
+ Aris.resolve_plugin(item)
380
+ else
381
+ item
382
+ end
383
+ end
384
+
385
+ if route_use_unresolved
386
+ resolved_route_use = Array(route_use_unresolved).flat_map do |item|
387
+ if item.is_a?(Symbol)
388
+ Aris.resolve_plugin(item)
389
+ else
390
+ item
391
+ end
392
+ end
393
+ route_use = resolved_route_use
394
+ else
395
+ route_use = nil
396
+ end
397
+
398
+ if route_use
399
+ final_use = merge_use(resolved_inherited, route_use)
400
+ else
401
+ final_use = resolved_inherited
402
+ end
403
+
404
+ # Handle localized routes
405
+ if localized_paths && !localized_paths.empty?
406
+ domain_config = self.domain_config(domain)
407
+ if domain_config && domain_config[:locales]
408
+ # Warn about missing locales (routes that don't cover all domain locales)
409
+ missing_locales = domain_config[:locales] - localized_paths.keys
410
+ if missing_locales.any?
411
+ warn "Warning: Route '#{build_pattern(path_segments)}' missing locales: #{missing_locales.inspect}"
412
+ end
413
+
414
+ # Register the base route for URL generation (without locale prefix)
415
+ if name
416
+ @route_name_registry.add(name)
417
+ base_metadata_key = build_metadata_key(domain, method, build_pattern(path_segments))
418
+ @metadata[base_metadata_key] = {
419
+ domain: domain,
420
+ name: name,
421
+ handler: handler,
422
+ use: final_use,
423
+ pattern: build_pattern(path_segments),
424
+ params: extract_param_names(path_segments),
425
+ segments: path_segments.dup,
426
+ constraints: constraints,
427
+ localized: true
428
+ }
429
+ @named_routes[name] = base_metadata_key
430
+ end
431
+
432
+ # Register locale-specific routes (with locale prefix and localized segment)
433
+ localized_paths.each do |locale, localized_path|
434
+ if domain_config[:locales].include?(locale)
435
+ # Parse the localized path (e.g., "about" or "products/:id")
436
+ localized_segments = parse_path_key(localized_path.to_s)
437
+
438
+ # Combine: locale + localized_segments
439
+ full_path_segments = [locale.to_s] + localized_segments
440
+
441
+ register_localized_route(
442
+ domain: domain,
443
+ method: method,
444
+ path_segments: full_path_segments,
445
+ route_config: route_config,
446
+ inherited_use: final_use,
447
+ name: name,
448
+ handler: handler,
449
+ constraints: constraints,
450
+ locale: locale
451
+ )
452
+ else
453
+ raise LocaleError, "Locale '#{locale}' not found in domain '#{domain}' locales: #{domain_config[:locales].inspect}"
454
+ end
455
+ end
456
+ end
457
+ return
458
+ end
459
+
460
+ # Original registration for non-localized routes
461
+ if name
462
+ if @route_name_registry.include?(name)
463
+ existing = @named_routes[name]
464
+ existing_meta = @metadata[existing]
465
+ raise DuplicateRouteNameError.new(
466
+ name: name,
467
+ existing_domain: existing_meta[:domain],
468
+ existing_pattern: existing_meta[:pattern],
469
+ new_domain: domain,
470
+ new_pattern: build_pattern(path_segments)
471
+ )
472
+ end
473
+ @route_name_registry.add(name)
474
+ end
475
+
476
+ pattern = build_pattern(path_segments)
477
+ param_names = extract_param_names(path_segments)
478
+ metadata_key = build_metadata_key(domain, method, pattern)
479
+ @metadata[metadata_key] = {
480
+ domain: domain,
481
+ name: name,
482
+ handler: handler,
483
+ use: final_use,
484
+ pattern: pattern,
485
+ params: param_names,
486
+ segments: path_segments.dup,
487
+ constraints: constraints
488
+ }
489
+ @named_routes[name] = metadata_key if name
490
+ insert_into_trie(domain, path_segments, method, metadata_key)
491
+ end
492
+
493
+ def register_localized_route(domain:, method:, path_segments:, route_config:, inherited_use:, name:, handler:, constraints:, locale:)
494
+ # Create locale-specific name
495
+ localized_name = name ? "#{name}_#{locale}".to_sym : nil
496
+
497
+ if localized_name && @route_name_registry.include?(localized_name)
498
+ existing = @named_routes[localized_name]
499
+ existing_meta = @metadata[existing]
500
+ raise DuplicateRouteNameError.new(
501
+ name: localized_name,
502
+ existing_domain: existing_meta[:domain],
503
+ existing_pattern: existing_meta[:pattern],
504
+ new_domain: domain,
505
+ new_pattern: build_pattern(path_segments)
506
+ )
507
+ end
508
+
509
+ # DO NOT add locale prefix here - the path_segments already include it from the route config
510
+ pattern = build_pattern(path_segments)
511
+ param_names = extract_param_names(path_segments)
512
+ metadata_key = build_metadata_key(domain, method, pattern)
513
+
514
+ @metadata[metadata_key] = {
515
+ domain: domain,
516
+ name: localized_name,
517
+ handler: handler,
518
+ use: inherited_use,
519
+ pattern: pattern,
520
+ params: param_names,
521
+ segments: path_segments.dup,
522
+ constraints: constraints,
523
+ locale: locale
524
+ }
525
+
526
+ @route_name_registry.add(localized_name) if localized_name
527
+ @named_routes[localized_name] = metadata_key if localized_name
528
+ insert_into_trie(domain, path_segments, method, metadata_key)
529
+ end
530
+ def enforce_constraints(params, constraints)
531
+ constraints.each do |param_name, regex|
532
+ value = params[param_name]
533
+ if value && !value.match?(regex)
534
+ return false
535
+ end
536
+ end
537
+ true
538
+ end
539
+ def extract_use(config)
540
+ return nil unless config.is_a?(Hash)
541
+ return nil unless config.key?(:use)
542
+ use_value = config[:use]
543
+ return [] if use_value.nil?
544
+ Array(use_value)
545
+ end
546
+
547
+ def new_trie_node
548
+ { literal_children: {}, param_child: nil, wildcard_child: nil, handlers: {} }
549
+ end
550
+
551
+ def insert_into_trie(domain, path_segments, method, metadata_key)
552
+ node = @tries[domain]
553
+ path_segments.each { |segment| node = insert_segment(node, segment) }
554
+ node[:handlers][method] = metadata_key
555
+ end
556
+
557
+ def insert_segment(node, segment)
558
+ if segment.start_with?('*')
559
+ wildcard_name = segment[1..].to_s
560
+ wildcard_name = 'path' if wildcard_name.empty?
561
+ unless node[:wildcard_child]
562
+ node[:wildcard_child] = { name: wildcard_name.to_sym, node: new_trie_node }
563
+ end
564
+ node[:wildcard_child][:node]
565
+ elsif segment.start_with?(':')
566
+ param_name = segment[1..].to_sym
567
+ unless node[:param_child]
568
+ node[:param_child] = { name: param_name, node: new_trie_node }
569
+ end
570
+ node[:param_child][:node]
571
+ else
572
+ node[:literal_children][segment] ||= new_trie_node
573
+ end
574
+ end
575
+
576
+ def traverse_trie(node, segments, params = {})
577
+ return nil unless node
578
+ return [node, params] if segments.empty?
579
+ current_segment = segments.first
580
+ remaining_segments = segments[1..]
581
+ if node[:literal_children][current_segment]
582
+ result = traverse_trie(node[:literal_children][current_segment], remaining_segments, params)
583
+ return result if result
584
+ end
585
+ if node[:param_child]
586
+ param_name = node[:param_child][:name]
587
+ params[param_name] = current_segment
588
+ result = traverse_trie(node[:param_child][:node], remaining_segments, params)
589
+ if result
590
+ return result
591
+ else
592
+ params.delete(param_name)
593
+ end
594
+ end
595
+ if node[:wildcard_child]
596
+ wildcard_name = node[:wildcard_child][:name]
597
+ (0..segments.size).each do |i|
598
+ captured_segments = segments[0..i]
599
+ remaining_after_wildcard = segments[(i + 1)..] || []
600
+ captured_path = captured_segments.join('/')
601
+ params[wildcard_name] = captured_path
602
+ result = traverse_trie(node[:wildcard_child][:node], remaining_after_wildcard, params)
603
+ if result
604
+ return result
605
+ else
606
+ params.delete(wildcard_name)
607
+ end
608
+ end
609
+ end
610
+ nil
611
+ end
612
+
613
+ def build_path(metadata, params)
614
+ segments = metadata[:segments].dup
615
+ required_params = metadata[:params]
616
+ provided_params = params.dup
617
+ missing_params = required_params - provided_params.keys
618
+ unless missing_params.empty?
619
+ raise ArgumentError, "Missing required param(s) #{missing_params.map { |p| "'#{p}'" }.join(', ')} for route :#{metadata[:name]}"
620
+ end
621
+ path_parts = segments.map do |segment|
622
+ if segment.start_with?(':')
623
+ param_name = segment[1..].to_sym
624
+ value = provided_params.delete(param_name)
625
+ URI.encode_www_form_component(value.to_s)
626
+ elsif segment.start_with?('*')
627
+ param_name = segment[1..].to_s
628
+ param_name = 'path' if param_name.empty?
629
+ value = provided_params.delete(param_name.to_sym)
630
+ value.to_s
631
+ else
632
+ segment
633
+ end
634
+ end
635
+ path = path_parts.empty? ? '/' : '/' + path_parts.join('/')
636
+ unless provided_params.empty?
637
+ query_string = URI.encode_www_form(provided_params)
638
+ path += "?#{query_string}"
639
+ end
640
+ path
641
+ end
642
+
643
+ def parse_path_key(key)
644
+ key.split('/').reject(&:empty?)
645
+ end
646
+
647
+ def build_pattern(segments)
648
+ segments.empty? ? '/' : '/' + segments.join('/')
649
+ end
650
+
651
+ def extract_param_names(segments)
652
+ segments.select { |s| s.start_with?(':') || s.start_with?('*') }.map { |s| s[1..].to_sym }
653
+ end
654
+
655
+ def build_metadata_key(domain, method, pattern)
656
+ "#{domain}:#{method.to_s.upcase}:#{pattern}"
657
+ end
658
+
659
+ def get_path_segments(normalized_path)
660
+ if cached = @segment_cache[normalized_path]
661
+ return cached
662
+ end
663
+
664
+ if normalized_path == '/'
665
+ segments = []
666
+ else
667
+ segments = normalized_path.split('/').reject(&:empty?)
668
+
669
+ # In strict mode, preserve trailing slash by appending to last segment
670
+ if Aris::Config.trailing_slash == :strict &&
671
+ normalized_path.end_with?('/') &&
672
+ normalized_path != '/' &&
673
+ segments.any?
674
+ segments[-1] = segments[-1] + '/'
675
+ end
676
+ end
677
+
678
+ if @segment_cache.size >= @cache_max_size
679
+ @segment_cache.clear
680
+ end
681
+ @segment_cache[normalized_path] = segments
682
+ segments
683
+ end
684
+ def normalize_path(path)
685
+ return '/' if path.empty?
686
+ return path if path == '/' || (path == '/users' && !path.include?('//'))
687
+
688
+ if path.include?('//')
689
+ path = path.gsub(%r{/+}, '/')
690
+ end
691
+
692
+ # Don't strip trailing slash in strict mode
693
+ normalized = if Aris::Config.trailing_slash == :strict
694
+ path # Keep trailing slash in strict mode
695
+ else
696
+ path.length > 1 && path.end_with?('/') ? path.chomp('/') : path
697
+ end
698
+
699
+ if normalized.include?('%')
700
+ begin
701
+ normalized = URI.decode_www_form_component(normalized)
702
+ rescue
703
+ end
704
+ end
705
+
706
+ normalized.downcase
707
+ end
708
+ class RouteNotFoundError < StandardError
709
+ def initialize(name, domain)
710
+ # FIX: Concatenate arguments into a single string before calling super
711
+ super("Named route :#{name} not found on domain '#{domain}' or '*' fallback.")
712
+ end
713
+ end
714
+ class DuplicateRouteNameError < StandardError
715
+ end
716
+ class LocaleError < StandardError
717
+ def initialize(message)
718
+ super(message)
719
+ end
720
+ end
721
+ end
722
+ end