dugway 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.github/workflows/main.yml +1 -1
  4. data/.gitignore +1 -0
  5. data/README.md +32 -7
  6. data/dugway.gemspec +11 -9
  7. data/lib/dugway/application.rb +5 -3
  8. data/lib/dugway/assets/big_cartel_logo.svg +4 -0
  9. data/lib/dugway/cli/build.rb +7 -1
  10. data/lib/dugway/cli/server.rb +69 -9
  11. data/lib/dugway/cli/templates/source/settings.json +8 -0
  12. data/lib/dugway/cli/validate.rb +9 -2
  13. data/lib/dugway/controller.rb +5 -1
  14. data/lib/dugway/liquid/drops/account_drop.rb +4 -0
  15. data/lib/dugway/liquid/drops/artists_drop.rb +12 -0
  16. data/lib/dugway/liquid/drops/base_drop.rb +27 -2
  17. data/lib/dugway/liquid/drops/categories_drop.rb +12 -0
  18. data/lib/dugway/liquid/drops/features_drop.rb +144 -0
  19. data/lib/dugway/liquid/drops/pages_drop.rb +30 -2
  20. data/lib/dugway/liquid/drops/product_drop.rb +11 -0
  21. data/lib/dugway/liquid/drops/products_drop.rb +39 -0
  22. data/lib/dugway/liquid/drops/theme_drop.rb +52 -6
  23. data/lib/dugway/liquid/drops/translations_drop.rb +122 -0
  24. data/lib/dugway/liquid/filters/core_filters.rb +169 -7
  25. data/lib/dugway/liquid/filters/font_filters.rb +1 -0
  26. data/lib/dugway/liquid/filters/util_filters.rb +1 -10
  27. data/lib/dugway/liquid/tags/get.rb +6 -6
  28. data/lib/dugway/liquid/tags/paginate.rb +61 -11
  29. data/lib/dugway/liquifier.rb +44 -8
  30. data/lib/dugway/store.rb +46 -3
  31. data/lib/dugway/theme.rb +151 -15
  32. data/lib/dugway/version.rb +1 -1
  33. data/lib/dugway.rb +55 -2
  34. data/locales/storefront.de.yml +81 -0
  35. data/locales/storefront.en-CA.yml +81 -0
  36. data/locales/storefront.en-GB.yml +81 -0
  37. data/locales/storefront.en-US.yml +81 -0
  38. data/locales/storefront.es-ES.yml +81 -0
  39. data/locales/storefront.es-MX.yml +81 -0
  40. data/locales/storefront.fr-CA.yml +81 -0
  41. data/locales/storefront.fr-FR.yml +81 -0
  42. data/locales/storefront.id.yml +81 -0
  43. data/locales/storefront.it.yml +81 -0
  44. data/locales/storefront.ja.yml +81 -0
  45. data/locales/storefront.ko.yml +81 -0
  46. data/locales/storefront.nl.yml +81 -0
  47. data/locales/storefront.pl.yml +81 -0
  48. data/locales/storefront.pt-BR.yml +81 -0
  49. data/locales/storefront.pt-PT.yml +81 -0
  50. data/locales/storefront.ro.yml +81 -0
  51. data/locales/storefront.sv.yml +81 -0
  52. data/locales/storefront.tr.yml +81 -0
  53. data/locales/storefront.zh-CN.yml +81 -0
  54. data/locales/storefront.zh-TW.yml +81 -0
  55. data/log/dugway.log +1 -0
  56. data/mise.toml +2 -0
  57. data/spec/features/page_rendering_spec.rb +4 -4
  58. data/spec/fixtures/theme/layout.html +2 -0
  59. data/spec/fixtures/theme/settings.json +6 -0
  60. data/spec/spec_helper.rb +7 -0
  61. data/spec/units/dugway/liquid/drops/features_drop_spec.rb +182 -0
  62. data/spec/units/dugway/liquid/drops/pages_drop_spec.rb +186 -7
  63. data/spec/units/dugway/liquid/drops/product_drop_spec.rb +17 -0
  64. data/spec/units/dugway/liquid/drops/theme_drop_spec.rb +45 -0
  65. data/spec/units/dugway/liquid/drops/translations_drop_spec.rb +292 -0
  66. data/spec/units/dugway/liquid/filters/core_filters_spec.rb +301 -3
  67. data/spec/units/dugway/store_spec.rb +55 -0
  68. data/spec/units/dugway/theme_spec.rb +543 -1
  69. metadata +84 -25
