ultra_settings 2.8.1 → 2.9.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -2
  3. data/README.md +40 -1
  4. data/VERSION +1 -1
  5. data/app/AGENTS.md +7 -0
  6. data/app/_config_description.html.erb +22 -25
  7. data/app/_config_list.html.erb +2 -14
  8. data/app/_data_source.html.erb +43 -29
  9. data/app/application.css +1019 -340
  10. data/app/application.js +825 -90
  11. data/app/application_vars.css.erb +136 -91
  12. data/app/configuration.html.erb +59 -51
  13. data/app/index.html.erb +164 -20
  14. data/app/layout.css +81 -16
  15. data/app/layout.html.erb +67 -5
  16. data/app/layout_vars.css.erb +29 -5
  17. data/app/locales/ar.json +71 -0
  18. data/app/locales/cs.json +71 -0
  19. data/app/locales/da.json +71 -0
  20. data/app/locales/de.json +71 -0
  21. data/app/locales/el.json +71 -0
  22. data/app/locales/en.json +85 -0
  23. data/app/locales/es.json +71 -0
  24. data/app/locales/fa.json +71 -0
  25. data/app/locales/fr.json +71 -0
  26. data/app/locales/gd.json +71 -0
  27. data/app/locales/he.json +71 -0
  28. data/app/locales/hi.json +71 -0
  29. data/app/locales/it.json +71 -0
  30. data/app/locales/ja.json +71 -0
  31. data/app/locales/ko.json +71 -0
  32. data/app/locales/lt.json +71 -0
  33. data/app/locales/nb.json +71 -0
  34. data/app/locales/nl.json +71 -0
  35. data/app/locales/pl.json +71 -0
  36. data/app/locales/pt-br.json +71 -0
  37. data/app/locales/pt.json +71 -0
  38. data/app/locales/ru.json +71 -0
  39. data/app/locales/sv.json +71 -0
  40. data/app/locales/ta.json +71 -0
  41. data/app/locales/tr.json +71 -0
  42. data/app/locales/uk.json +71 -0
  43. data/app/locales/ur.json +71 -0
  44. data/app/locales/vi.json +71 -0
  45. data/app/locales/zh-cn.json +71 -0
  46. data/app/locales/zh-tw.json +71 -0
  47. data/lib/ultra_settings/application_view.rb +21 -3
  48. data/lib/ultra_settings/coerce.rb +0 -6
  49. data/lib/ultra_settings/config_helper.rb +4 -4
  50. data/lib/ultra_settings/configuration.rb +14 -4
  51. data/lib/ultra_settings/configuration_view.rb +114 -92
  52. data/lib/ultra_settings/mini_i18n.rb +110 -0
  53. data/lib/ultra_settings/rack_app.rb +51 -1
  54. data/lib/ultra_settings/version.rb +1 -1
  55. data/lib/ultra_settings/web_view.rb +33 -2
  56. data/lib/ultra_settings.rb +56 -22
  57. data/ultra_settings.gemspec +1 -0
  58. metadata +33 -3
  59. data/AGENTS.md +0 -191
  60. data/app/_select_menu.html.erb +0 -53
@@ -15,13 +15,13 @@ module UltraSettings
15
15
  #
16
16
  # @param config_class [Class] The configuration class to use.
17
17
  # @return [void]
18
- def configuration_class(config_class)
19
- define_singleton_method :config do
18
+ def configuration_class(config_class, config_alias: :config)
19
+ define_singleton_method config_alias do
20
20
  config_class.instance
21
21
  end
22
22
 
23
- define_method :config do
24
- self.class.config
23
+ define_method config_alias do
24
+ self.class.send(config_alias)
25
25
  end
26
26
  end
27
27
  end
@@ -182,7 +182,7 @@ module UltraSettings
182
182
 
183
183
  @configuration_file = default_configuration_file
184
184
  end
185
- return nil? unless @configuration_file
185
+ return nil unless @configuration_file
186
186
 
187
187
  path = @configuration_file
188
188
  if path.relative? && yaml_config_path
@@ -560,7 +560,7 @@ module UltraSettings
560
560
 
561
561
  sources = []
562
562
  sources << :env if field.env_var
563
- sources << :settings if field.runtime_setting && UltraSettings.__runtime_settings__
563
+ sources << :settings if __runtime_setting_allowed?(field)
564
564
  sources << :yaml if field.yaml_key && self.class.configuration_file
