trmnl_preview 0.7.1 → 0.8.1

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 +72 -1
  3. data/README.md +206 -5
  4. data/bin/rake +6 -6
  5. data/bin/trmnlp +3 -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 +83 -44
  10. data/lib/trmnlp/browser_pool.rb +82 -0
  11. data/lib/trmnlp/cli.rb +28 -11
  12. data/lib/trmnlp/commands/base.rb +33 -10
  13. data/lib/trmnlp/commands/build.rb +56 -9
  14. data/lib/trmnlp/commands/clone.rb +12 -7
  15. data/lib/trmnlp/commands/init.rb +21 -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 +19 -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/firefox_driver.rb +27 -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 +28 -8
  50. data/lib/trmnlp/poller.rb +105 -0
  51. data/lib/trmnlp/renderer.rb +87 -0
  52. data/lib/trmnlp/reporter.rb +32 -0
  53. data/lib/trmnlp/screen.rb +16 -0
  54. data/lib/trmnlp/screen_generator.rb +11 -217
  55. data/lib/trmnlp/screenshot.rb +109 -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 +1 -1
  63. data/lib/trmnlp/watcher.rb +60 -0
  64. data/lib/trmnlp.rb +6 -10
  65. data/templates/init/bin/trmnlp +1 -1
  66. data/templates/init/src/settings.yml +1 -0
  67. data/templates/init/src/transform.py.example +14 -0
  68. data/trmnl_preview.gemspec +34 -34
  69. data/web/public/index.css +11 -0
  70. data/web/public/index.js +31 -18
  71. data/web/views/index.erb +6 -1
  72. data/web/views/render_html.erb +4 -2
  73. metadata +82 -56
data/lib/trmnlp/app.rb CHANGED
@@ -1,103 +1,142 @@
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
+ # Colour-codes the payload badge so an author notices when merge
22
+ # variables approach the size the hosted service starts rejecting.
23
+ # KB = 1024, matching format_bytes.
24
+ def payload_size_class(bytes)
25
+ return 'payload-size--over' if bytes >= 100 * 1024
26
+ return 'payload-size--warn' if bytes >= 75 * 1024
27
+
28
+ 'payload-size--ok'
29
+ end
30
+
31
+ # NOTE: render_html.erb's layout yields raw plugin HTML through `<%= yield %>`,
32
+ # so a global `escape_html` setting would corrupt the render. Escape per-value.
33
+ def h(text)
34
+ Rack::Utils.escape_html(text.to_s)
35
+ end
36
+ end
37
+
15
38
  def initialize(*args)
16
39
  super
17
40
 
18
41
  @context = settings.context
42
+ @poller = @context.poller
43
+ @renderer = @context.renderer
44
+ @user_data_assembler = @context.user_data_assembler
45
+ @transform_pipeline = @context.transform_pipeline
46
+ @watcher = @context.watcher
47
+ @screenshot = Screenshot.new(pool: settings.browser_pool)
19
48
 
20
- @context.poll_data
49
+ @poller.poll_data
21
50
 
22
- @context.start_filewatcher if @context.config.project.live_render?
51
+ @watcher.start if @context.config.project.live_render?
23
52
 
24
53
  @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
54
+ @watcher.on_change do |view, user_data|
55
+ payload = {
56
+ 'type' => 'reload',
57
+ 'view' => view,
58
+ 'user_data' => user_data
59
+ }
60
+ message = "data: #{payload.to_json}\n\n"
61
+ @live_reload_clients.each { |queue| queue << message }
35
62
  end
36
63
  end
37
64
 
38
65
  post '/webhook' do
39
- @context.put_webhook(request.body.read)
40
- "OK"
66
+ @poller.put_webhook(request.body.read)
67
+ 'OK'
41
68
  end
42
-
69
+
43
70
  get '/' do
44
71
  redirect '/full'
45
72
  end
46
73
 
47
74
  get '/data' do
48
75
  content_type :json
49
- JSON.pretty_generate(@context.user_data)
76
+ device = @user_data_assembler.device_from_params(params)
77
+ JSON.pretty_generate(@user_data_assembler.call(device:))
50
78
  end
51
79
 
80
+ # Live reload is a one-directional server->browser push, so it uses
81
+ # server-sent events rather than a websocket. Each client gets a blocking
82
+ # queue; the stream thread parks on queue.pop until the watcher broadcasts.
52
83
  get '/live_reload' do
53
- ws = Faye::WebSocket.new(request.env)
54
-
55
- ws.on(:open) do |event|
56
- @live_reload_clients << ws
84
+ content_type 'text/event-stream'
85
+ queue = Thread::Queue.new
86
+ @live_reload_clients << queue
87
+ stream(:keep_open) do |out|
88
+ out.callback { @live_reload_clients.delete(queue) }
89
+ out << queue.pop until out.closed?
57
90
  end