@@ -95,7 +95,11 @@ module Dugway
95
95
  end
96
96
 
97
97
  def render_page(variables={})
98
- render_file("#{ page['permalink'] }.html", variables.update({ :page => page }))
98
+ # Prioritize page object passed in variables, otherwise fetch default using Controller#page
99
+ current_page = variables[:page] || page
100
+ render_variables = variables.merge({ :page => current_page })
101
+ # Use the permalink from the determined page object
102
+ render_file("#{ current_page['permalink'] }.html", render_variables)
99
103
  end
100
104
 
101
105
  def render_json(object)
@@ -12,6 +12,10 @@ module Dugway
12
12
  def country
13
13
  @country ||= CountryDrop.new(source['country'])
14
14
  end
15
+
16
+ def website
17
+ Dugway.store.website
18
+ end
15
19
  end
16
20
  end
17
21
  end
@@ -1,6 +1,18 @@
1
1
  module Dugway
2
2
  module Drops
3
3
  class ArtistsDrop < BaseDrop
4
+ # Liquid 5.x compatibility: implement liquid_method_missing for method access
5
+ def liquid_method_missing(method)
6
+ # Try to find artist by permalink
7
+ result = before_method(method.to_s)
8
+ # Wrap hash results in ArtistDrop if it looks like an artist
9
+ if result.is_a?(Hash) && result['permalink']
10
+ ArtistDrop.new(result)
11
+ else
12
+ result
13
+ end
14
+ end
15
+
4
16
  def all
5
17
  @all ||= source
6
18
  end
@@ -34,9 +34,14 @@ module Dugway
34
34
  return source.send(method_or_key)
35
35
  elsif source.respond_to?('has_key?') && source.has_key?(method_or_key)
36
36
  return source[method_or_key]
37
- elsif source.is_a?(Array) && source.first.has_key?('permalink')
37
+ elsif source.is_a?(Array) && source.first
38
+ # Handle both hash items and drop items in array
38
39
  for item in source
39
- return item if item['permalink'] == method_or_key.to_s
40
+ if item.is_a?(Hash) && item['permalink'] == method_or_key.to_s
41
+ return item
42
+ elsif item.respond_to?(:source) && item.source.is_a?(Hash) && item.source['permalink'] == method_or_key.to_s
43
+ return item
44
+ end
40
45
  end
41
46
  end
42
47
 
@@ -47,6 +52,11 @@ module Dugway
47
52
  before_method(method.to_s)
48
53
  end
49
54
 
55
+ # Liquid 5.x compatibility: implement liquid_method_missing for property access
56
+ def liquid_method_missing(method_name)
57
+ before_method(method_name.to_s)
58
+ end
59
+
50
60
  def errors
51
61
  @context['errors']
52
62
  end
@@ -54,6 +64,21 @@ module Dugway
54
64
  def error(msg)
55
65
  errors << msg
56
66
  end
67
+
68
+ # Liquid 5.x compatibility: make drops JSON-serializable
69
+ # Only return source for JSON serialization, not for filter processing
70
+ def to_liquid
71
+ self
72
+ end
73
+
74
+ def as_json(*args)
75
+ if source.is_a?(Hash)
76
+ # Convert Ruby hash to proper JSON-compatible format
77
+ source.stringify_keys
78
+ else
79
+ {}
80
+ end
81
+ end
57
82
  end
