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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +192 -0
- data/LICENSE.txt +21 -0
- data/README.md +319 -0
- data/bin/rake +27 -0
- data/bin/trmnlp +13 -14
- data/db/data/form_fields.yml +24 -0
- data/db/data/framework_versions.yml +72 -0
- data/lib/trmnlp/api_client.rb +99 -0
- data/lib/trmnlp/app.rb +132 -0
- data/lib/trmnlp/browser_pool.rb +82 -0
- data/lib/trmnlp/cli.rb +81 -0
- data/lib/trmnlp/commands/base.rb +63 -0
- data/lib/trmnlp/commands/build.rb +26 -0
- data/lib/trmnlp/commands/clone.rb +35 -0
- data/lib/trmnlp/commands/init.rb +59 -0
- data/lib/trmnlp/commands/lint.rb +42 -0
- data/lib/trmnlp/commands/list.rb +40 -0
- data/lib/trmnlp/commands/login.rb +42 -0
- data/lib/trmnlp/commands/pull.rb +51 -0
- data/lib/trmnlp/commands/push.rb +77 -0
- data/lib/trmnlp/commands/serve.rb +54 -0
- data/lib/trmnlp/commands.rb +3 -0
- data/lib/trmnlp/config/app.rb +50 -0
- data/lib/trmnlp/config/plugin.rb +117 -0
- data/lib/trmnlp/config/project.rb +106 -0
- data/lib/trmnlp/config.rb +17 -0
- data/lib/trmnlp/context.rb +35 -0
- data/lib/trmnlp/errors.rb +15 -0
- data/lib/trmnlp/form_field.rb +42 -0
- data/lib/trmnlp/framework_version.rb +69 -0
- data/lib/trmnlp/image_quantizer.rb +58 -0
- data/lib/trmnlp/lint/check.rb +31 -0
- data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
- data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
- data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
- data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
- data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
- data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
- data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
- data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
- data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
- data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
- data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
- data/lib/trmnlp/lint/checks/title_length.rb +18 -0
- data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
- data/lib/trmnlp/lint/source.rb +42 -0
- data/lib/trmnlp/lint.rb +39 -0
- data/lib/trmnlp/paths.rb +78 -0
- data/lib/trmnlp/poller.rb +105 -0
- data/lib/trmnlp/renderer.rb +87 -0
- data/lib/trmnlp/reporter.rb +28 -0
- data/lib/trmnlp/screen.rb +16 -0
- data/lib/trmnlp/screen_generator.rb +34 -0
- data/lib/trmnlp/screenshot.rb +96 -0
- data/lib/trmnlp/transform_backend/http.rb +107 -0
- data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
- data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
- data/lib/trmnlp/transform_client.rb +47 -0
- data/lib/trmnlp/transform_pipeline.rb +65 -0
- data/lib/trmnlp/user_data_assembler.rb +96 -0
- data/lib/trmnlp/version.rb +5 -0
- data/lib/trmnlp/watcher.rb +60 -0
- data/lib/trmnlp.rb +10 -0
- data/templates/init/.trmnlp.yml +14 -0
- data/templates/init/bin/trmnlp +44 -0
- data/templates/init/src/full.liquid +1 -0
- data/templates/init/src/half_horizontal.liquid +1 -0
- data/templates/init/src/half_vertical.liquid +1 -0
- data/templates/init/src/quadrant.liquid +1 -0
- data/templates/init/src/settings.yml +16 -0
- data/templates/init/src/shared.liquid +1 -0
- data/templates/init/src/transform.py.example +14 -0
- data/trmnl_preview.gemspec +68 -0
- data/web/public/highlight/highlight.min.js +315 -0
- data/web/public/highlight/styles/atom-one-dark.min.css +1 -0
- data/web/public/index.css +119 -0
- data/web/public/index.js +96 -0
- data/web/public/trmnl-picker.js +656 -0
- data/web/views/index.erb +73 -0
- data/web/views/render_html.erb +25 -0
- 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
|