58
-
59
- ws.on(:close) do |event|
60
- @live_reload_clients.delete(ws)
61
- end
62
-
63
- ws.rack_response
64
91
  end
65
92
 
66
93
  get '/poll' do
67
- @context.poll_data
94
+ @poller.poll_data
68
95
  redirect back
69
96
  end
70
-
71
- VIEWS.each do |view|
97
+
98
+ Screen.all.each do |screen|
99
+ view = screen.name
72
100
  get "/#{view}" do
73
101
  @view = view
74
- @user_data = JSON.pretty_generate(@context.user_data)
102
+ device = @user_data_assembler.device_from_params(params)
103
+ user_data = @user_data_assembler.call(device:)
104
+ @user_data = JSON.pretty_generate(user_data)
105
+ # Measured on compact JSON, the way the hosted service sizes merge variables.
106
+ @payload_size = JSON.generate(user_data).bytesize
75
107
  @live_reload = @context.config.project.live_render?
108
+ @transform_error = @transform_pipeline.error
76
109
 
77
110
  erb :index
78
111
  end
79
112
 
80
113
  get "/render/#{view}.html" do
81
- @context.render_full_page(view, params)
114
+ @renderer.render_full_page(view, params)
82
115
  end
83
-
116
+
84
117
  get "/render/#{view}.png" do
85
118
  @view = view
86
- html = @context.render_full_page(view, params)
87
-
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
119
+ html = @renderer.render_full_page(view, params)
120
+ temp_image = render_png(html, params)
92
121
 
93
- generator = ScreenGenerator.new(html, image: true, width: width, height: height, color_depth: color_depth)
94
- temp_image = generator.process
95
-
96
122
  send_file temp_image.path, type: 'image/png', disposition: 'inline'
97
123
 
98
124
  temp_image.close
99
125
  temp_image.unlink
100
126
  end
101
127
  end
128
+
129
+ private
130
+
131
+ # ScreenGenerator is request-scoped — it carries the per-request width,
132
+ # height, and color_depth — so it is built here rather than on the shared
133
+ # Context graph. Screenshots are a serve-only concern and would not belong
134
+ # on a Context shared by every command (build, lint, push, ...).
135
+ def render_png(html, params)
136
+ ScreenGenerator.new(html, screenshot: @screenshot,
137
+ width: params[:width]&.to_i,
138
+ height: params[:height]&.to_i,
139
+ color_depth: params[:color_depth]&.to_i).process
140
+ end
102
141
  end
103
- end
142
+ 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
 
@@ -17,47 +19,62 @@ module TRMNLP
17
19
  def self.default_bind = File.exist?('/.dockerenv') ? '0.0.0.0' : '127.0.0.1'
18
20
 
19
21
  desc 'build', 'Generate static HTML files'
22
+ method_option :png, type: :boolean, default: false, desc: 'Also render a PNG per view'
23
+ method_option :width, type: :numeric, desc: 'PNG width in pixels (with --png)'
24
+ method_option :height, type: :numeric, desc: 'PNG height in pixels (with --png)'
25
+ method_option :color_depth, type: :numeric, desc: 'PNG bit depth: 1, 2, or 4 (with --png)'
20
26
  def build
21
- Commands::Build.new(options).call
27
+ Commands::Build.run(options)
22
28
  end
23
29
 
24
30
  desc 'login', 'Authenticate with TRMNL server'
25
31
  def login
26
- Commands::Login.new(options).call
32
+ Commands::Login.run(options)
27
33
  end
28
34
 
29
35
  desc 'init NAME', 'Start a new plugin project'
30
36
  method_option :skip_liquid, type: :boolean, default: false, desc: 'Skip generating liquid templates'
31
37
  def init(name)
32
- Commands::Init.new(options).call(name)
38
+ Commands::Init.run(options, name)
33
39
  end
34
40
 
35
41
  desc 'clone NAME ID', 'Copy a plugin project from TRMNL server'
36
42
  def clone(name, id)
37
- Commands::Clone.new(options).call(name, id)
43
+ Commands::Clone.run(options, name, id)
44
+ end
45
+
46
+ desc 'list', 'List private plugins from TRMNL server'
47
+ def list
48
+ Commands::List.run(options)
38
49
  end
39
50
 
40
51
  desc 'pull', 'Download latest plugin settings from TRMNL server'
41
52
  method_option :force, type: :boolean, default: false, aliases: '-f',
42
- desc: 'Skip confirmation prompts'
53
+ desc: 'Skip confirmation prompts'
43
54
  method_option :id, type: :string, aliases: '-i', desc: 'Plugin settings ID'
44
55
  def pull
45
- Commands::Pull.new(options).call
56
+ Commands::Pull.run(options)
46
57
  end
47
58
 
48
59
  desc 'push', 'Upload latest plugin settings to TRMNL server'
49
60
  method_option :force, type: :boolean, default: false, aliases: '-f',