58
83
  end
59
84
  end
@@ -1,6 +1,18 @@
1
1
  module Dugway
2
2
  module Drops
3
3
  class CategoriesDrop < BaseDrop
4
+ # Liquid 5.x compatibility: implement liquid_method_missing for method access
5
+ def liquid_method_missing(method)
6
+ # Try to find category by permalink
7
+ result = before_method(method.to_s)
8
+ # Wrap hash results in CategoryDrop if it looks like a category
9
+ if result.is_a?(Hash) && result['permalink']
10
+ CategoryDrop.new(result)
11
+ else
12
+ result
13
+ end
14
+ end
15
+
4
16
  def all
5
17
  @all ||= source
6
18
  end
@@ -0,0 +1,144 @@
1
+ require 'set'
2
+
3
+ module Dugway
4
+ module Drops
5
+ # Provides access to specific account feature flags within Liquid templates,
6
+ # mirroring the behavior of the storefront's FeaturesDrop but reading
7
+ # configuration from the Dugway store configuration (e.g., dugway.json).
8
+ #
9
+ # Feature flags can have a default status (enabled or disabled). This status,
10
+ # along with the list of exposed features, opt-ins, and opt-outs, should be
11
+ # defined under a top-level "features" key in the store configuration file,
12
+ # making it a peer to "store" and "customization".
13
+ #
14
+ # Example structure expected within the store configuration (e.g., dugway.json):
15
+ # {
16
+ # "store": { ... },
17
+ # "customization": { ... },
18
+ # "features": {
19
+ # "definitions": {
20
+ # "theme_bnpl_messaging": "enabled_by_default",
21
+ # "theme_category_collages": "disabled_by_default"
22
+ # },
23
+ # "opt_ins": ["some_opted_in_feature"],
24
+ # "opt_outs": ["some_opted_out_feature"]
25
+ # }
26
+ # }
27
+ # The `FeaturesDrop` receives the top-level `features` hash as its `source`
28
+ # via the ThemeDrop.
29
+ #
30
+ # Usage in Liquid:
31
+ # {% if features.has_theme_bnpl_messaging %} ... {% endif %}
32
+ # Note: Liquid might require the trailing '?' depending on context,
33
+ # but this implementation handles calls without it.
34
+ #
35
+ class FeaturesDrop < BaseDrop
36
+ # Memoized hash of feature definitions (name => default_status_string)
37
+ # read from the source (store configuration data).
38
+ # @return [Hash<String, String>]
39
+ def feature_definitions
40
+ @feature_definitions ||= source&.fetch('definitions', {}) || {}
41
+ end
42
+
43
+ # Returns a memoized list of features this account has explicitly opted *into*,
44
+ # filtered to only include features defined in the definitions.
45
+ # @return [Array<String>] List of opted-in feature names.
46
+ def opt_ins
47
+ return @opt_ins if defined?(@opt_ins)
48
+
49
+ source_opt_ins = source&.fetch('opt_ins', []) || []
50
+ # Ensure source data is treated as a Set for efficient intersection
51
+ source_set = source_opt_ins.respond_to?(:to_set) ? source_opt_ins.to_set : Set.new(Array(source_opt_ins))
52
+
53
+ # Only keep opt-ins that correspond to defined features
54
+ @opt_ins = (Set.new(feature_definitions.keys) & source_set).to_a
55
+ end
56
+
57
+ # Returns a memoized list of features this account has explicitly opted *out of*,
58
+ # filtered to only include features defined in the definitions.
59
+ # @return [Array<String>] List of opted-out feature names.
60
+ def opt_outs
61
+ return @opt_outs if defined?(@opt_outs)
62
+
63
+ source_opt_outs = source&.fetch('opt_outs', []) || []
64
+ # Ensure source data is treated as a Set for efficient intersection
65
+ source_set = source_opt_outs.respond_to?(:to_set) ? source_opt_outs.to_set : Set.new(Array(source_opt_outs))
66
+
67
+ # Only keep opt-outs that correspond to defined features
68
+ @opt_outs = (Set.new(feature_definitions.keys) & source_set).to_a
69
+ end
70
+
71
+ # Override BaseDrop's `before_method` to intercept `has_...` calls directly.
72
+ # This is called by BaseDrop's `method_missing` and allows us to intercept
73
+ # `has_...` calls before BaseDrop tries to resolve them against the source hash.
74
+ def before_method(method_or_key)
75
+ method_str = method_or_key.to_s
76
+
77
+ if method_str.start_with?('has_')
78
+ # Extract feature name, removing 'has_' prefix and optional trailing '?'
79
+ # to handle calls like `features.has_feature_name` or `features.has_feature_name?`
80
+ feature_name = method_str.sub(/^has_/, '').chomp('?')
81
+
82
+ # Check if this feature is defined
83
+ # If the extracted name doesn't match a defined feature, it's not available.
84
+ unless feature_definitions.key?(feature_name)
85
+ return false
86
+ end
87
+
88
+ # If defined, calculate availability based on defaults and overrides.
89
+ is_feature_available?(feature_name)
90
+ else
91
+ # If it's not a 'has_' method, delegate to BaseDrop's original logic.
92
+ super(method_or_key)
93
+ end
94
+ end
95
+
96
+ # Keep liquid_method_missing for compatibility or explicit Liquid contexts,
97
+ # We keep liquid_method_missing defined as it's the official Liquid hook,
98
+ # although our `before_method` override likely handles most cases in Liquid 3.
99
+ # This provides potential compatibility if Liquid internals change or call this directly.
100
+ def liquid_method_missing(method_name)
101
+ method_str = method_name.to_s
102
+
103
+ if method_str.start_with?('has_')
104
+ feature_name = method_str.sub(/^has_/, '').chomp('?')
105
+ # Check definition and calculate availability, returning false if undefined.
106
+ feature_definitions.key?(feature_name) ? is_feature_available?(feature_name) : false
107
+ else
108
+ # Fallback for non-'has_' methods.
109
+ super
110
+ end
111
+ end
112
+
113
+ private
114
+ # Determines if a specific feature is available for the account based on its
115
+ # default status (from store customization) and the account's explicit opt-ins/outs
116
+ # (also from store customization).
117
+ #
118
+ # @param feature_name [String] The name of the feature to check.
119
+ # @return [Boolean] True if the feature is available, false otherwise.
120
+ def is_feature_available?(feature_name)
121
+ default_behavior_str = feature_definitions[feature_name]
122
+ # Should not happen if called via before_method/liquid_method_missing,
123
+ # but return false if the definition was somehow missing.
124
+ return false unless default_behavior_str
125
+
126
+ is_opted_in = opt_ins.include?(feature_name)
127
+ is_opted_out = opt_outs.include?(feature_name)
128
+
129
+ # Determine final availability:
130
+ # - If explicitly opted in, it's available.
131
+ # - If explicitly opted out, it's unavailable.
132
+ # - Otherwise, availability depends on the defined default behavior string.
133
+ # Precedence: Opt-out > Opt-in > Default
134
+ if is_opted_out
135
+ false
136
+ elsif is_opted_in
137
+ true
138
+ else
139
+ default_behavior_str == 'enabled_by_default'
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -1,12 +1,40 @@
1
1
  module Dugway
