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
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class NoAsyncFunctions < Check
9
+ MESSAGE = 'Async JavaScript functions are not allowed due to browser ' \
10
+ 'timeout settings for generating screenshots.'
11
+
12
+ private
13
+
14
+ def pass? = !source.all_markup.downcase.include?('async function')
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class NoOpacity < Check
9
+ MESSAGE = 'Opacity should not be used, use the "--gray--##" Framework classes instead.'
10
+ LEARN_MORE = 'https://trmnl.com/framework/docs/text_color'
11
+ PATTERN = /opacity:\s*[\d.]+/
12
+
13
+ private
14
+
15
+ def pass? = !source.all_markup.match?(PATTERN)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class NoSizeClasses < Check
9
+ PATTERN = /\b(view(--|__)(full|half_horizontal|half_vertical|quadrant))\b("|')>/
10
+ MESSAGE = "We already apply the 'full', 'half_horizontal', 'half_vertical', and " \
11
+ "'quadrant' classes to each view, please remove them."
12
+
13
+ private
14
+
15
+ def pass? = !source.all_markup.match?(PATTERN)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class TitleCasing < Check
9
+ MESSAGE = 'Title should begin with a capital letter.'
10
+
11
+ private
12
+
13
+ def pass?
14
+ name = source.plugin_name
15
+ name.empty? || name[0] == name[0].upcase
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class TitleLength < Check
9
+ MAX_LENGTH = 50
10
+ MESSAGE = "Title should be <= #{MAX_LENGTH} characters long.".freeze
11
+
12
+ private
13
+
14
+ def pass? = source.plugin_name.length <= MAX_LENGTH
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class WaitsForDomLoad < Check
9
+ MESSAGE = 'JavaScript should listen for the DOMContentLoaded event, not window.onLoad()'
10
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/9510536-private-plugins#h_db7030f8b8'
11
+ FORBIDDEN = ['window.onload', 'window.addeventlistener("load")',
12
+ "window.addeventlistener('load')"].freeze
13
+
14
+ private
15
+
16
+ def pass?
17
+ markup = source.all_markup.downcase
18
+ FORBIDDEN.none? { |token| markup.include?(token) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module Lint
5
+ # The plugin data every lint check examines: markup per view, shared
6
+ # markup, the combined markup string, and the raw settings.yml. Built
7
+ # once and shared across all checks. Settings come straight from
8
+ # Config::Plugin (a single parse); checks read the raw `{{ }}` templates,
9
+ # which Config::Plugin's semantic readers render away.
10
+ class Source
11
+ VIEWS = %w[full half_horizontal half_vertical quadrant].freeze
12
+
13
+ def initialize(config:, paths:)
14
+ @config = config
15
+ @paths = paths
16
+ end
17
+
18
+ def plugin_name = settings['name'].to_s
19
+ def settings = config.plugin.settings
20
+ def custom_field_values = config.project.custom_fields
21
+ def custom_field_definitions = config.plugin.custom_field_definitions
22
+
23
+ def view_markup
24
+ @view_markup ||= VIEWS.to_h { |view| [view, read(paths.template(view))] }
25
+ end
26
+
27
+ def shared_markup
28
+ @shared_markup ||= read(paths.shared_template)
29
+ end
30
+
31
+ def all_markup
32
+ @all_markup ||= view_markup.values.join + shared_markup
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :config, :paths
38
+
39
+ def read(path) = path.exist? ? path.read.strip : ''
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lint/check'
4
+ require_relative 'lint/source'
5
+ require_relative 'lint/checks/title_casing'
6
+ require_relative 'lint/checks/title_length'
7
+ require_relative 'lint/checks/layouts_have_content'
8
+ require_relative 'lint/checks/no_async_functions'
9
+ require_relative 'lint/checks/waits_for_dom_load'
10
+ require_relative 'lint/checks/limited_inline_styles'
11
+ require_relative 'lint/checks/no_size_classes'
12
+ require_relative 'lint/checks/no_opacity'
13
+ require_relative 'lint/checks/highcharts_animations_disabled'
14
+ require_relative 'lint/checks/highcharts_elements_unique'
15
+ require_relative 'lint/checks/image_links_reachable'
16
+ require_relative 'lint/checks/custom_fields_used'
17
+ require_relative 'lint/checks/form_fields_valid'
18
+
19
+ module TRMNLP
20
+ # Markup best-practice checks behind `trmnlp lint`.
21
+ module Lint
22
+ # Every check the lint command runs, in report order.
23
+ CHECKS = [
24
+ Checks::TitleCasing,
25
+ Checks::TitleLength,
26
+ Checks::LayoutsHaveContent,
27
+ Checks::NoAsyncFunctions,
28
+ Checks::WaitsForDomLoad,
29
+ Checks::LimitedInlineStyles,
30
+ Checks::NoSizeClasses,
31
+ Checks::NoOpacity,
32
+ Checks::HighchartsAnimationsDisabled,
33
+ Checks::HighchartsElementsUnique,
34
+ Checks::ImageLinksReachable,
35
+ Checks::CustomFieldsUsed,
36
+ Checks::FormFieldsValid
37
+ ].freeze
38
+ end
39
+ end
data/lib/trmnlp/paths.rb CHANGED
@@ -1,22 +1,24 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'xdg'
2
4
 
3
5
  module TRMNLP
4
6
  class Paths
5
7
  attr_reader :root_dir
6
-
8
+
7
9
  def initialize(root_dir)
8
10
  @root_dir = Pathname.new(root_dir)
9
11
  @xdg = XDG.new
10
12
  end
11
-
13
+
12
14
  # --- trmnlp library ---
13
-
15
+
14
16
  def gem_dir = Pathname.new(__dir__).join('..', '..').expand_path
15
17
 
16
18
  def templates_dir = gem_dir.join('templates')
17
19
 
18
20
  # --- directories ---
19
-
21
+
20
22
  def src_dir = root_dir.join('src')
21
23
 
22
24
  def build_dir = root_dir.join('_build')
@@ -34,19 +36,37 @@ module TRMNLP
34
36
  def trmnlp_config = root_dir.join('.trmnlp.yml')
35
37
 
36
38
  def plugin_config = src_dir.join('settings.yml')
37
-
39
+
38
40
  def template(view) = src_dir.join("#{view}.liquid")
39
41
 
40
42
  def shared_template = template('shared')
41
-
43
+
42
44
  def app_config = app_config_dir.join('config.yml')
43
45
 
44
46
  def user_data = cache_dir.join('data.json')
45
47
 
46
48
  def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
47
-
49
+
48
50
  def src_files = src_dir.glob('*').select(&:file?)
49
51
 
52
+ # File extension → transform language identifier.
53
+ TRANSFORM_EXTENSIONS = {
54
+ '.py' => 'python',
55
+ '.rb' => 'ruby',
56
+ '.php' => 'php',
57
+ '.js' => 'node'
58
+ }.freeze
59
+
60
+ # Locate src/transform.{py,rb,php,js}. Returns [Pathname, language]
61
+ # or [nil, nil] if no transform file exists.
62
+ def transform_file
63
+ TRANSFORM_EXTENSIONS.each do |ext, language|
64
+ candidate = src_dir.join("transform#{ext}")
65
+ return [candidate, language] if candidate.exist?
66
+ end
67
+ [nil, nil]
68
+ end
69
+
50
70
  # --- utilities ---
51
71
 
52
72
  def expand(path) = Pathname.new(path).expand_path(root_dir)
@@ -55,4 +75,4 @@ module TRMNLP
55
75
 
56
76
  attr_reader :xdg
57
77
  end
58
- end
78
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/conversions'
4
+ require 'faraday'
5
+ require 'json'
6
+
7
+ require_relative 'reporter'
8
+
9
+ module TRMNLP
10
+ class Poller
11
+ def initialize(config:, paths:, reporter: Reporter.new)
12
+ @config = config
13
+ @paths = paths
14
+ @reporter = reporter
15
+ end
16
+
17
+ def poll_data
18
+ return unless config.plugin.polling?
19
+ raise InvalidConfig, 'config must specify polling_url or polling_urls' if config.plugin.polling_urls.empty?
20
+
21
+ data = aggregate_responses
22
+ write_user_data(data)
23
+ data
24
+ # NOTE: trmnlp is a dev tool — a flaky upstream API should surface a warning
25
+ # and keep the preview server alive, not crash the user's session. We
26
+ # deliberately swallow here and return {} so the renderer keeps rendering.
27
+ rescue StandardError => e
28
+ reporter.info("warning: #{e.message}")
29
+ {}
30
+ end
31
+
32
+ def put_webhook(payload)
33
+ write_user_data(wrap_array(JSON.parse(payload)))
34
+ # NOTE: Same rationale as #poll_data — a bad webhook payload shouldn't take
35
+ # down the dev server. Report a warning and keep serving.
36
+ rescue StandardError => e
37
+ reporter.info("webhook warning: #{e.message}")
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :config, :paths, :reporter
43
+
44
+ def aggregate_responses
45
+ responses = config.plugin.polling_urls.map { |url| fetch_one(url) }
46
+ return responses.first if responses.size == 1
47
+
48
+ responses.each_with_index.with_object({}) { |(r, i), h| h["IDX_#{i}"] = r }
49
+ end
50
+
51
+ def fetch_one(url)
52
+ verb = config.plugin.polling_verb.upcase
53
+ response = perform_request(url, verb)
54
+ reporter.info("#{verb} #{url} — received #{response.body.length} bytes (#{response.status} status)")
55
+ parse_response(response)
56
+ end
57
+
58
+ def perform_request(url, verb)
59
+ conn = Faraday.new(url:, headers: config.plugin.polling_headers)
60
+ verb == 'POST' ? conn.post { |req| req.body = config.plugin.polling_body } : conn.get
61
+ end
62
+
63
+ def parse_response(response)
64
+ return parse_failure(response.body) unless response.status == 200
65
+
66
+ parse_body(response.body, response.headers['content-type'])
67
+ end
68
+
69
+ def parse_failure(body)
70
+ reporter.info(body)
71
+ {}
72
+ end
73
+
74
+ def parse_body(body, content_type_header)
75
+ content_type = content_type_header&.split(';')&.first&.strip
76
+ case content_type
77
+ when 'application/json', %r{^application/.+\+json} then wrap_array(JSON.parse(body))
78
+ when 'text/xml', 'application/xml', %r{^application/.+\+xml} then wrap_array(Hash.from_xml(body))
79
+ when 'text/html', 'text/plain' then sniff_json(body) || { 'text' => body }
80
+ else log_unknown_type(content_type_header)
81
+ end
82
+ end
83
+
84
+ def log_unknown_type(header)
85
+ reporter.info("unknown content type received: #{header}")
86
+ {}
87
+ end
88
+
89
+ def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
90
+
91
+ def sniff_json(body)
92
+ trimmed = body.to_s.strip
93
+ return nil unless trimmed.start_with?('{', '[')
94
+
95
+ wrap_array(JSON.parse(trimmed))
96
+ rescue JSON::ParserError
97
+ nil
98
+ end
99
+
100
+ def write_user_data(data)
101
+ paths.create_cache_dir
102
+ paths.user_data.write(JSON.generate(data))
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'trmnl/liquid'
5
+
6
+ require_relative 'screen'
7
+
8
+ module TRMNLP
9
+ class Renderer
10
+ def initialize(config:, paths:, user_data_assembler:)
11
+ @config = config
12
+ @paths = paths
13
+ @user_data_assembler = user_data_assembler
14
+ end
15
+
16
+ def render_full_page(view, params = {})
17
+ device = user_data_assembler.device_from_params(params)
18
+ binding_obj = TemplateBinding.new(self, view, params)
19
+ ERB.new(paths.render_template.read).result(
20
+ binding_obj.get_binding { render_or_error(view, device:) }
21
+ )
22
+ end
23
+
24
+ def framework = config.plugin.framework_version
25
+
26
+ def screen_classes(classes = 'screen')
27
+ classes ||= 'screen' # an explicit nil (omitted screen_classes param) still needs a base
28
+ classes += ' screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
29
+ classes
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :config, :paths, :user_data_assembler
35
+
36
+ # NOTE: a missing template or Liquid syntax error is a user-facing
37
+ # signal — the plugin author needs to *see* what broke. We surface
38
+ # those as RenderError, then #render_or_error embeds the message
39
+ # inside the preview frame so the dev server keeps serving instead
40
+ # of 500-ing. Anything that's not a RenderError bubbles up as a bug.
41
+ def render_liquid_template(view, device: {})
42
+ template_path = paths.template(view)
43
+ raise RenderError, "Missing template: #{template_path}" unless template_path.exist?
44
+
45
+ parse_and_render(template_path, device:)
46
+ end
47
+
48
+ def parse_and_render(template_path, device:)
49
+ Liquid::Template.parse(full_markup(template_path), environment: liquid_environment)
50
+ .render(user_data_assembler.call(device:))
51
+ rescue StandardError => e
52
+ raise RenderError, e.message
53
+ end
54
+
55
+ def render_or_error(view, device:)
56
+ render_liquid_template(view, device:)
57
+ rescue RenderError => e
58
+ e.message
59
+ end
60
+
61
+ def full_markup(template_path)
62
+ shared = paths.shared_template
63
+ shared.exist? ? shared.read + template_path.read : template_path.read
64
+ end
65
+
66
+ def liquid_environment
67
+ @liquid_environment ||= TRMNL::Liquid.new do |env|
68
+ config.project.user_filters.each do |module_name, relative_path|
69
+ require paths.root_dir.join(relative_path)
70
+ env.register_filter(Object.const_get(module_name))
71
+ end
72
+ end
73
+ end
74
+
75
+ # bindings must match the `GET /render/{view}.html` route in app.rb
76
+ class TemplateBinding
77
+ def initialize(renderer, view, params)
78
+ @view = view
79
+ @screen_classes = renderer.screen_classes(params[:screen_classes])
80
+ @framework = renderer.framework
81
+ @mashup_classes = Screen.find(view)&.mashup_classes
82
+ end
83
+
84
+ def get_binding = binding
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ # Single sink for user-facing command output. Records every message so
5
+ # specs can assert on what a command would have said, and writes to the
6
+ # underlying stream unless quiet:.
7
+ class Reporter
8
+ attr_reader :messages
9
+
10
+ def initialize(quiet: false, stream: $stdout)
11
+ @quiet = quiet
12
+ @stream = stream
13
+ @messages = []
14
+ end
15
+
16
+ def info(message)
17
+ @messages << message
18
+ @stream.puts(message) unless @quiet
19
+ end
20
+
21
+ def green(text) = colorize(text, 32)
22
+ def yellow(text) = colorize(text, 33)
23
+
24
+ private
25
+
26
+ def colorize(text, code) = "\e[#{code}m#{text}\e[0m"
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ class Screen < Data.define(:name, :mashup_classes)
5
+ FULL = new(name: 'full', mashup_classes: nil)
6
+ HALF_HORIZONTAL = new(name: 'half_horizontal', mashup_classes: 'mashup mashup--1Tx1B')
7
+ HALF_VERTICAL = new(name: 'half_vertical', mashup_classes: 'mashup mashup--1Lx1R')
8
+ QUADRANT = new(name: 'quadrant', mashup_classes: 'mashup mashup--2x2')
9
+
10
+ ALL = [FULL, HALF_HORIZONTAL, HALF_VERTICAL, QUADRANT].freeze
11
+
12
+ def self.all = ALL
13
+ def self.find(name) = ALL.find { it.name == name }
14
+ def self.names = ALL.map(&:name)
15
+ end
16
+ end