trmnl_preview 0.7.1 → 0.8.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +180 -5
  4. data/bin/rake +6 -6
  5. data/bin/trmnlp +1 -0
  6. data/db/data/form_fields.yml +24 -0
  7. data/db/data/framework_versions.yml +72 -0
  8. data/lib/trmnlp/api_client.rb +41 -28
  9. data/lib/trmnlp/app.rb +73 -44
  10. data/lib/trmnlp/browser_pool.rb +82 -0
  11. data/lib/trmnlp/cli.rb +24 -11
  12. data/lib/trmnlp/commands/base.rb +33 -10
  13. data/lib/trmnlp/commands/build.rb +13 -8
  14. data/lib/trmnlp/commands/clone.rb +12 -7
  15. data/lib/trmnlp/commands/init.rb +17 -13
  16. data/lib/trmnlp/commands/lint.rb +42 -0
  17. data/lib/trmnlp/commands/list.rb +40 -0
  18. data/lib/trmnlp/commands/login.rb +28 -13
  19. data/lib/trmnlp/commands/pull.rb +14 -6
  20. data/lib/trmnlp/commands/push.rb +29 -19
  21. data/lib/trmnlp/commands/serve.rb +32 -3
  22. data/lib/trmnlp/commands.rb +3 -1
  23. data/lib/trmnlp/config/app.rb +6 -3
  24. data/lib/trmnlp/config/plugin.rb +56 -14
  25. data/lib/trmnlp/config/project.rb +59 -7
  26. data/lib/trmnlp/config.rb +3 -1
  27. data/lib/trmnlp/context.rb +21 -224
  28. data/lib/trmnlp/errors.rb +15 -0
  29. data/lib/trmnlp/form_field.rb +42 -0
  30. data/lib/trmnlp/framework_version.rb +69 -0
  31. data/lib/trmnlp/image_quantizer.rb +58 -0
  32. data/lib/trmnlp/lint/check.rb +31 -0
  33. data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
  34. data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
  35. data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
  36. data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
  37. data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
  38. data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
  39. data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
  40. data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
  41. data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
  42. data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
  43. data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
  44. data/lib/trmnlp/lint/checks/title_length.rb +18 -0
  45. data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
  46. data/lib/trmnlp/lint/source.rb +42 -0
  47. data/lib/trmnlp/lint.rb +39 -0
  48. data/lib/trmnlp/paths.rb +28 -8
  49. data/lib/trmnlp/poller.rb +105 -0
  50. data/lib/trmnlp/renderer.rb +87 -0
  51. data/lib/trmnlp/reporter.rb +28 -0
  52. data/lib/trmnlp/screen.rb +16 -0
  53. data/lib/trmnlp/screen_generator.rb +11 -217
  54. data/lib/trmnlp/screenshot.rb +96 -0
  55. data/lib/trmnlp/transform_backend/http.rb +107 -0
  56. data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
  57. data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
  58. data/lib/trmnlp/transform_client.rb +47 -0
  59. data/lib/trmnlp/transform_pipeline.rb +65 -0
  60. data/lib/trmnlp/user_data_assembler.rb +96 -0
  61. data/lib/trmnlp/version.rb +1 -1
  62. data/lib/trmnlp/watcher.rb +60 -0
  63. data/lib/trmnlp.rb +6 -10
  64. data/templates/init/bin/trmnlp +1 -1
  65. data/templates/init/src/settings.yml +1 -0
  66. data/templates/init/src/transform.py.example +14 -0
  67. data/trmnl_preview.gemspec +34 -34
  68. data/web/public/index.css +6 -0
  69. data/web/public/index.js +31 -18
  70. data/web/views/index.erb +6 -1
  71. data/web/views/render_html.erb +4 -2
  72. metadata +81 -56
@@ -1,27 +1,42 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'base'
4
+ require_relative '../api_client'
2
5
 
3
6
  module TRMNLP
4
7
  module Commands
5
8
  class Login < Base
9
+ Options = Data.define(:dir, :quiet)
10
+
6
11
  def call
7
12
  if config.app.logged_in?
