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,72 @@
1
+ # Mirrored from the TRMNL design-system source.
2
+ # Refresh with `rake framework:sync` — do not edit manually.
3
+ latest: 3.1.1
4
+ versions:
5
+ - number: 0.0.1
6
+ released_at: '2024-06-22'
7
+ - number: 0.0.2
8
+ released_at: '2024-06-23'
9
+ - number: 0.0.3
10
+ released_at: '2024-07-09'
11
+ - number: 0.0.4
12
+ released_at: '2024-07-11'
13
+ - number: 0.0.5
14
+ released_at: '2024-07-14'
15
+ - number: 0.0.6
16
+ released_at: '2024-08-18'
17
+ - number: 0.0.7
18
+ released_at: '2024-09-19'
19
+ - number: 1.0.0
20
+ released_at: '2024-11-03'
21
+ - number: 1.0.1
22
+ released_at: '2025-01-21'
23
+ - number: 1.0.2
24
+ released_at: '2025-02-04'
25
+ - number: 1.0.3
26
+ released_at: '2025-02-06'
27
+ - number: 1.0.4
28
+ released_at: '2025-02-24'
29
+ - number: 1.0.5
30
+ released_at: '2025-04-23'
31
+ - number: 1.1.0
32
+ released_at: '2025-05-19'
33
+ - number: 1.1.1
34
+ released_at: '2025-05-20'
35
+ - number: 1.2.0
36
+ released_at: '2025-07-28'
37
+ - number: 2.0.0
38
+ released_at: '2025-09-14'
39
+ - number: 2.0.1
40
+ released_at: '2025-11-10'
41
+ - number: 2.1.0
42
+ released_at: '2025-12-30'
43
+ - number: 2.2.0
44
+ released_at: '2026-01-13'
45
+ - number: 2.2.1
46
+ released_at: '2026-01-16'
47
+ - number: 2.3.0
48
+ released_at: '2026-02-07'
49
+ - number: 2.3.1
50
+ released_at: '2026-02-09'
51
+ - number: 2.3.2
52
+ released_at: '2026-02-26'
53
+ - number: 2.3.3
54
+ released_at: '2026-03-02'
55
+ - number: 2.3.7
56
+ released_at: '2026-03-20'
57
+ - number: 3.0.0
58
+ released_at: '2026-04-01'
59
+ - number: 3.0.1
60
+ released_at: '2026-04-03'
61
+ - number: 3.0.2
62
+ released_at: '2026-04-03'
63
+ - number: 3.0.3
64
+ released_at: '2026-04-07'
65
+ - number: 3.0.4
66
+ released_at: '2026-04-29'
67
+ - number: 3.0.5
68
+ released_at: '2026-05-01'
69
+ - number: 3.1.0
70
+ released_at: '2026-04-29'
71
+ - number: 3.1.1
72
+ released_at: '2026-05-01'
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/multipart'
5
+
6
+ require_relative 'config'
7
+
8
+ module TRMNLP
9
+ class APIClient
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def get_me
15
+ response = conn.get('me')
16
+
17
+ raise Error, "failed to fetch user info: #{response.status} #{response.body}" unless response.status == 200
18
+
19
+ JSON.parse(response.body)['data']
20
+ end
21
+
22
+ def get_plugin_settings
23
+ response = conn.get('plugin_settings')
24
+
25
+ raise Error, "failed to list plugin settings: #{response.status} #{response.body}" unless response.status == 200
26
+
27
+ JSON.parse(response.body)['data']
28
+ end
29
+
30
+ def get_plugin_setting_archive(id)
31
+ response = conn.get("plugin_settings/#{id}/archive")
32
+
33
+ unless response.status == 200
34
+ raise Error, "failed to download plugin settings archive: #{response.status} #{response.body}"
35
+ end
36
+
37
+ temp_file = Tempfile.new(["plugin_settings_#{id}", '.zip'])
38
+ temp_file.binmode
39
+ temp_file.write(response.body)
40
+ temp_file.rewind
41
+
42
+ # return the temp file IO
43
+ temp_file
44
+ end
45
+
46
+ def post_plugin_setting_archive(id, path)
47
+ filepart = Faraday::Multipart::FilePart.new(path, 'application/zip')
48
+
49
+ payload = {
50
+ file: filepart
51
+ }
52
+
53
+ response = conn.post("plugin_settings/#{id}/archive", payload)
54
+
55
+ filepart.close
56
+
57
+ unless response.status == 200
58
+ raise Error, "failed to upload plugin settings archive: #{response.status} #{response.body}"
59
+ end
60
+
61
+ JSON.parse(response.body)
62
+ end
63
+
64
+ def post_plugin_setting(params)
65
+ response = conn.post('plugin_settings', params.to_json, content_type: 'application/json')
66
+
67
+ raise Error, "failed to create plugin setting: #{response.status} #{response.body}" unless response.status == 200
68
+
69
+ JSON.parse(response.body)
70
+ end
71
+
72
+ def delete_plugin_setting(id)
73
+ response = conn.delete("plugin_settings/#{id}")
74
+
75
+ raise Error, "failed to delete plugin setting: #{response.status} #{response.body}" unless response.status == 204
76
+
77
+ true
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :config
83
+
84
+ def api_uri = config.app.api_uri
85
+
86
+ def conn
87
+ @conn ||= Faraday.new(url: api_uri, headers:) do |f|
88
+ f.request :multipart
89
+ end
90
+ end
91
+
92
+ def headers
93
+ {
94
+ 'Authorization' => "Bearer #{config.app.api_key}",
95
+ 'User-Agent' => "trmnlp/#{VERSION}"
96
+ }
97
+ end
98
+ end
99
+ end
data/lib/trmnlp/app.rb ADDED
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra'
4
+ require 'sinatra/base'
5
+
6
+ require_relative 'context'
7
+ require_relative 'screen_generator'
8
+ require_relative 'screenshot'
9
+
10
+ module TRMNLP
11
+ class App < Sinatra::Base
12
+ # Sinatra settings
13
+ set :views, File.join(File.dirname(__FILE__), '..', '..', 'web', 'views')
14
+ set :public_folder, File.join(File.dirname(__FILE__), '..', '..', 'web', 'public')
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
+
28
+ def initialize(*args)
29
+ super
30
+
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)
38
+
39
+ @poller.poll_data
40
+
41
+ @watcher.start if @context.config.project.live_render?
42
+
43
+ @live_reload_clients = []
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 }
52
+ end
53
+ end
54
+
55
+ post '/webhook' do
56
+ @poller.put_webhook(request.body.read)
57
+ 'OK'
58
+ end
59
+
60
+ get '/' do
61
+ redirect '/full'
62
+ end
63
+
64
+ get '/data' do
65
+ content_type :json
66
+ device = @user_data_assembler.device_from_params(params)
67
+ JSON.pretty_generate(@user_data_assembler.call(device:))
68
+ end
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.
73
+ get '/live_reload' do
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?
80
+ end
81
+ end
82
+
83
+ get '/poll' do
84
+ @poller.poll_data
85
+ redirect back
86
+ end
87
+
88
+ Screen.all.each do |screen|
89
+ view = screen.name
90
+ get "/#{view}" do
91
+ @view = view
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
97
+ @live_reload = @context.config.project.live_render?
98
+ @transform_error = @transform_pipeline.error
99
+
100
+ erb :index
101
+ end
102
+
103
+ get "/render/#{view}.html" do
104
+ @renderer.render_full_page(view, params)
105
+ end
106
+
107
+ get "/render/#{view}.png" do
108
+ @view = view
109
+ html = @renderer.render_full_page(view, params)
110
+ temp_image = render_png(html, params)
111
+
112
+ send_file temp_image.path, type: 'image/png', disposition: 'inline'
113
+
114
+ temp_image.close
115
+ temp_image.unlink
116
+ end
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
131
+ 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 ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ require_relative '../trmnlp'
6
+ require_relative '../trmnlp/commands'
7
+
8
+ module TRMNLP
9
+ class CLI < Thor
10
+ package_name 'trmnlp'
11
+
12
+ class_option :dir, type: :string, default: Dir.pwd, aliases: '-d',
13
+ desc: 'Plugin project directory'
14
+
15
+ class_option :quiet, type: :boolean, default: false, desc: 'Suppress output', aliases: '-q'
16
+
17
+ def self.exit_on_failure? = true
18
+
19
+ def self.default_bind = File.exist?('/.dockerenv') ? '0.0.0.0' : '127.0.0.1'
20
+
21
+ desc 'build', 'Generate static HTML files'
22
+ def build
23
+ Commands::Build.run(options)
24
+ end
25
+
26
+ desc 'login', 'Authenticate with TRMNL server'
27
+ def login
28
+ Commands::Login.run(options)
29
+ end
30
+
31
+ desc 'init NAME', 'Start a new plugin project'
32
+ method_option :skip_liquid, type: :boolean, default: false, desc: 'Skip generating liquid templates'
33
+ def init(name)
34
+ Commands::Init.run(options, name)
35
+ end
36
+
37
+ desc 'clone NAME ID', 'Copy a plugin project from TRMNL server'
38
+ def clone(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)
45
+ end
46
+
47
+ desc 'pull', 'Download latest plugin settings from TRMNL server'
48
+ method_option :force, type: :boolean, default: false, aliases: '-f',
49
+ desc: 'Skip confirmation prompts'
50
+ method_option :id, type: :string, aliases: '-i', desc: 'Plugin settings ID'
51
+ def pull
52
+ Commands::Pull.run(options)
53
+ end
54
+
55
+ desc 'push', 'Upload latest plugin settings to TRMNL server'
56
+ method_option :force, type: :boolean, default: false, aliases: '-f',
57
+ desc: 'Skip confirmation prompts'
58
+ method_option :id, type: :string, aliases: '-i', desc: 'Plugin settings ID'
59
+ def push
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)
67
+ end
68
+
69
+ desc 'serve', 'Start a local dev server'
70
+ method_option :bind, type: :string, default: default_bind, aliases: '-b', desc: 'Bind address'
71
+ method_option :port, type: :numeric, default: 4567, aliases: '-p', desc: 'Port number'
72
+ def serve
73
+ Commands::Serve.run(options)
74
+ end
75
+
76
+ desc 'version', 'Show version'
77
+ def version
78
+ puts VERSION
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../context'
4
+ require_relative '../form_field'
5
+ require_relative '../reporter'
6
+
7
+ module TRMNLP
8
+ module Commands
9
+ class Base
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)
28
+
29
+ @context = context
30
+ @options = options
31
+ @reporter = reporter || Reporter.new(quiet: options.quiet)
32
+ end
33
+
34
+ def call
35
+ raise NotImplementedError
36
+ end
37
+
38
+ protected
39
+
40
+ attr_accessor :options, :context, :reporter
41
+
42
+ def config = context.config
43
+ def paths = context.paths
44
+
45
+ def authenticate!
46
+ raise NotLoggedIn, 'please run `trmnlp login`' unless config.app.logged_in?
47
+ end
48
+
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
55
+ end
56
+
57
+ def prompt(message)
58
+ print message
59
+ $stdin.gets.chomp
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module TRMNLP
6
+ module Commands
7
+ class Build < Base
8
+ Options = Data.define(:dir, :quiet)
9
+
10
+ def call
11
+ context.validate!
12
+ report_form_field_warnings
13
+ context.poller.poll_data
14
+ context.paths.create_build_dir
15
+
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))
20
+ end
21
+
22
+ reporter.info 'Done!'
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'init'
5
+ require_relative 'pull'
6
+
7
+ module TRMNLP
8
+ module Commands
9
+ class Clone < Base
10
+ Options = Data.define(:dir, :quiet)
11
+
12
+ def call(directory_name, id)
13
+ authenticate!
14
+
15
+ destination_path = Pathname.new(options.dir).join(directory_name)
16
+ raise DirectoryExists, "directory #{destination_path} already exists, aborting" if destination_path.exist?
17
+
18
+ Init.run({ dir: options.dir, skip_liquid: true, quiet: true }, directory_name)
19
+
20
+ Pull.run({ dir: destination_path.to_s, force: true, id: id })
21
+
22
+ reporter.info <<~HEREDOC
23
+
24
+ To start the local server:
25
+
26
+ cd #{Pathname.new(destination_path).relative_path_from(Dir.pwd)} && trmnlp serve
27
+ HEREDOC
28
+ end
29
+
30
+ private
31
+
32
+ def template_dir = paths.templates_dir.join('init')
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ require_relative 'base'
6
+
7
+ module TRMNLP
8
+ module Commands
9
+ class Init < Base
10
+ Options = Data.define(:dir, :quiet, :skip_liquid)
11
+
12
+ def call(name)
13
+ destination_dir = Pathname.new(options.dir).join(name)
14
+
15
+ unless destination_dir.exist?
16
+ reporter.info "Creating #{destination_dir}"
17
+ destination_dir.mkpath
18
+ end
19
+
20
+ template_dir.glob('**/{*,.*}').each do |source_pathname|
21
+ next if source_pathname.directory?
22
+ next if options.skip_liquid && source_pathname.extname == '.liquid'
23
+
24
+ relative_pathname = source_pathname.relative_path_from(template_dir)
25
+ destination_pathname = destination_dir.join(relative_pathname)
26
+ destination_pathname.dirname.mkpath
27
+
28
+ if destination_pathname.exist?
29
+ answer = prompt("#{destination_pathname} already exists. Overwrite? (y/n): ").downcase
30
+ if answer != 'y'
31
+ reporter.info "Skipping #{destination_pathname}"
32
+ next
33
+ end
34
+ end
35
+
36
+ reporter.info "Creating #{destination_pathname}"
37
+ FileUtils.cp(source_pathname, destination_pathname)
38
+ end
39
+
40
+ reporter.info <<~HEREDOC
41
+
42
+ To start the local server:
43
+
44
+ cd #{Pathname.new(destination_dir).relative_path_from(Dir.pwd)}
45
+ trmnlp serve
46
+
47
+ To publish the plugin:
48
+
49
+ trmnlp login
50
+ trmnlp push
51
+ HEREDOC
52
+ end
53
+
54
+ private
55
+
56
+ def template_dir = paths.templates_dir.join('init')
57
+ end
58
+ 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