ultra_settings 2.10.0 → 2.10.1

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +18 -0
  4. data/VERSION +1 -1
  5. data/app/_config_description.html.erb +10 -1
  6. data/app/_data_source.html.erb +2 -2
  7. data/app/application.css +116 -24
  8. data/app/application_vars.css.erb +30 -12
  9. data/app/configuration.html.erb +19 -1
  10. data/app/layout.css +5 -5
  11. data/app/layout_vars.css.erb +4 -4
  12. data/app/locales/ar.json +5 -2
  13. data/app/locales/cs.json +5 -2
  14. data/app/locales/da.json +5 -2
  15. data/app/locales/de.json +5 -2
  16. data/app/locales/el.json +5 -2
  17. data/app/locales/en.json +5 -2
  18. data/app/locales/es.json +5 -2
  19. data/app/locales/fa.json +5 -2
  20. data/app/locales/fr.json +5 -2
  21. data/app/locales/gd.json +5 -2
  22. data/app/locales/he.json +5 -2
  23. data/app/locales/hi.json +5 -2
  24. data/app/locales/it.json +5 -2
  25. data/app/locales/ja.json +5 -2
  26. data/app/locales/ko.json +5 -2
  27. data/app/locales/lt.json +5 -2
  28. data/app/locales/nb.json +5 -2
  29. data/app/locales/nl.json +5 -2
  30. data/app/locales/pl.json +5 -2
  31. data/app/locales/pt-br.json +5 -2
  32. data/app/locales/pt.json +5 -2
  33. data/app/locales/ru.json +5 -2
  34. data/app/locales/sv.json +5 -2
  35. data/app/locales/ta.json +5 -2
  36. data/app/locales/tr.json +5 -2
  37. data/app/locales/uk.json +5 -2
  38. data/app/locales/ur.json +5 -2
  39. data/app/locales/vi.json +5 -2
  40. data/app/locales/zh-cn.json +5 -2
  41. data/app/locales/zh-tw.json +5 -2
  42. data/lib/ultra_settings/application_view.rb +13 -4
  43. data/lib/ultra_settings/audit_data_sources.rb +1 -1
  44. data/lib/ultra_settings/coerce.rb +1 -1
  45. data/lib/ultra_settings/configuration.rb +36 -10
  46. data/lib/ultra_settings/configuration_view.rb +166 -9
  47. data/lib/ultra_settings/mini_i18n.rb +14 -10
  48. data/lib/ultra_settings/rack_app.rb +4 -4
  49. data/lib/ultra_settings/view_helper.rb +27 -5
  50. data/lib/ultra_settings/web_view.rb +17 -10
  51. data/lib/ultra_settings/yaml_config.rb +10 -2
  52. data/lib/ultra_settings.rb +57 -1
  53. data/ultra_settings.gemspec +1 -1
  54. metadata +2 -2
data/app/locales/vi.json CHANGED
@@ -17,18 +17,21 @@
17
17
  "field.nil": "nil",
18
18
  "field.click_to_expand": "Bấm để mở rộng",
19
19
  "field.not_set": "chưa đặt",
20
+ "field.copy": "Sao chép giá trị",
21
+ "field.copy_disabled": "Không thể sao chép giá trị bí mật",
20
22
  "source.env": "ENV",
21
23
  "source.setting": "CÀI ĐẶT",
22
24
  "source.yaml": "YAML",
23
25
  "source.default": "MẶC ĐỊNH",
24
- "source.active": "● Hoạt động",
26
+ "source.active": "Hoạt động",
25
27
  "source.overridden": "Bị ghi đè",
26
28
  "detail.field_detail": "Chi tiết trường",
27
29
  "detail.close_panel": "Đóng bảng",
28
30
  "detail.type_label": "Loại:",
29
31
  "detail.secret_badge": "BÍ MẬT",
30
32
  "detail.secret_value": "•••••••• (bí mật)",
31
- "config.file_not_found": "(không tìm thấy)",
33
+ "config.file_not_found": "tệp không tồn tại",
34
+ "config.show_yaml_keys": "hiện khóa",
32
35
  "config.edit_tooltip": "Chỉnh sửa ${name}",
33
36
  "edit.title": "Chỉnh sửa cài đặt runtime",
34
37
  "edit.open_dashboard": "Mở trong bảng điều khiển cài đặt ↗",
@@ -17,18 +17,21 @@
17
17
  "field.nil": "nil",