8
- anonymous_key = config.app.api_key[0..10] + '*' * (config.app.api_key.length - 11)
9
- output "Currently authenticated as: #{anonymous_key}"
10
- confirm = prompt("You are already authenticated. Do you want to re-authenticate? (y/N): ")
13
+ anonymous_key = config.app.api_key[0..10] + ('*' * (config.app.api_key.length - 11))
14
+ reporter.info "Currently authenticated as: #{anonymous_key}"
15
+ confirm = prompt('You are already authenticated. Do you want to re-authenticate? (y/N): ')
11
16
  return unless confirm.strip.downcase == 'y'
12
17
  end
13
18
 
14
- output "Please visit #{config.app.account_uri} to grab your API key, then paste it here."
15
-
16
- api_key = prompt("API Key: ")
17
- raise Error, "API key cannot be empty" if api_key.empty?
18
- raise Error, "Invalid API key; did you copy it from the right place?" unless api_key.start_with?("user_")
19
-
19
+ reporter.info "Please visit #{config.app.account_uri} to grab your API key, then paste it here."
20
+
21
+ api_key = prompt('API Key: ')
22
+ raise InvalidApiKey, 'API key cannot be empty' if api_key.empty?
23
+ unless api_key.start_with?('user_')
24
+ raise InvalidApiKey,
25
+ 'Invalid API key; did you copy it from the right place?'
26
+ end
27
+
20
28
  config.app.api_key = api_key
21
- config.app.save
22
-
23
- output "Saved changes to #{paths.app_config}"
29
+
30
+ api_client = APIClient.new(config)
31
+ begin
32
+ user_info = api_client.get_me
33
+ reporter.info "Authenticated as #{user_info['name']} (#{user_info['email']})"
34
+ config.app.save
35
+ reporter.info "Saved changes to #{paths.app_config}"
36
+ rescue StandardError => e
37
+ raise AuthenticationFailed, "Authentication failed; changes were not saved.\n#{e.message}"
38
+ end
24
39
  end
25
40
  end
26
41
  end
27
- end
42
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'zip'
2
4
 
3
5
  require_relative 'base'
@@ -6,16 +8,18 @@ require_relative '../api_client'
6
8
  module TRMNLP
7
9
  module Commands
8
10
  class Pull < Base
11
+ Options = Data.define(:dir, :quiet, :id, :force)
12
+
9
13
  def call
10
14
  context.validate!
11
15
  authenticate!
12
16
 
13
17
  plugin_settings_id = options.id || config.plugin.id
14
- raise Error, 'plugin ID must be specified' if plugin_settings_id.nil?
18
+ raise PluginIdRequired, 'plugin ID must be specified' if plugin_settings_id.nil?
15
19
 
16
20
  unless options.force
17
- answer = prompt("Local plugin files will be overwritten. Are you sure? (y/n) ").downcase
18
- raise Error, 'aborting' unless answer == 'y' || answer == 'yes'
21
+ answer = prompt('Local plugin files will be overwritten. Are you sure? (y/n) ').downcase
22
+ raise Aborted, 'aborting' unless %w[y yes].include?(answer)
19
23
  end
20
24
 
21
25
  api = APIClient.new(config)
@@ -27,7 +31,11 @@ module TRMNLP
27
31
  zip_file.each do |entry|
28
32
  dest_path = paths.src_dir.join(entry.name)
29
33
  dest_path.dirname.mkpath
30
- zip_file.extract(entry, dest_path) { true } # overwrite existing
34
+ # NOTE: delete-before-extract avoids EACCES on Linux when an
35
+ # existing template-generated file is non-writable.
36
+ # rubyzip's block-based overwrite confirmation does not chmod.
37
+ dest_path.delete if dest_path.exist?
38
+ zip_file.extract(entry, destination_directory: paths.src_dir)
31
39
  end
32
40
  end
33
41
 
@@ -36,8 +44,8 @@ module TRMNLP
36
44
  tempfile.close
37
45
  end
38
46
 
39
- puts "Downloaded plugin (#{size} bytes)"
47
+ reporter.info "Downloaded plugin (#{size} bytes)"
40
48
  end
41
49
  end
42
50
  end
43
- end
51
+ end
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tmpdir'
1
5
  require 'zip'
2
6
 
3
7
  require_relative 'base'
@@ -6,62 +10,68 @@ require_relative '../api_client'
6
10
  module TRMNLP
7
11
  module Commands
8
12
  class Push < Base
