trmnl_preview 0.19.0 → 0.21.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10d19ade9dca541649486f6b8d105277f15400451bbde285591815f4cdc8f9a1
4
- data.tar.gz: d83fde4dc7312ec8316027b5f9f6370bca2194228e5ad55dfc92874a260c87d4
3
+ metadata.gz: d1b736ce74a0385dda5b989fe604d0256d0b1522b1955855aa0eac945ec3dd0b
4
+ data.tar.gz: f4b4321d72bd9129a025f07d21707f0584e965a5bfe072db3ecc95c2fbf04921
5
5
  SHA512:
6
- metadata.gz: 25d1251bbb5421d2fe762d46f20ce675d59179e69e42ab44dd6b25ee05dc5bf7609aca22a71bafe77841492b10797aa93153252917078cee61a13b31a2790c59
7
- data.tar.gz: 5e1dd277a7aa823e866ca8f2c674751baa04b0e4bcc00b7522064064887eb4c6f246e15f1b3884d8a64fc605005a67c7bc8274364bba7d29bf6fc7739d60def7
6
+ metadata.gz: 3997799add0b42fd60ffd8f6c7d9d3c99696152fe32316e8925cfe1a24eed73f410a7efc86a432a46697d1fbd7fa0fa44c7d8c63727c48c0ff48778035e993f5
7
+ data.tar.gz: 12cf46d3ae00fda1f1e82eb80c5ed2bbe2598208ad84de9ecdc878afcb96b51f27e4ed62630195cdb1a0fc60a8d5b6cbc1d6be2f1ef4833aefdc0c370cd1634d
data/CHANGELOG.md CHANGED
@@ -1,6 +1,38 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.21.0
5
+
6
+ - The `bin/trmnlp` script that `trmnlp init` writes pulls a newer `trmnl/trmnlp` image once a day, so a Docker user stays on the latest release. Before, it ran whatever image was pulled first. The README shows how to install the script as `trmnlp` with Docker alone.
7
+ - The `bin/trmnlp` script that `trmnlp init` writes runs every command in Docker as the gem would. It asked for a terminal it did not have on CI and in scripts (`the input device is not a TTY`), and published port 4567 for every command, so `lint` and `test` failed while `serve` ran and `serve --port` could not be reached. On Linux it runs as you, so snapshots and `_build` are not root's. A plugin's OAuth tokens and saved data are kept between runs, and `CI` and `TRMNL_API_KEY` reach trmnlp. Saved as `trmnlp` on the `PATH`, it no longer starts itself without end. An existing plugin gets the new script from `trmnlp init <its folder>`, answering `y` for `bin/trmnlp` only.
8
+ - The README asks for Ruby >= 4.0 to install the gem, as the gem does. It said 3.4.
9
+ - `trmnlp lint` reports a filter that neither Liquid nor trmnl-liquid defines (`no_unknown_filters`), such as a typo or a LiquidJS filter like `push`. TRMNL outputs the value unfiltered, and `serve` and `build` drew it the same way, so nothing showed the mistake.
10
+ - The Docker image includes geckodriver, so it draws screens on arm64 Linux such as a Raspberry Pi. Selenium downloaded it on every run that draws a screen (`test`, `build`, `serve`), which took about half a second and failed with no network, and on arm64 without x86 emulation it could not download one at all (`Unable to obtain geckodriver`).
11
+ - Every command checks RubyGems for a newer stable release, once a day, and suggests a Gem or Bundler update command on stderr; `trmnlp version` always asks. `--quiet` or `TRMNLP_NO_UPDATE_NOTIFIER=1` skips it, and network failures leave output and exit status unchanged.
12
+ - `have_no_overflow(except: '.forecast')` and `screen.overflowing(except:)` leave out a box that hides content on purpose, and everything inside it.
13
+
14
+ ## 0.20.0
15
+
16
+ - `trmnlp lint` skips the rules listed under `ignored_lint_rules` in `.trmnlp.yml`, in text and JSON output alike. An unknown rule ID is an error that lists the known ones.
17
+ - `trmnlp lint`, `build` and `serve` accept the `hidden` field type, which TRMNL renders as a hidden input. They warned "unknown field_type: hidden".
18
+ - The README lists `trmnlp test` under Commands, and the guide to testing plugins moved to `docs/testing.md`, with how to share setup through `tests/spec_helper.rb`.
19
+ - `trmnlp test`: `have_qr_code` on an element wholly off the left or top of the screen finds no code. It scanned part of the screen instead, because ImageMagick reads a crop 0 pixels wide as the whole width.
20
+ - `it_behaves_like 'a publishable recipe'` no longer checks overflow. `have_no_overflow` flags text cut short on purpose (`text-overflow: ellipsis`, `-webkit-line-clamp`) on most list and calendar recipes, so authors filtered it out; the matcher stays for examples that call it.
21
+ - `have_no_overflow` passes text cut short on purpose (`text-overflow: ellipsis`, `-webkit-line-clamp`) and text whose font is taller than its line, when only the empty space below it is cut. It reports a box only when a child box or a glyph's ink crosses its edge, so cut descenders still fail, and it now reports a scrolling box (`overflow: auto` or `scroll`) whose content is cut.
22
+ - `have_no_problems` ignores Firefox's "ResizeObserver loop completed with undelivered notifications", which it raises when a ResizeObserver resizes what it observes, as FullCalendar does. Browsers report it as a page error, but nothing is broken.
23
+ - `it_behaves_like 'a publishable recipe'` also uses the group's `variables` (for Plugin Merge recipes) and `now` when the group defines them. It passed only `mocks` and `custom_fields`, so such a recipe was drawn without its variables and on the real clock.
24
+ - `trmnlp test`: under `now:`, the page's clock starts at `now:` when the page runs. It started seconds late, by however long Firefox took to load the page. `Date()` without `new` no longer throws, and `Intl.DateTimeFormat#format` and `#formatToParts` given no date use `now:`.
25
+ - `trmnlp test --report` writes a report per process under parallel_tests (`report`, `report2`, ...) instead of each process overwriting the last. The README shows how to run a plugin's tests in parallel.
26
+ - `serve`, `build` and `test` skip resizing the page when it is already the size asked for, and check a resize every 0.01 s instead of 0.1 s. About 0.04 s faster a render.
27
+ - The bundled list of Framework versions goes up to 3.4.0, so offline, `trmnlp build` and `serve` no longer warn that a plugin pinned to 3.3.0 or later is not a published version. A release refreshes the list before it builds the gem.
28
+ - The `qr_code` filter draws a white border four modules wide inside the code's box, so it scans in dark mode and on a dark page. The box keeps its size and the modules shrink to make room.
29
+ - `trmnlp test`: `data:` reaches a static plugin. It was ignored there, so the test rendered the `static_data` in `settings.yml`.
30
+ - `trmnlp test` takes a screen's PNG only when a test asks for it (`match_snapshot`, `fit_image_size_limit`, `png_path`, `--report`). A test that only reads the page no longer waits for the screenshot and its quantizing, about 0.15 seconds a render. The picture is of the page when it is first asked for, so a page changed with `evaluate` before `match_snapshot` is drawn changed.
31
+ - `trmnlp test` opens each page from a local address instead of writing it into `about:blank` with its stylesheets inlined, so Firefox parses the Framework's 15 MB stylesheet once for the run: 10 renders take 5.5 seconds where they took 10.1, and trmnlp's own suite 89 seconds where it took 114. The page still gets no storage, cookies or `Referer`, as on TRMNL. Its `location` and the `Origin` of its cross-origin requests are `http://127.0.0.1:<port>`, and a relative URL is not found. (#193)
32
+ - Every screenshot is 0.1 to 0.2 s faster, in `test`, `build` and `serve`. Stopping the page's timers before the capture cleared 100,000 timer ids one by one; it now clears only the ids the page has used.
33
+ - A font file that never arrives no longer hangs a render until WebDriver's 30 second limit (`Selenium::WebDriver::Error::ScriptTimeoutError: Timed out after 30000 ms`), in `test`, `build` and `serve`. The page is given 10 seconds for its fonts, loaded once more, and then fails with the names of the fonts still loading.
34
+ - `have_qr_code` checks that a QR code scans in a screen's PNG, and with a String or Regexp what it reads; `within:` scans one element and `screen.qr_codes` lists every text. It uses zbar, which the Docker image now includes.
35
+
4
36
  ## 0.19.0
5
37
 
6
38
  - `it_behaves_like 'a publishable recipe'` also checks every screen for leaked values (`undefined`, `NaN`, `null`, `[object Object]`, `Liquid error`, raw `{{` or `{%`), draws the full view when the API answers with nothing, answers 500 or cannot be reached, and draws it with each option of every select field. New matchers: `have_no_leaked_text` and `have_no_transform_error`. (#181)
data/README.md CHANGED
@@ -90,6 +90,7 @@ trmnlp push # upload
90
90
  | `trmnlp serve` | Start a local dev server |
91
91
  | `trmnlp build` | Generate static HTML files, or PNGs with `--png` |
92
92
  | `trmnlp lint` | Check plugin code against TRMNL best practices |
93
+ | `trmnlp test` | Run the plugin's tests in `tests/` |
93
94
  | `trmnlp login` | Authenticate with TRMNL server |
94
95
  | `trmnlp list` | List private plugins from TRMNL server |
95
96
  | `trmnlp clone NAME ID` | Copy a plugin project from TRMNL server |
@@ -99,6 +100,17 @@ trmnlp push # upload
99
100
 
100
101
  `trmnlp lint` exits non-zero when it finds issues, so you can gate CI on it. Run `trmnlp help` for all flags.
101
102
 
103
+ Every command also checks RubyGems for a newer stable `trmnl_preview` release
104
+ and, when one is available, suggests `gem update trmnl_preview`, or
105
+ `bundle update trmnl_preview` when run through Bundler. An exact Gemfile pin must
106
+ be changed before Bundler can update it. Notices go to stderr, keeping lint JSON
107
+ output and exit status unchanged. The answer is cached for a day, so most runs
108
+ make no request; `trmnlp version` always asks RubyGems. `--quiet` or
109
+ `TRMNLP_NO_UPDATE_NOTIFIER=1` skips the check. Connection and read timeouts are
110
+ two seconds each; current versions and an unavailable registry stay silent. The
111
+ check never installs an update or changes your Gemfile or lockfile. Inside Docker there is no
112
+ check: the `bin/trmnlp` script pulls a newer image once a day instead.
113
+
102
114
  Lint findings include a stable snake_case rule ID, severity and source locations.
103
115
  Locations use project-relative paths and one-based line/column numbers, followed
104
116
  by a source excerpt (up to 240 characters). Aggregate checks show their contributing
@@ -122,6 +134,12 @@ Liquid in declaration values is counted without rendering the plugin, every bran
122
134
  Filters from `custom_filters` in `.trmnlp.yml` exist only in trmnlp. TRMNL does not load them
123
135
  and outputs the value unfiltered, so `no_custom_filters` reports each place the markup uses one.
124
136
  Filters that trmnl-liquid also provides are not reported.
137
+ A filter that neither Liquid nor trmnl-liquid defines, such as a typo or one from another
138
+ Liquid (Jekyll, LiquidJS), is also output unfiltered on TRMNL, so `no_unknown_filters` reports it.
139
+
140
+ To accept a rule's findings, list its rule ID under `ignored_lint_rules` in `.trmnlp.yml`.
141
+ `trmnlp lint` then skips that check in both formats, so the CLI and CI agree. An unknown
142
+ rule ID is an error that lists the known ones, so a typo does not pass quietly.
125
143
 
126
144
  ## Building Static Files
127
145
 
@@ -209,13 +227,13 @@ The `bin/trmnlp` script is provided as a convenience. It will use the local Ruby
209
227
 
210
228
  You can modify the `bin/trmnlp` script to set up environment variables (plugin secrets, etc.) before running the server.
211
229
 
212
- **Gem or Docker?** Install the gem if you already have Ruby >= 3.4 — it has the fastest startup. Use Docker for zero local setup.
230
+ **Gem or Docker?** Install the gem if you already have Ruby >= 4.0 — it has the fastest startup. Use Docker for zero local setup.
213
231
 
214
232
  ### Installing via RubyGems
215
233
 
216
234
  Prerequisites:
217
235
 
218
- - Ruby >= 3.4
236
+ - Ruby >= 4.0
219
237
  - For PNG rendering (optional):
220
238
  - Firefox
221
239
  - ImageMagick
@@ -227,6 +245,20 @@ trmnlp serve
227
245
 
228
246
  ### Installing via Docker
229
247
 
248
+ To type `trmnlp` as with the gem, copy the script from the image onto your `PATH`:
249
+
250
+ ```sh
251
+ mkdir -p ~/.local/bin
252
+ docker run --rm --entrypoint cat trmnl/trmnlp /app/templates/init/bin/trmnlp > ~/.local/bin/trmnlp
253
+ chmod +x ~/.local/bin/trmnlp
254
+ ```
255
+
256
+ It runs each command in the image and pulls a newer image once a day. To stay on one release,
257
+ set `IMAGE` in the script to a tag such as `trmnl/trmnlp:v0.20.0`. `trmnlp init` puts the same
258
+ script in each plugin as `bin/trmnlp`.
259
+
260
+ Or run the image yourself:
261
+
230
262
  ```sh
231
263
  docker run \
232
264
  --pull always \
@@ -338,6 +370,10 @@ variables:
338
370
  plugin_settings:
339
371
  instance_name: Kevin Bacon Facts
340
372
 
373
+ # rule IDs whose findings `trmnlp lint` drops
374
+ ignored_lint_rules:
375
+ - no_opacity
376
+
341
377
  # plugin_merge strategy: the plugins this one reads, as "<keyname>_<id>" on TRMNL,
342
378
  # each mapped to the trmnlp project whose last fetched data stands in for it
343
379
  merged_plugins:
@@ -473,40 +509,7 @@ See [TRMNL documentation](https://help.trmnl.com/en/articles/10542599-importing-
473
509
 
474
510
  ## Testing Plugins
475
511
 
476
- `trmnlp init` starts a plugin with `tests/plugin_spec.rb` (`it_behaves_like 'a publishable recipe'`, below) and a GitHub workflow that runs it in the `trmnl/trmnlp` image, uploads the report, can rewrite the snapshots on a manual run, and pushes to TRMNL only once lint and tests pass. `trmnlp test` runs the RSpec files in your plugin's `tests/` folder, through the same pipeline `serve` and `build` use, with fake APIs and a fixed clock:
477
-
478
- ```ruby
479
- # tests/weather_spec.rb
480
- RSpec.describe 'Weather' do
481
- let(:mocks) { { 'https://api.weather.example/*' => { json: { temp: 12 } } } }
482
-
483
- %w[og_plus v2].each do |device|
484
- it "shows the temperature on #{device}" do
485
- screen = trmnl.render(device:, now: '2030-01-02T08:00:00Z', custom_fields: { city: 'Amsterdam' }, mocks:)
486
-
487
- expect(screen).to have_text('12°')
488
- expect(screen).to have_no_overflow
489
- expect(screen.box('.title').bottom).to be <= screen.box('.content').top
490
- expect(screen).to match_snapshot
491
- end
492
- end
493
-
494
- it 'keeps its state when the API has nothing new' do
495
- run = trmnl.transform(state: { etag: 'abc' }, mocks: { 'https://api.weather.example/*' => { status: 304 } })
496
-
497
- expect(run.state).to eq('etag' => 'abc')
498
- end
499
- end
500
- ```
501
-
502
- - `trmnl.transform(...)` runs the transform and answers `data`, `state`, `requests`, `log`, `error`, `duration_ms` and `max_memory_mb`; `expect(run).to stay_within_serverless_limits` checks TRMNL's 5 seconds and 128 MB. `trmnl.render(view: 'full', ...)` renders a view in Firefox and answers a screen: Capybara's matchers (`have_text`, `have_css`, `within`...) see what is drawn, and `box(selector)`, `evaluate(js)`, `overflowing` and `problems` (script errors, unhandled rejections, `console.error` and files that failed to load; `have_no_problems`) ask the live page; `fresh_browser: true` renders in a new Firefox with nothing cached; `screen.result` holds the run's `data`, `state` and `requests`. `trmnl.plugin(dir)` tests the plugin in another folder, such as a built copy.
503
- - Inputs, all optional: `device:` (a TRMNL model name, or `{ width:, height:, bit_depth: }`), `palette:`, `orientation: :portrait`, `dark_mode:`, `theme:`, `now:`, `custom_fields:`, `variables:`, `state:`, `previous_merge_variables:`, `data:` (skips polling), `transform: false`. A test's `custom_fields` and `variables` replace those in `.trmnlp.yml`, so development values there never reach a test; the field defaults in `settings.yml` still apply, as on TRMNL.
504
- - A board drawn by its own script takes `head:` (markup added to the page's `<head>`) and `wait_for:` (a JavaScript expression the page must reach before it is captured, within `wait_for_timeout:` seconds); without it, the capture freezes timers once TRMNL's readiness flags are set.
505
- - `mocks:` answer every request the run makes: polling urls, and the transform's own requests in any language, HTTPS included. Keys are urls, with `*` wildcards, a Regexp, or a method first (`'POST https://...'`). Values are `{ json:, body:, status:, headers:, delay:, body_delay:, advance_clock:, error: :reset }` (times in seconds: `body_delay:` sends the headers first and the body later, `advance_clock:` moves the transform's clock on when it answers), a string body, a lambda that takes the request, or an array of answers used in order. An unmocked request gets a 599. `requests` lists every request with its `status`, and `aborted: true` for one the transform gave up on before its answer arrived; `duration_ms` is how long the transform ran.
506
- - `now:` starts every clock: Liquid's, the markup's scripts', and the transform's, through libfaketime (`brew install libfaketime` or `apt-get install libfaketime`; already in the Docker image). On macOS the interpreter must come from brew, mise or similar, since macOS will not hand libfaketime to its own `/usr/bin` binaries.
507
- - `trmnlp test --report report` also writes `report/index.html` and `report/report.json`: every example with each screen it rendered (with a switch that outlines every drawn box, and the page's problems) and each transform it ran (time, memory, requests). Under GitHub Actions the counts and failures go to the run's summary too.
508
- - `it_behaves_like 'a publishable recipe'` is what a recipe should hold before it is published: every view draws without overflow or page errors on the TRMNL OG (1-bit, and 2-bit in landscape and portrait) and the TRMNL X (landscape and portrait), and the transform runs without error within TRMNL's limits. Every screen is also checked for leaked values (`undefined`, `NaN`, `null`, `[object Object]`, `Liquid error`, raw `{{`); the full view must still draw when the API answers with nothing, answers 500 or cannot be reached; and it is drawn with each option of every select field (the first and last when a field has more than 20). It uses the group's `mocks` and `custom_fields` when the group defines them; `screens: [{ device: 'kobo_libra_2' }, ...]` draws on other devices.
509
- - `match_snapshot` stores a missing snapshot under `tests/snapshots/<os>/` and fails one on CI; `trmnlp test --update` rewrites them. Fonts render differently per operating system, so run tests in the Docker image when CI should share your snapshots. `fit_image_size_limit` checks the PNG against the model's limit, `have_no_leaked_text` the drawn text for leaked values, and `have_no_transform_error` a render's transform.
512
+ `trmnlp test` runs the RSpec files in your plugin's `tests/` folder through the same pipeline `serve` and `build` use, with fake APIs and a fixed clock. `trmnlp init` starts a plugin with a test and a GitHub workflow that runs it. See [Testing Plugins](https://github.com/usetrmnl/trmnlp/blob/main/docs/testing.md) for the API, the matchers, snapshots and running tests in parallel.
510
513
 
511
514
  ## Development
512
515
 
@@ -19,6 +19,7 @@ field_types:
19
19
  - copyable_webhook_url
20
20
  - date
21
21
  - google_photos_picker
22
+ - hidden
22
23
  - json
23
24
  - lat_lon
24
25
  - modal_trigger
@@ -1,6 +1,6 @@
1
1
  # Mirrored from the TRMNL design-system source.
2
2
  # Refresh with `rake framework:sync` — do not edit manually.
3
- latest: 3.2.0
3
+ latest: 3.4.0
4
4
  versions:
5
5
  - number: 0.0.1
6
6
  released_at: '2024-06-22'
@@ -86,3 +86,11 @@ versions:
86
86
  released_at: '2026-07-23'
87
87
  - number: 3.2.0
88
88
  released_at: '2026-08-05'
89
+ - number: 3.3.0
90
+ released_at: '2026-08-27'
91
+ - number: 3.3.1
92
+ released_at: '2026-09-01'
93
+ - number: 3.3.2
94
+ released_at: '2026-09-18'
95
+ - number: 3.4.0
96
+ released_at: '2026-09-28'
data/lib/trmnlp/cli.rb CHANGED
@@ -4,6 +4,8 @@ require 'thor'
4
4
 
5
5
  require_relative '../trmnlp'
6
6
  require_relative '../trmnlp/commands'
7
+ require_relative 'paths'
8
+ require_relative 'update_check'
7
9
 
8
10
  module TRMNLP
9
11
  class CLI < Thor
@@ -22,6 +24,23 @@ module TRMNLP
22
24
  def self.in_container? = File.exist?('/.dockerenv') || File.exist?('/run/.containerenv')
23
25
  def self.default_bind = in_container? ? '0.0.0.0' : '127.0.0.1'
24
26
 
27
+ no_commands do
28
+ # Thor routes every command through here, so the update notice covers
29
+ # them all without each command having to remember it. `version` runs
30
+ # its own fresh check after printing, and help has nothing to update.
31
+ def invoke_command(command, *)
32
+ check_for_update unless %w[help version].include?(command.name)
33
+ super
34
+ end
35
+
36
+ def check_for_update(fresh: false)
37
+ # In a container `gem update` does not apply; bin/trmnlp pulls a newer image instead.
38
+ return if options[:quiet] || UpdateCheck.disabled? || self.class.in_container?
39
+
40
+ UpdateCheck.new(Paths.new(options[:dir]).update_check).call(fresh:)
41
+ end
42
+ end
43
+
25
44
  desc 'build', 'Generate static HTML files'
26
45
  method_option :png, type: :boolean, default: false, desc: 'Also render a PNG per view'
27
46
  method_option :width, type: :numeric, desc: 'PNG width in pixels (with --png)'
@@ -96,6 +115,9 @@ module TRMNLP
96
115
  desc 'version', 'Show version'
97
116
  def version
98
117
  puts VERSION
118
+ # Keep the version first when stdout is a pipe and stderr is not.
119
+ $stdout.flush
120
+ check_for_update(fresh: true)
99
121
  end
100
122
  end
101
123
  end
@@ -24,12 +24,23 @@ module TRMNLP
24
24
  private
25
25
 
26
26
  def issues
27
- @issues ||= TRMNLP::Lint::CHECKS.flat_map do |type|
27
+ @issues ||= checks.flat_map do |type|
28
28
  check = type.new(source)
29
29
  check.issues.map { |finding| TRMNLP::Lint::Diagnostic.new(check, source, finding).to_h }
30
30
  end.uniq
31
31
  end
32
32
 
33
+ def checks
34
+ checks_by_rule_id = TRMNLP::Lint::CHECKS.to_h { |type| [TRMNLP::Lint.rule_id(type), type] }
35
+ unknown_rule_ids = config.project.ignored_lint_rules - checks_by_rule_id.keys
36
+ unless unknown_rule_ids.empty?
37
+ raise InvalidConfig, ".trmnlp.yml ignored_lint_rules has unknown rule IDs: #{unknown_rule_ids.join(', ')}. " \
38
+ "Known rule IDs: #{checks_by_rule_id.keys.join(', ')}"
39
+ end
40
+
41
+ checks_by_rule_id.except(*config.project.ignored_lint_rules).values
42
+ end
43
+
33
44
  def source
34
45
  @source ||= TRMNLP::Lint::Source.new(config:, paths:)
35
46
  end
@@ -54,6 +54,8 @@ module TRMNLP
54
54
 
55
55
  def time_zone = @config['time_zone'] || 'UTC'
56
56
 
57
+ def ignored_lint_rules = Array(@config['ignored_lint_rules'])
58
+
57
59
  # Local override for the framework asset host (offline / mirrored
58
60
  # dev). Trmnlp-specific (local dev only) — so it stays in
59
61
  # .trmnlp.yml. Consumed by Config::Plugin#framework_version.
@@ -18,12 +18,13 @@ module TRMNLP
18
18
  # The keywords after reporter let `trmnlp test` run the real pipeline with a test's inputs.
19
19
  # rubocop:disable-next Metrics/ParameterLists -- the composition root takes what it wires
20
20
  def initialize(root_dir, reporter: Reporter.new, cache_dir: nil, project_overrides: {}, outbound_request: nil,
21
- transform_client: nil)
21
+ transform_client: nil, source_data: nil)
22
22
  @paths = Paths.new(root_dir, cache_dir:)
23
23
  @config = Config.new(paths, project_overrides:)
24
24
  @reporter = reporter
25
25
  @outbound_request = outbound_request
26
26
  @transform_client = transform_client
27
+ @source_data = source_data
27
28
  end
28
29
 
29
30
  # Context is the composition root: it wires and memoizes the runtime
@@ -51,7 +52,8 @@ module TRMNLP
51
52
  end
52
53
 
53
54
  def user_data_assembler
54
- @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:, oauth_session:)
55
+ @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:, oauth_session:,
56
+ source_data: @source_data)
55
57
  end
56
58
 
57
59
  def renderer = @renderer ||= Renderer.new(config:, paths:, user_data_assembler:)
@@ -18,6 +18,9 @@ module TRMNLP
18
18
  Selenium::WebDriver::Firefox::Options.new(web_socket_url: true).tap do |opts|
19
19
  opts.add_argument('--headless')
20
20
  opts.add_argument('--disable-web-security')
21
+ # A page trmnlp test serves from 127.0.0.1 gets no storage, cookies or Referer, as on TRMNL's about:blank.
22
+ opts.add_preference('network.cookie.cookieBehavior', 2)
23
+ opts.add_preference('network.http.sendRefererHeader', 0)
21
24
  end
22
25
  end
23
26
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trmnl/liquid'
4
+
5
+ require_relative '../check'
6
+
7
+ module TRMNLP
8
+ module Lint
9
+ module Checks
10
+ # Reports markup that uses a filter neither Liquid nor trmnl-liquid defines, such
11
+ # as a typo or a filter from another Liquid (Jekyll, LiquidJS). TRMNL does not fail
12
+ # on one: it outputs the value unfiltered. custom_filters are no_custom_filters' to report.
13
+ class NoUnknownFilters < Check
14
+ def issues
15
+ unknown_filter_names.map do |name|
16
+ { message: "Filter '#{name}' is not a Liquid or TRMNL filter. TRMNL outputs the value unfiltered; " \
17
+ 'check the name, or use a built-in filter or the transform instead.' }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def unknown_filter_names
24
+ used_filter_names - environment.filter_method_names - source.local_only_filter_names
25
+ end
26
+
27
+ def used_filter_names = source.markup_files.values.flat_map { filter_names(it) }.uniq
28
+
29
+ # Liquid's own parse, so a pipe in a string or a raw block is not taken for a filter.
30
+ def filter_names(markup)
31
+ names = []
32
+ template = ::Liquid::Template.parse(markup, environment:)
33
+ ::Liquid::ParseTreeVisitor.for(template.root)
34
+ .add_callback_for(::Liquid::Variable) { names.concat(it.filters.map(&:first)) }
35
+ .visit
36
+ names
37
+ rescue ::Liquid::SyntaxError
38
+ [] # A template that does not parse fails to render, which says so.
39
+ end
40
+
41
+ # trmnl-liquid's template tag keeps its body as a String, which ParseTreeVisitor cannot walk.
42
+ def environment = @environment ||= TRMNL::Liquid.new { it.register_tag 'template', ::Liquid::Block }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -33,9 +33,7 @@ module TRMNLP
33
33
 
34
34
  attr_reader :check, :source, :finding
35
35
 
36
- def rule_id
37
- check.class.name.split('::').last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
38
- end
36
+ def rule_id = Lint.rule_id(check.class)
39
37
 
40
38
  def locations
41
39
  return source.yaml_location('src/settings.yml', SETTINGS_KEYS[rule_id]) if SETTINGS_KEYS.key?(rule_id)
@@ -46,7 +44,7 @@ module TRMNLP
46
44
  when 'layouts_have_content' then empty_view_locations
47
45
  when 'form_fields_valid' then form_field_locations
48
46
  when 'custom_fields_used' then project_field_locations
49
- when 'no_custom_filters' then custom_filter_locations
47
+ when 'no_custom_filters', 'no_unknown_filters' then filter_locations
50
48
  when 'image_links_reachable' then source.locations(Regexp.union(check.unreachable_urls))
51
49
  else []
52
50
  end
@@ -57,7 +55,7 @@ module TRMNLP
57
55
  css_class ? source.locations(Regexp.new(Regexp.escape(css_class))) : []
58
56
  end
59
57
 
60
- def custom_filter_locations
58
+ def filter_locations
61
59
  source.locations(Checks::NoCustomFilters.usage_pattern(finding[:message][/Filter '([^']+)'/, 1]))
62
60
  end
63
61
 
data/lib/trmnlp/lint.rb CHANGED
@@ -19,6 +19,7 @@ require_relative 'lint/checks/image_links_reachable'
19
19
  require_relative 'lint/checks/custom_fields_used'
20
20
  require_relative 'lint/checks/form_fields_valid'
21
21
  require_relative 'lint/checks/no_custom_filters'
22
+ require_relative 'lint/checks/no_unknown_filters'
22
23
 
23
24
  module TRMNLP
24
25
  # Markup best-practice checks behind `trmnlp lint`.
@@ -41,7 +42,10 @@ module TRMNLP
41
42
  Checks::ImageLinksReachable,
42
43
  Checks::CustomFieldsUsed,
43
44
  Checks::FormFieldsValid,
44
- Checks::NoCustomFilters
45
+ Checks::NoCustomFilters,
46
+ Checks::NoUnknownFilters
45
47
  ].freeze
48
+
49
+ def self.rule_id(check_type) = check_type.name.split('::').last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
46
50
  end
47
51
  end
data/lib/trmnlp/paths.rb CHANGED
@@ -55,6 +55,9 @@ module TRMNLP
55
55
 
56
56
  def transform_output = cache_dir.join('transform_output', "#{project_key}.json")
57
57
 
58
+ # One file for every project: the published gem version is not per project.
59
+ def update_check = cache_dir.join('update_check.json')
60
+
58
61
  def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
59
62
 
60
63
  def src_files = src_dir.glob('*').select(&:file?)
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trmnl/liquid'
4
+
5
+ module TRMNLP
6
+ # Mirrors core's config/initializers/trmnl_liquid_qr_code.rb: a white quiet zone inside the code's size.
7
+ module QrCode
8
+ QUIET_ZONE_MODULES = 4
9
+
10
+ def qr_code(data, size = 11, level = '', view = 'responsive')
11
+ svg = super
12
+ side_length = svg[/<rect width="(\d+)"/, 1]
13
+ return svg unless side_length
14
+
15
+ border = size * QUIET_ZONE_MODULES
16
+ box = side_length.to_i + (2 * border)
17
+ sizing = %(width="#{side_length}" height="#{side_length}")
18
+ sizing += ' style="max-width:100%;height:auto"' if view == 'responsive'
19
+ svg.sub(/ (?:viewBox="[^"]*"|width="\d+" height="\d+")/, '')
20
+ .sub('<svg ', %(<svg #{sizing} viewBox="-#{border} -#{border} #{box} #{box}" ))
21
+ .sub(%r{<rect [^>]*?(?=/>)}, %(<rect width="#{box}" height="#{box}" x="-#{border}" y="-#{border}" fill="#fff"))
22
+ end
23
+ end
24
+ end
25
+
26
+ TRMNL::Liquid::Filters.prepend(TRMNLP::QrCode)
@@ -24,18 +24,20 @@ module TRMNLP
24
24
  @requested_color_depth = opts[:color_depth]
25
25
  end
26
26
 
27
+ # The page as it is shown: chart libraries swapped for TRMNL's copies.
28
+ def html = CHART_LIBRARIES.reduce(@input) { |page, (from, to)| page.gsub(from, to) }
29
+
27
30
  def process
28
- html = CHART_LIBRARIES.reduce(@input) { |page, (from, to)| page.gsub(from, to) }
29
31
  output = @screenshot.call(html:, width:, height:)
30
32
  ImageQuantizer.new(depth: color_depth, dither: @input.include?('image-dither')).call(output.path)
31
33
  output
32
34
  end
33
35
 
34
- private
35
-
36
36
  def width = @requested_width || 800
37
37
  def height = @requested_height || 480
38
38
 
39
+ private
40
+
39
41
  def color_depth
40
42
  return @requested_color_depth if @requested_color_depth
41
43
  return ::Regexp.last_match(1).to_i if @input&.match(/screen--(\d+)bit/)
@@ -15,21 +15,29 @@ module TRMNLP
15
15
  return document.readyState === 'complete' && window.TRMNL_PLUGINS_READY === true &&
16
16
  window.TRMNL_HIGHCHARTS_DONE === true && window.TRMNL_CHILD_DITHER_DONE !== false
17
17
  JS
18
- # Stops timers and animation callbacks from changing the page mid-capture.
18
+ # A font file that never arrives would keep the page waiting until WebDriver's own 30 second limit.
19
+ FONTS_TIMEOUT_SECONDS = 10
20
+ FONTS_LOADED_SCRIPT = "return !document.fonts || document.fonts.status === 'loaded'"
21
+ FONTS_LOADING_SCRIPT =
22
+ "return [...document.fonts].filter((font) => font.status === 'loading').map((font) => font.family)"
23
+ # Stops timers and animation callbacks from changing the page mid-capture. A window numbers its timeouts
24
+ # and intervals upward from one counter, so a new timer's id is the highest there is to clear.
19
25
  FREEZE_TIMERS = <<~JS
20
26
  const noop = () => 0;
27
+ const newest = window.setTimeout(noop, 0);
21
28
  window.setTimeout = window.setInterval = noop;
22
29
  window.requestAnimationFrame = noop;
23
30
  if (window.requestIdleCallback) window.requestIdleCallback = noop;
24
- for (let i = 100000; i >= 0; i--) { window.clearTimeout(i); window.clearInterval(i); }
31
+ for (let i = typeof newest === 'number' ? newest : 100000; i >= 0; i--) { window.clearTimeout(i); window.clearInterval(i); }
25
32
  if (window.ResizeObserver) window.ResizeObserver.prototype.observe = noop;
26
33
  if (window.MutationObserver) window.MutationObserver.prototype.observe = noop;
27
34
  window.onresize = null;
28
35
  JS
29
36
 
30
- def initialize(pool:, viewport_timeout: 5)
37
+ def initialize(pool:, viewport_timeout: 5, fonts_timeout: FONTS_TIMEOUT_SECONDS)
31
38
  @pool = pool
32
39
  @viewport_timeout = viewport_timeout
40
+ @fonts_timeout = fonts_timeout
33
41
  end
34
42
 
35
43
  def call(html:, width:, height:)
@@ -48,9 +56,10 @@ module TRMNLP
48
56
  # Loads html into driver at width x height and waits until TRMNL would capture it, and until wait_for
49
57
  # (a JavaScript expression) is true when given: a page still drawing would be stopped by the frozen timers.
50
58
  # rubocop:disable-next Metrics/ParameterLists -- the page, its size, and what to wait for
51
- def show(driver, html, width, height, wait_for: nil, wait_for_timeout: READINESS_TIMEOUT_SECONDS)
59
+ # url: an address that serves html, opened in place of writing html into a blank page.
60
+ def show(driver, html, width, height, wait_for: nil, wait_for_timeout: READINESS_TIMEOUT_SECONDS, url: nil)
52
61
  resize(driver, width, height)
53
- load_page(driver, html) { wait_for_expression(driver, wait_for, wait_for_timeout) if wait_for }
62
+ load_page(driver, html, url:) { wait_for_expression(driver, wait_for, wait_for_timeout) if wait_for }
54
63
  end
55
64
 
56
65
  def capture(driver)
@@ -68,6 +77,8 @@ module TRMNLP
68
77
  end
69
78
 
70
79
  def resize(driver, width, height)
80
+ return if viewport(driver) == [width, height]
81
+
71
82
  set_viewport(driver, width, height)
72
83
  wait_for_viewport(driver, width, height)
73
84
  end
@@ -82,7 +93,7 @@ module TRMNLP
82
93
  # clipped the first screenshot short (800x433 instead of 800x480). Poll the
83
94
  # real viewport instead, re-applying the size until it lands.
84
95
  def wait_for_viewport(driver, width, height)
85
- Selenium::WebDriver::Wait.new(timeout: @viewport_timeout, interval: 0.1).until do
96
+ Selenium::WebDriver::Wait.new(timeout: @viewport_timeout, interval: 0.01).until do
86
97
  next true if viewport(driver) == [width, height]
87
98
 
88
99
  set_viewport(driver, width, height)
@@ -97,24 +108,41 @@ module TRMNLP
97
108
  driver.execute_script('return [window.innerWidth, window.innerHeight]')
98
109
  end
99
110
 
100
- def load_page(driver, html)
101
- driver.navigate.to('about:blank')
111
+ # A font that stalls is usually a dropped connection, so the page is loaded once more before giving up.
112
+ def load_page(driver, html, url: nil, &)
113
+ attempts = 0
114
+ begin
115
+ open_page(driver, html, url, &)
116
+ rescue Selenium::WebDriver::Error::TimeoutError
117
+ retry if (attempts += 1) <= 1
118
+ loading = driver.execute_script(FONTS_LOADING_SCRIPT).uniq.join(', ')
119
+ raise RenderError, "The page's fonts did not load within #{@fonts_timeout}s: #{loading}"
120
+ end
102
121
 
103
- driver.execute_script(<<~JS, html)
104
- document.open();
105
- document.write(arguments[0]);
106
- document.close();
122
+ driver.execute_script(<<~JS)
123
+ document.documentElement.style.overflow = 'hidden';
124
+ document.body.style.overflow = 'hidden';
107
125
  JS
126
+ driver.execute_script(FREEZE_TIMERS)
127
+ end
128
+
129
+ def open_page(driver, html, url)
130
+ url ? driver.navigate.to(url) : write_page(driver, html)
108
131
 
109
132
  wait_until_ready(driver, html)
110
133
  yield if block_given?
111
- driver.execute_script('return document.fonts && document.fonts.ready')
134
+ Selenium::WebDriver::Wait.new(timeout: @fonts_timeout, interval: 0.05)
135
+ .until { driver.execute_script(FONTS_LOADED_SCRIPT) }
136
+ end
112
137
 
113
- driver.execute_script(<<~JS)
114
- document.documentElement.style.overflow = 'hidden';
115
- document.body.style.overflow = 'hidden';
138
+ def write_page(driver, html)
139
+ driver.navigate.to('about:blank')
140
+
141
+ driver.execute_script(<<~JS, html)
142
+ document.open();
143
+ document.write(arguments[0]);
144
+ document.close();
116
145
  JS
117
- driver.execute_script(FREEZE_TIMERS)
118
146
  end
119
147
 
120
148
  def wait_for_expression(driver, expression, timeout)