565
565
  sources << :default unless field.default.nil?
566
566
  sources
@@ -592,7 +592,8 @@ module UltraSettings
592
592
  field = self.class.send(:defined_fields)[name]
593
593
  return nil unless field
594
594
 
595
- use_override = @ultra_settings_override_values[Thread.current.object_id]&.include?(name)
595
+ override_values = @ultra_settings_mutex.synchronize { @ultra_settings_override_values[Thread.current.object_id] }
596
+ use_override = override_values&.include?(name)
596
597
 
597
598
  if field.static? && !use_override && @ultra_settings_memoized_values.include?(name)
598
599
  return @ultra_settings_memoized_values[name]
@@ -600,7 +601,7 @@ module UltraSettings
600
601
 
601
602
  value = nil
602
603
  if use_override
603
- value = field.coerce(@ultra_settings_override_values[Thread.current.object_id][name])
604
+ value = field.coerce(override_values[name])
604
605
  else
605
606
  env = ENV if field.env_var
606
607
  settings = UltraSettings.__runtime_settings__ if field.runtime_setting
@@ -645,5 +646,14 @@ module UltraSettings
645
646
  def __yaml_config__
646
647
  @ultra_settings_yaml_config ||= self.class.load_yaml_config || {}
647
648
  end
649
+
650
+ def __runtime_setting_allowed?(field)
651
+ return false unless UltraSettings.__runtime_settings__
652
+ return false if field.static?
653
+ return false unless field.runtime_setting
654
+ return false if field.secret? && !UltraSettings.runtime_settings_secure?
655
+
656
+ true
657
+ end
648
658
  end
649
659
  end
@@ -16,16 +16,18 @@ module UltraSettings
16
16
  # Initialize the configuration view with a configuration instance.
17
17
  #
18
18
  # @param configuration [UltraSettings::Configuration] The configuration instance to display.
19
- def initialize(configuration)
19
+ # @param locale [String] The locale code for translations.
20
+ def initialize(configuration, locale: UltraSettings::MiniI18n::DEFAULT_LOCALE)
20
21
  @configuration = configuration
22
+ @locale = locale
21
23
  end
22
24
 
23
25
  # Render the HTML for the configuration view.
24
26
  #
25
- # @param table_class [String] CSS class for the table element (maintained for backwards compatibility).
27
+ # @param table_class [String] @deprecated CSS class for the table element (maintained for backwards compatibility).
26
28
  # @return [String] The rendered HTML.
27
29
  def render(table_class: "")
28
- configuration = @configuration
30
+ configuration = @configuration # used by ERB template via binding
29
31
  html = ViewHelper.erb_template("configuration.html.erb").result(binding)
30
32
  html = html.html_safe if html.respond_to?(:html_safe)
31
33
  html
@@ -40,6 +42,25 @@ module UltraSettings
40
42
 
41
43
  private
42
44
 
45
+ # Map an UltraSettings field type to a SuperSettings value type.
46
+ #
47
+ # @param type [Symbol] The UltraSettings field type.
48
+ # @return [String] The corresponding SuperSettings value type.
49
+ def super_settings_value_type(type)
50
+ case type.to_sym
51
+ when :symbol then "string"
52
+ else type.to_s
53
+ end
54
+ end
55
+
56
+ # Look up a translation key for the current locale.
57
+ #
58
+ # @param key [String] dotted translation key
59
+ # @return [String]
60
+ def t(key)
61
+ UltraSettings::MiniI18n.t(key, locale: @locale)
62
+ end
63
+
43
64
  def display_value(value)
44
65
  case value
45
66
  when Time
@@ -49,35 +70,9 @@ module UltraSettings
49
70
  end
50
71
  end
51
72
 
52
- def show_defined_value(label, value, secret)
53
- val = nil
54
- icon = nil
55
- css_class = nil
56
-
57
- if value.nil?
58
- val = "Not set"
59
- icon = not_set_icon
60
- css_class = "ultra-settings-icon-not-set"
61
- elsif secret
62
- val = secret_value(value)
63
- icon = lock_icon
64
- css_class = "ultra-settings-icon-secret"
65
- else
66
- val = display_value(value)
67
- icon = eye_icon
68
- css_class = "ultra-settings-icon-info"
69
- end
70
-
71
- <<~HTML
72
- <dfn class="#{css_class}" title="#{html_escape(val)}" onclick="#{html_escape(open_dialog_script)}" data-label="#{html_escape(label)}">
73
- #{icon}
74
- </dfn>
75
- HTML
76
- end
77
-
78
73
  def secret_value(value)
