trmnl_preview 0.31.3 → 0.32.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 +4 -0
- data/README.md +10 -0
- data/lib/trmnlp/app.rb +5 -1
- data/lib/trmnlp/cli.rb +2 -0
- data/lib/trmnlp/commands/build.rb +19 -3
- data/lib/trmnlp/commands/serve.rb +6 -0
- data/lib/trmnlp/commands/test.rb +1 -0
- data/lib/trmnlp/config/project.rb +71 -1
- data/lib/trmnlp/testing/asset_cache.rb +132 -4
- data/lib/trmnlp/testing/browser.rb +2 -1
- data/lib/trmnlp/testing/plugin.rb +12 -3
- data/lib/trmnlp/testing/report.html.erb +3 -0
- data/lib/trmnlp/testing/report.rb +2 -1
- data/lib/trmnlp/testing/screen.rb +5 -2
- data/lib/trmnlp/version.rb +1 -1
- data/web/public/index.js +21 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cff23f63336a6438a489b38cfdee98b090018a82ea3aa97733234bc4a163bda3
|
|
4
|
+
data.tar.gz: 02af5ef2992fceaf3452e99dce8aaea7500abeda1661ae452bd25b43b4cfd440
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f2bd44aa5e6f2a607719446356a76e97ad5eb30c8be7d671e59686d0d2d179a53c4e9801c027e0354e9b34397fd863b167347a0df9c4e40884ebc49dddcbdda
|
|
7
|
+
data.tar.gz: e3212337cd34f74ebd9c8994b0853341413d48b2b7c7d5e225e9266385405365f47822c1890d51d3b10329d49cbad249af0be35b60c50f84656cfa91d4ce608b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
|
|
2
2
|
# Changelog
|
|
3
3
|
|
|
4
|
+
## 0.32.0
|
|
5
|
+
|
|
6
|
+
- `asset_overrides` in `.trmnlp.yml` replaces a literal URL, or a URL prefix ending in `/`, with a local file, a local folder or another URL, so a plugin can be previewed and tested with a local build of its own script, images or fonts before they are published. `trmnlp serve`, `trmnlp test` and `trmnlp build --png` serve the overrides from the asset cache; `trmnlp build` keeps the published URLs, and `--bundle-assets` copies the files next to the HTML. A test can set its own overrides, also as inline content, and `--report` lists every swap. `trmnlp serve` redraws when an overridden file changes, from a local folder or from another URL such as a bundler's dev server.
|
|
7
|
+
|
|
4
8
|
## 0.31.3
|
|
5
9
|
|
|
6
10
|
- The Docker image draws TRMNLMaps maps. Firefox on Linux gets a WebGL context only from an X display, so maps drew blank under `trmnlp test` and `trmnlp serve` in the image; it now starts Xvfb and allows software WebGL, as TRMNL's render boxes do.
|
data/README.md
CHANGED
|
@@ -385,8 +385,18 @@ ignored_lint_rules:
|
|
|
385
385
|
# each mapped to the trmnlp project whose last fetched data stands in for it
|
|
386
386
|
merged_plugins:
|
|
387
387
|
private_plugin_42: ../weather
|
|
388
|
+
|
|
389
|
+
# replace literal asset URLs during serve, test, and build --png
|
|
390
|
+
asset_overrides:
|
|
391
|
+
https://example.com/logo.svg: assets/logo.svg
|
|
392
|
+
https://example.com/icons/: assets/icons
|
|
393
|
+
https://example.com/app.js: https://mirror.example.com/app.js
|
|
388
394
|
```
|
|
389
395
|
|
|
396
|
+
`asset_overrides` keys are literal URLs. A key ending in `/` matches a URL prefix and maps to a local folder or a URL prefix ending in `/`; the longest matching key wins. Other keys map to a local file or another URL. Local paths are relative to the plugin directory. Wildcards, regular expressions and placeholders are not supported. A missing local file is an error. `build` keeps published URLs in HTML by default; `build --bundle-assets` copies overridden assets into `_build/assets/` and uses relative links. In a plugin test, `trmnl.render(asset_overrides: { 'https://example.com/app.js' => { body: 'window.ready = true', content_type: 'text/javascript' } })` adds an override for that test. The test mapping wins on the same key, and `content_type` defaults to the URL's file type. `trmnl.transform` accepts the same mapping.
|
|
397
|
+
|
|
398
|
+
`trmnlp serve` redraws when an overridden local file or folder changes, without adding it to `watch`. For an override that points at another URL, such as a bundler's dev server, the open preview asks again every second for the files it loaded and redraws when one changes; trmnlp keeps each file as long as that server's cache-control allows.
|
|
399
|
+
|
|
390
400
|
An `async_polling` plugin gets `{{ callback_url }}` in its polling url. It points at
|
|
391
401
|
`trmnlp serve`'s `/callback` route, where the API posts `merge_variables` after answering 202.
|
|
392
402
|
|
data/lib/trmnlp/app.rb
CHANGED
|
@@ -81,6 +81,8 @@ module TRMNLP
|
|
|
81
81
|
redirect '/full'
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
get('/overrides/*') { settings.asset_cache.override_response(request.path_info, request.user_agent) || halt(502) }
|
|
85
|
+
|
|
84
86
|
get '/data' do
|
|
85
87
|
content_type :json
|
|
86
88
|
device = @user_data_assembler.device_from_params(params)
|
|
@@ -213,12 +215,14 @@ module TRMNLP
|
|
|
213
215
|
end
|
|
214
216
|
|
|
215
217
|
get "/render/#{view}.html" do
|
|
216
|
-
Screenshot.live_preview(@renderer.render_full_page(view, params))
|
|
218
|
+
html = Screenshot.live_preview(@renderer.render_full_page(view, params))
|
|
219
|
+
settings.asset_cache.point_overrides_at(html, overrides: @context.config.project.asset_overrides)
|
|
217
220
|
end
|
|
218
221
|
|
|
219
222
|
get "/render/#{view}.png" do
|
|
220
223
|
@view = view
|
|
221
224
|
html = @renderer.render_full_page(view, params)
|
|
225
|
+
html = settings.asset_cache.rewrite_if_configured(html, overrides: @context.config.project.asset_overrides)
|
|
222
226
|
temp_image = render_png(html, params)
|
|
223
227
|
|
|
224
228
|
send_file temp_image.path, type: 'image/png', disposition: 'inline'
|
data/lib/trmnlp/cli.rb
CHANGED
|
@@ -43,6 +43,8 @@ module TRMNLP
|
|
|
43
43
|
|
|
44
44
|
desc 'build', 'Generate static HTML files'
|
|
45
45
|
method_option :png, type: :boolean, default: false, desc: 'Also render a PNG per view'
|
|
46
|
+
method_option :bundle_assets, type: :boolean, default: false,
|
|
47
|
+
desc: 'Copy overridden assets into _build/assets and use relative links'
|
|
46
48
|
method_option :width, type: :numeric, desc: 'PNG width in pixels (with --png)'
|
|
47
49
|
method_option :height, type: :numeric, desc: 'PNG height in pixels (with --png)'
|
|
48
50
|
method_option :color_depth, type: :numeric, desc: 'PNG bit depth: 1-8 (with --png)'
|
|
@@ -7,11 +7,14 @@ require_relative '../browser_pool'
|
|
|
7
7
|
require_relative '../firefox_driver'
|
|
8
8
|
require_relative '../screen_generator'
|
|
9
9
|
require_relative '../screenshot'
|
|
10
|
+
require_relative '../testing/asset_cache'
|
|
10
11
|
|
|
11
12
|
module TRMNLP
|
|
12
13
|
module Commands
|
|
13
14
|
class Build < Base
|
|
14
|
-
Options = Data.define(:dir, :quiet, :png, :width, :height, :color_depth)
|
|
15
|
+
Options = Data.define(:dir, :quiet, :png, :width, :height, :color_depth, :bundle_assets) do
|
|
16
|
+
def initialize(bundle_assets: false, **) = super(**, bundle_assets:)
|
|
17
|
+
end
|
|
15
18
|
|
|
16
19
|
def call
|
|
17
20
|
context.validate!
|
|
@@ -25,14 +28,23 @@ module TRMNLP
|
|
|
25
28
|
reporter.info 'Done!'
|
|
26
29
|
ensure
|
|
27
30
|
@browser_pool&.shutdown
|
|
31
|
+
@asset_cache&.stop
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
private
|
|
31
35
|
|
|
32
36
|
def build_screen(screen)
|
|
33
37
|
html = context.renderer.render_full_page(screen.name)
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
overrides = context.config.project.asset_overrides
|
|
39
|
+
built = if options.bundle_assets && overrides.any?
|
|
40
|
+
asset_cache.rewrite(html, overrides:,
|
|
41
|
+
bundle_dir: context.paths.build_dir)
|
|
42
|
+
else
|
|
43
|
+
html
|
|
44
|
+
end
|
|
45
|
+
write_html(screen.name, built)
|
|
46
|
+
preview = options.png && overrides.any? ? asset_cache.rewrite(html, overrides:) : html
|
|
47
|
+
write_png(screen.name, preview) if options.png
|
|
36
48
|
end
|
|
37
49
|
|
|
38
50
|
def write_html(view, html)
|
|
@@ -61,6 +73,10 @@ module TRMNLP
|
|
|
61
73
|
|
|
62
74
|
def screenshot = @screenshot ||= Screenshot.new(pool: browser_pool)
|
|
63
75
|
|
|
76
|
+
def asset_cache
|
|
77
|
+
@asset_cache ||= Testing::AssetCache.new(dir: context.paths.cache_dir.join('assets')).start
|
|
78
|
+
end
|
|
79
|
+
|
|
64
80
|
def browser_pool
|
|
65
81
|
@browser_pool ||= BrowserPool.new(driver_factory: FirefoxDriver.method(:build))
|
|
66
82
|
end
|
|
@@ -4,6 +4,7 @@ require_relative 'base'
|
|
|
4
4
|
require_relative '../api_client'
|
|
5
5
|
require_relative '../browser_pool'
|
|
6
6
|
require_relative '../firefox_driver'
|
|
7
|
+
require_relative '../testing/asset_cache'
|
|
7
8
|
|
|
8
9
|
module TRMNLP
|
|
9
10
|
module Commands
|
|
@@ -12,6 +13,7 @@ module TRMNLP
|
|
|
12
13
|
|
|
13
14
|
def call
|
|
14
15
|
context.validate!
|
|
16
|
+
context.config.project.asset_overrides
|
|
15
17
|
report_form_field_warnings
|
|
16
18
|
report_framework_version_warning
|
|
17
19
|
|
|
@@ -20,6 +22,8 @@ module TRMNLP
|
|
|
20
22
|
|
|
21
23
|
# Now we can configure things
|
|
22
24
|
App.set(:context, context)
|
|
25
|
+
@asset_cache = Testing::AssetCache.new(dir: context.paths.cache_dir.join('assets')).start
|
|
26
|
+
App.set(:asset_cache, @asset_cache)
|
|
23
27
|
App.set(:browser_pool, BrowserPool.new(driver_factory: FirefoxDriver.method(:build)))
|
|
24
28
|
App.set(:bind, options.bind)
|
|
25
29
|
App.set(:port, options.port)
|
|
@@ -31,6 +35,8 @@ module TRMNLP
|
|
|
31
35
|
|
|
32
36
|
# Finally, start the app!
|
|
33
37
|
App.run!
|
|
38
|
+
ensure
|
|
39
|
+
@asset_cache&.stop
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
private
|
data/lib/trmnlp/commands/test.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'trmnl/liquid'
|
|
4
4
|
require 'yaml'
|
|
5
|
+
require 'uri'
|
|
5
6
|
|
|
6
7
|
require_relative '../errors'
|
|
7
8
|
require_relative '../framework_version'
|
|
@@ -33,7 +34,7 @@ module TRMNLP
|
|
|
33
34
|
def live_render? = !watch_paths.empty?
|
|
34
35
|
|
|
35
36
|
def watch_paths
|
|
36
|
-
(@config['watch'] || []).map { |watch_path| paths.expand(watch_path) }.uniq
|
|
37
|
+
((@config['watch'] || []) + local_asset_override_targets).map { |watch_path| paths.expand(watch_path) }.uniq
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def custom_fields = @config.fetch('custom_fields', {}).transform_values { |v| stringify_field_value(v) }
|
|
@@ -61,6 +62,13 @@ module TRMNLP
|
|
|
61
62
|
# .trmnlp.yml. Consumed by Config::Plugin#framework_version.
|
|
62
63
|
def asset_host = @config['framework_asset_host'] || FrameworkVersion::DEFAULT_ASSET_HOST
|
|
63
64
|
|
|
65
|
+
def asset_overrides(extra = {}, allow_inline: false)
|
|
66
|
+
mapping = @config.fetch('asset_overrides', {}) || {}
|
|
67
|
+
raise InvalidConfig, 'asset_overrides must be a mapping' unless mapping.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
normalize_asset_overrides(mapping).merge(normalize_asset_overrides(extra, allow_inline:))
|
|
70
|
+
end
|
|
71
|
+
|
|
64
72
|
# Toggles serverless transform support. Enabled by default; set to
|
|
65
73
|
# 'disabled' in .trmnlp.yml to turn it off. A transform only runs
|
|
66
74
|
# when a src/transform.* file is also present, so the default is
|
|
@@ -89,6 +97,68 @@ module TRMNLP
|
|
|
89
97
|
|
|
90
98
|
private
|
|
91
99
|
|
|
100
|
+
# Read without asset_overrides' checks, so a deleted file does not stop the watcher.
|
|
101
|
+
def local_asset_override_targets
|
|
102
|
+
mapping = @config['asset_overrides']
|
|
103
|
+
mapping.is_a?(Hash) ? mapping.values.grep(String).grep_v(%r{\Ahttps?://}) : []
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_asset_overrides(mapping, allow_inline: false)
|
|
107
|
+
raise InvalidConfig, 'asset_overrides must be a mapping' unless mapping.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
mapping.to_h do |source, target|
|
|
110
|
+
validate_asset_key!(source)
|
|
111
|
+
[source, normalize_asset_target(source, target, allow_inline:)]
|
|
112
|
+
rescue URI::InvalidURIError
|
|
113
|
+
raise InvalidConfig, "asset_overrides key must be a literal URL: #{source.inspect}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_asset_key!(source)
|
|
118
|
+
uri = URI.parse(source.to_s)
|
|
119
|
+
valid = source.is_a?(String) && %w[http https].include?(uri.scheme) && uri.host &&
|
|
120
|
+
!source.match?(/[*{}\[\]\\()|]/)
|
|
121
|
+
raise InvalidConfig, "asset_overrides key must be a literal URL: #{source.inspect}" unless valid
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_asset_target(source, target, allow_inline:)
|
|
125
|
+
return inline_asset_target(source, target, allow_inline:) if target.is_a?(Hash)
|
|
126
|
+
return url_asset_target(source, target) if target.is_a?(String) && target.match?(%r{\Ahttps?://})
|
|
127
|
+
|
|
128
|
+
local_asset_target(source, target)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def inline_asset_target(source, target, allow_inline:)
|
|
132
|
+
raise InvalidConfig, "asset_overrides #{source}: inline body needs a literal file URL" if source.end_with?('/')
|
|
133
|
+
unless allow_inline && (target.key?(:body) || target.key?('body'))
|
|
134
|
+
raise InvalidConfig, "asset_overrides #{source}: inline body is only allowed in tests"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
target.transform_keys(&:to_sym)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def url_asset_target(source, target)
|
|
141
|
+
uri = URI.parse(target)
|
|
142
|
+
unless uri.host && (!source.end_with?('/') || target.end_with?('/'))
|
|
143
|
+
raise InvalidConfig, "asset_overrides #{source}: a URL prefix target must end in /"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
target
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def local_asset_target(source, target)
|
|
150
|
+
path = paths.expand(target.to_s)
|
|
151
|
+
raise InvalidConfig, "asset_overrides #{source}: local path does not exist: #{path}" unless path.exist?
|
|
152
|
+
if source.end_with?('/') && !path.directory?
|
|
153
|
+
raise InvalidConfig, "asset_overrides #{source}: a URL prefix needs a local folder"
|
|
154
|
+
end
|
|
155
|
+
if !source.end_with?('/') && path.directory?
|
|
156
|
+
raise InvalidConfig, "asset_overrides #{source}: a folder needs a URL prefix ending in /"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
path.to_s
|
|
160
|
+
end
|
|
161
|
+
|
|
92
162
|
def read_config
|
|
93
163
|
return {} unless paths.trmnlp_config.exist?
|
|
94
164
|
|
|
@@ -4,9 +4,12 @@ require 'digest'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'net/http'
|
|
7
|
+
require 'rack/mime'
|
|
7
8
|
require 'socket'
|
|
9
|
+
require 'uri'
|
|
8
10
|
|
|
9
11
|
require_relative '../framework_version'
|
|
12
|
+
require_relative '../errors'
|
|
10
13
|
|
|
11
14
|
module TRMNLP
|
|
12
15
|
module Testing
|
|
@@ -18,10 +21,15 @@ module TRMNLP
|
|
|
18
21
|
SAFE_PATH = %r{\A/(?:[\w.-]+/)*[\w.-]+\z}
|
|
19
22
|
DOWNLOAD_TIMEOUT_SECONDS = 10
|
|
20
23
|
BAD_GATEWAY = "HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
|
|
24
|
+
URL_LINK = %r{https?://[^\s"'<>),;]+}
|
|
25
|
+
OVERRIDE_PATH = %r{\A/overrides/(\h{16})/(.*)\z}
|
|
26
|
+
LOCAL = { 'cache_control' => 'no-store', 'allow_origin' => '*' }.freeze
|
|
21
27
|
|
|
22
28
|
def initialize(dir:, clock: -> { Time.now })
|
|
23
29
|
@dir = dir.to_s
|
|
24
30
|
@clock = clock
|
|
31
|
+
@overrides = {}
|
|
32
|
+
@lock = Mutex.new
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def start
|
|
@@ -37,10 +45,126 @@ module TRMNLP
|
|
|
37
45
|
|
|
38
46
|
def url = "http://127.0.0.1:#{@server.addr[1]}"
|
|
39
47
|
|
|
40
|
-
def
|
|
48
|
+
def rewrite_if_configured(html, overrides:) = overrides.empty? ? html : rewrite(html, overrides:)
|
|
49
|
+
|
|
50
|
+
# Rewrites text, including URL strings in scripts, before the normal Framework and font substitutions.
|
|
51
|
+
def rewrite(html, overrides: {}, swaps: nil, bundle_dir: nil)
|
|
52
|
+
rewritten = point_overrides_at(html, overrides:, base: "#{url}/overrides", swaps:, bundle_dir:)
|
|
53
|
+
return rewritten if bundle_dir
|
|
54
|
+
|
|
55
|
+
rewritten = rewritten.gsub(FRAMEWORK_LINK) do
|
|
56
|
+
original = ::Regexp.last_match(0) + ::Regexp.last_match.post_match[/\A[^\s"'<>),;]*/].to_s
|
|
57
|
+
swaps&.push(url: original, target: original.sub(HOST, url), kind: 'framework')
|
|
58
|
+
url
|
|
59
|
+
end
|
|
60
|
+
rewritten.gsub(GOOGLE_FONTS_LINK) do
|
|
61
|
+
host = ::Regexp.last_match(1)
|
|
62
|
+
original = ::Regexp.last_match(0) + ::Regexp.last_match.post_match[/\A[^\s"'<>),;]*/].to_s
|
|
63
|
+
swaps&.push(url: original, target: original.sub('https://', "#{url}/"), kind: 'fonts')
|
|
64
|
+
"#{url}/#{host}/"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A folder key keeps the rest of the URL, so files the asset links relative to itself resolve in the same folder.
|
|
69
|
+
# The default base is the page's own server, so serve's preview reaches it from outside a container.
|
|
70
|
+
def point_overrides_at(html, overrides:, base: '/overrides', swaps: nil, bundle_dir: nil)
|
|
71
|
+
html.gsub(URL_LINK) do |source|
|
|
72
|
+
key = overrides.keys.select { folder?(it) ? source.start_with?(it) : source == it }.max_by(&:length)
|
|
73
|
+
next source unless key
|
|
74
|
+
|
|
75
|
+
target = overrides.fetch(key)
|
|
76
|
+
rest = folder?(key) ? source.delete_prefix(key) : File.basename(URI(source).path)
|
|
77
|
+
check_local_file!(source, key, target, rest)
|
|
78
|
+
swaps&.push(url: source, target: target.is_a?(Hash) ? '[inline body]' : resolve(key, target, rest),
|
|
79
|
+
kind: 'override')
|
|
80
|
+
id = register_override(key, target)
|
|
81
|
+
bundle_dir ? bundle_asset(id, key, target, rest, bundle_dir) : "#{base}/#{id}/#{rest}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def override_response(path, user_agent)
|
|
86
|
+
asset = override_asset(path, user_agent) or return
|
|
87
|
+
[200, { 'content-type' => asset['content_type'], 'cache-control' => asset['cache_control'],
|
|
88
|
+
'access-control-allow-origin' => asset['allow_origin'] }.compact, [asset['body']]]
|
|
89
|
+
end
|
|
41
90
|
|
|
42
91
|
private
|
|
43
92
|
|
|
93
|
+
def override_asset(path, user_agent)
|
|
94
|
+
id, rest = path.match(OVERRIDE_PATH)&.captures
|
|
95
|
+
key, target = @lock.synchronize { @overrides[id] }
|
|
96
|
+
return unless key
|
|
97
|
+
|
|
98
|
+
rest = URI.decode_uri_component(rest)
|
|
99
|
+
return published_neighbor(key, rest, user_agent) unless folder?(key) || rest == File.basename(URI(key).path)
|
|
100
|
+
|
|
101
|
+
overridden(key, target, rest, user_agent)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def folder?(key) = key.end_with?('/')
|
|
105
|
+
|
|
106
|
+
def url?(target) = target.is_a?(String) && target.match?(%r{\Ahttps?://})
|
|
107
|
+
|
|
108
|
+
def overridden(key, target, rest, user_agent)
|
|
109
|
+
found = resolve(key, target, rest)
|
|
110
|
+
type = mime(folder?(key) ? "#{key}#{rest}" : key)
|
|
111
|
+
if found.is_a?(Hash)
|
|
112
|
+
{ 'body' => found.fetch(:body).to_s, 'content_type' => found[:content_type] || type }.merge(LOCAL)
|
|
113
|
+
elsif url?(found) then remote_asset(found, user_agent)
|
|
114
|
+
elsif found && File.file?(found) then { 'body' => File.binread(found), 'content_type' => type }.merge(LOCAL)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The file, URL or inline content an overridden URL stands for; nil for a path outside the folder.
|
|
119
|
+
def resolve(key, target, rest)
|
|
120
|
+
return target unless folder?(key)
|
|
121
|
+
return "#{target}#{rest}" if url?(target)
|
|
122
|
+
|
|
123
|
+
path = File.expand_path(rest.split('?', 2).first.to_s, target)
|
|
124
|
+
path if path == File.expand_path(target) || path.start_with?("#{File.expand_path(target)}/")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def check_local_file!(source, key, target, rest)
|
|
128
|
+
return if target.is_a?(Hash) || url?(target) || rest.empty?
|
|
129
|
+
|
|
130
|
+
path = resolve(key, target, rest)
|
|
131
|
+
raise InvalidConfig, "asset_overrides #{source}: no local file at #{path}" unless File.exist?(path.to_s)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Keyed by what is swapped, so a page rewritten again gets the same address and the table stays one row per key.
|
|
135
|
+
def register_override(key, target)
|
|
136
|
+
Digest::SHA256.hexdigest("#{key} #{target.inspect}")[0, 16].tap { |id| @lock.synchronize { @overrides[id] = [key, target] } }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A file next to an overridden single file comes from where it was published, on the same host.
|
|
140
|
+
def published_neighbor(key, rest, user_agent)
|
|
141
|
+
neighbor = URI.join(key, rest)
|
|
142
|
+
remote_asset(neighbor.to_s, user_agent) if neighbor.host == URI(key).host
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def bundle_asset(id, key, target, rest, dir)
|
|
146
|
+
folder = File.join(dir, 'assets', id)
|
|
147
|
+
if folder?(key) && !url?(target)
|
|
148
|
+
FileUtils.mkdir_p(folder)
|
|
149
|
+
FileUtils.cp_r("#{target}/.", folder)
|
|
150
|
+
elsif !rest.empty? && !rest.end_with?('/')
|
|
151
|
+
body = overridden(key, target, rest, nil)&.fetch('body')
|
|
152
|
+
raise InvalidConfig, "asset_overrides #{key}: could not fetch #{resolve(key, target, rest)}" unless body
|
|
153
|
+
|
|
154
|
+
FileUtils.mkdir_p(File.dirname(destination = File.join(folder, rest.split('?', 2).first)))
|
|
155
|
+
File.binwrite(destination, body)
|
|
156
|
+
end
|
|
157
|
+
"assets/#{id}/#{rest}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def remote_asset(target, user_agent)
|
|
161
|
+
key = "/remote/#{Digest::SHA256.hexdigest(target)}"
|
|
162
|
+
cached = cached(key)
|
|
163
|
+
cached && cached['expires_at'] > @clock.call.to_i ? cached : download(key, URI(target), user_agent) || cached
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def mime(source) = Rack::Mime.mime_type(File.extname(URI(source).path), 'application/octet-stream')
|
|
167
|
+
|
|
44
168
|
def point_google_fonts_at_cache(text) = text.gsub(GOOGLE_FONTS_LINK) { "#{url}/#{::Regexp.last_match(1)}/" }
|
|
45
169
|
|
|
46
170
|
def serve(socket)
|
|
@@ -49,9 +173,11 @@ module TRMNLP
|
|
|
49
173
|
while (line = socket.gets) && !line.strip.empty?
|
|
50
174
|
user_agent = line.split(':', 2).last.strip if line.downcase.start_with?('user-agent:')
|
|
51
175
|
end
|
|
52
|
-
asset =
|
|
176
|
+
asset = if path.to_s.start_with?('/overrides/') then override_asset(path, user_agent)
|
|
177
|
+
elsif !path.to_s.include?('..') && path.to_s.match?(SAFE_PATH) then asset(path, query, user_agent)
|
|
178
|
+
end
|
|
53
179
|
socket.write(asset ? response(asset) : BAD_GATEWAY)
|
|
54
|
-
rescue IOError, SystemCallError
|
|
180
|
+
rescue IOError, SystemCallError, URI::Error
|
|
55
181
|
nil
|
|
56
182
|
ensure
|
|
57
183
|
socket.close unless socket.closed?
|
|
@@ -82,7 +208,9 @@ module TRMNLP
|
|
|
82
208
|
def download(key, uri, user_agent)
|
|
83
209
|
headers = { 'Origin' => url, 'User-Agent' => user_agent }.compact
|
|
84
210
|
timeouts = { open_timeout: DOWNLOAD_TIMEOUT_SECONDS, read_timeout: DOWNLOAD_TIMEOUT_SECONDS }
|
|
85
|
-
answer = Net::HTTP.start(uri.host, uri.port, use_ssl:
|
|
211
|
+
answer = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', **timeouts) do |http|
|
|
212
|
+
http.get(uri.request_uri, headers)
|
|
213
|
+
end
|
|
86
214
|
answer.is_a?(Net::HTTPSuccess) ? cache_download(key, answer) : nil
|
|
87
215
|
rescue StandardError
|
|
88
216
|
nil
|
|
@@ -32,7 +32,8 @@ module TRMNLP
|
|
|
32
32
|
# Loads the screen's page; its picture is only taken when a test asks for it.
|
|
33
33
|
def load(screen)
|
|
34
34
|
generator = generator(screen)
|
|
35
|
-
|
|
35
|
+
screen.asset_swaps = []
|
|
36
|
+
html = @asset_cache.rewrite(generator.html, overrides: screen.asset_overrides, swaps: screen.asset_swaps)
|
|
36
37
|
@pages[screen] = [html, generator.width, generator.height, @page_server.add(html)]
|
|
37
38
|
show(screen)
|
|
38
39
|
end
|
|
@@ -6,6 +6,8 @@ require_relative 'device_models'
|
|
|
6
6
|
require_relative 'report'
|
|
7
7
|
require_relative 'run'
|
|
8
8
|
require_relative 'screen'
|
|
9
|
+
require_relative '../config/project'
|
|
10
|
+
require_relative '../paths'
|
|
9
11
|
|
|
10
12
|
module TRMNLP
|
|
11
13
|
module Testing
|
|
@@ -58,7 +60,8 @@ module TRMNLP
|
|
|
58
60
|
# The plugin in another folder, rendered in the same browser: a built copy, say.
|
|
59
61
|
def plugin(dir) = self.class.new(File.expand_path(dir), browser: @browser, authority: @authority)
|
|
60
62
|
|
|
61
|
-
def transform(device: 'og_plus', orientation: :landscape, now: nil, **)
|
|
63
|
+
def transform(device: 'og_plus', orientation: :landscape, now: nil, asset_overrides: {}, **)
|
|
64
|
+
overrides(asset_overrides)
|
|
62
65
|
device = DeviceModels.find(device, orientation:)
|
|
63
66
|
run(now, **).transform(device: device.render_params).tap { Report.current&.record_run(it) }
|
|
64
67
|
end
|
|
@@ -67,19 +70,25 @@ module TRMNLP
|
|
|
67
70
|
# JavaScript expression the page must reach before it is captured) and wait_for_timeout: (seconds).
|
|
68
71
|
# rubocop:disable-next Metrics/ParameterLists -- one keyword per choice a render offers
|
|
69
72
|
def render(view: 'full', device: 'og_plus', orientation: :landscape, palette: nil, dark_mode: false, theme: nil,
|
|
70
|
-
now: nil, head: nil, wait_for: nil, wait_for_timeout: 5, fresh_browser: false,
|
|
73
|
+
now: nil, head: nil, wait_for: nil, wait_for_timeout: 5, fresh_browser: false,
|
|
74
|
+
asset_overrides: {}, **)
|
|
71
75
|
device = DeviceModels.find(device, orientation:, palette:)
|
|
72
76
|
classes = [device.screen_classes, ('screen--dark-mode' if dark_mode)].compact.join(' ')
|
|
73
77
|
result = run(now, **).render(view:, device: device.render_params, screen_classes: classes, theme:)
|
|
74
78
|
html = with_problems_trap(with_head(with_page_clock(result.html, now), head))
|
|
75
79
|
browser = fresh_browser ? @browser.fresh : @browser
|
|
76
80
|
Report.current&.record_run(result)
|
|
77
|
-
Screen.new(html:, device:, view:, browser:, result:, wait_for:, wait_for_timeout
|
|
81
|
+
Screen.new(html:, device:, view:, browser:, result:, wait_for:, wait_for_timeout:,
|
|
82
|
+
asset_overrides: overrides(asset_overrides))
|
|
78
83
|
.tap { Report.current&.record_screen(it) }
|
|
79
84
|
end
|
|
80
85
|
|
|
81
86
|
private
|
|
82
87
|
|
|
88
|
+
def overrides(extra)
|
|
89
|
+
Config::Project.new(Paths.new(@dir)).asset_overrides(extra, allow_inline: true)
|
|
90
|
+
end
|
|
91
|
+
|
|
83
92
|
def run(now, **) = Run.new(plugin: @dir, authority: @authority, now: time(now), **)
|
|
84
93
|
|
|
85
94
|
def time(value)
|
|
@@ -67,6 +67,9 @@
|
|
|
67
67
|
<% end %></table>
|
|
68
68
|
<% end %>
|
|
69
69
|
<% end %>
|
|
70
|
+
<% example[:screens].flat_map { it[:asset_swaps] || [] }.each do |swap| %>
|
|
71
|
+
<div class="run"><%= h(swap[:kind]) %>: <%= h(swap[:url]) %> → <%= h(swap[:target]) %></div>
|
|
72
|
+
<% end %>
|
|
70
73
|
<div class="screens">
|
|
71
74
|
<% example[:screens].each do |screen| %>
|
|
72
75
|
<figure>
|
|
@@ -40,7 +40,8 @@ module TRMNLP
|
|
|
40
40
|
remember_full_landscape(screen, image)
|
|
41
41
|
entry[:screens].push(
|
|
42
42
|
label: "#{screen.device.name} · #{screen.view}", width: screen.device.width, height: screen.device.height,
|
|
43
|
-
image:, outlines: screen.evaluate(OUTLINES_SCRIPT), problems: screen.problems
|
|
43
|
+
image:, outlines: screen.evaluate(OUTLINES_SCRIPT), problems: screen.problems,
|
|
44
|
+
asset_swaps: screen.asset_swaps
|
|
44
45
|
)
|
|
45
46
|
end
|
|
46
47
|
|
|
@@ -145,10 +145,12 @@ module TRMNLP
|
|
|
145
145
|
return copy.outerHTML;
|
|
146
146
|
JS
|
|
147
147
|
|
|
148
|
-
attr_reader :html, :device, :view, :result, :wait_for, :wait_for_timeout
|
|
148
|
+
attr_reader :html, :device, :view, :result, :wait_for, :wait_for_timeout, :asset_overrides
|
|
149
|
+
attr_accessor :asset_swaps
|
|
149
150
|
|
|
150
151
|
# rubocop:disable-next Metrics/ParameterLists -- what a render produced and how to wait for it
|
|
151
|
-
def initialize(html:, device:, view:, browser:, result:, wait_for: nil, wait_for_timeout: 5
|
|
152
|
+
def initialize(html:, device:, view:, browser:, result:, wait_for: nil, wait_for_timeout: 5,
|
|
153
|
+
asset_overrides: {})
|
|
152
154
|
@html = html
|
|
153
155
|
@wait_for = wait_for
|
|
154
156
|
@wait_for_timeout = wait_for_timeout
|
|
@@ -156,6 +158,7 @@ module TRMNLP
|
|
|
156
158
|
@view = view
|
|
157
159
|
@browser = browser
|
|
158
160
|
@result = result
|
|
161
|
+
@asset_overrides = asset_overrides
|
|
159
162
|
browser.load(self)
|
|
160
163
|
super(Capybara.string(drawn_dom(browser.on(self) { it.execute_script(DRAWN_DOM_SCRIPT) })))
|
|
161
164
|
end
|
data/lib/trmnlp/version.rb
CHANGED
data/web/public/index.js
CHANGED
|
@@ -20,6 +20,26 @@ trmnlp.connectLiveRender = function () {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
// The file watcher cannot see an override served by another URL, such as a bundler's dev server.
|
|
24
|
+
trmnlp.watchOverrides = function () {
|
|
25
|
+
let bodies = new Map();
|
|
26
|
+
trmnlp.iframe.addEventListener("load", () => { bodies = new Map(); });
|
|
27
|
+
|
|
28
|
+
const poll = async () => {
|
|
29
|
+
const entries = trmnlp.iframe.contentWindow?.performance?.getEntriesByType("resource") || [];
|
|
30
|
+
for (const { name } of entries.filter((entry) => new URL(entry.name).pathname.startsWith("/overrides/"))) {
|
|
31
|
+
const body = await fetch(name, { cache: "no-store" }).then((response) => response.text()).catch(() => null);
|
|
32
|
+
if (bodies.has(name) && bodies.get(name) !== body) {
|
|
33
|
+
trmnlp.fetchPreview();
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
bodies.set(name, body);
|
|
37
|
+
}
|
|
38
|
+
setTimeout(poll, 1000);
|
|
39
|
+
};
|
|
40
|
+
poll();
|
|
41
|
+
};
|
|
42
|
+
|
|
23
43
|
// trmnl.device values the picked model and palette decide.
|
|
24
44
|
trmnlp.deviceParams = function (state) {
|
|
25
45
|
const bitDepth = Math.ceil(Math.log2(state.palette?.grays || 2));
|
|
@@ -274,6 +294,7 @@ document.addEventListener("DOMContentLoaded", async function () {
|
|
|
274
294
|
|
|
275
295
|
if (trmnlp.isLiveReloadEnabled) {
|
|
276
296
|
trmnlp.connectLiveRender();
|
|
297
|
+
trmnlp.watchOverrides();
|
|
277
298
|
}
|
|
278
299
|
|
|
279
300
|
const formatValue = localStorage.getItem("trmnlp-format") || "html";
|