rsb-settings 0.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.
- checksums.yaml +7 -0
- data/LICENSE +15 -0
- data/README.md +71 -0
- data/Rakefile +25 -0
- data/app/models/rsb/settings/setting.rb +26 -0
- data/db/migrate/20260208000001_create_rsb_settings_settings.rb +15 -0
- data/lib/generators/rsb/settings/install/install_generator.rb +30 -0
- data/lib/generators/rsb/settings/install/templates/initializer.rb +14 -0
- data/lib/rsb/settings/configuration.rb +71 -0
- data/lib/rsb/settings/engine.rb +31 -0
- data/lib/rsb/settings/locale_helper.rb +43 -0
- data/lib/rsb/settings/locale_middleware.rb +168 -0
- data/lib/rsb/settings/registry.rb +89 -0
- data/lib/rsb/settings/resolver.rb +91 -0
- data/lib/rsb/settings/schema.rb +81 -0
- data/lib/rsb/settings/seo_helper.rb +84 -0
- data/lib/rsb/settings/seo_settings_schema.rb +50 -0
- data/lib/rsb/settings/test_helper.rb +58 -0
- data/lib/rsb/settings/validation_error.rb +7 -0
- data/lib/rsb/settings/version.rb +9 -0
- data/lib/rsb/settings.rb +101 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 76cbb39669f95cda57ffe84f51976a7b1abad0490f0eb10817e1477501883bcf
|
|
4
|
+
data.tar.gz: ef58b6e75169cd038540c1ac6d5ade7b083c65bc7def22f4969027d579e93df3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5aa780a9fb91f29fc9c64ee2c872283e7dd8c3b5c5e1d8ba37a4ba4c063e85bc627374cb2157ace53254927785687f60cdae31dad9a3404e0017a316b9cd6298
|
|
7
|
+
data.tar.gz: 37ac5f82736660dc1bf7e03111c75e7019dc73e3dbfc8cdf2bb7b0b15abebc1cc78261093e6d0aeba5915e2de785b060276aa22e43baff5ea934af30cce106cd
|
data/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Rails SaaS Builder (RSB)
|
|
2
|
+
Copyright (C) 2026 Aleksandr Marchenko
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU Lesser General Public License as published by
|
|
6
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
(at your option) any later version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU Lesser General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU Lesser General Public License
|
|
15
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
data/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# rsb-settings
|
|
2
|
+
|
|
3
|
+
Foundation gem for Rails SaaS Builder. Provides a dynamic settings system with a schema registry that other gems register with. Settings resolve through a priority chain: database overrides → initializer configuration → environment variables → schema defaults. Includes locale management middleware and SEO helper utilities.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### As part of Rails SaaS Builder
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "rails-saas-builder"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Standalone
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "rsb-settings"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
rails generate rsb_settings:install
|
|
24
|
+
rails db:migrate
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Key Features
|
|
28
|
+
|
|
29
|
+
- Schema-based setting definitions with type validation (string, integer, boolean, float, array, duration)
|
|
30
|
+
- Priority resolution chain: DB → initializer → ENV (`RSB_CATEGORY_KEY`) → default
|
|
31
|
+
- ActiveRecord encryption for sensitive settings
|
|
32
|
+
- Initializer-level locks to prevent runtime changes
|
|
33
|
+
- Change callbacks for reactive updates
|
|
34
|
+
- Grouped settings display for admin UI
|
|
35
|
+
- Locale middleware with cookie/Accept-Language negotiation
|
|
36
|
+
|
|
37
|
+
## Basic Usage
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# Define a settings schema
|
|
41
|
+
RSB::Settings.registry.define("billing") do
|
|
42
|
+
setting :currency, type: :string, default: "usd"
|
|
43
|
+
setting :trial_days, type: :integer, default: 14
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Read settings
|
|
47
|
+
RSB::Settings.get("billing.currency") #=> "usd"
|
|
48
|
+
RSB::Settings.for("billing") #=> { currency: "usd", trial_days: 14 }
|
|
49
|
+
|
|
50
|
+
# Write settings (persists to database)
|
|
51
|
+
RSB::Settings.set("billing.currency", "eur")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
RSB::Settings.configure do |config|
|
|
58
|
+
config.set("billing.currency", "eur") # initializer override
|
|
59
|
+
config.lock("billing.currency") # prevent runtime changes
|
|
60
|
+
config.available_locales = %w[en de fr]
|
|
61
|
+
config.default_locale = "en"
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Documentation
|
|
66
|
+
|
|
67
|
+
Part of [Rails SaaS Builder](../README.md). See the main README for the full picture.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
[LGPL-3.0](../LICENSE)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'rake/testtask'
|
|
6
|
+
|
|
7
|
+
task :prepare_test_db do
|
|
8
|
+
ENV['RAILS_ENV'] = 'test'
|
|
9
|
+
db = File.expand_path('test/dummy/db/test.sqlite3', __dir__)
|
|
10
|
+
FileUtils.rm_f(db)
|
|
11
|
+
require_relative 'test/dummy/config/environment'
|
|
12
|
+
ActiveRecord::Migration.verbose = false
|
|
13
|
+
ActiveRecord::MigrationContext.new(Rails.application.paths['db/migrate'].to_a).migrate
|
|
14
|
+
schema = File.expand_path('test/dummy/db/schema.rb', __dir__)
|
|
15
|
+
File.open(schema, 'w') { |f| ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, f) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Rake::TestTask.new(:test) do |t|
|
|
19
|
+
t.libs << 'test'
|
|
20
|
+
t.pattern = 'test/**/*_test.rb'
|
|
21
|
+
t.verbose = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
task test: :prepare_test_db
|
|
25
|
+
task default: :test
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
class Setting < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'rsb_settings_settings'
|
|
7
|
+
|
|
8
|
+
encrypts :value
|
|
9
|
+
|
|
10
|
+
validates :category, presence: true
|
|
11
|
+
validates :key, presence: true, uniqueness: { scope: :category }
|
|
12
|
+
|
|
13
|
+
scope :for_category, ->(cat) { where(category: cat.to_s) }
|
|
14
|
+
|
|
15
|
+
def self.get(category, key)
|
|
16
|
+
find_by(category: category.to_s, key: key.to_s)&.value
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.set(category, key, value)
|
|
20
|
+
record = find_or_initialize_by(category: category.to_s, key: key.to_s)
|
|
21
|
+
record.update!(value: value.to_s)
|
|
22
|
+
record
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRSBSettingsSettings < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rsb_settings_settings do |t|
|
|
6
|
+
t.string :category, null: false
|
|
7
|
+
t.string :key, null: false
|
|
8
|
+
t.string :value
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :rsb_settings_settings, %i[category key], unique: true
|
|
13
|
+
add_index :rsb_settings_settings, :category
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
namespace 'rsb:settings:install'
|
|
7
|
+
source_root File.expand_path('templates', __dir__)
|
|
8
|
+
|
|
9
|
+
desc 'Install rsb-settings: copy migrations, create initializer.'
|
|
10
|
+
|
|
11
|
+
def copy_migrations
|
|
12
|
+
rake 'rsb_settings:install:migrations'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create_initializer
|
|
16
|
+
template 'initializer.rb', 'config/initializers/rsb_settings.rb'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def print_post_install
|
|
20
|
+
say ''
|
|
21
|
+
say 'rsb-settings installed successfully!', :green
|
|
22
|
+
say ''
|
|
23
|
+
say 'Next steps:'
|
|
24
|
+
say ' 1. rails db:migrate'
|
|
25
|
+
say ' 2. Settings are ready — other RSB gems will register their schemas automatically.'
|
|
26
|
+
say ''
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RSB Settings configuration
|
|
4
|
+
# See: https://github.com/Rails-SaaS-Builder/rails-saas-builder
|
|
5
|
+
|
|
6
|
+
RSB::Settings.configure do |config|
|
|
7
|
+
# Lock settings so they're visible in admin but not editable:
|
|
8
|
+
# config.lock "auth.registration_mode"
|
|
9
|
+
# config.lock "entitlements.default_currency"
|
|
10
|
+
|
|
11
|
+
# Set initializer-level overrides (takes priority over ENV and defaults):
|
|
12
|
+
# config.set "auth.registration_mode", "open"
|
|
13
|
+
# config.set "auth.password_min_length", 10
|
|
14
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
class Configuration
|
|
6
|
+
BUILT_IN_LOCALE_NAMES = {
|
|
7
|
+
'en' => 'English',
|
|
8
|
+
'de' => 'Deutsch',
|
|
9
|
+
'fr' => 'Français',
|
|
10
|
+
'es' => 'Español',
|
|
11
|
+
'pt' => 'Português',
|
|
12
|
+
'it' => 'Italiano',
|
|
13
|
+
'nl' => 'Nederlands',
|
|
14
|
+
'ja' => '日本語',
|
|
15
|
+
'zh' => '中文',
|
|
16
|
+
'ko' => '한국어',
|
|
17
|
+
'ru' => 'Русский',
|
|
18
|
+
'ar' => 'العربية',
|
|
19
|
+
'pl' => 'Polski',
|
|
20
|
+
'sv' => 'Svenska',
|
|
21
|
+
'da' => 'Dansk',
|
|
22
|
+
'nb' => 'Norsk',
|
|
23
|
+
'fi' => 'Suomi',
|
|
24
|
+
'cs' => 'Čeština',
|
|
25
|
+
'tr' => 'Türkçe',
|
|
26
|
+
'uk' => 'Українська'
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
attr_accessor :available_locales, :default_locale
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@overrides = {} # { "auth.registration_mode" => "open" }
|
|
33
|
+
@locks = Set.new # { "auth.registration_mode" }
|
|
34
|
+
@available_locales = ['en']
|
|
35
|
+
@default_locale = 'en'
|
|
36
|
+
@custom_locale_display_names = {}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def locale_display_names=(names)
|
|
40
|
+
@custom_locale_display_names = names || {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def locale_display_names
|
|
44
|
+
BUILT_IN_LOCALE_NAMES.merge(@custom_locale_display_names)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Set an initializer-level override
|
|
48
|
+
def set(key, value)
|
|
49
|
+
@overrides[key.to_s] = value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Lock a setting (visible in admin but not editable)
|
|
53
|
+
def lock(key)
|
|
54
|
+
@locks << key.to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def locked?(key)
|
|
58
|
+
@locks.include?(key.to_s)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def locked_keys
|
|
62
|
+
@locks.to_a
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Read initializer override for resolver
|
|
66
|
+
def initializer_value(category, key)
|
|
67
|
+
@overrides["#{category}.#{key}"]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace RSB::Settings
|
|
7
|
+
|
|
8
|
+
# Insert locale middleware into the Rails middleware stack.
|
|
9
|
+
# Applies to all requests (host app + all engines).
|
|
10
|
+
# Host app can remove: config.middleware.delete RSB::Settings::LocaleMiddleware
|
|
11
|
+
initializer 'rsb_settings.locale_middleware' do |app|
|
|
12
|
+
app.middleware.use RSB::Settings::LocaleMiddleware
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Signal readiness — downstream gems hook in after this
|
|
16
|
+
initializer 'rsb_settings.ready' do
|
|
17
|
+
# Registry is available. Other gems can now register schemas.
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer 'rsb_settings.register_seo_settings', after: 'rsb_settings.ready' do
|
|
21
|
+
RSB::Settings.registry.register(RSB::Settings::SeoSettingsSchema.build)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
config.generators do |g|
|
|
25
|
+
g.test_framework :minitest, fixture: false
|
|
26
|
+
g.assets false
|
|
27
|
+
g.helper false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
module LocaleHelper
|
|
6
|
+
def rsb_available_locales
|
|
7
|
+
RSB::Settings.available_locales
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def rsb_current_locale
|
|
11
|
+
I18n.locale.to_s
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rsb_locale_display_name(code)
|
|
15
|
+
RSB::Settings.locale_display_name(code)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rsb_locale_switcher(current_path: nil)
|
|
19
|
+
locales = RSB::Settings.available_locales
|
|
20
|
+
return ''.html_safe if locales.size <= 1
|
|
21
|
+
|
|
22
|
+
path = current_path || (respond_to?(:request) ? request.fullpath : '/')
|
|
23
|
+
current = rsb_current_locale
|
|
24
|
+
|
|
25
|
+
parts = locales.map do |loc|
|
|
26
|
+
if loc == current
|
|
27
|
+
%(<span class="rsb-locale-current">#{ERB::Util.html_escape(RSB::Settings.locale_display_name(loc))}</span>)
|
|
28
|
+
else
|
|
29
|
+
<<~HTML.strip
|
|
30
|
+
<form action="/rsb/locale" method="post" style="display:inline">
|
|
31
|
+
<input type="hidden" name="locale" value="#{ERB::Util.html_escape(loc)}">
|
|
32
|
+
<input type="hidden" name="redirect_to" value="#{ERB::Util.html_escape(path)}">
|
|
33
|
+
<button type="submit" class="rsb-locale-link">#{ERB::Util.html_escape(RSB::Settings.locale_display_name(loc))}</button>
|
|
34
|
+
</form>
|
|
35
|
+
HTML
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
%(<nav class="rsb-locale-switcher">#{parts.join(' ')}</nav>).html_safe
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack/request'
|
|
4
|
+
require 'rack/response'
|
|
5
|
+
|
|
6
|
+
module RSB
|
|
7
|
+
module Settings
|
|
8
|
+
# Rack middleware that resolves locale from cookie, Accept-Language header,
|
|
9
|
+
# or configured default. Also handles POST /rsb/locale to set the locale cookie.
|
|
10
|
+
#
|
|
11
|
+
# Resolution chain (highest priority first):
|
|
12
|
+
# 1. Cookie (rsb_locale)
|
|
13
|
+
# 2. Accept-Language header (best match from available_locales)
|
|
14
|
+
# 3. Configured default_locale
|
|
15
|
+
#
|
|
16
|
+
# The middleware auto-inserts into the Rails middleware stack via the
|
|
17
|
+
# rsb_settings engine initializer. Applies to all requests (host app + engines).
|
|
18
|
+
#
|
|
19
|
+
# @example Remove from host app if not wanted
|
|
20
|
+
# config.middleware.delete RSB::Settings::LocaleMiddleware
|
|
21
|
+
#
|
|
22
|
+
class LocaleMiddleware
|
|
23
|
+
COOKIE_NAME = 'rsb_locale'
|
|
24
|
+
COOKIE_MAX_AGE = 31_536_000 # 1 year in seconds
|
|
25
|
+
LOCALE_PATH = '/rsb/locale'
|
|
26
|
+
|
|
27
|
+
def initialize(app)
|
|
28
|
+
@app = app
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param env [Hash] Rack environment
|
|
32
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
33
|
+
def call(env)
|
|
34
|
+
request = Rack::Request.new(env)
|
|
35
|
+
|
|
36
|
+
# Handle locale switching endpoint
|
|
37
|
+
return handle_locale_change(request) if request.post? && request.path_info == LOCALE_PATH
|
|
38
|
+
|
|
39
|
+
# Resolve and set locale for this request
|
|
40
|
+
locale = resolve_locale(request)
|
|
41
|
+
I18n.locale = locale
|
|
42
|
+
env['rsb.locale'] = locale.to_s
|
|
43
|
+
|
|
44
|
+
@app.call(env)
|
|
45
|
+
ensure
|
|
46
|
+
I18n.locale = I18n.default_locale
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Handles POST /rsb/locale: validates locale, sets cookie, redirects back.
|
|
52
|
+
#
|
|
53
|
+
# @param request [Rack::Request]
|
|
54
|
+
# @return [Array] Rack response tuple
|
|
55
|
+
def handle_locale_change(request)
|
|
56
|
+
locale = request.params['locale'].to_s.strip
|
|
57
|
+
available = RSB::Settings.available_locales
|
|
58
|
+
|
|
59
|
+
# Empty locale = redirect without setting cookie
|
|
60
|
+
return redirect_response(redirect_path(request)) if locale.empty?
|
|
61
|
+
|
|
62
|
+
# Invalid locale falls back to default
|
|
63
|
+
locale = RSB::Settings.default_locale unless available.include?(locale)
|
|
64
|
+
|
|
65
|
+
response = Rack::Response.new
|
|
66
|
+
response.set_cookie(COOKIE_NAME, {
|
|
67
|
+
value: locale,
|
|
68
|
+
path: '/',
|
|
69
|
+
max_age: COOKIE_MAX_AGE,
|
|
70
|
+
same_site: :lax,
|
|
71
|
+
httponly: false
|
|
72
|
+
})
|
|
73
|
+
response.redirect(redirect_path(request))
|
|
74
|
+
response.finish
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Resolves locale from cookie, Accept-Language, or default.
|
|
78
|
+
#
|
|
79
|
+
# @param request [Rack::Request]
|
|
80
|
+
# @return [String] resolved locale code
|
|
81
|
+
def resolve_locale(request)
|
|
82
|
+
available = RSB::Settings.available_locales
|
|
83
|
+
default = RSB::Settings.default_locale
|
|
84
|
+
|
|
85
|
+
# 1. Cookie
|
|
86
|
+
cookie_locale = request.cookies[COOKIE_NAME].to_s.strip
|
|
87
|
+
return cookie_locale if cookie_locale.present? && available.include?(cookie_locale)
|
|
88
|
+
|
|
89
|
+
# 2. Accept-Language header
|
|
90
|
+
accept_locale = parse_accept_language(
|
|
91
|
+
request.env['HTTP_ACCEPT_LANGUAGE'],
|
|
92
|
+
available
|
|
93
|
+
)
|
|
94
|
+
return accept_locale if accept_locale
|
|
95
|
+
|
|
96
|
+
# 3. Default
|
|
97
|
+
default
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Parses the Accept-Language header and returns the best match.
|
|
101
|
+
#
|
|
102
|
+
# @param header [String, nil] Accept-Language header value
|
|
103
|
+
# @param available [Array<String>] available locale codes
|
|
104
|
+
# @return [String, nil] best matching locale or nil
|
|
105
|
+
def parse_accept_language(header, available)
|
|
106
|
+
return nil if header.nil? || header.empty?
|
|
107
|
+
|
|
108
|
+
locales = header.split(',').filter_map do |part|
|
|
109
|
+
part = part.strip
|
|
110
|
+
next if part.empty?
|
|
111
|
+
|
|
112
|
+
lang, quality_str = part.split(';', 2)
|
|
113
|
+
lang = lang.to_s.split('-').first.to_s.downcase.strip
|
|
114
|
+
next if lang.empty?
|
|
115
|
+
|
|
116
|
+
quality = if quality_str
|
|
117
|
+
q_match = quality_str.match(/q\s*=\s*([\d.]+)/)
|
|
118
|
+
q_match ? q_match[1].to_f : 1.0
|
|
119
|
+
else
|
|
120
|
+
1.0
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
[lang, quality]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
locales
|
|
127
|
+
.sort_by { |_, q| -q }
|
|
128
|
+
.each { |lang, _| return lang if available.include?(lang) }
|
|
129
|
+
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Determines a safe redirect path from request params or Referer.
|
|
134
|
+
#
|
|
135
|
+
# @param request [Rack::Request]
|
|
136
|
+
# @return [String] safe redirect path (always starts with "/")
|
|
137
|
+
def redirect_path(request)
|
|
138
|
+
# 1. redirect_to param
|
|
139
|
+
redirect_to = request.params['redirect_to'].to_s
|
|
140
|
+
return redirect_to if redirect_to.start_with?('/') && !redirect_to.start_with?('//')
|
|
141
|
+
|
|
142
|
+
# 2. Referer header (extract path only)
|
|
143
|
+
if request.referrer.present?
|
|
144
|
+
begin
|
|
145
|
+
uri = URI.parse(request.referrer)
|
|
146
|
+
path = uri.path.to_s
|
|
147
|
+
return path if path.start_with?('/')
|
|
148
|
+
rescue URI::InvalidURIError
|
|
149
|
+
# fall through
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# 3. Fallback
|
|
154
|
+
'/'
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Creates a simple redirect response without setting a cookie.
|
|
158
|
+
#
|
|
159
|
+
# @param path [String] redirect target
|
|
160
|
+
# @return [Array] Rack response tuple
|
|
161
|
+
def redirect_response(path)
|
|
162
|
+
response = Rack::Response.new
|
|
163
|
+
response.redirect(path)
|
|
164
|
+
response.finish
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
class Registry
|
|
6
|
+
attr_reader :schemas, :change_callbacks
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@schemas = {}
|
|
10
|
+
@change_callbacks = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Register a schema (from a gem's pure-data definition)
|
|
14
|
+
def register(schema)
|
|
15
|
+
raise ArgumentError, "Expected RSB::Settings::Schema, got #{schema.class}" unless schema.is_a?(Schema)
|
|
16
|
+
|
|
17
|
+
@schemas[schema.category] = if @schemas[schema.category]
|
|
18
|
+
@schemas[schema.category].merge(schema)
|
|
19
|
+
else
|
|
20
|
+
schema
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Convenience: define and register in one step
|
|
25
|
+
def define(category, &block)
|
|
26
|
+
schema = Schema.new(category, &block)
|
|
27
|
+
register(schema)
|
|
28
|
+
schema
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Query
|
|
32
|
+
def for(category)
|
|
33
|
+
@schemas[category.to_s]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def all
|
|
37
|
+
@schemas.values
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def categories
|
|
41
|
+
@schemas.keys
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_definition(key)
|
|
45
|
+
category, setting_key = key.to_s.split('.', 2)
|
|
46
|
+
@schemas[category]&.find(setting_key)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns definitions for a category grouped by the `group` field.
|
|
50
|
+
# Settings with a nil group are placed under "General".
|
|
51
|
+
# "General" always appears first if present, followed by other groups
|
|
52
|
+
# in the order their first setting was registered.
|
|
53
|
+
#
|
|
54
|
+
# @param category [String] the settings category name
|
|
55
|
+
# @return [Hash<String, Array<SettingDefinition>>] ordered hash of group name to definitions
|
|
56
|
+
# @example
|
|
57
|
+
# registry.grouped_definitions("auth")
|
|
58
|
+
# # => { "General" => [defn1], "Session & Security" => [defn2, defn3], "Registration" => [defn4] }
|
|
59
|
+
def grouped_definitions(category)
|
|
60
|
+
schema = @schemas[category.to_s]
|
|
61
|
+
return {} unless schema
|
|
62
|
+
|
|
63
|
+
groups = {}
|
|
64
|
+
schema.definitions.each do |defn|
|
|
65
|
+
group_name = defn.group || 'General'
|
|
66
|
+
groups[group_name] ||= []
|
|
67
|
+
groups[group_name] << defn
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Ensure "General" is first if present
|
|
71
|
+
return groups unless groups.key?('General') && groups.keys.first != 'General'
|
|
72
|
+
|
|
73
|
+
general = groups.delete('General')
|
|
74
|
+
{ 'General' => general }.merge(groups)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Change callbacks
|
|
78
|
+
def on_change(key, &block)
|
|
79
|
+
@change_callbacks[key.to_s] ||= []
|
|
80
|
+
@change_callbacks[key.to_s] << block
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fire_change(key, old_value, new_value)
|
|
84
|
+
callbacks = @change_callbacks[key.to_s] || []
|
|
85
|
+
callbacks.each { |cb| cb.call(old_value, new_value) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
class Resolver
|
|
6
|
+
def initialize(registry:, configuration:)
|
|
7
|
+
@registry = registry
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
@cache = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def get(category, key)
|
|
13
|
+
cache_key = "#{category}.#{key}"
|
|
14
|
+
return @cache[cache_key] if @cache.key?(cache_key)
|
|
15
|
+
|
|
16
|
+
value = resolve(category, key)
|
|
17
|
+
@cache[cache_key] = value
|
|
18
|
+
value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def set(category, key, value)
|
|
22
|
+
full_key = "#{category}.#{key}"
|
|
23
|
+
old_value = get(category, key)
|
|
24
|
+
new_value = value
|
|
25
|
+
|
|
26
|
+
Setting.transaction do
|
|
27
|
+
Setting.set(category, key, value)
|
|
28
|
+
invalidate(category, key)
|
|
29
|
+
@registry.fire_change(full_key, old_value, new_value)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
new_value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def for(category)
|
|
36
|
+
schema = @registry.for(category)
|
|
37
|
+
return {} unless schema
|
|
38
|
+
|
|
39
|
+
schema.definitions.each_with_object({}) do |defn, hash|
|
|
40
|
+
hash[defn.key] = get(category, defn.key.to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def invalidate(category = nil, key = nil)
|
|
45
|
+
if category && key
|
|
46
|
+
@cache.delete("#{category}.#{key}")
|
|
47
|
+
else
|
|
48
|
+
@cache.clear
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def resolve(category, key)
|
|
55
|
+
definition = @registry.find_definition("#{category}.#{key}")
|
|
56
|
+
|
|
57
|
+
# 1. Database (admin panel / runtime override)
|
|
58
|
+
db_value = Setting.get(category, key)
|
|
59
|
+
return cast(db_value, definition) if db_value.present?
|
|
60
|
+
|
|
61
|
+
# 2. Initializer (code-level config overrides)
|
|
62
|
+
init_value = @configuration.initializer_value(category, key)
|
|
63
|
+
return init_value unless init_value.nil?
|
|
64
|
+
|
|
65
|
+
# 3. Environment variable (RSB_AUTH_REGISTRATION_MODE)
|
|
66
|
+
env_key = "RSB_#{category.to_s.upcase}_#{key.to_s.upcase}"
|
|
67
|
+
env_value = ENV[env_key]
|
|
68
|
+
return cast(env_value, definition) if env_value.present?
|
|
69
|
+
|
|
70
|
+
# 4. Default (from schema definition)
|
|
71
|
+
definition&.default
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cast(value, definition)
|
|
75
|
+
return value unless definition
|
|
76
|
+
return value unless value.is_a?(String)
|
|
77
|
+
|
|
78
|
+
case definition.type
|
|
79
|
+
when :integer then value.to_i
|
|
80
|
+
when :float then value.to_f
|
|
81
|
+
when :boolean then ActiveModel::Type::Boolean.new.cast(value)
|
|
82
|
+
when :symbol then value.to_sym
|
|
83
|
+
when :string then value.to_s
|
|
84
|
+
when :array then value.is_a?(Array) ? value : value.split(',').map(&:strip)
|
|
85
|
+
when :duration then value.to_i.seconds
|
|
86
|
+
else value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
class Schema
|
|
6
|
+
SettingDefinition = Data.define(
|
|
7
|
+
:key, :type, :default, :description,
|
|
8
|
+
:enum, :validates, :encrypted, :locked,
|
|
9
|
+
:group, :depends_on, :label
|
|
10
|
+
) do
|
|
11
|
+
def initialize(key:, type:, default:, description:, enum:, validates:, encrypted:, locked:, group: nil,
|
|
12
|
+
depends_on: nil, label: nil)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :category, :definitions
|
|
18
|
+
|
|
19
|
+
def initialize(category, &block)
|
|
20
|
+
@category = category.to_s
|
|
21
|
+
@definitions = []
|
|
22
|
+
instance_eval(&block) if block_given?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Define a setting within this schema.
|
|
26
|
+
#
|
|
27
|
+
# @param key [Symbol] setting key within the category
|
|
28
|
+
# @param type [Symbol] value type (:string, :integer, :boolean, :float, :symbol, :array, :duration)
|
|
29
|
+
# @param default [Object] default value (nil if not specified)
|
|
30
|
+
# @param description [String] human-readable description shown in admin UI
|
|
31
|
+
# @param enum [Array, Proc, nil] allowed values (static array or dynamic proc)
|
|
32
|
+
# @param validates [Hash, nil] custom validation rules
|
|
33
|
+
# @param encrypted [Boolean] encrypt value in schema (default: false)
|
|
34
|
+
# @param locked [Boolean] lock by default, preventing runtime changes (default: false)
|
|
35
|
+
# @param group [String, nil] subgroup name for admin UI grouping. nil = "General" group
|
|
36
|
+
# @param depends_on [String, nil] full setting key (e.g., "auth.account_enabled") this setting depends on.
|
|
37
|
+
# When the referenced setting resolves to a falsy value, this setting is rendered disabled in the admin UI.
|
|
38
|
+
# @return [void]
|
|
39
|
+
def setting(key, type:, default: nil, description: '', enum: nil, validates: nil, encrypted: false,
|
|
40
|
+
locked: false, group: nil, depends_on: nil, label: nil)
|
|
41
|
+
@definitions << SettingDefinition.new(
|
|
42
|
+
key: key.to_sym,
|
|
43
|
+
type: type.to_sym,
|
|
44
|
+
default: default,
|
|
45
|
+
description: description,
|
|
46
|
+
enum: enum,
|
|
47
|
+
validates: validates,
|
|
48
|
+
encrypted: encrypted,
|
|
49
|
+
locked: locked,
|
|
50
|
+
group: group,
|
|
51
|
+
depends_on: depends_on,
|
|
52
|
+
label: label
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def keys
|
|
57
|
+
@definitions.map(&:key)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def defaults
|
|
61
|
+
@definitions.each_with_object({}) { |d, h| h[d.key] = d.default }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def find(key)
|
|
65
|
+
@definitions.find { |d| d.key == key.to_sym }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def valid?
|
|
69
|
+
!@category.nil? && !@category.empty? && @definitions.all? { |d| !d.key.nil? && !d.type.nil? }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def merge(other_schema)
|
|
73
|
+
raise ArgumentError, 'Cannot merge schemas from different categories' unless other_schema.category == @category
|
|
74
|
+
|
|
75
|
+
merged = Schema.new(@category)
|
|
76
|
+
merged.instance_variable_set(:@definitions, @definitions + other_schema.definitions)
|
|
77
|
+
merged
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
module SeoHelper
|
|
6
|
+
def rsb_seo_title
|
|
7
|
+
page_title = @rsb_page_title.to_s
|
|
8
|
+
app_name = RSB::Settings.get('seo.app_name').to_s
|
|
9
|
+
|
|
10
|
+
title = if app_name.present? && page_title.present?
|
|
11
|
+
format_str = RSB::Settings.get('seo.title_format').to_s
|
|
12
|
+
format_str.gsub('%<page_title>s', page_title).gsub('%<app_name>s', app_name)
|
|
13
|
+
elsif app_name.present?
|
|
14
|
+
app_name
|
|
15
|
+
else
|
|
16
|
+
page_title
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
"<title>#{ERB::Util.html_escape(title)}</title>".html_safe
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rsb_seo_meta_tags
|
|
23
|
+
tags = []
|
|
24
|
+
tags << rsb_seo_title
|
|
25
|
+
|
|
26
|
+
is_admin = (@rsb_seo_context == :admin)
|
|
27
|
+
|
|
28
|
+
# Meta description (auth only)
|
|
29
|
+
unless is_admin
|
|
30
|
+
description = @rsb_meta_description.to_s
|
|
31
|
+
tags << %(<meta name="description" content="#{ERB::Util.html_escape(description)}" />) if description.present?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Robots
|
|
35
|
+
if is_admin
|
|
36
|
+
tags << '<meta name="robots" content="noindex, nofollow" />'
|
|
37
|
+
elsif RSB::Settings.get('seo.auth_indexable') == false
|
|
38
|
+
tags << '<meta name="robots" content="noindex, nofollow" />'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Open Graph (auth only)
|
|
42
|
+
unless is_admin
|
|
43
|
+
page_title = @rsb_page_title.to_s
|
|
44
|
+
tags << %(<meta property="og:title" content="#{ERB::Util.html_escape(page_title)}" />) if page_title.present?
|
|
45
|
+
|
|
46
|
+
description = @rsb_meta_description.to_s
|
|
47
|
+
if description.present?
|
|
48
|
+
tags << %(<meta property="og:description" content="#{ERB::Util.html_escape(description)}" />)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
tags << '<meta property="og:type" content="website" />'
|
|
52
|
+
|
|
53
|
+
if respond_to?(:request) && request.present?
|
|
54
|
+
canonical = canonical_url
|
|
55
|
+
tags << %(<meta property="og:url" content="#{ERB::Util.html_escape(canonical)}" />)
|
|
56
|
+
tags << %(<link rel="canonical" href="#{ERB::Util.html_escape(canonical)}" />)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
og_image = RSB::Settings.get('seo.og_image_url').to_s
|
|
60
|
+
tags << %(<meta property="og:image" content="#{ERB::Util.html_escape(og_image)}" />) if og_image.present?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
tags.join("\n").html_safe
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def rsb_seo_head_tags
|
|
67
|
+
value = RSB::Settings.get('seo.head_tags').to_s
|
|
68
|
+
value.present? ? value.html_safe : ''
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def rsb_seo_body_tags
|
|
72
|
+
value = RSB::Settings.get('seo.body_tags').to_s
|
|
73
|
+
value.present? ? value.html_safe : ''
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def canonical_url
|
|
79
|
+
uri = URI.parse(request.original_url)
|
|
80
|
+
"#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless [80, 443].include?(uri.port)}#{uri.path}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
class SeoSettingsSchema
|
|
6
|
+
# Build the SEO settings schema for the "seo" category.
|
|
7
|
+
#
|
|
8
|
+
# @return [RSB::Settings::Schema]
|
|
9
|
+
def self.build
|
|
10
|
+
RSB::Settings::Schema.new('seo') do
|
|
11
|
+
setting :app_name,
|
|
12
|
+
type: :string,
|
|
13
|
+
default: '',
|
|
14
|
+
group: 'General',
|
|
15
|
+
description: 'App name used in page title suffix (empty = no suffix)'
|
|
16
|
+
|
|
17
|
+
setting :title_format,
|
|
18
|
+
type: :string,
|
|
19
|
+
default: '%<page_title>s | %<app_name>s',
|
|
20
|
+
group: 'General',
|
|
21
|
+
description: 'Format pattern for <title> tag (use %<page_title>s and %<app_name>s placeholders)'
|
|
22
|
+
|
|
23
|
+
setting :og_image_url,
|
|
24
|
+
type: :string,
|
|
25
|
+
default: '',
|
|
26
|
+
group: 'Open Graph',
|
|
27
|
+
description: 'Default Open Graph image URL for social sharing'
|
|
28
|
+
|
|
29
|
+
setting :auth_indexable,
|
|
30
|
+
type: :boolean,
|
|
31
|
+
default: true,
|
|
32
|
+
group: 'Robots',
|
|
33
|
+
description: 'Allow search engines to index auth pages (login, register, etc.)'
|
|
34
|
+
|
|
35
|
+
setting :head_tags,
|
|
36
|
+
type: :string,
|
|
37
|
+
default: '',
|
|
38
|
+
group: 'Script Injection',
|
|
39
|
+
description: 'HTML to inject in <head> on all RSB pages (analytics, fonts, etc.)'
|
|
40
|
+
|
|
41
|
+
setting :body_tags,
|
|
42
|
+
type: :string,
|
|
43
|
+
default: '',
|
|
44
|
+
group: 'Script Injection',
|
|
45
|
+
description: 'HTML to inject before </body> on all RSB pages'
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Settings
|
|
5
|
+
module TestHelper
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
teardown do
|
|
10
|
+
RSB::Settings.reset!
|
|
11
|
+
RSB::Settings::Setting.delete_all if RSB::Settings::Setting.table_exists?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Temporarily override settings within a block
|
|
16
|
+
def with_settings(overrides = {})
|
|
17
|
+
originals = {}
|
|
18
|
+
overrides.each do |key, value|
|
|
19
|
+
originals[key] = begin
|
|
20
|
+
RSB::Settings.get(key)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
RSB::Settings.set(key, value)
|
|
25
|
+
end
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
originals.each do |key, value|
|
|
29
|
+
category, setting_key = key.to_s.split('.', 2)
|
|
30
|
+
if value.nil?
|
|
31
|
+
RSB::Settings::Setting.find_by(category: category, key: setting_key)&.destroy
|
|
32
|
+
# Invalidate cache so the resolver picks up the deletion
|
|
33
|
+
RSB::Settings.send(:resolver).invalidate(category, setting_key)
|
|
34
|
+
else
|
|
35
|
+
RSB::Settings.set(key, value)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Register a test schema quickly
|
|
41
|
+
def register_test_schema(category, **settings)
|
|
42
|
+
RSB::Settings.registry.define(category) do
|
|
43
|
+
settings.each do |key, default|
|
|
44
|
+
type = case default
|
|
45
|
+
when Integer then :integer
|
|
46
|
+
when true, false then :boolean
|
|
47
|
+
when Float then :float
|
|
48
|
+
when Symbol then :symbol
|
|
49
|
+
when Array then :array
|
|
50
|
+
else :string
|
|
51
|
+
end
|
|
52
|
+
setting key, type: type, default: default
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/rsb/settings.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/inflector'
|
|
4
|
+
ActiveSupport::Inflector.inflections(:en) { |inflect| inflect.acronym 'RSB' }
|
|
5
|
+
|
|
6
|
+
require 'rsb/settings/version'
|
|
7
|
+
require 'rsb/settings/engine'
|
|
8
|
+
require 'rsb/settings/schema'
|
|
9
|
+
require 'rsb/settings/registry'
|
|
10
|
+
require 'rsb/settings/resolver'
|
|
11
|
+
require 'rsb/settings/configuration'
|
|
12
|
+
require 'rsb/settings/validation_error'
|
|
13
|
+
require 'rsb/settings/locale_helper'
|
|
14
|
+
require 'rsb/settings/locale_middleware'
|
|
15
|
+
require 'rsb/settings/seo_settings_schema'
|
|
16
|
+
require 'rsb/settings/seo_helper'
|
|
17
|
+
|
|
18
|
+
module RSB
|
|
19
|
+
module Settings
|
|
20
|
+
class << self
|
|
21
|
+
# --- Instance-based registry (resettable for tests) ---
|
|
22
|
+
|
|
23
|
+
def registry
|
|
24
|
+
@registry ||= Registry.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# --- Public API ---
|
|
28
|
+
|
|
29
|
+
def get(key)
|
|
30
|
+
category, setting_key = parse_key(key)
|
|
31
|
+
resolver.get(category, setting_key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def set(key, value)
|
|
35
|
+
category, setting_key = parse_key(key)
|
|
36
|
+
resolver.set(category, setting_key, value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def for(category)
|
|
40
|
+
resolver.for(category)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# --- Configuration (locks, initializer overrides) ---
|
|
44
|
+
|
|
45
|
+
def configure
|
|
46
|
+
yield(configuration)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def configuration
|
|
50
|
+
@configuration ||= Configuration.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Clear the resolver cache. Use after a transaction rollback
|
|
54
|
+
# to prevent stale cached values from being returned.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def invalidate_cache!
|
|
58
|
+
resolver.invalidate
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# --- Locale configuration ---
|
|
62
|
+
|
|
63
|
+
def available_locales
|
|
64
|
+
configuration.available_locales
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def default_locale
|
|
68
|
+
configuration.default_locale
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def locale_display_name(code)
|
|
72
|
+
configuration.locale_display_names[code.to_s] || code.to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def locale_display_names
|
|
76
|
+
configuration.locale_display_names
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# --- Test support ---
|
|
80
|
+
|
|
81
|
+
def reset!
|
|
82
|
+
@registry = Registry.new
|
|
83
|
+
@resolver = nil
|
|
84
|
+
@configuration = Configuration.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def resolver
|
|
90
|
+
@resolver ||= Resolver.new(registry: registry, configuration: configuration)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_key(key)
|
|
94
|
+
parts = key.to_s.split('.', 2)
|
|
95
|
+
raise ArgumentError, "Key must be in 'category.key' format, got: #{key}" if parts.size != 2
|
|
96
|
+
|
|
97
|
+
parts
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rsb-settings
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.9.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Aleksandr Marchenko
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
description: Foundation gem for RSB. Provides a dynamic settings system with a schema
|
|
27
|
+
registry that other gems register with. Settings resolve via DB → initializer →
|
|
28
|
+
ENV → default.
|
|
29
|
+
email:
|
|
30
|
+
- alex@marchenko.me
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- app/models/rsb/settings/setting.rb
|
|
39
|
+
- db/migrate/20260208000001_create_rsb_settings_settings.rb
|
|
40
|
+
- lib/generators/rsb/settings/install/install_generator.rb
|
|
41
|
+
- lib/generators/rsb/settings/install/templates/initializer.rb
|
|
42
|
+
- lib/rsb/settings.rb
|
|
43
|
+
- lib/rsb/settings/configuration.rb
|
|
44
|
+
- lib/rsb/settings/engine.rb
|
|
45
|
+
- lib/rsb/settings/locale_helper.rb
|
|
46
|
+
- lib/rsb/settings/locale_middleware.rb
|
|
47
|
+
- lib/rsb/settings/registry.rb
|
|
48
|
+
- lib/rsb/settings/resolver.rb
|
|
49
|
+
- lib/rsb/settings/schema.rb
|
|
50
|
+
- lib/rsb/settings/seo_helper.rb
|
|
51
|
+
- lib/rsb/settings/seo_settings_schema.rb
|
|
52
|
+
- lib/rsb/settings/test_helper.rb
|
|
53
|
+
- lib/rsb/settings/validation_error.rb
|
|
54
|
+
- lib/rsb/settings/version.rb
|
|
55
|
+
homepage: https://github.com/Rails-SaaS-Builder/rails-saas-builder
|
|
56
|
+
licenses:
|
|
57
|
+
- LGPL-3.0
|
|
58
|
+
metadata:
|
|
59
|
+
source_code_uri: https://github.com/Rails-SaaS-Builder/rails-saas-builder
|
|
60
|
+
bug_tracker_uri: https://github.com/Rails-SaaS-Builder/rails-saas-builder/issues
|
|
61
|
+
changelog_uri: https://github.com/Rails-SaaS-Builder/rails-saas-builder/blob/master/CHANGELOG.md
|
|
62
|
+
rdoc_options: []
|
|
63
|
+
require_paths:
|
|
64
|
+
- lib
|
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '3.2'
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 3.6.8
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: Dynamic runtime settings with schema registry for Rails SaaS Builder
|
|
79
|
+
test_files: []
|