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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +39 -36
- data/db/data/form_fields.yml +1 -0
- data/db/data/framework_versions.yml +9 -1
- data/lib/trmnlp/cli.rb +22 -0
- data/lib/trmnlp/commands/lint.rb +12 -1
- data/lib/trmnlp/config/project.rb +2 -0
- data/lib/trmnlp/context.rb +4 -2
- data/lib/trmnlp/firefox_driver.rb +3 -0
- data/lib/trmnlp/lint/checks/no_unknown_filters.rb +46 -0
- data/lib/trmnlp/lint/diagnostic.rb +3 -5
- data/lib/trmnlp/lint.rb +5 -1
- data/lib/trmnlp/paths.rb +3 -0
- data/lib/trmnlp/qr_code.rb +26 -0
- data/lib/trmnlp/screen_generator.rb +5 -3
- data/lib/trmnlp/screenshot.rb +45 -17
- data/lib/trmnlp/testing/browser.rb +23 -18
- data/lib/trmnlp/testing/page_server.rb +57 -0
- data/lib/trmnlp/testing/plugin.rb +22 -6
- data/lib/trmnlp/testing/publishable_recipe.rb +4 -3
- data/lib/trmnlp/testing/qr_scanner.rb +55 -0
- data/lib/trmnlp/testing/report.rb +1 -1
- data/lib/trmnlp/testing/rspec.rb +26 -4
- data/lib/trmnlp/testing/run.rb +1 -1
- data/lib/trmnlp/testing/screen.rb +76 -8
- data/lib/trmnlp/update_check.rb +94 -0
- data/lib/trmnlp/user_data_assembler.rb +6 -2
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp.rb +1 -1
- data/templates/init/bin/trmnlp +70 -21
- data/templates/init/tests/plugin_spec.rb +1 -1
- metadata +6 -3
- data/lib/trmnlp/qr_code_intrinsic_size.rb +0 -22
- data/lib/trmnlp/testing/inline_stylesheets.rb +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1b736ce74a0385dda5b989fe604d0256d0b1522b1955855aa0eac945ec3dd0b
|
|
4
|
+
data.tar.gz: f4b4321d72bd9129a025f07d21707f0584e965a5bfe072db3ecc95c2fbf04921
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 >=
|
|
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 >=
|
|
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
|
|
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
|
|
data/db/data/form_fields.yml
CHANGED
|
@@ -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.
|
|
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
|
data/lib/trmnlp/commands/lint.rb
CHANGED
|
@@ -24,12 +24,23 @@ module TRMNLP
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
26
|
def issues
|
|
27
|
-
@issues ||=
|
|
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.
|
data/lib/trmnlp/context.rb
CHANGED
|
@@ -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
|
|
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
|
|
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/)
|
data/lib/trmnlp/screenshot.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
104
|
-
document.
|
|
105
|
-
document.
|
|
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
|
-
|
|
134
|
+
Selenium::WebDriver::Wait.new(timeout: @fonts_timeout, interval: 0.05)
|
|
135
|
+
.until { driver.execute_script(FONTS_LOADED_SCRIPT) }
|
|
136
|
+
end
|
|
112
137
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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)
|