79
74
  if value.nil?
80
- "nil"
75
+ t("field.nil")
81
76
  else
82
77
  "••••••••••••••••"
83
78
  end
@@ -92,22 +87,79 @@ module UltraSettings
92
87
  path.relative_path_from(root_path)
93
88
  end
94
89
 
95
- def info_icon(size = 16)
96
- <<~HTML
97
- <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
98
- <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
99
- <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
100
- </svg>
101
- HTML
90
+ def source_chip_label(source)
91
+ case source
92
+ when :env then t("source.env")
93
+ when :settings then t("source.setting")
94
+ when :yaml then t("source.yaml")
95
+ when :default then t("source.default")
96
+ else source.to_s.upcase
97
+ end
102
98
  end
103
99
 
104
- def not_set_icon(size = 16)
105
- <<~HTML
106
- <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
107
- <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
108
- <path d="M11.354 4.646a.5.5 0 0 0-.708 0l-6 6a.5.5 0 0 0 .708.708l6-6a.5.5 0 0 0 0-.708"/>
109
- </svg>
110
- HTML
100
+ def source_chip_class(source)
101
+ case source
102
+ when :env then "ultra-settings-chip-env"
103
+ when :settings then "ultra-settings-chip-setting"
104
+ when :yaml then "ultra-settings-chip-yaml"
105
+ when :default then "ultra-settings-chip-default"
106
+ else "ultra-settings-chip-default"
107
+ end
108
+ end
109
+
110
+ def source_key_name(field, source)
111
+ case source
112
+ when :env then field.env_var
113
+ when :settings then field.runtime_setting
114
+ when :yaml then field.yaml_key
115
+ when :default then nil
116
+ end
117
+ end
118
+
119
+ def open_panel_script
120
+ <<~JAVASCRIPT.gsub(/\s+/, " ").tr('"', "'")
121
+ var el = this;
122
+ var panel = document.getElementById('ultra-settings-detail-panel');
123
+ if (panel) {
124
+ var name = el.dataset.name || '';
125
+ var value = el.dataset.value || '';
126
+ var type = el.dataset.type || '';
127
+ var isSecret = el.dataset.secret || 'false';
128
+ document.getElementById('ultra-settings-dp-title').textContent = name;
129
+ document.getElementById('ultra-settings-dp-value').textContent = isSecret === 'true' ? window.__ultraSettingsI18n['detail.secret_value'] : value;
130
+ document.getElementById('ultra-settings-dp-meta').innerHTML = window.__ultraSettingsI18n['detail.type_label'] + ' <span>' + type.toUpperCase() + '</span>' + (isSecret === 'true' ? ' \u00B7 <span style=color:var(--badge-secret-text)>' + window.__ultraSettingsI18n['detail.secret_badge'] + '</span>' : '');
131
+ document.getElementById('ultra-settings-panel-bg').classList.add('open');
132
+ panel.classList.add('open');
133
+ } else {
134
+ var block = el.closest('.ultra-settings-block');
135
+ if (block) {
136
+ var dialog = block.querySelector('.ultra-settings-dialog');
137
+ if (dialog) {
138
+ var title = dialog.querySelector('.ultra-settings-dialog-title');
139
+ var val = dialog.querySelector('.ultra-settings-dialog-value');
140
+ if (title) title.textContent = el.dataset.name || '';
141
+ if (val) val.textContent = el.dataset.value || '';
142
+ dialog.showModal();
143
+ }
144
+ }
145
+ }
146
+ JAVASCRIPT
147
+ end
148
+
149
+ def source_priority
150
+ [:env, :settings, :yaml, :default]
151
+ end
152
+
153
+ def source_overridden_by(current_source, active_source)
154
+ return nil if current_source == active_source
155
+
156
+ current_index = source_priority.index(current_source)
157
+ active_index = source_priority.index(active_source)
158
+
159
+ return nil if current_index.nil? || active_index.nil?
160
+ return nil if current_index < active_index
161
+
162
+ active_source
111
163
  end
112
164
 
113
165
  def lock_icon(size = 16)
@@ -118,77 +170,47 @@ module UltraSettings
118
170
  HTML
119
171
  end
120
172
 
121
- def edit_icon(size = 16)
173
+ def pin_icon(size = 16)
122
174
  <<~HTML
123
175
  <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