2
2
  module Drops
3
3
  class PagesDrop < BaseDrop
4
+ # Liquid 5.x compatibility: implement liquid_method_missing for method access
5
+ def liquid_method_missing(method)
6
+ # Try to find page by permalink
7
+ result = before_method(method.to_s)
8
+ # Wrap hash results in PageDrop if it looks like a page
9
+ if result.is_a?(Hash) && result['permalink']
10
+ PageDrop.new(result)
11
+ else
12
+ result
13
+ end
14
+ end
15
+
4
16
  def all
5
- @all ||= source.select { |page| page['category'] == 'custom' }
17
+ @all ||= source.select { |page| (page.source['category'] == 'custom' || page.source['category'] == 'external') && !page.source['required'] }
6
18
  end
7
19
 
8
20
  def cart
9
- @cart ||= source.find { |page| page['permalink'] == 'cart' }
21
+ @cart ||= source.find { |page| page.source['permalink'] == 'cart' }
22
+ end
23
+
24
+ def subscribe_page
25
+ @subscribe_page ||= store.subscribe_url ? source.find { |page| page.source['url'] == store.subscribe_url } : nil
26
+ end
27
+
28
+ def custom_pages
29
+ @custom_pages ||= source.select { |page| page.source['category'] == 'custom' && !page.source['required'] }
30
+ end
31
+
32
+ def external_pages
33
+ @external_pages ||= source.select { |page| page.source['category'] == 'external' && !page.source['required'] }
34
+ end
35
+
36
+ def required_pages
37
+ @required_pages ||= source.select { |page| page.source['required'] == true }
10
38
  end
