trmnl_preview 0.7.2 → 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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +192 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +319 -0
  5. data/bin/rake +27 -0
  6. data/bin/trmnlp +13 -14
  7. data/db/data/form_fields.yml +24 -0
  8. data/db/data/framework_versions.yml +72 -0
  9. data/lib/trmnlp/api_client.rb +99 -0
  10. data/lib/trmnlp/app.rb +132 -0
  11. data/lib/trmnlp/browser_pool.rb +82 -0
  12. data/lib/trmnlp/cli.rb +81 -0
  13. data/lib/trmnlp/commands/base.rb +63 -0
  14. data/lib/trmnlp/commands/build.rb +26 -0
  15. data/lib/trmnlp/commands/clone.rb +35 -0
  16. data/lib/trmnlp/commands/init.rb +59 -0
  17. data/lib/trmnlp/commands/lint.rb +42 -0
  18. data/lib/trmnlp/commands/list.rb +40 -0
  19. data/lib/trmnlp/commands/login.rb +42 -0
  20. data/lib/trmnlp/commands/pull.rb +51 -0
  21. data/lib/trmnlp/commands/push.rb +77 -0
  22. data/lib/trmnlp/commands/serve.rb +54 -0
  23. data/lib/trmnlp/commands.rb +3 -0
  24. data/lib/trmnlp/config/app.rb +50 -0
  25. data/lib/trmnlp/config/plugin.rb +117 -0
  26. data/lib/trmnlp/config/project.rb +106 -0
  27. data/lib/trmnlp/config.rb +17 -0
  28. data/lib/trmnlp/context.rb +35 -0
  29. data/lib/trmnlp/errors.rb +15 -0
  30. data/lib/trmnlp/form_field.rb +42 -0
  31. data/lib/trmnlp/framework_version.rb +69 -0
  32. data/lib/trmnlp/image_quantizer.rb +58 -0
  33. data/lib/trmnlp/lint/check.rb +31 -0
  34. data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
  35. data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
  36. data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
  37. data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
  38. data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
  39. data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
  40. data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
  41. data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
  42. data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
  43. data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
  44. data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
  45. data/lib/trmnlp/lint/checks/title_length.rb +18 -0
  46. data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
  47. data/lib/trmnlp/lint/source.rb +42 -0
  48. data/lib/trmnlp/lint.rb +39 -0
  49. data/lib/trmnlp/paths.rb +78 -0
  50. data/lib/trmnlp/poller.rb +105 -0
  51. data/lib/trmnlp/renderer.rb +87 -0
  52. data/lib/trmnlp/reporter.rb +28 -0
  53. data/lib/trmnlp/screen.rb +16 -0
  54. data/lib/trmnlp/screen_generator.rb +34 -0
  55. data/lib/trmnlp/screenshot.rb +96 -0
  56. data/lib/trmnlp/transform_backend/http.rb +107 -0
  57. data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
  58. data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
  59. data/lib/trmnlp/transform_client.rb +47 -0
  60. data/lib/trmnlp/transform_pipeline.rb +65 -0
  61. data/lib/trmnlp/user_data_assembler.rb +96 -0
  62. data/lib/trmnlp/version.rb +5 -0
  63. data/lib/trmnlp/watcher.rb +60 -0
  64. data/lib/trmnlp.rb +10 -0
  65. data/templates/init/.trmnlp.yml +14 -0
  66. data/templates/init/bin/trmnlp +44 -0
  67. data/templates/init/src/full.liquid +1 -0
  68. data/templates/init/src/half_horizontal.liquid +1 -0
  69. data/templates/init/src/half_vertical.liquid +1 -0
  70. data/templates/init/src/quadrant.liquid +1 -0
  71. data/templates/init/src/settings.yml +16 -0
  72. data/templates/init/src/shared.liquid +1 -0
  73. data/templates/init/src/transform.py.example +14 -0
  74. data/trmnl_preview.gemspec +68 -0
  75. data/web/public/highlight/highlight.min.js +315 -0
  76. data/web/public/highlight/styles/atom-one-dark.min.css +1 -0
  77. data/web/public/index.css +119 -0
  78. data/web/public/index.js +96 -0
  79. data/web/public/trmnl-picker.js +656 -0
  80. data/web/views/index.erb +73 -0
  81. data/web/views/render_html.erb +25 -0
  82. metadata +324 -15
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../api_client'
5
+
6
+ module TRMNLP
7
+ module Commands
8
+ class List < Base
9
+ Options = Data.define(:dir, :quiet)
10
+
11
+ PRIVATE_PLUGIN_ID = 37
12
+
13
+ def call
14
+ authenticate!
15
+
16
+ api = APIClient.new(config)
17
+ response = api.get_plugin_settings
18
+ plugins = (response || [])
19
+ .select { |p| p['plugin_id'] == PRIVATE_PLUGIN_ID }
20
+ .sort_by { |p| (p['name'] || '').downcase }
21
+
22
+ if plugins.empty?
23
+ reporter.info 'No plugins found.'
24
+ return
25
+ end
26
+
27
+ reporter.info "Your plugins:\n\n"
28
+ reporter.info ' ID NAME'
29
+ reporter.info " #{'-' * 50}"
30
+
31
+ plugins.each do |plugin|
32
+ reporter.info format(' %-8s %s', plugin['id'], plugin['name'])
33
+ end
34
+
35
+ reporter.info "\nTo clone a plugin:"
36
+ reporter.info ' trmnlp clone [folder_name] [id]'
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../api_client'
5
+
6
+ module TRMNLP
7
+ module Commands
8
+ class Login < Base
9
+ Options = Data.define(:dir, :quiet)
10
+
11
+ def call
12
+ if config.app.logged_in?
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): ')
16
+ return unless confirm.strip.downcase == 'y'
17
+ end
18
+
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
+
28
+ config.app.api_key = api_key
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
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zip'
4
+
5
+ require_relative 'base'
6
+ require_relative '../api_client'
7
+
8
+ module TRMNLP
9
+ module Commands
10
+ class Pull < Base
11
+ Options = Data.define(:dir, :quiet, :id, :force)
12
+
13
+ def call
14
+ context.validate!
15
+ authenticate!
16
+
17
+ plugin_settings_id = options.id || config.plugin.id
18
+ raise PluginIdRequired, 'plugin ID must be specified' if plugin_settings_id.nil?
19
+
20
+ unless options.force
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)
23
+ end
24
+
25
+ api = APIClient.new(config)
26
+ tempfile = api.get_plugin_setting_archive(plugin_settings_id)
27
+ size = 0
28
+
29
+ begin
30
+ Zip::File.open(tempfile.path) do |zip_file|
31
+ zip_file.each do |entry|
32
+ dest_path = paths.src_dir.join(entry.name)
33
+ dest_path.dirname.mkpath
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)
39
+ end
40
+ end
41
+
42
+ size = File.size(tempfile.path)
43
+ ensure
44
+ tempfile.close
45
+ end
46
+
47
+ reporter.info "Downloaded plugin (#{size} bytes)"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+ require 'zip'
6
+
7
+ require_relative 'base'
8
+ require_relative '../api_client'
9
+
10
+ module TRMNLP
11
+ module Commands
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
+
20
+ def call
21
+ context.validate!
22
+ authenticate!
23
+
24
+ is_new = false
25
+
26
+ api = APIClient.new(config)
27
+
28
+ plugin_settings_id = options.id || config.plugin.id
29
+ if plugin_settings_id.nil?
30
+ reporter.info 'Creating a new plugin on the server...'
31
+ response = api.post_plugin_setting(name: 'New TRMNLP Plugin', plugin_id: 37) # hardcoded id for private_plugin
32
+ plugin_settings_id = response.dig('data', 'id')
33
+ is_new = true
34
+ end
35
+
36
+ unless is_new || options.force
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)
39
+ end
40
+
41
+ Zip::File.open(ZIP_PATH, create: true) do |zip_file|
42
+ paths.src_files.each do |file|
43
+ zip_file.add(File.basename(file), file)
44
+ end
45
+ end
46
+
47
+ response = api.post_plugin_setting_archive(plugin_settings_id, ZIP_PATH)
48
+ paths.plugin_config.write(response.dig('data', 'settings_yaml'))
49
+
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)}
55
+ HEREDOC
56
+
57
+ if is_new
58
+ reporter.info <<~HEREDOC
59
+
60
+ IMPORTANT! Don't forget to add it to your device playlist!
61
+
62
+ #{config.app.playlists_uri}
63
+ HEREDOC
64
+ end
65
+ rescue StandardError
66
+ if is_new && plugin_settings_id
67
+ reporter.info 'Error during creation, cleaning up...'
68
+ api.delete_plugin_setting(plugin_settings_id)
69
+ end
70
+
71
+ raise
72
+ ensure
73
+ FileUtils.rm_f(ZIP_PATH)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+
5
+ require_relative 'base'
6
+ require_relative '../api_client'
7
+ require_relative '../browser_pool'
8
+
9
+ module TRMNLP
10
+ module Commands
11
+ class Serve < Base
12
+ Options = Data.define(:dir, :quiet, :bind, :port)
13
+
14
+ def call
15
+ context.validate!
16
+ report_form_field_warnings
17
+
18
+ # Must come AFTER parsing options
19
+ require_relative '../app'
20
+
21
+ # Now we can configure things
22
+ App.set(:context, context)
23
+ App.set(:browser_pool, BrowserPool.new(driver_factory: method(:build_firefox_driver)))
24
+ App.set(:bind, options.bind)
25
+ App.set(:port, options.port)
26
+ permit_all_hosts if codespaces?
27
+
28
+ # Finally, start the app!
29
+ App.run!
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
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(__dir__, 'commands', '*.rb')].each { |file| require file }
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module TRMNLP
6
+ class Config
7
+ # Stores trmnlp-wide configuration (irrespective of the current plugin)
8
+ class App
9
+ def initialize(paths)
10
+ @paths = paths
11
+ @config = read_config
12
+ end
13
+
14
+ def save
15
+ paths.app_config_dir.mkpath
16
+ paths.app_config.write(YAML.dump(@config))
17
+ end
18
+
19
+ def logged_in? = api_key && !api_key.empty?
20
+ def logged_out? = !logged_in?
21
+
22
+ def api_key
23
+ env_api_key = ENV.fetch('TRMNL_API_KEY', nil)
24
+ return env_api_key if env_api_key && !env_api_key.empty?
25
+
26
+ @config['api_key']
27
+ end
28
+
29
+ def api_key=(key)
30
+ @config['api_key'] = key
31
+ end
32
+
33
+ def base_uri = URI.parse(@config['base_url'] || 'https://trmnl.com')
34
+
35
+ def api_uri = URI.join(base_uri, '/api')
36
+
37
+ def account_uri = URI.join(base_uri, '/account')
38
+
39
+ def edit_plugin_settings_uri(id) = URI.join(base_uri, "/plugin_settings/#{id}/edit")
40
+
41
+ def playlists_uri = URI.join(base_uri, '/playlists')
42
+
43
+ private
44
+
45
+ attr_reader :paths
46
+
47
+ def read_config = paths.app_config.exist? ? YAML.safe_load(paths.app_config.read) : {}
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'yaml'
5
+
6
+ require_relative '../errors'
7
+ require_relative '../framework_version'
8
+
9
+ module TRMNLP
10
+ class Config
11
+ class Plugin
12
+ def initialize(paths, project_config)
13
+ @paths = paths
14
+ @project_config = project_config
15
+ reload!
16
+ end
17
+
18
+ def reload!
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}"
26
+ end
27
+
28
+ def strategy = @config['strategy']
29
+ def polling? = strategy == 'polling'
30
+ def webhook? = strategy == 'webhook'
31
+ def static? = strategy == 'static'
32
+
33
+ def polling_urls
34
+ # allow project-level config to override
35
+ urls = project_config.user_data_overrides.dig('trmnl', 'plugin_settings',
36
+ 'polling_url') || @config['polling_url']
37
+
38
+ return [] if urls.nil?
39
+
40
+ with_custom_fields(urls).strip.split("\n")
41
+ end
42
+
43
+ # for {{ trmnl }}
44
+ def polling_url_text = polling_urls.join("\r\n")
45
+
46
+ def polling_verb = @config['polling_verb'] || 'GET'
47
+
48
+ def polling_headers
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)
54
+ end
55
+
56
+ # for {{ trmnl }}
57
+ def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&')
58
+
59
+ def polling_body = with_custom_fields(@config['polling_body'] || '')
60
+
61
+ def dark_mode = @config['dark_mode'] || 'no'
62
+
63
+ def no_screen_padding = @config['no_screen_padding'] || 'no'
64
+
65
+ def id = @config['id']
66
+
67
+ def static_data
68
+ JSON.parse(@config['static_data'] || '{}')
69
+ rescue JSON::ParserError
70
+ raise InvalidConfig, 'invalid JSON in static_data'
71
+ end
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
+
101
+ private
102
+
103
+ attr_reader :paths, :project_config
104
+
105
+ def with_custom_fields(value) = project_config.with_custom_fields(value)
106
+
107
+ def string_to_hash(str, delimiter: '=')
108
+ str.split('&').map do |k_v|
109
+ key, value = k_v.split(delimiter)
110
+ next if value.nil?
111
+
112
+ { key => CGI.unescape_uri_component(value) }
113
+ end.compact.reduce({}, :merge)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trmnl/liquid'
4
+ require 'yaml'
5
+
6
+ require_relative '../errors'
7
+ require_relative '../framework_version'
8
+
9
+ module TRMNLP
10
+ class Config
11
+ class Project
12
+ attr_reader :paths
13
+
14
+ def initialize(paths)
15
+ @paths = paths
16
+ reload!
17
+ end
18
+
19
+ def reload!
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}"
27
+ end
28
+
29
+ def user_filters = @config['custom_filters'] || []
30
+
31
+ def live_render? = !watch_paths.empty?
32
+
33
+ def watch_paths
34
+ (@config['watch'] || []).map { |watch_path| paths.expand(watch_path) }.uniq
35
+ end
36
+
37
+ def custom_fields = @config.fetch('custom_fields', {}).transform_values { |v| stringify_field_value(v) }
38
+
39
+ def user_data_overrides = @config['variables'] || {}
40
+
41
+ # for interpolating custom_fields into polling_* options
42
+ def with_custom_fields(value)
43
+ custom_fields_with_env = custom_fields.transform_values { |v| with_env(v) }
44
+ parse_liquid(value).render(custom_fields_with_env)
45
+ end
46
+
47
+ def time_zone = @config['time_zone'] || 'UTC'
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
+
80
+ private
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
+
93
+ # for interpolating ENV vars into custom_fields
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
+
98
+ parse_liquid(value).render({ 'env' => ENV.to_h })
99
+ end
100
+
101
+ def parse_liquid(contents)
102
+ Liquid::Template.parse(contents, environment: TRMNL::Liquid.new)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'config/app'
4
+ require_relative 'config/plugin'
5
+ require_relative 'config/project'
6
+
7
+ module TRMNLP
8
+ class Config
9
+ attr_reader :app, :project, :plugin
10
+
11
+ def initialize(path)
12
+ @app = App.new(path)
13
+ @project = Project.new(path)
14
+ @plugin = Plugin.new(path, @project)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'config'
4
+ require_relative 'paths'
5
+ require_relative 'poller'
6
+ require_relative 'renderer'
7
+ require_relative 'reporter'
8
+ require_relative 'transform_pipeline'
9
+ require_relative 'user_data_assembler'
10
+ require_relative 'watcher'
11
+
12
+ module TRMNLP
13
+ class Context
14
+ attr_reader :config, :paths, :reporter
15
+
16
+ def initialize(root_dir, reporter: Reporter.new)
17
+ @paths = Paths.new(root_dir)
18
+ @config = Config.new(paths)
19
+ @reporter = reporter
20
+ end
21
+
22
+ # Context is the composition root: it wires and memoizes the runtime
23
+ # object graph. Callers take the collaborator they need and talk to it
24
+ # directly — Context does not forward methods on their behalf.
25
+ def poller = @poller ||= Poller.new(config:, paths:, reporter:)
26
+ def transform_pipeline = @transform_pipeline ||= TransformPipeline.new(config:, paths:, reporter:)
27
+ def user_data_assembler = @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:)
28
+ def renderer = @renderer ||= Renderer.new(config:, paths:, user_data_assembler:)
29
+ def watcher = @watcher ||= Watcher.new(config:, user_data_assembler:, transform_pipeline:, reporter:)
30
+
31
+ def validate!
32
+ raise NotAPlugin, "not a plugin directory (did not find #{paths.trmnlp_config})" unless paths.valid?
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ class Error < StandardError; end
5
+
6
+ class NotLoggedIn < Error; end
7
+ class NotAPlugin < Error; end
8
+ class DirectoryExists < Error; end
9
+ class Aborted < Error; end
10
+ class PluginIdRequired < Error; end
11
+ class InvalidApiKey < Error; end
12
+ class AuthenticationFailed < Error; end
13
+ class InvalidConfig < Error; end
14
+ class RenderError < Error; end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module TRMNLP
6
+ # Validates entries in a plugin's settings.yml custom_fields list.
7
+ #
8
+ # The required keys and field-type allowlist are vendored from the hosted
9
+ # service into db/data/form_fields.yml — the same db/data convention
10
+ # FrameworkVersion uses. See that file's header for the refresh policy.
11
+ #
12
+ # Validation is intentionally lenient: it mirrors the hosted service's
13
+ # live form-field validation (presence checks only) and does NOT reject
14
+ # unknown keys — the hosted form views read far more keys than they
15
+ # validate.
16
+ module FormField
17
+ DATA_PATH = File.expand_path('../../db/data/form_fields.yml', __dir__)
18
+
19
+ def self.schema = @schema ||= YAML.safe_load_file(DATA_PATH).freeze
20
+ def self.required_keys = schema.fetch('required_keys')
21
+ def self.field_types = schema.fetch('field_types')
22
+
23
+ def self.multi_select?(field) = field['field_type'] == 'select' && field['multiple']
24
+
25
+ def self.validate(field)
26
+ warnings = []
27
+
28
+ required_keys.each do |key|
29
+ warnings << "missing required key: #{key}" unless field.key?(key) || field.key?(key.to_sym)
30
+ end
31
+
32
+ type = field['field_type'] || field[:field_type]
33
+ warnings << "unknown field_type: #{type}" if type && !field_types.include?(type)
34
+
35
+ warnings
36
+ end
37
+
38
+ def self.validate_all(fields)
39
+ Array(fields).flat_map { |field| validate(field) }
40
+ end
41
+ end
42
+ end