18
18
  "field.click_to_expand": "点击展开",
19
19
  "field.not_set": "未设置",
20
+ "field.copy": "复制值",
21
+ "field.copy_disabled": "无法复制机密值",
20
22
  "source.env": "ENV",
21
23
  "source.setting": "设置",
22
24
  "source.yaml": "YAML",
23
25
  "source.default": "默认",
24
- "source.active": "● 当前生效",
26
+ "source.active": "当前生效",
25
27
  "source.overridden": "已覆盖",
26
28
  "detail.field_detail": "字段详情",
27
29
  "detail.close_panel": "关闭面板",
28
30
  "detail.type_label": "类型:",
29
31
  "detail.secret_badge": "机密",
30
32
  "detail.secret_value": "•••••••• (机密)",
31
- "config.file_not_found": "(未找到)",
33
+ "config.file_not_found": "文件不存在",
34
+ "config.show_yaml_keys": "显示键",
32
35
  "config.edit_tooltip": "编辑 ${name}",
33
36
  "edit.title": "编辑运行时设置",
34
37
  "edit.open_dashboard": "在设置仪表板中打开 ↗",
@@ -17,18 +17,21 @@
17
17
  "field.nil": "nil",
18
18
  "field.click_to_expand": "點擊以展開",
19
19
  "field.not_set": "未設定",
20
+ "field.copy": "複製值",
21
+ "field.copy_disabled": "無法複製機密值",
20
22
  "source.env": "ENV",
21
23
  "source.setting": "設定",
22
24
  "source.yaml": "YAML",
23
25
  "source.default": "預設",
24
- "source.active": "● 目前生效",
26
+ "source.active": "目前生效",
25
27
  "source.overridden": "已覆寫",
26
28
  "detail.field_detail": "欄位詳細資料",
27
29
  "detail.close_panel": "關閉面板",
28
30
  "detail.type_label": "類型:",
29
31
  "detail.secret_badge": "機密",
30
32
  "detail.secret_value": "•••••••• (機密)",
31
- "config.file_not_found": "(找不到)",
33
+ "config.file_not_found": "檔案不存在",
34
+ "config.show_yaml_keys": "顯示鍵",
32
35
  "config.edit_tooltip": "編輯 ${name}",
33
36
  "edit.title": "編輯執行階段設定",
34
37
  "edit.open_dashboard": "在設定儀表板中開啟 ↗",
@@ -28,14 +28,23 @@ module UltraSettings
28
28
 
29
29
  # Render the HTML for the configuration settings UI.
30
30
  #
31
+ # The runtime settings cache is reloaded before rendering so that values changed
32
+ # from the UI are displayed immediately rather than after the runtime settings
33
+ # engine next refreshes itself.
34
+ #
31
35
  # @param select_class [String] @deprecated; no longer used.
32
36
  # @param table_class [String] @deprecated; no longer used.
33
37
  # @return [String] The rendered HTML.
34
38
  def render(select_class: nil, table_class: nil)
35
- locale = @locale # used by ERB template via binding
36
- html = ViewHelper.erb_template("index.html.erb").result(binding)
37
- html = html.html_safe if html.respond_to?(:html_safe)
38
- html
39
+ UltraSettings.with_runtime_settings_reloaded do
40
+ # Expose locale as a local for the ERB template without a direct
41
+ # assignment, which would emit an unused variable warning under ruby -w.
42
+ template_binding = binding
43
+ template_binding.local_variable_set(:locale, @locale)
44
+ html = ViewHelper.erb_template("index.html.erb").result(template_binding)
45
+ html = html.html_safe if html.respond_to?(:html_safe)
46
+ html
47
+ end
39
48
  end
40
49
 
41
50
  # Generate an HTML style tag with the CSS for the view.
@@ -91,7 +91,7 @@ module UltraSettings
91
91
  def default_config_value(config, field)
92
92
  yaml_value = config.__value_from_source__(field.name, :yaml)
93
93
  default_value = config.__value_from_source__(field.name, :default)
94
- yaml_value || default_value
94
+ yaml_value.nil? ? default_value : yaml_value
95
95
  end
96
96
  end
97
97
  end
@@ -60,8 +60,8 @@ module UltraSettings
60
60
  # @param value [Object]
61
61
  # @return [Boolean]
62
62
  def boolean(value)
63
- return nil if blank?(value)
64
63
  return false if value == false