50
- desc: 'Skip confirmation prompts'
61
+ desc: 'Skip confirmation prompts'
51
62
  method_option :id, type: :string, aliases: '-i', desc: 'Plugin settings ID'
52
63
  def push
53
- Commands::Push.new(options).call
64
+ Commands::Push.run(options)
65
+ end
66
+
67
+ desc 'lint', 'Check plugin code against TRMNL best practices'
68
+ def lint
69
+ # Exit non-zero when issues are found so CI pipelines can gate on it.
70
+ exit(1) unless Commands::Lint.run(options)
54
71
  end
55
72
 
56
73
  desc 'serve', 'Start a local dev server'
57
74
  method_option :bind, type: :string, default: default_bind, aliases: '-b', desc: 'Bind address'
58
75
  method_option :port, type: :numeric, default: 4567, aliases: '-p', desc: 'Port number'
59
76
  def serve
60
- Commands::Serve.new(options).call
77
+ Commands::Serve.run(options)
61
78
  end
62
79
 
63
80
  desc 'version', 'Show version'
@@ -65,4 +82,4 @@ module TRMNLP
65
82
  puts VERSION
66
83
  end
67
84
  end
68
- end
85
+ 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(reporter.yellow("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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
1
5
  require_relative 'base'
6
+ require_relative '../browser_pool'
7
+ require_relative '../firefox_driver'
8
+ require_relative '../screen_generator'
9
+ require_relative '../screenshot'
2
10
 
3
11
  module TRMNLP
4
12
  module Commands
5
13
  class Build < Base
14
+ Options = Data.define(:dir, :quiet, :png, :width, :height, :color_depth)
15
+
6
16
  def call
7
17
  context.validate!
8
- context.poll_data
18
+ report_form_field_warnings
19
+ context.poller.poll_data
9
20
  context.paths.create_build_dir
10
21
 
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))
15
- end
16
-
17
- output "Done!"
22
+ Screen.all.each { |screen| build_screen(screen) }
23
+
24
+ reporter.info 'Done!'
25
+ ensure
26
+ @browser_pool&.shutdown
27
+ end
28
+
29
+ private
30
+
31
+ def build_screen(screen)
32
+ html = context.renderer.render_full_page(screen.name)
33
+ write_html(screen.name, html)
34
+ write_png(screen.name, html) if options.png
35
+ end
36
+
37
+ def write_html(view, html)
38
+ path = context.paths.build_dir.join("#{view}.html")
39
+ reporter.info "Writing #{path}..."
40
+ path.write(html)
41
+ end
42
+
43
+ # --png is additive: the HTML is rendered either way, so it stays on
44
+ # disk alongside the PNG rather than being replaced by it.
45
+ def write_png(view, html)
46
+ path = context.paths.build_dir.join("#{view}.png")
47
+ reporter.info "Writing #{path}..."
48
+ image = screen_generator(html).process
49
+ FileUtils.cp(image.path, path)
50
+ ensure
51
+ image&.close!
52
+ end
53
+
54
+ # --width/--height/--color-depth are optional; nil lets ScreenGenerator
55
+ # fall back to 800x480 and the screen--Nbit depth sniffed from the markup.
56
+ def screen_generator(html)
57
+ ScreenGenerator.new(html, screenshot:, width: options.width,
58
+ height: options.height, color_depth: options.color_depth)
59
+ end
60
+
61
+ def screenshot = @screenshot ||= Screenshot.new(pool: browser_pool)
62
+
63
+ def browser_pool
64
+ @browser_pool ||= BrowserPool.new(driver_factory: FirefoxDriver.method(:build))
18
65
  end
19
66
  end
20
67
  end
21
- end
68
+ 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,40 @@ 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)
38
+ # NOTE: cp preserves the source mode. Templates installed read-only
39
+ # (e.g. NixOS /nix/store is 0444) would leave the author unable to
40
+ # edit their own project. Add owner-write; keep any exec bit.
41
+ destination_pathname.chmod(destination_pathname.stat.mode | 0o200)
34
42
  end
35
43
 
36
- output <<~HEREDOC
44
+ reporter.info <<~HEREDOC
37
45
 
38
- To start the local server:
46
+ To start the local server:
39
47
 
40
- cd #{Pathname.new(destination_dir).relative_path_from(Dir.pwd)}
41
- trmnlp serve
48
+ cd #{Pathname.new(destination_dir).relative_path_from(Dir.pwd)}
49
+ trmnlp serve
42
50
 
43
- To publish the plugin:
51
+ To publish the plugin:
44
52
 
45
- trmnlp login
46
- trmnlp push
53
+ trmnlp login
54
+ trmnlp push
47
55
  HEREDOC
48
56
  end
49
57
 
50
58
  private
51
59
 
52
- def template_dir = paths.templates_dir.join('init')
60
+ def template_dir = paths.templates_dir.join('init')
53
61
  end
54
62
  end
55
- end
63
+ 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