13
+ Options = Data.define(:dir, :quiet, :id, :force)
14
+
15
+ # Build the archive under the system temp dir, not the working
16
+ # directory — a relative path would litter the user's project (or the
17
+ # repo root, in specs) if a run is interrupted before the ensure-cleanup.
18
+ ZIP_PATH = File.join(Dir.tmpdir, "trmnlp-upload-#{Process.pid}.zip").freeze
19
+
9
20
  def call
10
21
  context.validate!
11
22
  authenticate!
12
23
 
13
24
  is_new = false
14
- zip_path = 'upload.zip'
15
25
 
16
26
  api = APIClient.new(config)
17
27
 
18
28
  plugin_settings_id = options.id || config.plugin.id
19
29
  if plugin_settings_id.nil?
20
- output 'Creating a new plugin on the server...'
30
+ reporter.info 'Creating a new plugin on the server...'
21
31
  response = api.post_plugin_setting(name: 'New TRMNLP Plugin', plugin_id: 37) # hardcoded id for private_plugin
22
32
  plugin_settings_id = response.dig('data', 'id')
23
33
  is_new = true
24
34
  end
25
35
 
26
36
  unless is_new || options.force
27
- answer = prompt("Plugin settings on the server will be overwritten. Are you sure? (y/n) ").downcase
28
- raise Error, 'aborting' unless answer == 'y' || answer == 'yes'
37
+ answer = prompt('Plugin settings on the server will be overwritten. Are you sure? (y/n) ').downcase
38
+ raise Aborted, 'aborting' unless %w[y yes].include?(answer)
29
39
  end
30
40
 
31
- Zip::File.open(zip_path, Zip::File::CREATE) do |zip_file|
41
+ Zip::File.open(ZIP_PATH, create: true) do |zip_file|
32
42
  paths.src_files.each do |file|
33
43
  zip_file.add(File.basename(file), file)
34
44
  end
35
45
  end
36
-
37
- response = api.post_plugin_setting_archive(plugin_settings_id, zip_path)
46
+
47
+ response = api.post_plugin_setting_archive(plugin_settings_id, ZIP_PATH)
38
48
  paths.plugin_config.write(response.dig('data', 'settings_yaml'))
39
49
 