64
+ return nil if blank?(value)
65
65
 
66
66
  !FALSE_VALUES.include?(value.to_s.downcase)
67
67
  end
@@ -7,6 +7,9 @@ module UltraSettings
7
7
  ALLOWED_NAME_PATTERN = /\A[a-z_][a-zA-Z0-9_]*\z/
8
8
  ALLOWED_TYPES = [:string, :symbol, :integer, :float, :boolean, :datetime, :array].freeze
9
9
 
10
+ DESCENDANTS_MUTEX = Mutex.new
11
+ private_constant :DESCENDANTS_MUTEX
12
+
10
13
  @env_var_prefix = nil
11
14
  @runtime_setting_prefix = nil
12
15
  @description = nil
@@ -373,10 +376,15 @@ module UltraSettings
373
376
 
374
377
  # Get all descendant configuration classes (subclasses and their subclasses, recursively).
375
378
  #
379
+ # Note that in Rails development mode this list can include stale classes from
380
+ # previous code reloads; callers that display the results should filter out
381
+ # classes that no longer resolve to a defined constant (see
382
+ # UltraSettings.__configurations__).
383
+ #
376
384
  # @return [Array<Class>] All classes that inherit from this class.
377
385
  def descendant_configurations
378
- @descendants ||= []
379
- @descendants.flat_map { |subclass| [subclass] + subclass.descendant_configurations }
386
+ descendants = DESCENDANTS_MUTEX.synchronize { (@descendants || []).dup }
387
+ descendants.flat_map { |subclass| [subclass] + subclass.descendant_configurations }
380
388
  end
381
389
 
382
390
  private
@@ -387,8 +395,10 @@ module UltraSettings
387
395
  # @return [void]
388
396
  def inherited(subclass)
389
397
  super
390
- @descendants ||= []
391
- @descendants << subclass
398
+ DESCENDANTS_MUTEX.synchronize do
399
+ @descendants ||= []
400
+ @descendants << subclass
401
+ end
392
402
  end
393
403
 
394
404
  def defined_fields
@@ -496,7 +506,8 @@ module UltraSettings
496
506
  end
497
507
 
498
508
  def override!(values, &block)
499
- save_val = @ultra_settings_override_values[Thread.current.object_id]
509
+ thread_id = Thread.current.object_id
510
+ save_val = @ultra_settings_mutex.synchronize { @ultra_settings_override_values[thread_id] }
500
511
 
501
512
  temp_values = (save_val || {}).dup
502
513
  values.each do |key, value|
@@ -505,12 +516,18 @@ module UltraSettings
505
516
 
506
517
  begin
507
518
  @ultra_settings_mutex.synchronize do
508
- @ultra_settings_override_values[Thread.current.object_id] = temp_values
519
+ @ultra_settings_override_values[thread_id] = temp_values
509
520
  end
510
521
  yield
511
522
  ensure
512
523
  @ultra_settings_mutex.synchronize do
513
- @ultra_settings_override_values[Thread.current.object_id] = save_val
524
+ if save_val.nil?
525
+ # Remove the key entirely so the hash doesn't accumulate an entry
526
+ # for every thread that has ever used override!.
527
+ @ultra_settings_override_values.delete(thread_id)
528
+ else
529
+ @ultra_settings_override_values[thread_id] = save_val
530
+ end
514
531
  end
515
532
  end
516
533
  end
@@ -592,11 +609,20 @@ module UltraSettings
592
609
  field = self.class.send(:defined_fields)[name]
593
610
  return nil unless field
594
611
 
595
- override_values = @ultra_settings_mutex.synchronize { @ultra_settings_override_values[Thread.current.object_id] }
612
+ override_values = nil
613
+ memoized = false
614
+ memoized_value = nil
615
+ @ultra_settings_mutex.synchronize do
616
+ override_values = @ultra_settings_override_values[Thread.current.object_id]
617
+ if field.static? && @ultra_settings_memoized_values.include?(name)
618
+ memoized = true
619
+ memoized_value = @ultra_settings_memoized_values[name]
620
+ end
621
+ end
596
622
  use_override = override_values&.include?(name)
597
623
 
598
- if field.static? && !use_override && @ultra_settings_memoized_values.include?(name)
599
- return @ultra_settings_memoized_values[name]
624
+ if memoized && !use_override
625
+ return memoized_value
600
626
  end
601
627
 
602
628
  value = nil
