trmnl_preview 0.8.2 → 0.8.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 236a5502b02ab161fffc5330fc712c329810f877038c71baadc86b376e607157
4
- data.tar.gz: 41400bbbe0b54639311ae928280277ee1717f58f1de74bd71ab92f213bbe92db
3
+ metadata.gz: 9fe003b113f4c1d832529b4d01cc931a8eb63259f385db8d6ff2dfca919a7cb9
4
+ data.tar.gz: 2254239e25186dab5f29bebf22ea8a87a180362521cf89a40900d86dda9bb9fc
5
5
  SHA512:
6
- metadata.gz: 604a7f74231c2e738dbf61995469a64e488cc1d69fa9eb84076fb9badfb2d83fd8c490fa261154471852e7c7effb6071e458d40e748bc70de7ea27455d5a8485
7
- data.tar.gz: c4396159f68882180618578725f2e386e703a9d992a195a5ce2de463d8dab407d9b0a30655495963e0b1af35ee2ab0813fae75ed77633d616312fa64246ef753
6
+ metadata.gz: d5e588f871e595f7c8dd637b6a2203cc1bde1696b91a5c397b1c176c4306f28e4f96ecc76a583f23da75ab3355a4288897b2ee823c7d8e1d658a2114c13d6f6d
7
+ data.tar.gz: 55efd439389300e0b18fe29025446632dd04412564a6ff36f8bea60d06f12e324bf18c97429b237d90df3eda94262a6009158cd82df8aab30b3b629dd6d1ff5e
data/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.8.4
5
+
6
+ - Fixed `trmnlp serve` hanging after switching between browser tabs. Live reload now uses `rack.hijack` so SSE connections release their Puma worker thread immediately instead of holding it for the lifetime of the tab.
7
+ - Fixed Ctrl-C requiring three presses to stop the dev server. filewatcher 3.0.1 was clobbering Puma's signal handlers with its own `trap('INT') { exit }`.
8
+ - Fixed scaffolded plugins silently skipping their transform when `settings.yml` had `serverless_language: ''`. The empty string was treated as truthy in Ruby and short-circuited the file-extension fallback.
9
+ - Docker examples in the README now use `--pull always` (and `pull_policy: always` for Compose) so a new release is picked up on the next `docker run` without a manual pull.
10
+
11
+ ## 0.8.3
12
+
13
+ - `trmnlp init` and `trmnlp clone` now scaffold a `.github/workflows/trmnl.yml` CI workflow and a `.gitignore`, and run `git init -b main`, so a cloned plugin is ready to push to GitHub and deploy on every commit to `main`
14
+ - Added `--skip-git` to `trmnlp init` and `trmnlp clone` for projects that manage Git themselves
15
+ - The Docker image now ships `git` so the `docker run trmnl/trmnlp clone` flow leaves a ready-to-push project on the host
16
+ - View templates now ship canonical `layout` + `title_bar` markup that passes `trmnlp lint`
17
+
4
18
  ## 0.8.2
5
19
 