124
- <path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
125
- <path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z"/>
176
+ <path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5a.5.5 0 0 1-1 0V10h-4A.5.5 0 0 1 3 9.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354"/>
126
177
  </svg>
127
178
  HTML
128
179
  end
129
180
 
130
- def eye_icon(size = 16)
181
+ def file_icon(size = 13)
131
182
  <<~HTML
132
- <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
133
- <path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8M1.173 8a13 13 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5s3.879 1.168 5.168 2.457A13 13 0 0 1 14.828 8q-.086.13-.195.288c-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5s-3.879-1.168-5.168-2.457A13 13 0 0 1 1.172 8z"/>
134
- <path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0"/>
183
+ <svg width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
184
+ <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
185
+ <polyline points="13 2 13 9 20 9"/>
135
186
  </svg>
136
187
  HTML
137
188
  end
138
189
 
139
- def close_icon(size = 16)
190
+ def edit_icon(size = 16)
140
191
  <<~HTML
141
192
  <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
142
- <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
143
- <path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708"/>
193
+ <path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
194
+ <path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z"/>
144
195
  </svg>
145
196
  HTML
146
197
  end
147
198
 
148
- def open_dialog_script
149
- <<~JAVASCRIPT.gsub(/\s+/, " ").tr('"', "'")
150
- this.closest('.ultra-settings-configuration').querySelector('.ultra-settings-dialog-title').textContent = this.dataset.label;
151
- this.closest('.ultra-settings-configuration').querySelector('.ultra-settings-dialog-value').textContent = this.title;
152
- this.closest('.ultra-settings-configuration').querySelector('.ultra-settings-dialog').showModal();
153
- this.closest('.ultra-settings-configuration').querySelector('.ultra-settings-dialog-close').blur();
154
- JAVASCRIPT
155
- end
156
-
157
- def source_priority
158
- [:env, :settings, :yaml, :default]
159
- end
160
-
161
- def source_overridden_by(current_source, active_source)
162
- return nil if current_source == active_source
163
-
164
- current_index = source_priority.index(current_source)
165
- active_index = source_priority.index(active_source)
166
-
167
- return nil if current_index.nil? || active_index.nil?
168
- return nil if current_index < active_index
169
-
170
- active_source
171
- end
172
-
173
- def override_indicator(overridden_by_source)
174
- source_names = {
175
- env: "environment variable",
176
- settings: "runtime setting",
177
- yaml: "configuration file",
178
- default: "default value"
179
- }
199
+ def close_icon(size = 16)
180
200
  <<~HTML
181
- <span class="ultra-settings-source-override" title="Overridden by #{source_names[overridden_by_source]}">
182
- #{warning_icon(14)}
183
- <span class="ultra-settings-source-override-text">Overridden by #{source_names[overridden_by_source]}</span>
184
- </span>
201
+ <svg width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
202
+ <line x1="18" y1="6" x2="6" y2="18"/>
203
+ <line x1="6" y1="6" x2="18" y2="18"/>
204
+ </svg>
185
205
  HTML
186
206
  end
187
207
 
188
208
  def warning_icon(size = 16)
189
209
  <<~HTML
190
- <svg width="#{size}" height="#{size}" fill="currentColor" viewBox="0 0 16 16">
191
- <path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
210
+ <svg width="#{size}" height="#{size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
211
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke-width="2" stroke-linejoin="round"/>
212
+ <line x1="12" y1="10" x2="12" y2="14" stroke-width="2.5" stroke-linecap="round"/>
213
+ <circle cx="12" cy="17" r="1" fill="currentColor"/>
192
214
  </svg>
193
215
  HTML