@@ -24,13 +24,24 @@ module UltraSettings
24
24
 
25
25
  # Render the HTML for the configuration view.
26
26
  #
27
+ # The runtime settings cache is reloaded before rendering so that values changed
28
+ # from the UI are displayed immediately rather than after the runtime settings
29
+ # engine next refreshes itself. If a page renders more than one view, wrap them all
30
+ # in `UltraSettings.with_runtime_settings_reloaded` so that the settings are only
31
+ # reloaded once for the page instead of once per view.
32
+ #
27
33
  # @param table_class [String] @deprecated CSS class for the table element (maintained for backwards compatibility).
28
34
  # @return [String] The rendered HTML.
29
35
  def render(table_class: "")
30
- configuration = @configuration # used by ERB template via binding
31
- html = ViewHelper.erb_template("configuration.html.erb").result(binding)
32
- html = html.html_safe if html.respond_to?(:html_safe)
33
- html
36
+ UltraSettings.with_runtime_settings_reloaded do
37
+ # Expose configuration as a local for the ERB template without a direct
38
+ # assignment, which would emit an unused variable warning under ruby -w.
39
+ template_binding = binding
40
+ template_binding.local_variable_set(:configuration, @configuration)
41
+ html = ViewHelper.erb_template("configuration.html.erb").result(template_binding)
42
+ html = html.html_safe if html.respond_to?(:html_safe)
43
+ html
44
+ end
34
45
  end
35
46
 
36
47
  # Convert the view to a string by rendering it.
@@ -70,6 +81,25 @@ module UltraSettings
70
81
  end
71
82
  end
72
83
 
84
+ # The text placed on the clipboard by the copy button. This is the raw value
85
+ # rather than the inspected value shown in the UI so that, for example, copying
86
+ # a string setting does not include the surrounding quotes.
87
+ #
88
+ # @param value [Object] The setting value.
89
+ # @return [String] The text to copy.
90
+ def copy_value(value)
91
+ case value
92
+ when nil
93
+ ""
94
+ when Time
95
+ value.iso8601
96
+ when Array
97
+ value.join("\n")
98
+ else
99
+ value.to_s
100
+ end
101
+ end
102
+
73
103
  def secret_value(value)
74
104
  if value.nil?
75
105
  t("field.nil")
@@ -78,13 +108,63 @@ module UltraSettings
78
108
  end
79
109
  end
80
110
 
111
+ # Shorten a file path for display by making it relative to the working
112
+ # directory or to the YAML configuration directory. The absolute path is
113
+ # used if the file is not inside either directory.
114
+ #
115
+ # @param path [Pathname] The absolute file path.
116
+ # @return [String] The path to display.
81
117
  def relative_path(path)
82
- root_path = Pathname.new(Dir.pwd)
83
- config_path = UltraSettings::Configuration.yaml_config_path
84
- unless config_path.realpath.to_s.start_with?("#{root_path.realpath}#{File::SEPARATOR}")
85
- root_path = config_path
118
+ paths = display_paths(Pathname.new(path).expand_path)
119
+ display_path_roots.each do |root|
120
+ paths.each do |file_path|
121
+ relative = path_inside(file_path, root)
122
+ return relative if relative
123
+ end
86
124
  end
87
- path.relative_path_from(root_path)
125
+ paths.first.to_s
126
+ end
127
+
128
+ # The file path in both its literal and symlink resolved forms so that it can
129
+ # be matched against a directory that is specified in either form.
130
+ #
131
+ # @param path [Pathname] The absolute file path.
132
+ # @return [Array<Pathname>]
133
+ def display_paths(path)
134
+ [path, resolved_path(path.dirname)&.join(path.basename)].compact.uniq
135
+ end
136
+
137
+ # Directories that a displayed path can be made relative to, in order of
138
+ # preference. Directories are listed in both their literal and symlink
139
+ # resolved forms since a file path can be in either form.
140
+ #
141
+ # @return [Array<Pathname>]
142
+ def display_path_roots
143
+ roots = [Pathname.new(Dir.pwd)]
144
+ config_path = UltraSettings::Configuration.yaml_config_path
145
+ roots << Pathname.new(config_path) if config_path
146
+ roots.flat_map { |root| [root.expand_path, resolved_path(root)] }.compact.uniq
147
+ end
148
+
149
+ # @param path [Pathname] The absolute file path.
150
+ # @param root [Pathname] The absolute directory path.
151
+ # @return [String, nil] The path relative to the directory or nil if it is not inside it.
152
+ def path_inside(path, root)
153
+ relative = path.relative_path_from(root).to_s.delete_prefix("./")
154
+ return nil if relative == "." || relative.start_with?("..")
155
+
156
+ relative
157
+ rescue ArgumentError
158
+ # relative_path_from raises if the paths have no common root (e.g. different drives).
159
+ nil
160
+ end
161
+
162
+ # @param path [Pathname] The directory path.
163
+ # @return [Pathname, nil] The path with symlinks resolved or nil if it does not exist.
164
+ def resolved_path(path)
165
+ path.realpath
166
+ rescue SystemCallError
167
+ nil
88
168
  end