6
20
  - Fixed `framework_version: latest` rendering against the auto-upgrading `/latest/` asset path instead of the current concrete release, matching the hosted service (#99)
data/README.md CHANGED
@@ -28,6 +28,10 @@ This is the structure of a plugin project:
28
28
 
29
29
  ```
30
30
  .
31
+ ├── .github
32
+ │ └── workflows
33
+ │ └── trmnl.yml
34
+ ├── .gitignore
31
35
  ├── .trmnlp.yml
32
36
  ├── bin
33
37
  │ └── trmnlp
@@ -42,6 +46,8 @@ This is the structure of a plugin project:
42
46
 
43
47
  | File | Purpose |
44
48
  |---|---|
49
+ | `.github/workflows/trmnl.yml` | GitHub Actions workflow — lints every PR, deploys to TRMNL on `main` |
50
+ | `.gitignore` | Keeps `trmnlp build` output out of version control |
45
51
  | `.trmnlp.yml` | Local dev-server config — not uploaded to TRMNL |
46
52
  | `src/full.liquid` | Markup for the full screen |
47
53
  | `src/half_horizontal.liquid` | Top or bottom half of a stacked mashup |
@@ -123,9 +129,12 @@ If an environment variable is more convenient (for example in a CI/CD pipeline),
123
129
 
124
130
  ## Continuous Integration
125
131
 
126
- `trmnlp` runs in GitHub Actions without `trmnlp login` set the `TRMNL_API_KEY`
127
- environment variable and it's used in place of the saved config. Add it as a
128
- repository secret, then drop this into `.github/workflows/trmnl.yml`:
132
+ `trmnlp init` and `trmnlp clone` scaffold a `.github/workflows/trmnl.yml`
133
+ workflow and initialize a Git repository, so a fresh project is ready to push
134
+ to GitHub. The workflow runs in GitHub Actions without `trmnlp login` — set the
135
+ `TRMNL_API_KEY` environment variable and it's used in place of the saved
136
+ config. Add it as a repository secret to activate the workflow; it looks like
137
+ this:
129
138
 
130
139
  ```yaml
131
140
  name: TRMNL
@@ -194,11 +203,14 @@ trmnlp serve
194
203
 
195
204
  ```sh
196
205
  docker run \
206
+ --pull always \
197
207
  --publish 4567:4567 \
198
208
  --volume "$(pwd):/plugin" \
199
209
  trmnl/trmnlp serve
200
210
  ```
201
211
 
212
+ `--pull always` checks the registry on every run and pulls a newer image if one exists, so you don't have to remember to `docker pull` after each release.
213
+
202
214
  Inside a container, `serve` binds to `0.0.0.0` automatically (it detects `/.dockerenv`) so the preview is reachable from your host browser. Outside Docker it binds to `127.0.0.1`.
203
215
 
204
216
  Swap `serve` for any other command (`lint`, `login`, `clone`, etc.) to run it in a one-off container.
@@ -209,6 +221,7 @@ For running multiple commands (login, clone, serve), you can start an interactiv
209
221
 
210
222
  ```sh
211
223
  docker run -it \
224
+ --pull always \
212
225
  --publish 4567:4567 \
213
226
  --volume "$HOME/.config/trmnlp:/root/.config/trmnlp" \
214
227
  --volume "$(pwd):/plugin" \
@@ -235,6 +248,7 @@ For a checked-in config — like [`examples/hn-stories/`](examples/hn-stories/)
235
248
  services:
236
249
  trmnlp:
237
250
  image: trmnl/trmnlp
251
+ pull_policy: always
238
252
  command: ["serve"]
239
253
  ports:
240
254
  - "4567:4567"
data/lib/trmnlp/app.rb CHANGED
@@ -77,17 +77,40 @@ module TRMNLP
77
77
  JSON.pretty_generate(@user_data_assembler.call(device:))
78
78
  end
79
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.
80
+ # Live reload uses rack.hijack so the Puma worker thread is released the
81
+ # instant we have the raw socket broadcasting then runs on our own
82
+ # thread, never competing with HTTP request workers. Adapted from the
83
+ # Faye::EventSource pattern in faye-websocket (lib/faye/rack_stream.rb)
84
+ # but without the EventMachine dependency: where Faye uses EM.attach to
85
+ # get a reactor callback on socket close, we detect close synchronously
86
+ # via the IOError/EPIPE raised by the next heartbeat write.
87
+ HEARTBEAT_SECONDS = 5
88
+
83
89
  get '/live_reload' do
84
- content_type 'text/event-stream'
90
+ hijack = env['rack.hijack']
91
+ halt 500, 'rack.hijack unavailable' unless hijack
92
+ hijack.call
93
+ io = env['rack.hijack_io']
94
+
85
95
  queue = Thread::Queue.new
86
96
  @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?
97
+
98
+ Thread.new do
99
+ io.write("HTTP/1.1 200 OK\r\n" \
100
+ "Content-Type: text/event-stream\r\n" \
101
+ "Cache-Control: no-cache\r\n" \
102
+ "Connection: close\r\n\r\n")
103
+ run_live_reload_loop(io, queue)
104
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
105
+ # client disconnected mid-write — normal termination
106
+ ensure
107
+ @live_reload_clients.delete(queue)
108
+ io.close
90
109
  end
110
+
111
+ # -1 status tells the server we've hijacked the response. The body
112
+ # is never iterated; the thread above owns the socket from here.
113
+ [-1, {}, []]
91
114
  end
92
115
 
93
116
  get '/poll' do
@@ -128,6 +151,16 @@ module TRMNLP
128
151
 
129
152
  private
130
153
 
154
+ # On timeout (queue idle), a colon-prefixed SSE comment line both
155
+ # keeps proxies awake and surfaces a dead client via the next
156
+ # io.write — the route's outer rescue then cleans up.
157
+ def run_live_reload_loop(io, queue)
158
+ loop do
159
+ message = queue.pop(timeout: HEARTBEAT_SECONDS)
160
+ io.write(message || ": heartbeat\n\n")
161
+ end
162
+ end
163
+
131
164
  # ScreenGenerator is request-scoped — it carries the per-request width,
132
165
  # height, and color_depth — so it is built here rather than on the shared
133
166
  # Context graph. Screenshots are a serve-only concern and would not belong
data/lib/trmnlp/cli.rb CHANGED
@@ -34,11 +34,13 @@ module TRMNLP
34
34
 
35
35
  desc 'init NAME', 'Start a new plugin project'
36
36
  method_option :skip_liquid, type: :boolean, default: false, desc: 'Skip generating liquid templates'
37
+ method_option :skip_git, type: :boolean, default: false, desc: 'Skip initializing a git repository'
37
38
  def init(name)
38
39
  Commands::Init.run(options, name)
39
40
  end
40
41
 
41
42
  desc 'clone NAME ID', 'Copy a plugin project from TRMNL server'
43
+ method_option :skip_git, type: :boolean, default: false, desc: 'Skip initializing a git repository'
42
44
  def clone(name, id)
43
45
  Commands::Clone.run(options, name, id)
44
46
  end
@@ -7,7 +7,7 @@ require_relative 'pull'
7
7
  module TRMNLP
8
8
  module Commands
9
9
  class Clone < Base
10
- Options = Data.define(:dir, :quiet)
10
+ Options = Data.define(:dir, :quiet, :skip_git)
11
11
 
12
12
  def call(directory_name, id)
13
13
  authenticate!
@@ -15,7 +15,7 @@ module TRMNLP
15
15
  destination_path = Pathname.new(options.dir).join(directory_name)
16
16
  raise DirectoryExists, "directory #{destination_path} already exists, aborting" if destination_path.exist?
17
17
 
18
- Init.run({ dir: options.dir, skip_liquid: true, quiet: true }, directory_name)
18
+ Init.run({ dir: options.dir, skip_liquid: true, quiet: true, skip_git: options.skip_git }, directory_name)
19
19
 
20
20
  Pull.run({ dir: destination_path.to_s, force: true, id: id })
21
21
 
@@ -7,7 +7,7 @@ require_relative 'base'
7
7
  module TRMNLP
8
8
  module Commands
9
9
  class Init < Base
10
- Options = Data.define(:dir, :quiet, :skip_liquid)
10
+ Options = Data.define(:dir, :quiet, :skip_liquid, :skip_git)
11
11
 
12
12
  def call(name)
13
13
  destination_dir = Pathname.new(options.dir).join(name)
@@ -17,7 +17,9 @@ module TRMNLP
17
17
  destination_dir.mkpath
18
18
  end
19
19
 
20
- template_dir.glob('**/{*,.*}').each do |source_pathname|
20
+ # NOTE: FNM_DOTMATCH so the glob descends into hidden template
21
+ # directories (e.g. .github/); without it those files are skipped.
22
+ template_dir.glob('**/{*,.*}', File::FNM_DOTMATCH).each do |source_pathname|
21
23
  next if source_pathname.directory?
22
24
  next if options.skip_liquid && source_pathname.extname == '.liquid'
23
25
 
@@ -41,6 +43,8 @@ module TRMNLP
41
43
  destination_pathname.chmod(destination_pathname.stat.mode | 0o200)
42
44
  end
43
45
 
46
+ init_git_repo(destination_dir) unless options.skip_git
47
+
44
48
  reporter.info <<~HEREDOC
45
49
 
46
50
  To start the local server:
@@ -58,6 +62,13 @@ module TRMNLP
58
62
  private
59
63
 
60
64
  def template_dir = paths.templates_dir.join('init')
65
+
66
+ # Make the scaffold a Git repository on `main` so it's ready to push
67
+ # to GitHub (the workflow's `branches: [main]` trigger requires it,
68
+ # regardless of the host's init.defaultBranch).
69
+ # Does nothing when git is unavailable — `system` returns nil rather
70
+ # than raising, so the scaffold itself still succeeds.
71
+ def init_git_repo(dir) = system('git', 'init', '-q', '-b', 'main', dir.to_s)
61
72
  end
62
73
  end
63
74
  end
@@ -22,6 +22,9 @@ module TRMNLP
22
22
  App.set(:browser_pool, BrowserPool.new(driver_factory: FirefoxDriver.method(:build)))
23
23
  App.set(:bind, options.bind)
24
24
  App.set(:port, options.port)
25
+ # Each live-reload SSE connection holds a Puma thread for the tab's
26
+ # lifetime; the default 0:5 pool runs out fast with multiple tabs open.
27
+ App.set(:server_settings, Threads: '1:16')
25
28
  permit_all_hosts if codespaces?
26
29
 
27
30
  # Finally, start the app!
@@ -73,8 +73,13 @@ module TRMNLP
73
73
  # Explicit language for transform.* code. If absent, the language
74
74
  # is inferred from the file extension by Paths#transform_file.
75
75
  # This one lives on the plugin (settings.yml) because production
76
- # stores it on the plugin_setting record.
77
- def serverless_language = @config['serverless_language']
76
+ # stores it on the plugin_setting record. The scaffold emits
77
+ # `serverless_language: ''`, so empty strings collapse to nil here
78
+ # to let the `||` in the pipeline fall through to the inferred value.
79
+ def serverless_language
80
+ value = @config['serverless_language']
81
+ value unless value.to_s.empty?
82
+ end
78
83
 
79
84
  # The TRMNL design-system version this plugin renders against.
80
85
  # Lives on the plugin (settings.yml), like serverless_language,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.8.2'
4
+ VERSION = '0.8.4'
5
5
  end
@@ -4,6 +4,24 @@ require 'filewatcher'
4
4
 
5
5
  require_relative 'reporter'
6
6
 
7
+ # filewatcher 3.0.1's #watch installs `trap('INT') { exit }` (and HUP/TERM)
8
+ # unconditionally, clobbering Puma's clean-shutdown handler — Ctrl-C then
9
+ # raises SystemExit instead of triggering Puma's graceful stop, which is
10
+ # why the container needs three SIGINTs to die. The gem offers no opt-out;
11
+ # its supported shutdown is Filewatcher#stop, which we're not using. We
12
+ # replace the trap-installing #watch with the rest of its body so signals
13
+ # fall through to whatever handler the host process installed (Puma).
14
+ Filewatcher.prepend(Module.new do
15
+ def watch(&on_update)
16
+ @on_update = on_update
17
+ @keep_watching = true
18
+ yield({ '' => '' }) if @immediate
19
+ main_cycle
20
+ @end_snapshot = current_snapshot
21
+ finalize(&on_update)
22
+ end
23
+ end)
24
+
7
25
  module TRMNLP
8
26
  class Watcher
9
27
  def initialize(config:, user_data_assembler:, transform_pipeline:, reporter: Reporter.new)
@@ -0,0 +1,30 @@
1
+ name: TRMNL
2
+ on:
3
+ pull_request:
4
+ push:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ lint:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v6
12
+ - uses: ruby/setup-ruby@v1
13
+ with:
14
+ ruby-version: "4.0"
15
+ - run: gem install trmnl_preview
16
+ - run: trmnlp lint
17
+
18
+ push:
19
+ needs: lint
20
+ if: github.ref == 'refs/heads/main'
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v6
24
+ - uses: ruby/setup-ruby@v1
25
+ with:
26
+ ruby-version: "4.0"
27
+ - run: gem install trmnl_preview
28
+ - run: trmnlp push --force
29
+ env:
30
+ TRMNL_API_KEY: ${{ secrets.TRMNL_API_KEY }}
@@ -0,0 +1,2 @@
1
+ # trmnlp build output
2
+ _build/
@@ -1 +1,7 @@
1
- full!
1
+ <div class="layout layout--col layout--center">
2
+ <span class="title title--large">Hello, TRMNL!</span>
3
+ </div>
4
+
5
+ <div class="title_bar">
6
+ <span class="title">My Plugin</span>
7
+ </div>
@@ -1 +1,7 @@
1
- half horizontal!
1
+ <div class="layout layout--col layout--center">
2
+ <span class="title title--large">Half horizontal</span>
3
+ </div>
4
+
5
+ <div class="title_bar">
6
+ <span class="title">My Plugin</span>
7
+ </div>
@@ -1 +1,7 @@
1
- half vertical!
1
+ <div class="layout layout--col layout--center">
2
+ <span class="title title--large">Half vertical</span>
3
+ </div>
4
+
5
+ <div class="title_bar">
6
+ <span class="title">My Plugin</span>
7
+ </div>
@@ -1 +1,7 @@
1
- quadrant!
1
+ <div class="layout layout--col layout--center">
2
+ <span class="title title--large">Quadrant</span>
3
+ </div>
4
+
5
+ <div class="title_bar">
6
+ <span class="title">My Plugin</span>
7
+ </div>
@@ -21,17 +21,20 @@ Gem::Specification.new do |spec|
21
21
  spec.metadata['rubygems_mfa_required'] = 'true'
22
22
 
23
23
  spec.files = Dir.chdir(__dir__) do
24
- [
24
+ files = [
25
25
  'bin/**/*',
26
26
  'db/**/*',
27
27
  'lib/**/*',
28
- 'templates/**/{*,.*}',
29
28
  'web/**/*',
30
29
  'CHANGELOG.md',
31
30
  'LICENSE.txt',
32
31
  'README.md',
33
32
  'trmnl_preview.gemspec'
34
33
  ].flat_map { |glob| Dir[glob] }
34
+
35
+ # FNM_DOTMATCH so the glob descends into the templates' hidden directories
36
+ # (e.g. .github/) — a plain Dir[] skips them and drops the file from the gem.
37
+ files + Dir.glob('templates/**/{*,.*}', File::FNM_DOTMATCH)
35
38
  end
36
39
  spec.bindir = 'bin'
37
40
  spec.executables = ['trmnlp']
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trmnl_preview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock
@@ -319,6 +319,8 @@ files:
319
319
  - lib/trmnlp/user_data_assembler.rb
320
320
  - lib/trmnlp/version.rb
321
321
  - lib/trmnlp/watcher.rb
322
+ - templates/init/.github/workflows/trmnl.yml
323
+ - templates/init/.gitignore
322
324
  - templates/init/.trmnlp.yml
323
325
  - templates/init/bin/trmnlp
324
326
  - templates/init/src/full.liquid