11
39
  end
12
40
  end
@@ -1,6 +1,11 @@
1
1
  module Dugway
2
2
  module Drops
3
3
  class ProductDrop < BaseDrop
4
+
5
+ # Liquid 5.x compatibility: ensure proper string representation
6
+ def to_s
7
+ name || "Product ##{id}"
8
+ end
4
9
  def created_at
5
10
  Time.parse(source['created_at'])
6
11
  end
@@ -112,6 +117,12 @@ module Dugway
112
117
  end
113
118
  end
114
119
 
120
+ # Used to simulate price suffix behavior
121
+ # In prod this is a per-product setting but for dugway it needs to be global
122
+ def price_suffix
123
+ store.price_suffix
124
+ end
125
+
115
126
  private
116
127
 
117
128
  def price_min_max
@@ -3,6 +3,45 @@ require 'will_paginate/array'
3
3
  module Dugway
4
4
  module Drops
5
5
  class ProductsDrop < BaseDrop
6
+
7
+ # Liquid 5.x compatibility: implement liquid_method_missing for method access
8
+ def liquid_method_missing(method)
9
+ method_str = method.to_s
10
+
11
+ # Handle known methods
12
+ case method_str
13
+ when 'all'
14
+ all
15
+ when 'current'
16
+ current
17
+ when 'on_sale'
18
+ on_sale
19
+ else
20
+ # Try to find product by permalink
21
+ result = before_method(method_str)
22
+ # Wrap hash results in ProductDrop if it looks like a product
23
+ if result.is_a?(Hash) && result['permalink']
24
+ ProductDrop.new(result)
25
+ else
26
+ result
27
+ end
28
+ end
29
+ end
30
+
31
+ # Also ensure before_method works as fallback
32
+ def before_method(method_or_key)
33
+ case method_or_key.to_s
34
+ when 'all'
35
+ all
36
+ when 'current'
37
+ current
38
+ when 'on_sale'
39
+ on_sale
40
+ else
41
+ super
42
+ end
43
+ end
44
+
6
45
  def all
7
46
  sort_and_paginate source
8
47
  end
@@ -1,13 +1,43 @@
1
1
  module Dugway
2
2
  module Drops
3
3
  class ThemeDrop < BaseDrop
4
- def before_method(method_or_key)
5
- # We should try to get away from this api and use the newer one below
6
- if source.respond_to?('has_key?') && source.has_key?(method_or_key) && settings_images.find { |image| image['variable'] == method_or_key.to_s }
7
- return ImageDrop.new(source[method_or_key].stringify_keys)
8
- end
4
+ # @param customization [Hash] Theme customization values.
5
+ # @param definitions [Hash] Theme setting definitions (from settings.json).
6
+ def initialize(customization, definitions = {})
7
+ super(customization)
8
+ @definitions = definitions || {}
9
+ end
9
10
 