194
216
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module UltraSettings
6
+ # Internationalization support for the web UI. Translations are stored as
7
+ # JSON files in +app/locales/+ with one file per locale (e.g. +en.json+).
8
+ #
9
+ # Ruby templates use `#t` to look up a dotted key. JavaScript receives the
10
+ # full translation hash inlined via a +<script>+ tag so that the same JSON
11
+ # file drives both server-side and client-side strings.
12
+ module MiniI18n
13
+ DEFAULT_LOCALE = "en"
14
+
15
+ @cache = {}
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ # Return the list of available locale codes derived from the JSON files
20
+ # present in +app/locales/+.
21
+ #
22
+ # @return [Array<String>] locale codes, e.g. +["en"]+
23
+ def available_locales
24
+ load_all_locales.keys.sort
25
+ end
26
+
27
+ # Look up a translation by dotted key for the given locale.
28
+ # Falls back to the default locale when the key is missing, and
29
+ # ultimately returns the key itself if no translation is found.
30
+ #
31
+ # @param key [String] dotted translation key, e.g. +"page.title"+
32
+ # @param locale [String] the locale code (default: +DEFAULT_LOCALE+)
33
+ # @return [String] the translated string
34
+ def t(key, locale: DEFAULT_LOCALE)
35
+ translations = translations_for(locale)
36
+ translations[key] || translations_for(DEFAULT_LOCALE)[key] || key
37
+ end
38
+
39
+ # Return the full translation hash for a locale. Used to inline
40
+ # translations into the HTML page for JavaScript consumption.
41
+ #
42
+ # @param locale [String] the locale code
43
+ # @return [Hash<String, String>] all key/value translations
44
+ def translations_for(locale)
45
+ load_all_locales[normalize_locale(locale)] || load_all_locales[DEFAULT_LOCALE] || {}
46
+ end
47
+
48
+ # Return the text direction for the given locale. Reads the +"dir"+ key
49
+ # from the locale's translations hash, falling back to +"ltr"+ when not set.
50
+ #
51
+ # @param locale [String] the locale code
52
+ # @return [String] +"ltr"+ or +"rtl"+
53
+ def text_direction(locale = DEFAULT_LOCALE)
54
+ dir = translations_for(locale)["dir"]
55
+ (dir == "rtl") ? "rtl" : "ltr"
56
+ end
57
+
58
+ # Clear the translation cache. Called automatically in development mode.
59
+ #
60
+ # @return [void]
61
+ def clear_cache!
62
+ @mutex.synchronize { @cache = {} }
63
+ end
64
+
65
+ private
66
+
67
+ # Normalize a locale string to just the language subtag if the full
68
+ # tag is not available (e.g. "en-US" → "en").
69
+ def normalize_locale(locale)
70
+ locale = locale.to_s.strip.tr("_", "-").downcase
71
+ return locale if @cache.key?(locale)
72
+
73
+ # Try the language subtag (e.g. "en-us" → "en")
74
+ lang = locale.split("-").first
75
+ lang if @cache.key?(lang)
76
+ end
77
+
78
+ # Load every JSON file from the locales directory, keyed by filename
79
+ # stem (e.g. "en").
80
+ def load_all_locales
81
+ if development_mode?
82
+ @mutex.synchronize { @cache = {} }
83
+ end
84
+
85
+ return @cache unless @cache.empty?
86
+
87
+ @mutex.synchronize do
88
+ return @cache unless @cache.empty?
89
+
90
+ Dir.glob(File.join(locales_dir, "*.json")).each do |path|
91
+ code = File.basename(path, ".json").downcase
92
+ @cache[code] = JSON.parse(File.read(path))
93
+ rescue JSON::ParserError
94
+ # Skip malformed locale files
95
+ end
96
+ end
97
+
98
+ @cache
99
+ end
100
+
101
+ def locales_dir
102
+ File.expand_path(File.join("..", "..", "app", "locales"), __dir__)
103
+ end
104
+
105
+ def development_mode?
106
+ ENV.fetch("RACK_ENV", "development") == "development"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -18,7 +18,9 @@ module UltraSettings
18
18
  # @param env [Hash] The Rack environment.
19
19
  # @return [Array] A Rack response array [status, headers, body].
20
20
  def call(env)
21
- [200, {"content-type" => "text/html; charset=utf8"}, [webview.render_settings]]
21
+ request = Rack::Request.new(env)
22
+ locale = resolve_locale(request)
23
+ [200, {"content-type" => "text/html; charset=utf8"}, [webview.render_settings(request, locale: locale)]]
22
24
  end
23
25
 
24
26
  private
@@ -29,5 +31,53 @@ module UltraSettings
29
31
  end
30
32
  @webview ||= WebView.new(color_scheme: @color_scheme)
31
33
  end