40
- size = File.size(zip_path)
41
-
42
- output <<~HEREDOC
43
- Uploaded plugin (#{size} bytes)
44
- Dashboard: #{config.app.edit_plugin_settings_uri(plugin_settings_id)}
50
+ size = File.size(ZIP_PATH)
51
+
52
+ reporter.info <<~HEREDOC
53
+ Uploaded plugin (#{size} bytes)
54
+ Dashboard: #{config.app.edit_plugin_settings_uri(plugin_settings_id)}
45
55
  HEREDOC
46
56
 
47
57
  if is_new
48
- output <<~HEREDOC
58
+ reporter.info <<~HEREDOC
49
59
 
50
- IMPORTANT! Don't forget to add it to your device playlist!
60
+ IMPORTANT! Don't forget to add it to your device playlist!
51
61
 
52
- #{config.app.playlists_uri}
62
+ #{config.app.playlists_uri}
53
63
  HEREDOC
54
64
  end
55
- rescue
65
+ rescue StandardError
56
66
  if is_new && plugin_settings_id
57
- output 'Error during creation, cleaning up...'
67
+ reporter.info 'Error during creation, cleaning up...'
58
68
  api.delete_plugin_setting(plugin_settings_id)
59
69
  end
60
70
 
61
71
  raise
62
72
  ensure
63
- File.delete(zip_path) if File.exist?(zip_path)
73
+ FileUtils.rm_f(ZIP_PATH)
64
74
  end
65
75
  end
66
76
  end
67
- end
77
+ end
@@ -1,25 +1,54 @@
1
- require 'zip'
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
2
4
 
3
5
  require_relative 'base'
4
6
  require_relative '../api_client'
7
+ require_relative '../browser_pool'
5
8
 
6
9
  module TRMNLP
7
10
  module Commands
8
11
  class Serve < Base
12
+ Options = Data.define(:dir, :quiet, :bind, :port)
13
+
9
14
  def call
10
15
  context.validate!
11
-
16
+ report_form_field_warnings
17
+
12
18
  # Must come AFTER parsing options
13
19
  require_relative '../app'
14
20
 
15
21
  # Now we can configure things
16
22
  App.set(:context, context)
23
+ App.set(:browser_pool, BrowserPool.new(driver_factory: method(:build_firefox_driver)))
17
24
  App.set(:bind, options.bind)
18
25
  App.set(:port, options.port)
26
+ permit_all_hosts if codespaces?
19
27
 
20
28
  # Finally, start the app!
21
29
  App.run!
22
30
  end
31
+
32
+ private
33
+
34
+ # Codespaces forwards the dev port through a proxy whose Host header Sinatra rejects.
35
+ def codespaces? = ENV['CODESPACES'] == 'true'
36
+
37
+ def permit_all_hosts
38
+ App.set(:host_authorization, { allow_if: ->(_env) { true } })
39
+ end
40
+
41
+ def build_firefox_driver
42
+ options = Selenium::WebDriver::Firefox::Options.new
43
+ options.add_argument('--headless')
44
+ options.add_argument('--disable-web-security')
45
+ # Disable subpixel antialiasing — its colour fringing quantizes badly on 1-bit e-ink.
46
+ options.add_preference('gfx.text.disable-aa', true)
47
+ options.add_preference('gfx.text.subpixel-position.force-disabled', true)
48
+ Selenium::WebDriver.for(:firefox, options: options).tap do |driver|
49
+ driver.manage.window.maximize
50
+ end
51
+ end
23
52
  end
24
53
  end
25
- end
54
+ end
@@ -1 +1,3 @@
1
- Dir[File.join(__dir__, 'commands', '*.rb')].each { |file| require file }
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(__dir__, 'commands', '*.rb')].each { |file| require file }
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'yaml'
2
4
 
3
5
  module TRMNLP
@@ -18,8 +20,9 @@ module TRMNLP
18
20
  def logged_out? = !logged_in?
19
21
 
20
22
  def api_key
21
- env_api_key = ENV['TRMNL_API_KEY']
23
+ env_api_key = ENV.fetch('TRMNL_API_KEY', nil)
22
24
  return env_api_key if env_api_key && !env_api_key.empty?
25
+
23
26
  @config['api_key']
24
27
  end
25
28
 
@@ -33,7 +36,7 @@ module TRMNLP
33
36
 
34
37
  def account_uri = URI.join(base_uri, '/account')
35
38
 
36
- def edit_plugin_settings_uri(id) = URI.join(base_uri, "/plugin_settings/#{id.to_s}/edit")
39
+ def edit_plugin_settings_uri(id) = URI.join(base_uri, "/plugin_settings/#{id}/edit")
37
40
 
38
41
  def playlists_uri = URI.join(base_uri, '/playlists')
39
42
 
@@ -44,4 +47,4 @@ module TRMNLP
44
47
  def read_config = paths.app_config.exist? ? YAML.safe_load(paths.app_config.read) : {}
45
48
  end
46
49
  end
47
- end
50
+ end
@@ -1,5 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
1
4
  require 'yaml'
2
5
 
6
+ require_relative '../errors'
7
+ require_relative '../framework_version'
8
+
3
9
  module TRMNLP
4
10
  class Config
5
11
  class Plugin
@@ -10,13 +16,15 @@ module TRMNLP
10
16
  end
11
17
 
12
18
  def reload!
13
- if paths.plugin_config.exist?
14
- @config = YAML.load_file(paths.plugin_config)
15
- else
16
- @config = {}
17
- end
19
+ @config = if paths.plugin_config.exist?
20
+ YAML.safe_load_file(paths.plugin_config, permitted_classes: [Date, Time]) || {}
21
+ else
22
+ {}
23
+ end
24
+ rescue Psych::SyntaxError => e
25
+ raise InvalidConfig, "settings.yml is not valid YAML: #{e.message}"
18
26
  end
19
-
27
+
20
28
  def strategy = @config['strategy']
21
29
  def polling? = strategy == 'polling'
22
30
  def webhook? = strategy == 'webhook'
@@ -24,25 +32,32 @@ module TRMNLP
24
32
 
25
33
  def polling_urls
26
34
  # allow project-level config to override
27
- urls = project_config.user_data_overrides.dig('trmnl', 'plugin_settings', 'polling_url') || @config['polling_url']
35
+ urls = project_config.user_data_overrides.dig('trmnl', 'plugin_settings',
36
+ 'polling_url') || @config['polling_url']
28
37
 
29
38
  return [] if urls.nil?
30
39
 
31
40
  with_custom_fields(urls).strip.split("\n")
32
41
  end
33
42
 
34
- def polling_url_text = polling_urls.join("\r\n") # for {{ trmnl }}
43
+ # for {{ trmnl }}
44
+ def polling_url_text = polling_urls.join("\r\n")
35
45
 
36
46
  def polling_verb = @config['polling_verb'] || 'GET'
37
47
 
38
48
  def polling_headers
39
- string_to_hash(@config['polling_headers'] || '').transform_values { |v| with_custom_fields(v) }
49
+ # NOTE: render Liquid across the full headers string first so {% if %} blocks
50
+ # spanning multiple key=value pairs are preserved. Splitting on
51
+ # '&' or '=' before rendering would shatter tags into multiple values.
52
+ rendered = with_custom_fields(@config['polling_headers'] || '')
53
+ string_to_hash(rendered)
40
54
  end
41
55
 
42
- def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&') # for {{ trmnl }}
56
+ # for {{ trmnl }}
57
+ def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&')
43
58
 
44
59
  def polling_body = with_custom_fields(@config['polling_body'] || '')
45
-
60
+
46
61
  def dark_mode = @config['dark_mode'] || 'no'
47
62
 
48
63
  def no_screen_padding = @config['no_screen_padding'] || 'no'
@@ -52,16 +67,43 @@ module TRMNLP
52
67
  def static_data
53
68
  JSON.parse(@config['static_data'] || '{}')
54
69
  rescue JSON::ParserError
55
- raise Error, 'invalid JSON in static_data'
70
+ raise InvalidConfig, 'invalid JSON in static_data'
56
71
  end
57
72
 
73
+ # Explicit language for transform.* code. If absent, the language
74
+ # is inferred from the file extension by Paths#transform_file.
75
+ # This one lives on the plugin (settings.yml) because production
76
+ # stores it on the plugin_setting record.
77
+ def serverless_language = @config['serverless_language']
78
+
79
+ # The TRMNL design-system version this plugin renders against.
80
+ # Lives on the plugin (settings.yml), like serverless_language,
81
+ # because production stores it on the plugin_setting record — so it
82
+ # round-trips through `trmnlp push` / `pull`. Accepts 'latest'
83
+ # (default), a pinned version, or nil (treated as latest). See
84
+ # db/data/framework_versions.yml for the supported set.
85
+ def framework_version
86
+ FrameworkVersion.new(@config['framework_version'], asset_host: project_config.asset_host)
87
+ rescue ArgumentError => e
88
+ raise InvalidConfig, e.message
89
+ end
90
+
91
+ # The custom-field *definitions* declared in settings.yml — the list
92
+ # of field hashes (keyname/name/field_type/...). Distinct from
93
+ # Config::Project#custom_fields, which holds the field *values*.
94
+ def custom_field_definitions = @config['custom_fields'] || []
95
+
96
+ # The raw parsed settings.yml hash. Most callers want the semantic
97
+ # readers above; `trmnlp lint` needs the uninterpreted values because
98
+ # it searches the raw {{ }} templates the semantic readers render away.
99
+ def settings = @config
100
+
58
101
  private
59
102
 
60
103
  attr_reader :paths, :project_config
61
104
 
62
105
  def with_custom_fields(value) = project_config.with_custom_fields(value)
63
106
 
64
- # copied from TRMNL core
65
107
  def string_to_hash(str, delimiter: '=')
66
108
  str.split('&').map do |k_v|
67
109
  key, value = k_v.split(delimiter)
@@ -72,4 +114,4 @@ module TRMNLP
72
114
  end
73
115
  end
74
116
  end
75
- end
117
+ end
@@ -1,6 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'trmnl/liquid'
2
4
  require 'yaml'
3
5
 
6
+ require_relative '../errors'
7
+ require_relative '../framework_version'
8
+
4
9
  module TRMNLP
5
10
  class Config
6
11
  class Project
@@ -12,11 +17,13 @@ module TRMNLP
12
17
  end
13
18
 
14
19
  def reload!
15
- if paths.trmnlp_config.exist?
16
- @config = YAML.load_file(paths.trmnlp_config)
17
- else
18
- @config = {}
19
- end
20
+ @config = if paths.trmnlp_config.exist?
21
+ YAML.safe_load_file(paths.trmnlp_config, permitted_classes: [Date, Time]) || {}
22
+ else
23
+ {}
24
+ end
25
+ rescue Psych::SyntaxError => e
26
+ raise InvalidConfig, ".trmnlp.yml is not valid YAML: #{e.message}"
20
27
  end
21
28
 
22
29
  def user_filters = @config['custom_filters'] || []
@@ -27,7 +34,7 @@ module TRMNLP
27
34
  (@config['watch'] || []).map { |watch_path| paths.expand(watch_path) }.uniq
28
35
  end
29
36
 
30
- def custom_fields = @config.fetch('custom_fields', {}).transform_values(&:to_s)
37
+ def custom_fields = @config.fetch('custom_fields', {}).transform_values { |v| stringify_field_value(v) }
31
38
 
32
39
  def user_data_overrides = @config['variables'] || {}
33
40
 
@@ -39,15 +46,60 @@ module TRMNLP
39
46
 
40
47
  def time_zone = @config['time_zone'] || 'UTC'
41
48
 
49
+ # Local override for the framework asset host (offline / mirrored
50
+ # dev). Trmnlp-specific (local dev only) — so it stays in
51
+ # .trmnlp.yml. Consumed by Config::Plugin#framework_version.
52
+ def asset_host = @config['framework_asset_host'] || FrameworkVersion::DEFAULT_ASSET_HOST
53
+
54
+ # Toggles serverless transform support. Enabled by default; set to
55
+ # 'disabled' in .trmnlp.yml to turn it off. A transform only runs
56
+ # when a src/transform.* file is also present, so the default is
57
+ # inert until the plugin actually ships one. Transforms run
58
+ # in-process via the bundled python/node/php/ruby interpreters; set
59
+ # serverless_daemon_url to route to a remote transform daemon
60
+ # instead. Lives in .trmnlp.yml because this is purely a
61
+ # local-dev decision.
62
+ def transform_runtime = @config['transform_runtime'] || 'enabled'
63
+
64
+ # Opt-in URL of a remote transform daemon. When set, transforms
65
+ # POST here instead of running locally — useful for
66
+ # production-fidelity testing or shared team daemons.
67
+ def serverless_daemon_url = @config['serverless_daemon_url']
68
+
69
+ # Bearer token for the remote transform daemon. Mirrors Config::App's
70
+ # ENV-first pattern so the secret stays out of version control —
71
+ # $TRMNL_SERVERLESS_DAEMON_API_KEY takes priority, falls through to
72
+ # the .trmnlp.yml key if no env var is set.
73
+ def serverless_daemon_api_key
74
+ env_key = ENV.fetch('TRMNL_SERVERLESS_DAEMON_API_KEY', nil)
75
+ return env_key if env_key && !env_key.empty?
76
+
77
+ @config['serverless_daemon_api_key']
78
+ end
79
+
42
80
  private
43
81
 
82
+ # NOTE: arrays (multi-select fields) and hashes are preserved as-is;
83
+ # only their leaf values are stringified to match production
84
+ # behavior.
85
+ def stringify_field_value(value)
86
+ case value
87
+ when Array then value.map(&:to_s)
88
+ when Hash then value.transform_values { |v| stringify_field_value(v) }
89
+ else value.to_s
90
+ end
91
+ end
92
+
44
93
  # for interpolating ENV vars into custom_fields
45
94
  def with_env(value)
95
+ return value.map { |v| with_env(v) } if value.is_a?(Array)
96
+ return value.transform_values { |v| with_env(v) } if value.is_a?(Hash)
97
+
46
98
  parse_liquid(value).render({ 'env' => ENV.to_h })
47
99
  end
48
100
 
49
101
  def parse_liquid(contents)
50
- Liquid::Template.parse(contents, environment: TRMNL::Liquid.build_environment)
102
+ Liquid::Template.parse(contents, environment: TRMNL::Liquid.new)
51
103
  end
52
104
  end
53
105
  end
data/lib/trmnlp/config.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'config/app'
2
4
  require_relative 'config/plugin'
3
5
  require_relative 'config/project'
@@ -12,4 +14,4 @@ module TRMNLP
12
14
  @plugin = Plugin.new(path, @project)
13
15
  end
14
16
  end
15
- end
17
+ end