10
- super
11
+ # Liquid 5.x compatibility: implement liquid_method_missing for property access
12
+ def liquid_method_missing(method)
13
+ method_str = method.to_s
14
+ method_sym = method.to_sym
15
+
16
+ # Try different key formats - source might have string or symbol keys
17
+ # Use has_key? to handle false/nil values properly
18
+ if source.has_key?(method_str)
19
+ value = source[method_str]
20
+ elsif source.has_key?(method_sym)
21
+ value = source[method_sym]
22
+ elsif source.has_key?(method_str.to_sym)
23
+ value = source[method_str.to_sym]
24
+ elsif source.has_key?(method_sym.to_s)
25
+ value = source[method_sym.to_s]
26
+ else
27
+ value = nil
28
+ end
29
+
30
+ # Check for image settings - need to wrap in ImageDrop
31
+ if !value.nil? && settings_images.find { |image| image['variable'] == method_str }
32
+ return ImageDrop.new(value.is_a?(Hash) ? value.stringify_keys : value)
33
+ end
34
+
35
+ value
36
+ end
37
+
38
+ def before_method(method_or_key)
39
+ # Fallback for older Liquid versions
40
+ liquid_method_missing(method_or_key)
11
41
  end
12
42
 
13
43
  # Newer API for theme images.
@@ -20,6 +50,22 @@ module Dugway
20
50
  Drops::ThemeImageSetsDrop.new(source)
21
51
  end
22
52
 
53
+ def features
54
+ # Fetch features config from the top-level Dugway options hash,
55
+ # which should contain the parsed store configuration (e.g., dugway.json).
56
+ # This allows 'features' to be a peer to 'store' and 'customization'.
57
+ feature_data = Dugway.options&.fetch('features', {}) || {}
58
+ Drops::FeaturesDrop.new(feature_data)
59
+ end
60
+
61
+ # Provides access to theme translations via theme.translations
62
+ def translations
63
+ # Instantiate and return the TranslationsDrop, passing settings.
64
+ # Memoize the instance for efficiency within a single render cycle.
65
+ # Pass both customization (source) and definitions to TranslationsDrop.
66
+ @translations_drop ||= Drops::TranslationsDrop.new(source, @definitions)
67
+ end
68
+
23
69
  private
24
70
  def settings_images
25
71
  @settings_images ||= settings.has_key?('images') ? settings['images'] : []
