trmnl_preview 0.10.0 → 0.12.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 +10 -0
- data/README.md +3 -1
- data/db/data/framework_versions.yml +17 -1
- data/lib/trmnlp/app.rb +76 -0
- data/lib/trmnlp/commands/base.rb +9 -0
- data/lib/trmnlp/commands/build.rb +1 -0
- data/lib/trmnlp/commands/serve.rb +1 -0
- data/lib/trmnlp/config/plugin.rb +3 -2
- data/lib/trmnlp/config/project.rb +2 -2
- data/lib/trmnlp/errors.rb +1 -0
- data/lib/trmnlp/framework_version.rb +46 -5
- data/lib/trmnlp/version.rb +1 -1
- data/trmnl_preview.gemspec +2 -2
- data/web/public/index.css +102 -0
- data/web/public/index.js +197 -0
- data/web/views/index.erb +11 -0
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e476239f7e0c53801c4a3100d40640de825bda5ad90174893caf919d26c7a8c4
|
|
4
|
+
data.tar.gz: 66552a72bd4a6e761090064712f8bc01cb84c5cbff4a8a166b14020f6fcf8f79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 54e5c7f1044484fb55f26881a8c853455fdf0a3149ad9567633dec55fc2004c36645ab84c00589984cb82192298fd0a8ab40040ed5c57442058595d532102508
|
|
7
|
+
data.tar.gz: dea4cfcecfeb3fabf9f0df522cc23bffbf90dc1638f03406d17d1022ae777f2846fe9fd7acd1ced2b7c379c2bd6e3d79d665e7b94e80864a64698c3c617ea4ef
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
# Changelog
|
|
3
3
|
|
|
4
|
+
## 0.12.0
|
|
5
|
+
|
|
6
|
+
- `required_ruby_version` is now `>= 4.0`, matching the Ruby version trmnlp actually needs. It has understated the real floor since 0.4.0: both `xdg` (Ruby 4.0 in the 10.x series pinned since 0.8.0) and `trmnl-liquid` (Ruby 4.0 since 0.5.0) require more than the gemspec claimed. RubyGems resolves against the declared value, so `gem install trmnl_preview` on an older Ruby did not fail. It quietly walked backwards to the newest version whose dependency tree resolved, reporting "Successfully installed" for a build up to twenty months old. On Ruby 3.4 that was 0.7.1, and on Ruby 3.3 and below it was 0.3.2, which predates the Thor CLI and offers only `serve`, `build` and `version`. Asking for the current version explicitly (`gem install trmnl_preview -v 0.11.0`) crashed RubyGems inside its own conflict reporting rather than explaining the problem. Installing on an unsupported Ruby now stops with "requires Ruby version >= 4.0".
|
|
7
|
+
- Dependencies updated to their current releases. `trmnl-liquid` moves from 0.7.0 to 0.8.2, the only pin that excluded a newer release; every other dependency already resolved to its newest version on a fresh install. The two trmnl-liquid versions ship identical code and identical dependencies, so rendering is unchanged.
|
|
8
|
+
|
|
9
|
+
## 0.11.0
|
|
10
|
+
|
|
11
|
+
- `framework_version:` now resolves against the release manifest published by the design system, not only the version list shipped inside the gem, so a framework version released after your trmnlp install can be pinned without upgrading. The manifest is read once per run with a 2 second timeout. If the request fails, or the response is not a version list, the copy bundled in the gem is used and rendering continues. That bundled copy is refreshed here too: `latest` moves from 3.1.1 to 3.2.0.
|
|
12
|
+
- `rake framework:sync` reads the published manifest by default. Pass a local design-system checkout (`rake framework:sync[/path/to/repo]`) for the previous behaviour.
|
|
13
|
+
|
|
4
14
|
## 0.10.0
|
|
5
15
|
|
|
6
16
|
- Added support for the `description` plugin setting, an optional one-line summary of the plugin. `trmnlp init` scaffolds the key in `src/settings.yml`, `trmnlp lint` reports descriptions longer than 35 characters (the hosted service's limit), and `trmnlp list` shows a DESCRIPTION column when any plugin has one. Only the length is checked. Storing the value needs the matching hosted service release; until then `trmnlp push` drops it, because it rewrites the local `settings.yml` from the server's response.
|
data/README.md
CHANGED
|
@@ -13,6 +13,8 @@ The server watches the filesystem for changes to the Liquid templates, seamlessl
|
|
|
13
13
|
|
|
14
14
|
## Quick Start
|
|
15
15
|
|
|
16
|
+
trmnlp requires Ruby 4.0 or newer. Check with `ruby -v` before installing.
|
|
17
|
+
|
|
16
18
|
```sh
|
|
17
19
|
gem install trmnl_preview # install
|
|
18
20
|
trmnlp init my_plugin # scaffold a project
|
|
@@ -427,7 +429,7 @@ A complete worked example lives at [`examples/hn-stories/`](examples/hn-stories/
|
|
|
427
429
|
|
|
428
430
|
The `settings.yml` file is part of the plugin definition, and is uploaded and downloaded by `trmnlp push` / `pull`.
|
|
429
431
|
|
|
430
|
-
`framework_version:` pins the [TRMNL Design System](https://trmnl.com/framework) version this plugin renders against — `latest` (the default) tracks the newest release, or set a specific version for reproducibility. It lives here rather than in `.trmnlp.yml` so the value round-trips with the hosted plugin service.
|
|
432
|
+
`framework_version:` pins the [TRMNL Design System](https://trmnl.com/framework) version this plugin renders against — `latest` (the default) tracks the newest release, or set a specific version for reproducibility. It lives here rather than in `.trmnlp.yml` so the value round-trips with the hosted plugin service. The list of known versions is read from the [design system's published manifest](https://github.com/usetrmnl/trmnl-framework) once per run; if that request fails, the copy bundled in the gem is used. Because either list can lag a release, any well-formed version number is accepted — a version the manifest has not heard of still renders against that version's assets rather than failing — `trmnlp serve` and `build` note it with a warning, in case it was a typo.
|
|
431
433
|
|
|
432
434
|
`description:` is an optional one-line summary of the plugin, up to 35 characters. `trmnlp lint` reports anything longer, and `trmnlp list` shows it next to each plugin name.
|
|
433
435
|
|
|
@@ -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.2.0
|
|
4
4
|
versions:
|
|
5
5
|
- number: 0.0.1
|
|
6
6
|
released_at: '2024-06-22'
|
|
@@ -70,3 +70,19 @@ versions:
|
|
|
70
70
|
released_at: '2026-04-29'
|
|
71
71
|
- number: 3.1.1
|
|
72
72
|
released_at: '2026-05-01'
|
|
73
|
+
- number: 3.1.2
|
|
74
|
+
released_at: '2026-07-09'
|
|
75
|
+
- number: 3.1.3
|
|
76
|
+
released_at: '2026-07-14'
|
|
77
|
+
- number: 3.1.4
|
|
78
|
+
released_at: '2026-07-15'
|
|
79
|
+
- number: 3.1.5
|
|
80
|
+
released_at: '2026-07-16'
|
|
81
|
+
- number: 3.1.6
|
|
82
|
+
released_at: '2026-07-17'
|
|
83
|
+
- number: 3.1.7
|
|
84
|
+
released_at: '2026-07-18'
|
|
85
|
+
- number: 3.1.8
|
|
86
|
+
released_at: '2026-07-23'
|
|
87
|
+
- number: 3.2.0
|
|
88
|
+
released_at: '2026-08-05'
|
data/lib/trmnlp/app.rb
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
require 'securerandom'
|
|
4
4
|
require 'sinatra'
|
|
5
5
|
require 'sinatra/base'
|
|
6
|
+
require 'yaml'
|
|
6
7
|
|
|
7
8
|
require_relative 'context'
|
|
9
|
+
require_relative 'errors'
|
|
8
10
|
require_relative 'screen_generator'
|
|
9
11
|
require_relative 'screenshot'
|
|
10
12
|
|
|
@@ -127,6 +129,46 @@ module TRMNLP
|
|
|
127
129
|
redirect back
|
|
128
130
|
end
|
|
129
131
|
|
|
132
|
+
# API: current project config (custom field values) plus the plugin's
|
|
133
|
+
# field definitions, so the editor can render plugin-declared fields.
|
|
134
|
+
get '/api/config' do
|
|
135
|
+
content_type :json
|
|
136
|
+
|
|
137
|
+
# author_bio is informational-only — not a data field a user can set.
|
|
138
|
+
plugin_fields = @context.config.plugin.custom_field_definitions.reject do |field|
|
|
139
|
+
field['field_type'] == 'author_bio'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
custom_fields: @context.config.project.custom_fields,
|
|
144
|
+
plugin_fields: plugin_fields
|
|
145
|
+
}.to_json
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# API: update the project config's custom_fields in .trmnlp.yml
|
|
149
|
+
post '/api/config' do
|
|
150
|
+
content_type :json
|
|
151
|
+
|
|
152
|
+
custom_fields = validate_custom_fields!(request.body.read)
|
|
153
|
+
|
|
154
|
+
config_path = @context.paths.trmnlp_config
|
|
155
|
+
config = read_project_config(config_path)
|
|
156
|
+
config['custom_fields'] = custom_fields
|
|
157
|
+
config_path.write(YAML.dump(config))
|
|
158
|
+
|
|
159
|
+
# Reload config and re-poll so URLs/headers pick up the new values
|
|
160
|
+
@context.config.project.reload!
|
|
161
|
+
@poller.poll_data
|
|
162
|
+
|
|
163
|
+
{ success: true, custom_fields: @context.config.project.custom_fields }.to_json
|
|
164
|
+
rescue InvalidCustomFields => e
|
|
165
|
+
status 400
|
|
166
|
+
{ error: e.message }.to_json
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
status 500
|
|
169
|
+
{ error: e.message }.to_json
|
|
170
|
+
end
|
|
171
|
+
|
|
130
172
|
get '/oauth/connect' do
|
|
131
173
|
halt 400, 'OAuth is not configured. Add the oauth_* keys to src/settings.yml.' unless @oauth_session.configured?
|
|
132
174
|
|
|
@@ -189,6 +231,40 @@ module TRMNLP
|
|
|
189
231
|
|
|
190
232
|
private
|
|
191
233
|
|
|
234
|
+
# Validates the incoming custom-fields payload, raising InvalidCustomFields
|
|
235
|
+
# (reported to the user as a 400) on bad input. JSON.parse only yields
|
|
236
|
+
# strings, numbers, booleans, nil, arrays, and hashes — all of which
|
|
237
|
+
# Config::Project#custom_fields can stringify — so the meaningful checks
|
|
238
|
+
# are the payload shape and the field names.
|
|
239
|
+
def validate_custom_fields!(body)
|
|
240
|
+
custom_fields = parse_json_object(body).fetch('custom_fields', {})
|
|
241
|
+
|
|
242
|
+
unless custom_fields.is_a?(Hash)
|
|
243
|
+
raise InvalidCustomFields, 'custom_fields must be a JSON object of field name/value pairs'
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
raise InvalidCustomFields, 'custom field names cannot be blank' if custom_fields.keys.any? { |k| k.strip.empty? }
|
|
247
|
+
|
|
248
|
+
custom_fields
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def parse_json_object(body)
|
|
252
|
+
json = JSON.parse(body)
|
|
253
|
+
raise InvalidCustomFields, 'request body must be a JSON object' unless json.is_a?(Hash)
|
|
254
|
+
|
|
255
|
+
json
|
|
256
|
+
rescue JSON::ParserError => e
|
|
257
|
+
raise InvalidCustomFields, "request body is not valid JSON: #{e.message}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Mirrors Config::Project#reload! so a round-trip through the editor
|
|
261
|
+
# preserves whatever else lives in .trmnlp.yml.
|
|
262
|
+
def read_project_config(config_path)
|
|
263
|
+
return {} unless config_path.exist?
|
|
264
|
+
|
|
265
|
+
YAML.safe_load_file(config_path, permitted_classes: [Date, Time]) || {}
|
|
266
|
+
end
|
|
267
|
+
|
|
192
268
|
# On timeout (queue idle), a colon-prefixed SSE comment line both
|
|
193
269
|
# keeps proxies awake and surfaces a dead client via the next
|
|
194
270
|
# io.write — the route's outer rescue then cleans up.
|
data/lib/trmnlp/commands/base.rb
CHANGED
|
@@ -54,6 +54,15 @@ module TRMNLP
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
def report_framework_version_warning
|
|
58
|
+
framework = config.plugin.framework_version
|
|
59
|
+
return if framework.known?
|
|
60
|
+
|
|
61
|
+
warning = "warning: settings.yml framework_version #{framework} — not in the design " \
|
|
62
|
+
"system's published version list; its assets may not exist"
|
|
63
|
+
reporter.info(reporter.yellow(warning))
|
|
64
|
+
end
|
|
65
|
+
|
|
57
66
|
def prompt(message)
|
|
58
67
|
print message
|
|
59
68
|
$stdin.gets.chomp
|
data/lib/trmnlp/config/plugin.rb
CHANGED
|
@@ -85,8 +85,9 @@ module TRMNLP
|
|
|
85
85
|
# Lives on the plugin (settings.yml), like serverless_language,
|
|
86
86
|
# because production stores it on the plugin_setting record — so it
|
|
87
87
|
# round-trips through `trmnlp push` / `pull`. Accepts 'latest'
|
|
88
|
-
# (default),
|
|
89
|
-
#
|
|
88
|
+
# (default), any well-formed version number, or nil (treated as
|
|
89
|
+
# latest) — see FrameworkVersion for why a release the bundled
|
|
90
|
+
# db/data/framework_versions.yml has not heard of is still allowed.
|
|
90
91
|
def framework_version
|
|
91
92
|
FrameworkVersion.new(@config['framework_version'], asset_host: project_config.asset_host)
|
|
92
93
|
rescue ArgumentError => e
|
|
@@ -85,8 +85,8 @@ module TRMNLP
|
|
|
85
85
|
# behavior.
|
|
86
86
|
def stringify_field_value(value)
|
|
87
87
|
case value
|
|
88
|
-
when Array then value.map(
|
|
89
|
-
when Hash then value.transform_values { |
|
|
88
|
+
when Array then value.map { |item| stringify_field_value(item) }
|
|
89
|
+
when Hash then value.transform_values { |item| stringify_field_value(item) }
|
|
90
90
|
else value.to_s
|
|
91
91
|
end
|
|
92
92
|
end
|
data/lib/trmnlp/errors.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'faraday'
|
|
3
5
|
require 'yaml'
|
|
4
6
|
|
|
5
7
|
module TRMNLP
|
|
@@ -11,10 +13,31 @@ module TRMNLP
|
|
|
11
13
|
|
|
12
14
|
DEFAULT_ASSET_HOST = 'https://trmnl.com'
|
|
13
15
|
DATA_PATH = File.expand_path('../../db/data/framework_versions.yml', __dir__)
|
|
16
|
+
# DATA_PATH holds a copy of this file, refreshed by `rake framework:sync`.
|
|
17
|
+
MANIFEST_URL = 'https://raw.githubusercontent.com/usetrmnl/trmnl-framework/main/db/data/framework_versions.yml'
|
|
18
|
+
MANIFEST_TIMEOUT = 2
|
|
14
19
|
|
|
15
20
|
attr_reader :number
|
|
16
21
|
|
|
17
|
-
def self.config = @config ||= YAML.load_file(DATA_PATH).freeze
|
|
22
|
+
def self.config = @config ||= (remote_config || YAML.load_file(DATA_PATH)).freeze
|
|
23
|
+
|
|
24
|
+
# Answers nil on any failure, so rendering never depends on the network.
|
|
25
|
+
# The shape check rejects error pages, which are also valid YAML.
|
|
26
|
+
def self.remote_config
|
|
27
|
+
response = Faraday.get(MANIFEST_URL) do |request|
|
|
28
|
+
request.options.open_timeout = MANIFEST_TIMEOUT
|
|
29
|
+
request.options.timeout = MANIFEST_TIMEOUT
|
|
30
|
+
end
|
|
31
|
+
return unless response.success?
|
|
32
|
+
|
|
33
|
+
# Release dates are quoted today, but the manifest is generated
|
|
34
|
+
# upstream and an unquoted date would otherwise be rejected here.
|
|
35
|
+
manifest = YAML.safe_load(response.body, permitted_classes: [Date])
|
|
36
|
+
manifest if manifest.is_a?(Hash) && manifest.key?('latest') && manifest.key?('versions')
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
private_class_method :remote_config
|
|
18
41
|
|
|
19
42
|
def self.version_numbers = config.fetch('versions').map { |v| v['number'] }.freeze
|
|
20
43
|
|
|
@@ -28,22 +51,27 @@ module TRMNLP
|
|
|
28
51
|
newest_first.map { |number| { "v#{number}" => number } }
|
|
29
52
|
end
|
|
30
53
|
|
|
54
|
+
# A release the manifest has not heard of is still served by the CDN, and
|
|
55
|
+
# the manifest in hand may simply be stale — the bundled copy ages with
|
|
56
|
+
# the gem, and it is what we fall back to whenever the published one is
|
|
57
|
+
# unreachable. So any well-formed number is accepted and only nonsense is
|
|
58
|
+
# rejected; #known? reports whether the manifest lists it.
|
|
31
59
|
def initialize(number, asset_host: DEFAULT_ASSET_HOST)
|
|
32
60
|
@asset_host = asset_host
|
|
33
61
|
|
|
34
62
|
if number.nil? || number == 'latest'
|
|
35
63
|
@number = self.class.config.fetch('latest')
|
|
36
64
|
@pinned = false
|
|
37
|
-
elsif self.class.version_numbers.include?(number)
|
|
38
|
-
@number = number
|
|
39
|
-
@pinned = true
|
|
40
65
|
else
|
|
41
|
-
|
|
66
|
+
@number = validated(number)
|
|
67
|
+
@pinned = true
|
|
42
68
|
end
|
|
43
69
|
end
|
|
44
70
|
|
|
45
71
|
def pinned? = @pinned
|
|
46
72
|
|
|
73
|
+
def known? = self.class.version_numbers.include?(number)
|
|
74
|
+
|
|
47
75
|
# Both a pinned and an unpinned ("latest") version resolve to a
|
|
48
76
|
# concrete release here — #number is never the literal "latest" — so a
|
|
49
77
|
# local preview renders the same bundle as the hosted service instead
|
|
@@ -63,5 +91,18 @@ module TRMNLP
|
|
|
63
91
|
def as_json(*) = number
|
|
64
92
|
|
|
65
93
|
def to_s = number
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Gem::Version is deliberately the only gate: it accepts every number the
|
|
98
|
+
# framework has shipped, and rejects the path traversal or stray markup an
|
|
99
|
+
# unvalidated value would otherwise splice into the asset URLs.
|
|
100
|
+
# Answers a String so an unquoted `framework_version: 3.1` in settings.yml
|
|
101
|
+
# — which YAML hands over as a Float — still compares against the manifest.
|
|
102
|
+
def validated(number)
|
|
103
|
+
Gem::Version.correct?(number) or raise ArgumentError, "invalid framework version: #{number}"
|
|
104
|
+
|
|
105
|
+
number.to_s
|
|
106
|
+
end
|
|
66
107
|
end
|
|
67
108
|
end
|
data/lib/trmnlp/version.rb
CHANGED
data/trmnl_preview.gemspec
CHANGED
|
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.description = 'Automatically rebuild and preview TRNML plugins in multiple views'
|
|
13
13
|
spec.homepage = 'https://github.com/usetrmnl/trmnlp'
|
|
14
14
|
spec.license = 'MIT'
|
|
15
|
-
spec.required_ruby_version = '>=
|
|
15
|
+
spec.required_ruby_version = '>= 4.0'
|
|
16
16
|
|
|
17
17
|
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
18
18
|
|
|
@@ -47,7 +47,7 @@ Gem::Specification.new do |spec|
|
|
|
47
47
|
|
|
48
48
|
# HTML rendering
|
|
49
49
|
spec.add_dependency 'activesupport', '~> 8.0'
|
|
50
|
-
spec.add_dependency 'trmnl-liquid', '~> 0.
|
|
50
|
+
spec.add_dependency 'trmnl-liquid', '~> 0.8.2'
|
|
51
51
|
|
|
52
52
|
# PNG rendering
|
|
53
53
|
# spec.add_dependency 'puppeteer-ruby', '~> 0.45.6'
|
data/web/public/index.css
CHANGED
|
@@ -115,6 +115,108 @@ pre > code {
|
|
|
115
115
|
.payload-size--warn { color: #bf8700; opacity: 1; }
|
|
116
116
|
.payload-size--over { color: #d1242f; opacity: 1; }
|
|
117
117
|
|
|
118
|
+
/* Custom Fields Editor */
|
|
119
|
+
.custom-fields-editor {
|
|
120
|
+
margin: 1em 0;
|
|
121
|
+
max-width: 950px;
|
|
122
|
+
border: 1px solid var(--border-color);
|
|
123
|
+
border-radius: 0.5em;
|
|
124
|
+
background: var(--bg-color);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.custom-fields-editor summary {
|
|
128
|
+
padding: 0.75em 1em;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
font-weight: bold;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Border only when expanded, so a collapsed editor has no doubled edge. */
|
|
134
|
+
.custom-fields-editor[open] summary {
|
|
135
|
+
border-bottom: 1px solid var(--border-color);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#custom-fields-container {
|
|
139
|
+
padding: 1em;
|
|
140
|
+
display: flex;
|
|
141
|
+
flex-direction: column;
|
|
142
|
+
gap: 0.5em;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.custom-field-row {
|
|
146
|
+
display: flex;
|
|
147
|
+
gap: 0.5em;
|
|
148
|
+
align-items: center;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.custom-field-row input {
|
|
152
|
+
padding: 0.5em;
|
|
153
|
+
border: 1px solid var(--border-color);
|
|
154
|
+
border-radius: 0.25em;
|
|
155
|
+
background: var(--bg-color);
|
|
156
|
+
color: var(--text-color);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.custom-field-row input[name="key"] {
|
|
160
|
+
width: 150px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.custom-field-row input[name="key"].required-field {
|
|
164
|
+
background: #3b82f620;
|
|
165
|
+
border-color: #3b82f6;
|
|
166
|
+
font-weight: bold;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.custom-field-row input[name="value"] {
|
|
170
|
+
flex: 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.custom-field-row select[name="value"],
|
|
174
|
+
.custom-field-select {
|
|
175
|
+
flex: 1;
|
|
176
|
+
padding: 0.5em;
|
|
177
|
+
border: 1px solid var(--border-color);
|
|
178
|
+
border-radius: 0.25em;
|
|
179
|
+
background: var(--bg-color);
|
|
180
|
+
color: var(--text-color);
|
|
181
|
+
font: inherit;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.custom-field-row .btn-remove {
|
|
185
|
+
padding: 0.3em 0.6em;
|
|
186
|
+
background: #ef4444;
|
|
187
|
+
color: white;
|
|
188
|
+
border: none;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.custom-fields-actions {
|
|
192
|
+
padding: 1em;
|
|
193
|
+
border-top: 1px solid var(--border-color);
|
|
194
|
+
display: flex;
|
|
195
|
+
gap: 0.5em;
|
|
196
|
+
justify-content: flex-end;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Matches the transform-error banner styling used above the preview. */
|
|
200
|
+
.custom-fields-error {
|
|
201
|
+
margin: 0 1em 1em;
|
|
202
|
+
padding: 0.5em 0.75em;
|
|
203
|
+
background: #fee;
|
|
204
|
+
border: 1px solid #c33;
|
|
205
|
+
border-radius: 4px;
|
|
206
|
+
color: #900;
|
|
207
|
+
white-space: pre-wrap;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.btn-primary {
|
|
211
|
+
background: #3b82f6;
|
|
212
|
+
color: white;
|
|
213
|
+
border-color: #2563eb;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.btn-primary:hover {
|
|
217
|
+
filter: brightness(110%);
|
|
218
|
+
}
|
|
219
|
+
|
|
118
220
|
@media (prefers-color-scheme: dark) {
|
|
119
221
|
:root {
|
|
120
222
|
--bg-color: #191b21;
|
data/web/public/index.js
CHANGED
|
@@ -59,6 +59,200 @@ trmnlp.refreshUserData = async function (state) {
|
|
|
59
59
|
}
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
// Load custom fields from API and populate editor
|
|
63
|
+
trmnlp.loadCustomFields = async function () {
|
|
64
|
+
const container = document.getElementById('custom-fields-container');
|
|
65
|
+
container.innerHTML = ''; // Clear existing fields
|
|
66
|
+
|
|
67
|
+
let config;
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch('/api/config');
|
|
70
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
71
|
+
config = await response.json();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
trmnlp.showCustomFieldsError(`Failed to load custom fields: ${err.message}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
trmnlp.clearCustomFieldsError();
|
|
78
|
+
const customFields = config.custom_fields || {};
|
|
79
|
+
const pluginFields = config.plugin_fields || [];
|
|
80
|
+
|
|
81
|
+
// Create a set of existing field keys
|
|
82
|
+
const existingKeys = new Set(Object.keys(customFields));
|
|
83
|
+
|
|
84
|
+
// First, add rows for plugin-defined fields (with or without values)
|
|
85
|
+
pluginFields.forEach(field => {
|
|
86
|
+
const keyname = field.keyname;
|
|
87
|
+
const value = customFields[keyname] || field.default || '';
|
|
88
|
+
|
|
89
|
+
if (field.field_type === 'select' && field.options) {
|
|
90
|
+
// Render as dropdown
|
|
91
|
+
trmnlp.addSelectFieldRow(container, keyname, value, field.options);
|
|
92
|
+
} else {
|
|
93
|
+
// Render as text input
|
|
94
|
+
const placeholder = field.description || 'Value';
|
|
95
|
+
trmnlp.addFieldRowWithHint(container, keyname, value, placeholder, true);
|
|
96
|
+
}
|
|
97
|
+
existingKeys.delete(keyname);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Then add any custom fields that aren't in plugin definition
|
|
101
|
+
existingKeys.forEach(key => {
|
|
102
|
+
trmnlp.addFieldRow(container, key, customFields[key]);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Custom fields editor functionality
|
|
107
|
+
trmnlp.initCustomFieldsEditor = async function () {
|
|
108
|
+
const container = document.getElementById('custom-fields-container');
|
|
109
|
+
const addBtn = document.getElementById('add-field-btn');
|
|
110
|
+
const saveBtn = document.getElementById('save-fields-btn');
|
|
111
|
+
|
|
112
|
+
await trmnlp.loadCustomFields();
|
|
113
|
+
|
|
114
|
+
// Add field button
|
|
115
|
+
addBtn.addEventListener('click', () => {
|
|
116
|
+
trmnlp.addFieldRow(container, '', '');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Save button
|
|
120
|
+
saveBtn.addEventListener('click', async () => {
|
|
121
|
+
const fields = trmnlp.collectCustomFields(container);
|
|
122
|
+
if (!fields) return; // validation error already shown
|
|
123
|
+
|
|
124
|
+
saveBtn.disabled = true;
|
|
125
|
+
saveBtn.textContent = 'Saving...';
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch('/api/config', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ custom_fields: fields })
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
let result = {};
|
|
135
|
+
try {
|
|
136
|
+
result = await response.json();
|
|
137
|
+
} catch {
|
|
138
|
+
// non-JSON response body; fall through to the status check
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!response.ok || result.error) {
|
|
142
|
+
trmnlp.showCustomFieldsError(`Error saving: ${result.error || `HTTP ${response.status}`}`);
|
|
143
|
+
} else {
|
|
144
|
+
trmnlp.clearCustomFieldsError();
|
|
145
|
+
// Refresh the preview and user data display
|
|
146
|
+
trmnlp.fetchPreview();
|
|
147
|
+
trmnlp.refreshUserData(trmnlp.picker?.state);
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
trmnlp.showCustomFieldsError(`Error saving: ${err.message}`);
|
|
151
|
+
} finally {
|
|
152
|
+
saveBtn.disabled = false;
|
|
153
|
+
saveBtn.textContent = 'Save & Reload';
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Gathers field rows into an object, validating before anything is sent to
|
|
159
|
+
// the server. Returns null (with the error banner shown) on invalid input.
|
|
160
|
+
trmnlp.collectCustomFields = function (container) {
|
|
161
|
+
const rows = container.querySelectorAll('.custom-field-row');
|
|
162
|
+
// Null prototype so field names like "constructor" behave as plain keys.
|
|
163
|
+
const fields = Object.create(null);
|
|
164
|
+
const problems = [];
|
|
165
|
+
|
|
166
|
+
rows.forEach((row, index) => {
|
|
167
|
+
const key = row.querySelector('input[name="key"]').value.trim();
|
|
168
|
+
// Value can be either input or select
|
|
169
|
+
const valueInput = row.querySelector('input[name="value"]');
|
|
170
|
+
const valueSelect = row.querySelector('select[name="value"]');
|
|
171
|
+
const value = valueInput ? valueInput.value : (valueSelect ? valueSelect.value : '');
|
|
172
|
+
|
|
173
|
+
if (!key) {
|
|
174
|
+
if (value.trim()) problems.push(`row ${index + 1} has a value but no field name`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (key in fields) {
|
|
178
|
+
problems.push(`duplicate field name "${key}"`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
fields[key] = value;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (problems.length > 0) {
|
|
185
|
+
trmnlp.showCustomFieldsError(`Not saved: ${problems.join('; ')}`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
trmnlp.clearCustomFieldsError();
|
|
190
|
+
return fields;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
trmnlp.showCustomFieldsError = function (message) {
|
|
194
|
+
const banner = document.getElementById('custom-fields-error');
|
|
195
|
+
banner.textContent = message;
|
|
196
|
+
banner.hidden = false;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
trmnlp.clearCustomFieldsError = function () {
|
|
200
|
+
const banner = document.getElementById('custom-fields-error');
|
|
201
|
+
banner.textContent = '';
|
|
202
|
+
banner.hidden = true;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
trmnlp.addFieldRow = function (container, key, value) {
|
|
206
|
+
trmnlp.addFieldRowWithHint(container, key, value, 'Value', false);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
trmnlp.addSelectFieldRow = function (container, key, selectedValue, options) {
|
|
210
|
+
const row = document.createElement('div');
|
|
211
|
+
row.className = 'custom-field-row';
|
|
212
|
+
|
|
213
|
+
const optionsHtml = options.map(opt => {
|
|
214
|
+
const selected = String(opt) === String(selectedValue) ? 'selected' : '';
|
|
215
|
+
return `<option value="${trmnlp.escapeHtml(String(opt))}" ${selected}>${trmnlp.escapeHtml(String(opt))}</option>`;
|
|
216
|
+
}).join('');
|
|
217
|
+
|
|
218
|
+
row.innerHTML = `
|
|
219
|
+
<input type="text" name="key" placeholder="Field name" value="${trmnlp.escapeHtml(key)}" readonly class="required-field">
|
|
220
|
+
<select name="value" class="custom-field-select">
|
|
221
|
+
${optionsHtml}
|
|
222
|
+
</select>
|
|
223
|
+
<button type="button" class="btn btn-remove" disabled>×</button>
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
container.appendChild(row);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
trmnlp.addFieldRowWithHint = function (container, key, value, placeholder, isRequired) {
|
|
230
|
+
const row = document.createElement('div');
|
|
231
|
+
row.className = 'custom-field-row';
|
|
232
|
+
const keyReadonly = isRequired ? 'readonly' : '';
|
|
233
|
+
const keyClass = isRequired ? 'required-field' : '';
|
|
234
|
+
row.innerHTML = `
|
|
235
|
+
<input type="text" name="key" placeholder="Field name" value="${trmnlp.escapeHtml(key)}" ${keyReadonly} class="${keyClass}">
|
|
236
|
+
<input type="text" name="value" placeholder="${trmnlp.escapeHtml(placeholder)}" value="${trmnlp.escapeHtml(value)}">
|
|
237
|
+
<button type="button" class="btn btn-remove" ${isRequired ? 'disabled' : ''}>×</button>
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
const removeBtn = row.querySelector('.btn-remove');
|
|
241
|
+
if (!isRequired) {
|
|
242
|
+
removeBtn.addEventListener('click', () => {
|
|
243
|
+
row.remove();
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
container.appendChild(row);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
trmnlp.escapeHtml = function (text) {
|
|
251
|
+
const div = document.createElement('div');
|
|
252
|
+
div.textContent = text;
|
|
253
|
+
return div.innerHTML;
|
|
254
|
+
};
|
|
255
|
+
|
|
62
256
|
document.addEventListener("DOMContentLoaded", async function () {
|
|
63
257
|
trmnlp.view = document.querySelector("meta[name='trmnl-view']").content;
|
|
64
258
|
trmnlp.iframe = document.querySelector("iframe");
|
|
@@ -93,4 +287,7 @@ document.addEventListener("DOMContentLoaded", async function () {
|
|
|
93
287
|
});
|
|
94
288
|
|
|
95
289
|
trmnlp.picker = await TRMNLPicker.create('picker-form', { localStorageKey: 'trmnlp-picker' });
|
|
290
|
+
|
|
291
|
+
// Initialize custom fields editor
|
|
292
|
+
await trmnlp.initCustomFieldsEditor();
|
|
96
293
|
});
|
data/web/views/index.erb
CHANGED
|
@@ -72,6 +72,17 @@
|
|
|
72
72
|
|
|
73
73
|
<iframe style="width: 800px; height: 480px; border: 1px solid black; background: white;">Rendering…</iframe>
|
|
74
74
|
|
|
75
|
+
<!-- Custom Fields Editor -->
|
|
76
|
+
<details id="custom-fields-editor" class="custom-fields-editor">
|
|
77
|
+
<summary>Custom Fields</summary>
|
|
78
|
+
<div id="custom-fields-container"></div>
|
|
79
|
+
<div id="custom-fields-error" class="custom-fields-error" hidden></div>
|
|
80
|
+
<div class="custom-fields-actions">
|
|
81
|
+
<button class="btn" id="add-field-btn">+ Add Field</button>
|
|
82
|
+
<button class="btn btn-primary" id="save-fields-btn">Save & Reload</button>
|
|
83
|
+
</div>
|
|
84
|
+
</details>
|
|
85
|
+
|
|
75
86
|
<div class="payload-size <%= payload_size_class(@payload_size) %>">Payload: <%= format_bytes(@payload_size) %></div>
|
|
76
87
|
<pre id="user-data"><code><%= h @user_data %></code></pre>
|
|
77
88
|
</main>
|
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.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rockwell Schrock
|
|
@@ -71,14 +71,14 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 0.
|
|
74
|
+
version: 0.8.2
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 0.
|
|
81
|
+
version: 0.8.2
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: mini_magick
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -376,14 +376,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
376
376
|
requirements:
|
|
377
377
|
- - ">="
|
|
378
378
|
- !ruby/object:Gem::Version
|
|
379
|
-
version: '
|
|
379
|
+
version: '4.0'
|
|
380
380
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
381
381
|
requirements:
|
|
382
382
|
- - ">="
|
|
383
383
|
- !ruby/object:Gem::Version
|
|
384
384
|
version: '0'
|
|
385
385
|
requirements: []
|
|
386
|
-
rubygems_version: 4.0.
|
|
386
|
+
rubygems_version: 4.0.16
|
|
387
387
|
specification_version: 4
|
|
388
388
|
summary: Local web server to preview TRMNL plugins
|
|
389
389
|
test_files: []
|