89
169
 
90
170
  def source_chip_label(source)
@@ -116,6 +196,34 @@ module UltraSettings
116
196
  end
117
197
  end
118
198
 
199
+ # True if the YAML keys for a configuration are hidden behind a toggle button.
200
+ # They are hidden by default when the YAML file does not exist since the keys
201
+ # are not used by the application.
202
+ #
203
+ # @param configuration [UltraSettings::Configuration] The configuration instance.
204
+ # @return [Boolean]
205
+ def hide_yaml_keys?(configuration)
206
+ config_class = configuration.class
207
+ file = config_class.configuration_file
208
+ file.is_a?(Pathname) && !file.exist? && config_class.fields.any?(&:yaml_key)
209
+ end
210
+
211
+ # Inline script for the button that shows and hides the YAML keys. It is
212
+ # inlined on the element so that the button also works when the configuration
213
+ # view is embedded in a host application page that does not include the
214
+ # bundled JavaScript.
215
+ #
216
+ # @return [String] JavaScript source for an onclick attribute.
217
+ def toggle_yaml_keys_script
218
+ <<~JAVASCRIPT.gsub(/\s+/, " ").tr('"', "'")
219
+ var block = this.closest('.ultra-settings-block');
220
+ if (block) {
221
+ var hidden = block.classList.toggle('ultra-settings-yaml-hidden');
222
+ this.setAttribute('aria-checked', hidden ? 'false' : 'true');
223
+ }
224
+ JAVASCRIPT
225
+ end
226
+
119
227
  def open_panel_script
120
228
  <<~JAVASCRIPT.gsub(/\s+/, " ").tr('"', "'")
121
229
  var el = this;
@@ -146,6 +254,38 @@ module UltraSettings
146
254
  JAVASCRIPT
147
255
  end
148
256
 
257
+ # Inline script for the copy button. It is inlined on the element so that the
258
+ # button also works when the configuration view is embedded in a host
259
+ # application page that does not include the bundled JavaScript.
260
+ #
261
+ # @return [String] JavaScript source for an onclick attribute.
262
+ def copy_value_script
263
+ <<~JAVASCRIPT.gsub(/\s+/, " ").tr('"', "'")
264
+ var btn = this;
265
+ var text = btn.dataset.copyValue || '';
266
+ var flash = function() {
267
+ btn.classList.add('copied');
268
+ window.setTimeout(function() { btn.classList.remove('copied'); }, 1500);
269
+ };
270
+ var fallback = function() {
271
+ var input = document.createElement('textarea');
272
+ input.value = text;
273
+ input.setAttribute('readonly', '');
274
+ input.style.position = 'fixed';
275
+ input.style.opacity = '0';
276
+ document.body.appendChild(input);
277
+ input.select();
278
+ try { if (document.execCommand('copy')) { flash(); } } catch (e) {}
279
+ document.body.removeChild(input);
280
+ };
281
+ if (navigator.clipboard && navigator.clipboard.writeText) {
282
+ navigator.clipboard.writeText(text).then(flash, fallback);
283
+ } else {
284
+ fallback();
285
+ }
286
+ JAVASCRIPT
287
+ end
288
+
149
289
  def source_priority
150
290
  [:env, :settings, :yaml, :default]
151
291
  end
@@ -196,6 +336,23 @@ module UltraSettings
196
336
  HTML
197
337
  end
198
338
 
339
+ def copy_icon(size = 13)
340
+ <<~HTML
341
+ <svg class="ultra-settings-copy-icon" width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
342
+ <rect x="9" y="9" width="13" height="13" rx="2"/>
343
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
344
+ </svg>
345
+ HTML
346
+ end
347
+
348
+ def check_icon(size = 13)
349
+ <<~HTML
350
+ <svg class="ultra-settings-copy-check" width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
351
+ <polyline points="20 6 9 17 4 12"/>
352
+ </svg>
353
+ HTML
354
+ end
355
+
199
356
  def close_icon(size = 16)
