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 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Settings
5
+ class ValidationError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../../lib/rsb/version' unless defined?(RSB::VERSION)
4
+
5
+ module RSB
6
+ module Settings
7
+ VERSION = RSB::VERSION
8
+ end
9
+ end
@@ -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: []