@@ -0,0 +1,122 @@
1
+ require 'i18n'
2
+
3
+ module Dugway
4
+ module Drops
5
+ class TranslationsDrop < BaseDrop
6
+ class MissingTranslationError < StandardError; end
7
+
8
+ # @param settings [Hash] Theme customization values (resolved settings).
9
+ # @param definitions [Hash] Raw theme setting definitions (from settings.json).
10
+ def initialize(settings, definitions = {})
11
+ @settings = settings || {}
12
+ @definitions = definitions || {} # Store definitions
13
+ # Read settings using both symbol and string keys for flexibility.
14
+ @translation_mode = (@settings[:translation_mode] || @settings['translation_mode'])&.to_s
15
+ # Use explicit theme setting for locale if present, otherwise default to current I18n.locale
16
+ @translation_locale = (@settings[:translation_locale] || @settings['translation_locale'])&.to_s.presence || I18n.locale.to_s
17
+ super()
18
+ end
19
+
20
+ # Liquid access: `{{ translations['key.name'] }}`
21
+ # Returns the translation string (which could be an empty string),
22
+ # or a "[MISSING TRANSLATION: ...]" string if the key is not found or its value is nil.
23
+ #
24
+ # @param key [String, Symbol] The translation key.
25
+ # @return [String, nil] The translation or missing translation indicator.
26
+ def [](key)
27
+ key_str = key.to_s
28
+ translation = find_translation(key_str)
29
+
30
+ if translation.nil?
31
+ # Key is missing, or explicitly nil in manual mode - treat both as missing.
32
+ log_missing_translation(key_str)
33
+ return missing_translation_message(key_str)
34
+ end
35
+
36
+ # Key found, return its value.
37
+ translation
38
+ end
39
+
40
+ private
41
+
42
+ def manual_mode?
43
+ @translation_mode == 'manual'
44
+ end
45
+
46
+ # Looks up translation using I18n.
47
+ # Returns nil if the key is not found or the locale is invalid.
48
+ def lookup_automatic_translation(key)
49
+ locale_to_use = @translation_locale
50
+ # `default: nil` prevents I18n from raising an error on missing keys.
51
+ I18n.t("storefront.#{key}", locale: locale_to_use, default: nil)
52
+ rescue I18n::InvalidLocale
53
+ nil # Treat invalid locale as a missing translation.
54
+ end
55
+
56
+ # Converts a canonical key ('a.b.c') to the theme setting key format (:a_b_c_tr_text).
57
+ def derive_manual_setting_key(key)
58
+ "#{key.tr('.', '_')}_tr_text".to_sym
59
+ end
60
+
61
+ # Finds the translation based on the current mode.
62
+ # In manual mode, respects the 'allow_blank' setting definition.
63
+ # Returns the translation string, or nil if not found or disallowed blank.
64
+ def find_translation(key_str)
65
+ if manual_mode?
66
+ manual_setting_key_sym = derive_manual_setting_key(key_str)
67
+ manual_setting_key_str = manual_setting_key_sym.to_s
68
+ custom_value = nil
69
+ setting_exists = false
70
+
71
+ # Check both symbol and string keys for manual overrides.
72
+ if @settings.key?(manual_setting_key_sym)
73
+ custom_value = @settings[manual_setting_key_sym]
74
+ setting_exists = true
75
+ elsif @settings.key?(manual_setting_key_str)
76
+ custom_value = @settings[manual_setting_key_str]
77
+ setting_exists = true
78
+ end
79
+
80
+ if setting_exists
81
+ # Find the corresponding definition to check allow_blank
82
+ # Assuming manual translations are defined similarly to 'options'
83
+ definition = find_setting_definition(manual_setting_key_str) || {}
84
+ allow_blank = definition['allow_blank'].to_s == 'true'
85
+
86
+ # Return custom value if it's present, or if it's blank but allowed
87
+ return custom_value if custom_value.present? || (allow_blank && custom_value == '')
88
+
89
+ # If we reach here, the value is blank but blank is not allowed.
90
+ # Fall through to automatic lookup.
91
+ end
92
+
93
+ # If setting key didn't exist, fall through to automatic lookup.
94
+
95
+ end # End manual_mode? check
96
+
97
+ # Automatic mode or fallback from manual mode
98
+ lookup_automatic_translation(key_str)
99
+ end
100
+
101
+ # Helper to find a setting definition by its variable name across common sections.
102
+ def find_setting_definition(variable_name)
103
+ # Look primarily in 'options' as manual translations often mimic them.
104
+ # Add other potential sections if needed in the future.
105
+ (@definitions['options'] || []).find { |d| d['variable'] == variable_name }
106
+ end
107
+
108
+ # Logs a warning message for a missing translation.
109
+ def log_missing_translation(key_str)
110
+ mode = manual_mode? ? 'manual' : 'automatic'
111
+ locale_info = manual_mode? ? '' : " locale='#{@translation_locale}'"
112
+ message = "Missing translation: key='#{key_str}'#{locale_info} mode='#{mode}'"
113
+ Dugway.logger.warn(message)
114
+ end
115
+
116
+ # Returns the standard string indicating a missing translation.
117
+ def missing_translation_message(key_str)
118
+ "[MISSING TRANSLATION: #{key_str}]"
119
+ end
120
+ end
121
+ end
122
+ end