200
357
  <<~HTML
201
358
  <svg width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
@@ -76,26 +76,30 @@ module UltraSettings
76
76
  end
77
77
 
78
78
  # Load every JSON file from the locales directory, keyed by filename
79
- # stem (e.g. "en").
79
+ # stem (e.g. "en"). The cache is built into a local hash and then
80
+ # published with a single assignment so that concurrent readers never
81
+ # see a partially loaded cache.
80
82
  def load_all_locales
81
- if development_mode?
82
- @mutex.synchronize { @cache = {} }
83
- end
83
+ clear_cache! if development_mode?
84
84
 
85
- return @cache unless @cache.empty?
85
+ cached = @cache
86
+ return cached unless cached.empty?
86
87
 
87
88
  @mutex.synchronize do
88
- return @cache unless @cache.empty?
89
+ cached = @cache
90
+ return cached unless cached.empty?
89
91
 
92
+ cache = {}
90
93
  Dir.glob(File.join(locales_dir, "*.json")).each do |path|
91
94
  code = File.basename(path, ".json").downcase
92
- @cache[code] = JSON.parse(File.read(path))
95
+ cache[code] = JSON.parse(File.read(path, encoding: Encoding::UTF_8))
93
96
  rescue JSON::ParserError
94
97
  # Skip malformed locale files
95
98
  end
96
- end
97
99
 
98
- @cache
100
+ @cache = cache
101
+ cache
102
+ end
99
103
  end
100
104
 
101
105
  def locales_dir
@@ -103,7 +107,7 @@ module UltraSettings
103
107
  end
104
108
 
105
109
  def development_mode?
106
- ENV.fetch("RACK_ENV", "development") == "development"
110
+ UltraSettings.__development_mode__?
107
111
  end
108
112
  end
109
113
  end
@@ -26,9 +26,9 @@ module UltraSettings
26
26
  private
27
27
 
28
28
  def webview
29
- if ENV.fetch("RAILS_ENV", ENV.fetch("RACK_ENV", "development")) == "development"
30
- @webview = nil
31
- end
29
+ # Don't cache the view in development mode so that changes to the app files
30
+ # are picked up without having to restart the server.
31
+ @webview = nil if UltraSettings.__development_mode__?
32
32
  @webview ||= WebView.new(color_scheme: @color_scheme)
33
33
  end
34
34
 
@@ -62,7 +62,7 @@ module UltraSettings
62
62
  parts = entry.strip.split(";")
63
63
  tag = parts[0].to_s.strip.downcase.tr("_", "-")
64
64
  q = 1.0
65
- parts[1..-1].each do |p|
65
+ parts[1..].each do |p|
66
66
  if p.strip.start_with?("q=")
67
67
  q = p.strip.sub("q=", "").to_f
68
68
  end
@@ -4,6 +4,7 @@ module UltraSettings
4
4
  # Base class for rendering views.
5
5
  module ViewHelper
6
6
  @cache = {}
7
+ @mutex = Mutex.new
7
8
 
8
9
  class << self
9
10
  # Get an ERB template for rendering.
@@ -11,8 +12,7 @@ module UltraSettings
11
12
  # @param path [String] The path to the template file.
12
13
  # @return [ERB] The compiled ERB template.
13
14
  def erb_template(path)
14
- @cache.clear if development_mode?
15
- @cache["erb:#{path}"] ||= ERB.new(read_app_file(path))
15
+ fetch("erb:#{path}") { ERB.new(read_app_file(path)) }
16
16
  end
17
17
 
18
18
  # Read a file from the app directory.
@@ -20,8 +20,7 @@ module UltraSettings
20
20
  # @param path [String] The path to the file relative to the app directory.
21
21
  # @return [String] The contents of the file.
22
22
  def read_app_file(path)
23
- @cache.clear if development_mode?
24
- @cache["file:#{path}"] ||= File.read(File.join(app_dir, path))
23
+ fetch("file:#{path}") { File.read(File.join(app_dir, path), encoding: Encoding::UTF_8) }
25
24
  end
26
25
 
27
26
  # Get the app directory path.
