trmnl_preview 0.31.3 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c07e1813b7c71b84fc849092cdd5062c567999d0e0a86751e6307dd0e776eeb1
4
- data.tar.gz: 575ed56ce3f0138f1018028be28dc0d415ea53c8cfc9fe858194d2f61e36ca1a
3
+ metadata.gz: 3466bcd07fed1c8ff658708ab71e6a7d8c42c70395baff5f72de8671dbdec9d2
4
+ data.tar.gz: 871fdb7df75979caf9a19727f26621653a028fd0d37a9d39afeee10c67ac920f
5
5
  SHA512:
6
- metadata.gz: 88e02be63365d656f4bd9556df374874a510fcaeb90765738a209ca6dff2fc6194015d2a3c78a0796694c5daa8b63eefbbd2186f96d27029d6e16bf50986b7fc
7
- data.tar.gz: 3af42a4e80fdae57cc2e68a9d426e21d5f765101a6cbf52ff416551faee09a3aa657b9e25848682ddd5bad1a0f8ccd17f581ef1b738ad852a523a634c068f958
6
+ metadata.gz: d818264f98865e742f29d61bc8514726f951ccc3c0074e78a00472d19e10fd7c4071940506228a389d07989a6231f47d16342f7d119027a231ba514b415e020e
7
+ data.tar.gz: 69f1e91eacc992904e2de3a6d289e5abda4b65caf51dcc6948f602b5794322b993dd8628e7a946cffe8aad2a757a24759ad12c5390bd2e96b9cdf4b69861a749
data/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.32.1
5
+
6
+ - `trmnlp test`'s publishable recipe fails a view that has no markup file and no shared markup. It drew only "Missing template", and passed. The message now names the file from the plugin folder, such as `src/full.liquid`, not the folders on your computer.
7
+
8
+ ## 0.32.0
9
+
10
+ - `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.
11
+
4
12
  ## 0.31.3
5
13
 
6
14
  - 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
- write_html(screen.name, html)
35
- write_png(screen.name, html) if options.png
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
@@ -11,6 +11,7 @@ module TRMNLP
11
11
 
12
12
  def call(paths = [])
13
13
  context.validate!
14
+ context.config.project.asset_overrides
14
15
  require 'rspec/core'
15
16
 
16
17
  ENV['TRMNLP_PLUGIN_DIR'] = context.paths.root_dir.to_s
@@ -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
 
@@ -64,7 +64,7 @@ module TRMNLP
64
64
  def render_liquid_template(view, device: {})
65
65
  template_path = paths.template(view)
66
66
  unless template_path.exist? || paths.shared_template.exist?
67
- raise RenderError, "Missing template: #{template_path}"
67
+ raise RenderError, "Missing template: #{template_path.relative_path_from(paths.root_dir)}"
68
68
  end
69
69
 
70
70
  parse_and_render(template_path, device:)
@@ -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 point_framework_links_at_cache(html) = point_google_fonts_at_cache(html.gsub(FRAMEWORK_LINK, url))
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 = !path.to_s.include?('..') && path.to_s.match?(SAFE_PATH) && asset(path, query, user_agent)
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: true, **timeouts) { it.get(uri.request_uri, headers) }
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
- html = @asset_cache.point_framework_links_at_cache(generator.html)
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
 
@@ -21,8 +21,9 @@ require_relative 'snapshot'
21
21
  module TRMNLP
22
22
  module Testing
23
23
  PLUGIN_DIR_ENV_KEY = 'TRMNLP_PLUGIN_DIR'
24
- # What a template or script draws when its data is missing.
25
- LEAKED_TEXT_PATTERN = /Liquid (?:syntax )?error|\bundefined\b|\bNaN\b|\bnull\b|\[object Object\]|\{\{|\{%/
24
+ # What a page draws when its data or one of its templates is missing.
25
+ LEAKED_TEXT_PATTERN = Regexp.union(/Missing template|Liquid (?:syntax )?error/,
26
+ /\bundefined\b|\bNaN\b|\bnull\b|\[object Object\]|\{\{|\{%/)
26
27
 
27
28
  def self.plugin_dir = ENV.fetch(PLUGIN_DIR_ENV_KEY, Dir.pwd)
28
29
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.31.3'
4
+ VERSION = '0.32.1'
5
5
  end
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";
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trmnl_preview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.3
4
+ version: 0.32.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock