trmnl_preview 0.7.0 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -6
  3. data/README.md +182 -7
  4. data/bin/rake +6 -6
  5. data/bin/trmnlp +2 -1
  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 +7 -4
  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 +2 -1
  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/public/trmnl-picker.js +2 -2
  71. data/web/views/index.erb +6 -1
  72. data/web/views/render_html.erb +4 -2
  73. metadata +81 -56
data/lib/trmnlp/app.rb CHANGED
@@ -1,103 +1,132 @@
1
+ # frozen_string_literal: true
1
2
 
2
- require 'faye/websocket'
3
3
  require 'sinatra'
4
4
  require 'sinatra/base'
5
5
 
6
6
  require_relative 'context'
7
7
  require_relative 'screen_generator'
8
+ require_relative 'screenshot'
8
9
 
9
10
  module TRMNLP
10
11
  class App < Sinatra::Base
11
12
  # Sinatra settings
12
13
  set :views, File.join(File.dirname(__FILE__), '..', '..', 'web', 'views')
13
14
  set :public_folder, File.join(File.dirname(__FILE__), '..', '..', 'web', 'public')
14
-
15
+
16
+ helpers do
17
+ def format_bytes(bytes)
18
+ bytes < 1024 ? "#{bytes} bytes" : format('%.1f KB', bytes / 1024.0)
19
+ end
20
+
21
+ # NOTE: render_html.erb's layout yields raw plugin HTML through `<%= yield %>`,
22
+ # so a global `escape_html` setting would corrupt the render. Escape per-value.
23
+ def h(text)
24
+ Rack::Utils.escape_html(text.to_s)
25
+ end
26
+ end
27
+
15
28
  def initialize(*args)
16
29
  super
17
30
 
18
31
  @context = settings.context
32
+ @poller = @context.poller
33
+ @renderer = @context.renderer
34
+ @user_data_assembler = @context.user_data_assembler
35
+ @transform_pipeline = @context.transform_pipeline
36
+ @watcher = @context.watcher
37
+ @screenshot = Screenshot.new(pool: settings.browser_pool)
19
38
 
20
- @context.poll_data
39
+ @poller.poll_data
21
40
 
22
- @context.start_filewatcher if @context.config.project.live_render?
41
+ @watcher.start if @context.config.project.live_render?
23
42
 
24
43
  @live_reload_clients = []
25
- @context.on_view_change do |view, user_data|
26
- @live_reload_clients.each do |ws|
27
- payload = {
28
- 'type' => 'reload',
29
- 'view' => view,
30
- 'user_data' => user_data
31
- }
32
-
33
- ws.send(payload.to_json)
34
- end
44
+ @watcher.on_change do |view, user_data|
45
+ payload = {
46
+ 'type' => 'reload',
47
+ 'view' => view,
48
+ 'user_data' => user_data
49
+ }
50
+ message = "data: #{payload.to_json}\n\n"
51
+ @live_reload_clients.each { |queue| queue << message }
35
52
  end
36
53
  end
37
54
 
38
55
  post '/webhook' do
39
- @context.put_webhook(request.body.read)
40
- "OK"
56
+ @poller.put_webhook(request.body.read)
57
+ 'OK'
41
58
  end
42
-
59
+
43
60
  get '/' do
44
61
  redirect '/full'
45
62
  end
46
63
 
47
64
  get '/data' do
48
65
  content_type :json
49
- JSON.pretty_generate(@context.user_data)
66
+ device = @user_data_assembler.device_from_params(params)
67
+ JSON.pretty_generate(@user_data_assembler.call(device:))
50
68
  end
51
69
 
70
+ # Live reload is a one-directional server->browser push, so it uses
71
+ # server-sent events rather than a websocket. Each client gets a blocking
72
+ # queue; the stream thread parks on queue.pop until the watcher broadcasts.
52
73
  get '/live_reload' do
53
- ws = Faye::WebSocket.new(request.env)
54
-
55
- ws.on(:open) do |event|
56
- @live_reload_clients << ws
57
- end
58
-
59
- ws.on(:close) do |event|
60
- @live_reload_clients.delete(ws)
74
+ content_type 'text/event-stream'
75
+ queue = Thread::Queue.new
76
+ @live_reload_clients << queue
77
+ stream(:keep_open) do |out|
78
+ out.callback { @live_reload_clients.delete(queue) }
79
+ out << queue.pop until out.closed?
61
80
  end
62
-
63
- ws.rack_response
64
81
  end
65
82
 
66
83
  get '/poll' do
67
- @context.poll_data
84
+ @poller.poll_data
68
85
  redirect back
69
86
  end
70
-
71
- VIEWS.each do |view|
87
+
88
+ Screen.all.each do |screen|
89
+ view = screen.name
72
90
  get "/#{view}" do
73
91
  @view = view
74
- @user_data = JSON.pretty_generate(@context.user_data)
92
+ device = @user_data_assembler.device_from_params(params)
93
+ user_data = @user_data_assembler.call(device:)
94
+ @user_data = JSON.pretty_generate(user_data)
95
+ # Measured on compact JSON, the way the hosted service sizes merge variables.
96
+ @payload_size = JSON.generate(user_data).bytesize
75
97
  @live_reload = @context.config.project.live_render?
98
+ @transform_error = @transform_pipeline.error
76
99
 
77
100
  erb :index
78
101
  end
79
102
 
80
103
  get "/render/#{view}.html" do
81
- @context.render_full_page(view, params)
104
+ @renderer.render_full_page(view, params)
82
105
  end
83
-
106
+
84
107
  get "/render/#{view}.png" do
85
108
  @view = view
86
- html = @context.render_full_page(view, params)
109
+ html = @renderer.render_full_page(view, params)
110
+ temp_image = render_png(html, params)
87
111
 
88
- # Parse optional rendering params (sent by the web UI for PNG output)
89
- width = params[:width] && params[:width].to_i
90
- height = params[:height] && params[:height].to_i
91
- color_depth = params[:color_depth] && params[:color_depth].to_i
92
-
93
- generator = ScreenGenerator.new(html, image: true, width: width, height: height, color_depth: color_depth)
94
- temp_image = generator.process
95
-
96
112
  send_file temp_image.path, type: 'image/png', disposition: 'inline'
97
113
 
98
114
  temp_image.close
99
115
  temp_image.unlink
100
116
  end
101
117
  end
118
+
119
+ private
120
+
121
+ # ScreenGenerator is request-scoped — it carries the per-request width,
122
+ # height, and color_depth — so it is built here rather than on the shared
123
+ # Context graph. Screenshots are a serve-only concern and would not belong
124
+ # on a Context shared by every command (build, lint, push, ...).
125
+ def render_png(html, params)
126
+ ScreenGenerator.new(html, screenshot: @screenshot,
127
+ width: params[:width]&.to_i,
128
+ height: params[:height]&.to_i,
129
+ color_depth: params[:color_depth]&.to_i).process
130
+ end
102
131
  end
103
- end
132
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ class BrowserPool
5
+ def initialize(driver_factory:, max_size: 2)
6
+ @driver_factory = driver_factory
7
+ @max_size = max_size
8
+ @drivers = []
9
+ @available = Queue.new
10
+ @mutex = Mutex.new
11
+ @shutdown = false
12
+
13
+ at_exit { shutdown }
14
+ end
15
+
16
+ def with_driver
17
+ driver = checkout
18
+ yield driver
19
+ ensure
20
+ checkin(driver) if driver
21
+ end
22
+
23
+ def shutdown
24
+ @mutex.synchronize do
25
+ return if @shutdown
26
+
27
+ @shutdown = true
28
+ @drivers.each do |driver|
29
+ driver.quit
30
+ rescue StandardError
31
+ nil
32
+ end
33
+ @drivers.clear
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def checkout
40
+ driver = acquire
41
+ healthy?(driver) ? driver : recycle(driver)
42
+ end
43
+
44
+ def acquire
45
+ pop_available || build_new || @available.pop
46
+ end
47
+
48
+ def pop_available
49
+ @available.pop(true)
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ def build_new
55
+ @mutex.synchronize do
56
+ return nil if @drivers.size >= @max_size
57
+
58
+ @driver_factory.call.tap { |d| @drivers << d }
59
+ end
60
+ end
61
+
62
+ def healthy?(driver)
63
+ driver.title
64
+ true
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ def recycle(driver)
70
+ @mutex.synchronize do
71
+ @drivers.delete(driver)
72
+ @driver_factory.call.tap { |d| @drivers << d }
73
+ end
74
+ end
75
+
76
+ def checkin(driver)
77
+ return if @shutdown
78
+
79
+ @available.push(driver)
80
+ end
81
+ end
82
+ end
data/lib/trmnlp/cli.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thor'
2
4
 
3
5
  require_relative '../trmnlp'
@@ -8,7 +10,7 @@ module TRMNLP
8
10
  package_name 'trmnlp'
9
11
 
10
12
  class_option :dir, type: :string, default: Dir.pwd, aliases: '-d',
11
- desc: 'Plugin project directory'
13
+ desc: 'Plugin project directory'
12
14
 
13
15
  class_option :quiet, type: :boolean, default: false, desc: 'Suppress output', aliases: '-q'
14
16
 
@@ -18,46 +20,57 @@ module TRMNLP
18
20
 
19
21
  desc 'build', 'Generate static HTML files'
20
22
  def build
21
- Commands::Build.new(options).call
23
+ Commands::Build.run(options)
22
24
  end
23
25
 
24
26
  desc 'login', 'Authenticate with TRMNL server'
25
27
  def login
26
- Commands::Login.new(options).call
28
+ Commands::Login.run(options)
27
29
  end
28
30
 
29
31
  desc 'init NAME', 'Start a new plugin project'
30
32
  method_option :skip_liquid, type: :boolean, default: false, desc: 'Skip generating liquid templates'
31
33
  def init(name)
32
- Commands::Init.new(options).call(name)
34
+ Commands::Init.run(options, name)
33
35
  end
34
36
 
35
37
  desc 'clone NAME ID', 'Copy a plugin project from TRMNL server'
36
38
  def clone(name, id)
37
- Commands::Clone.new(options).call(name, id)
39
+ Commands::Clone.run(options, name, id)
40
+ end
41
+
42
+ desc 'list', 'List private plugins from TRMNL server'
43
+ def list
44
+ Commands::List.run(options)
38
45
  end
39
46
 
40
47
  desc 'pull', 'Download latest plugin settings from TRMNL server'
41
48
  method_option :force, type: :boolean, default: false, aliases: '-f',
42
- desc: 'Skip confirmation prompts'
49
+ desc: 'Skip confirmation prompts'
43
50
  method_option :id, type: :string, aliases: '-i', desc: 'Plugin settings ID'
44
51
  def pull
45
- Commands::Pull.new(options).call
52
+ Commands::Pull.run(options)
46
53
  end
47
54
 
48
55
  desc 'push', 'Upload latest plugin settings to TRMNL server'
49
56
  method_option :force, type: :boolean, default: false, aliases: '-f',
50
- desc: 'Skip confirmation prompts'
57
+ desc: 'Skip confirmation prompts'
51
58
  method_option :id, type: :string, aliases: '-i', desc: 'Plugin settings ID'
52
59
  def push
53
- Commands::Push.new(options).call
60
+ Commands::Push.run(options)
61
+ end
62
+
63
+ desc 'lint', 'Check plugin code against TRMNL best practices'
64
+ def lint
65
+ # Exit non-zero when issues are found so CI pipelines can gate on it.
66
+ exit(1) unless Commands::Lint.run(options)
54
67
  end
55
68
 
56
69
  desc 'serve', 'Start a local dev server'
57
70
  method_option :bind, type: :string, default: default_bind, aliases: '-b', desc: 'Bind address'
58
71
  method_option :port, type: :numeric, default: 4567, aliases: '-p', desc: 'Port number'
59
72
  def serve
60
- Commands::Serve.new(options).call
73
+ Commands::Serve.run(options)
61
74
  end
62
75
 
63
76
  desc 'version', 'Show version'
@@ -65,4 +78,4 @@ module TRMNLP
65
78
  puts VERSION
66
79
  end
67
80
  end
68
- end
81
+ end
@@ -1,15 +1,34 @@
1
- require 'thor/core_ext/hash_with_indifferent_access'
1
+ # frozen_string_literal: true
2
2
 
3
3
  require_relative '../context'
4
+ require_relative '../form_field'
5
+ require_relative '../reporter'
4
6
 
5
7
  module TRMNLP
6
8
  module Commands
7
9
  class Base
8
- include Thor::CoreExt
10
+ def self.run(input, *)
11
+ options = options_from(input)
12
+ reporter = Reporter.new(quiet: options.quiet)
13
+ new(context: Context.new(options.dir, reporter:), options:, reporter:).call(*)
14
+ end
15
+
16
+ # NOTE: Thor only includes flags the user actually passed, but Data.define
17
+ # requires every member. We pad missing members with nil so partial Thor
18
+ # hashes round-trip into a fully-populated typed Options struct.
19
+ def self.options_from(input)
20
+ return input if input.is_a?(self::Options)
21
+
22
+ hash = input.to_h.transform_keys(&:to_sym)
23
+ self::Options.new(**self::Options.members.to_h { [it, hash[it]] })
24
+ end
25
+
26
+ def initialize(context:, options:, reporter: nil)
27
+ raise ArgumentError, "options must be a #{self.class}::Options" unless options.is_a?(self.class::Options)
9
28
 
10
- def initialize(options = HashWithIndifferentAccess.new)
11
- @options = HashWithIndifferentAccess.new(options)
12
- @context = Context.new(@options.dir)
29
+ @context = context
30
+ @options = options
31
+ @reporter = reporter || Reporter.new(quiet: options.quiet)
13
32
  end
14
33
 
15
34
  def call
@@ -18,17 +37,21 @@ module TRMNLP
18
37
 
19
38
  protected
20
39
 
21
- attr_accessor :options, :context
40
+ attr_accessor :options, :context, :reporter
22
41
 
23
42
  def config = context.config
24
43
  def paths = context.paths
25
44
 
26
45
  def authenticate!
27
- raise Error, "please run `trmnlp login`" unless config.app.logged_in?
46
+ raise NotLoggedIn, 'please run `trmnlp login`' unless config.app.logged_in?
28
47
  end
29
48
 
30
- def output(message)
31
- puts(message) unless options.quiet?
49
+ # Non-blocking: warn about malformed settings.yml custom_fields so a
50
+ # plugin author notices before the field misbehaves in production.
51
+ def report_form_field_warnings
52
+ FormField.validate_all(config.plugin.custom_field_definitions).each do |warning|
53
+ reporter.info("warning: settings.yml custom_fields — #{warning}")
54
+ end
32
55
  end
33
56
 
34
57
  def prompt(message)
@@ -37,4 +60,4 @@ module TRMNLP
37
60
  end
38
61
  end
39
62
  end
40
- end
63
+ end
@@ -1,21 +1,26 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'base'
2
4
 
3
5
  module TRMNLP
4
6
  module Commands
5
7
  class Build < Base
8
+ Options = Data.define(:dir, :quiet)
9
+
6
10
  def call
7
11
  context.validate!
8
- context.poll_data
12
+ report_form_field_warnings
13
+ context.poller.poll_data
9
14
  context.paths.create_build_dir
10
15
 
11
- VIEWS.each do |view|
12
- output_path = context.paths.build_dir.join("#{view}.html")
13
- output "Writing #{output_path}..."
14
- output_path.write(context.render_full_page(view))
16
+ Screen.all.each do |screen|
17
+ output_path = context.paths.build_dir.join("#{screen.name}.html")
18
+ reporter.info "Writing #{output_path}..."
19
+ output_path.write(context.renderer.render_full_page(screen.name))
15
20
  end
16
-
17
- output "Done!"
21
+
22
+ reporter.info 'Done!'
18
23
  end
19
24
  end
20
25
  end
21
- end
26
+ end
@@ -1,24 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'base'
4
+ require_relative 'init'
2
5
  require_relative 'pull'
3
6
 
4
7
  module TRMNLP
5
8
  module Commands
6
9
  class Clone < Base
10
+ Options = Data.define(:dir, :quiet)
11
+
7
12
  def call(directory_name, id)
8
13
  authenticate!
9
14
 
10
15
  destination_path = Pathname.new(options.dir).join(directory_name)
11
- raise Error, "directory #{destination_path} already exists, aborting" if destination_path.exist?
16
+ raise DirectoryExists, "directory #{destination_path} already exists, aborting" if destination_path.exist?
12
17
 
13
- Init.new(dir: options.dir, skip_liquid: true, quiet: true).call(directory_name)
18
+ Init.run({ dir: options.dir, skip_liquid: true, quiet: true }, directory_name)
14
19
 
15
- Pull.new(dir: destination_path.to_s, force: true, id: id).call
20
+ Pull.run({ dir: destination_path.to_s, force: true, id: id })
16
21
 
17
- output <<~HEREDOC
22
+ reporter.info <<~HEREDOC
18
23
 
19
- To start the local server:
24
+ To start the local server:
20
25
 
21
- cd #{Pathname.new(destination_path).relative_path_from(Dir.pwd)} && trmnlp serve
26
+ cd #{Pathname.new(destination_path).relative_path_from(Dir.pwd)} && trmnlp serve
22
27
  HEREDOC
23
28
  end
24
29
 
@@ -27,4 +32,4 @@ module TRMNLP
27
32
  def template_dir = paths.templates_dir.join('init')
28
33
  end
29
34
  end
30
- end
35
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'fileutils'
2
4
 
3
5
  require_relative 'base'
@@ -5,11 +7,13 @@ require_relative 'base'
5
7
  module TRMNLP
6
8
  module Commands
7
9
  class Init < Base
10
+ Options = Data.define(:dir, :quiet, :skip_liquid)
11
+
8
12
  def call(name)
9
13
  destination_dir = Pathname.new(options.dir).join(name)
10
14
 
11
15
  unless destination_dir.exist?
12
- output "Creating #{destination_dir}"
16
+ reporter.info "Creating #{destination_dir}"
13
17
  destination_dir.mkpath
14
18
  end
15
19
 
@@ -20,36 +24,36 @@ module TRMNLP
20
24
  relative_pathname = source_pathname.relative_path_from(template_dir)
21
25
  destination_pathname = destination_dir.join(relative_pathname)
22
26
  destination_pathname.dirname.mkpath
23
-
27
+
24
28
  if destination_pathname.exist?
25
29
  answer = prompt("#{destination_pathname} already exists. Overwrite? (y/n): ").downcase
26
30
  if answer != 'y'
27
- output "Skipping #{destination_pathname}"
31
+ reporter.info "Skipping #{destination_pathname}"
28
32
  next
29
33
  end
30
34
  end
31
35
 
32
- output "Creating #{destination_pathname}"
36
+ reporter.info "Creating #{destination_pathname}"
33
37
  FileUtils.cp(source_pathname, destination_pathname)
34
38
  end
35
39
 
36
- output <<~HEREDOC
40
+ reporter.info <<~HEREDOC
37
41
 
38
- To start the local server:
42
+ To start the local server:
39
43
 
40
- cd #{Pathname.new(destination_dir).relative_path_from(Dir.pwd)}
41
- trmnlp serve
44
+ cd #{Pathname.new(destination_dir).relative_path_from(Dir.pwd)}
45
+ trmnlp serve
42
46
 
43
- To publish the plugin:
47
+ To publish the plugin:
44
48
 
45
- trmnlp login
46
- trmnlp push
49
+ trmnlp login
50
+ trmnlp push
47
51
  HEREDOC
48
52
  end
49
53
 
50
54
  private
51
55
 
52
- def template_dir = paths.templates_dir.join('init')
56
+ def template_dir = paths.templates_dir.join('init')
53
57
  end
54
58
  end
55
- end
59
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../lint'
5
+
6
+ module TRMNLP
7
+ module Commands
8
+ # Runs the markup best-practice checks and reports their findings.
9
+ class Lint < Base
10
+ Options = Data.define(:dir, :quiet)
11
+
12
+ def call
13
+ context.validate!
14
+ report
15
+ issues.empty?
16
+ end
17
+
18
+ private
19
+
20
+ def issues
21
+ @issues ||= TRMNLP::Lint::CHECKS.flat_map { |check| check.new(source).issues }.uniq
22
+ end
23
+
24
+ def source
25
+ @source ||= TRMNLP::Lint::Source.new(config:, paths:)
26
+ end
27
+
28
+ def report
29
+ return reporter.info(reporter.green('✓ All checks passed!')) if issues.empty?
30
+
31
+ reporter.info(reporter.yellow("#{issues.size} issue#{'s' if issues.size > 1} found:\n"))
32
+ issues.each_with_index { |issue, index| report_issue(issue, index) }
33
+ reporter.info('')
34
+ end
35
+
36
+ def report_issue(issue, index)
37
+ reporter.info(" #{index + 1}. #{issue[:message]}")
38
+ reporter.info(" Learn more: #{issue[:learn_more]}") if issue[:learn_more]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -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