@@ -33,8 +32,31 @@ module UltraSettings
33
32
 
34
33
  private
35
34
 
35
+ # Fetch a value from the cache, generating it with the block if needed.
36
+ # The value is generated outside of the lock since the block may itself
37
+ # fetch other cached values. The cache hash is never mutated in place; a
38
+ # copy is published with a single assignment so concurrent readers always
39
+ # see a consistent hash. Two threads may generate the same value
40
+ # concurrently; the first one to publish wins.
41
+ def fetch(key, &block)
42
+ return yield if development_mode?
43
+
44
+ cached = @cache
45
+ return cached[key] if cached.include?(key)
46
+
47
+ value = yield
48
+ @mutex.synchronize do
49
+ if @cache.include?(key)
50
+ @cache[key]
51
+ else
52
+ @cache = @cache.merge(key => value)
53
+ value
54
+ end
55
+ end
56
+ end
57
+
36
58
  def development_mode?
37
- ENV.fetch("RACK_ENV", "development") == "development"
59
+ UltraSettings.__development_mode__?
38
60
  end
39
61
  end
40
62
  end
@@ -17,17 +17,30 @@ module UltraSettings
17
17
  @dark_mode_selector = @color_scheme.nil? ? "[data-theme=dark]" : nil
18
18
  @layout_template = ViewHelper.erb_template("layout.html.erb")
19
19
  @layout_css = scheme_layout_css(@color_scheme, @dark_mode_selector)
20
+ @locale = nil
20
21
  end
21
22
 
22
23
  # Render the complete settings page HTML.
23
24
  #
24
- # @param request [Rack::Request, nil] The current Rack request for access control.
25
+ # This instance may be shared between concurrent requests, so per-request
26
+ # state is set on a copy of the view rather than on the shared instance.
27
+ #
28
+ # @param request [Rack::Request, nil] The current Rack request. It is accepted
29
+ # for backward compatibility but is not used by the view; access control and
30
+ # locale resolution are the caller's responsibility.
25
31
  # @param locale [String] The locale code for translations.
26
32
  # @return [String] The rendered HTML page.
27
33
  def render_settings(request = nil, locale: UltraSettings::MiniI18n::DEFAULT_LOCALE)
28
- @request = request
29
- @locale = locale
30
- refresh_super_settings!
34
+ renderer = dup
35
+ renderer.instance_variable_set(:@locale, locale)
36
+ renderer.render_layout
37
+ end
38
+
39
+ # Render the layout template in the context of this instance.
40
+ #
41
+ # @return [String] The rendered HTML page.
42
+ # @api private
43
+ def render_layout
31
44
  @layout_template.result(binding)
32
45
  end
33
46
 
@@ -64,11 +77,5 @@ module UltraSettings
64
77
  css = ViewHelper.read_app_file("layout.css")
65
78
  "#{vars}\n#{css}"
66
79
  end
67
-
68
- def refresh_super_settings!
69
- return unless defined?(SuperSettings) && UltraSettings.__runtime_settings__ == SuperSettings
70
-
71
- SuperSettings.refresh_settings
72
- end
73
80
  end
74
81
  end
@@ -52,17 +52,25 @@ module UltraSettings
52
52
  private
53
53
 
54
54
  def load_yaml(path)
55
- yaml = File.read(path)
55
+ yaml = File.read(path, encoding: Encoding::UTF_8)
56
56
 
57
57
  if yaml.include?("<%")
58
58
  yaml = ERB.new(yaml).result
59
59
  end
60
60
 
61
- hash = YAML.load(yaml) # rubocop:disable Security/YAMLLoad
61
+ hash = parse_yaml(yaml)
62
62
  hash = {} unless hash.is_a?(Hash)
63
63
  hash
64
64
  end
65
65
 
66
+ def parse_yaml(yaml)
67
+ YAML.load(yaml, aliases: true, permitted_classes: [Symbol, Date, Time]) # rubocop:disable Security/YAMLLoad
68
+ rescue ArgumentError
69
+ # Psych 3 does not support the aliases or permitted_classes options; its
70
+ # YAML.load already allows aliases and arbitrary classes.
71
+ YAML.load(yaml) # rubocop:disable Security/YAMLLoad
72
+ end
73
+
66
74
  def environment_config(yaml, environment)
67
75
  shared = flatten_hash(yaml.fetch("shared", {}))
68
76
  env = flatten_hash(yaml.fetch(environment, {}))