34
+
35
+ # Determine the locale for a request. Precedence:
36
+ # 1. ?lang= query parameter
37
+ # 2. ultra_settings_locale cookie (set by the language picker)
38
+ # 3. Accept-Language header
39
+ # 4. Default locale
40
+ def resolve_locale(request)
41
+ available = UltraSettings::MiniI18n.available_locales
42
+
43
+ # 1. Explicit query parameter
44
+ lang = request.params["lang"] if request.respond_to?(:params)
45
+ return lang if lang && available.include?(lang)
46
+
47
+ # 2. Cookie
48
+ cookie = request.cookies["ultra_settings_locale"] if request.respond_to?(:cookies)
49
+ return cookie if cookie && available.include?(cookie)
50
+
51
+ # 3. Accept-Language header
52
+ accept = request.env["HTTP_ACCEPT_LANGUAGE"] if request.respond_to?(:env)
53
+ locale_from_accept_language(accept.to_s, available) || UltraSettings::MiniI18n::DEFAULT_LOCALE
54
+ end
55
+
56
+ # Parse the Accept-Language header and return the best matching locale.
57
+ def locale_from_accept_language(header, available)
58
+ return nil if header.nil? || header.empty?
59
+
60
+ # Parse tags with optional quality values, e.g. "en-US,en;q=0.9,fr;q=0.8"
61
+ tags = header.split(",").map { |entry|
62
+ parts = entry.strip.split(";")
63
+ tag = parts[0].to_s.strip.downcase.tr("_", "-")
64
+ q = 1.0
65
+ parts[1..-1].each do |p|
66
+ if p.strip.start_with?("q=")
67
+ q = p.strip.sub("q=", "").to_f
68
+ end
69
+ end
70
+ [tag, q]
71
+ }.sort_by { |_, q| -q }
72
+
73
+ tags.each do |tag, _|
74
+ return tag if available.include?(tag)
75
+ # Try language subtag
76
+ lang = tag.split("-").first
77
+ return lang if available.include?(lang)
78
+ end
79
+
80
+ nil
81
+ end
32
82
  end
33
83
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UltraSettings
4
- VERSION = File.read(File.join(__dir__, "..", "..", "VERSION")).strip
4
+ VERSION = File.read(File.expand_path("../../VERSION", __dir__)).strip.freeze
5
5
  end
@@ -3,6 +3,8 @@
3
3
  module UltraSettings
4
4
  # Helper class for rendering the settings information in an HTML page.
5
5
  class WebView
6
+ include RenderHelper
7
+
6
8
  attr_reader :layout_css
7
9
 
8
10
  # Initialize a new WebView with the specified color scheme.
@@ -17,8 +19,13 @@ module UltraSettings
17
19
 
18
20
  # Render the complete settings page HTML.
19
21
  #
22
+ # @param request [Rack::Request, nil] The current Rack request for access control.
23
+ # @param locale [String] The locale code for translations.
20
24
  # @return [String] The rendered HTML page.
21
- def render_settings
25
+ def render_settings(request = nil, locale: UltraSettings::MiniI18n::DEFAULT_LOCALE)
26
+ @request = request
27
+ @locale = locale
28
+ refresh_super_settings!
22
29
  @layout_template.result(binding)
23
30
  end
24
31
 
@@ -26,7 +33,25 @@ module UltraSettings
26
33
  #
27
34
  # @return [String] The HTML content for the settings.
28
35
  def content
29
- UltraSettings::ApplicationView.new(color_scheme: @color_scheme).render
36
+ UltraSettings::ApplicationView.new(
37
+ color_scheme: @color_scheme,
38
+ locale: @locale || UltraSettings::MiniI18n::DEFAULT_LOCALE
39
+ ).render
40
+ end
41
+
42
+ # Look up a translation key for the current locale.
43
+ #
44
+ # @param key [String] dotted translation key
45
+ # @return [String]
46
+ def t(key)
47
+ UltraSettings::MiniI18n.t(key, locale: @locale || UltraSettings::MiniI18n::DEFAULT_LOCALE)
48
+ end
49
+
50
+ # Return the text direction (+"ltr"+ or +"rtl"+) for the current locale.
51
+ #
52
+ # @return [String]
53
+ def text_direction
54
+ UltraSettings::MiniI18n.text_direction(@locale || UltraSettings::MiniI18n::DEFAULT_LOCALE)
30
55
  end
31
56
 
32
57
  private
@@ -36,5 +61,11 @@ module UltraSettings
36
61
  css = ViewHelper.read_app_file("layout.css")
37
62
  "#{vars}\n#{css}"
38
63
  end
64
+
65
+ def refresh_super_settings!
66
+ return unless defined?(SuperSettings) && UltraSettings.__runtime_settings__ == SuperSettings
67
+
68
+ SuperSettings.refresh_settings
69
+ end
39
70
  end
40
71
  end