trmnl_preview 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdc22b090a3269c3fdbfa52287c287cbab548e6f40aa08428d037903f40dbb5b
4
- data.tar.gz: 92886e598aa5e83e750bee7fdba8ee1b9d8b5f13fb6c021e2653cfa169900c44
3
+ metadata.gz: e476239f7e0c53801c4a3100d40640de825bda5ad90174893caf919d26c7a8c4
4
+ data.tar.gz: 66552a72bd4a6e761090064712f8bc01cb84c5cbff4a8a166b14020f6fcf8f79
5
5
  SHA512:
6
- metadata.gz: d4d8ef476cd8a5f52d49b546eccc28e6e75277cd881d01667ea137c069c15b8a3a93a5029a855ae10ade990f8c8a5ecc61383e7b7f1c5922f0cf71babdf203df
7
- data.tar.gz: 8b1dfa0578b38e11a317bb47ff3f2985e27236f5199a248431a49acc2f76bd88ed8bac2c6980ab066d19fd3f931ca0f122f8b19501899f2e0e38182cace55f3c
6
+ metadata.gz: 54e5c7f1044484fb55f26881a8c853455fdf0a3149ad9567633dec55fc2004c36645ab84c00589984cb82192298fd0a8ab40040ed5c57442058595d532102508
7
+ data.tar.gz: dea4cfcecfeb3fabf9f0df522cc23bffbf90dc1638f03406d17d1022ae777f2846fe9fd7acd1ced2b7c379c2bd6e3d79d665e7b94e80864a64698c3c617ea4ef
data/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
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
+
4
9
  ## 0.11.0
5
10
 
6
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.
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. The list of pinnable versions is read from the [design system's published manifest](https://github.com/usetrmnl/trmnl-framework) once per run, so a newly released version can be pinned without upgrading trmnlp. If that request fails, the copy bundled in the gem is used.
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
 
@@ -85,4 +85,4 @@ versions:
85
85
  - number: 3.1.8
86
86
  released_at: '2026-07-23'
87
87
  - number: 3.2.0
88
- released_at: '2026-08-03'
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.
@@ -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
@@ -16,6 +16,7 @@ module TRMNLP
16
16
  def call
17
17
  context.validate!
18
18
  report_form_field_warnings
19
+ report_framework_version_warning
19
20
  context.poller.poll_data
20
21
  context.paths.create_build_dir
21
22
 
@@ -13,6 +13,7 @@ module TRMNLP
13
13
  def call
14
14
  context.validate!
15
15
  report_form_field_warnings
16
+ report_framework_version_warning
16
17
 
17
18
  # Must come AFTER parsing options
18
19
  require_relative '../app'
@@ -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), a pinned version, or nil (treated as latest). See
89
- # db/data/framework_versions.yml for the supported set.
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(&:to_s)
89
- when Hash then value.transform_values { |v| stringify_field_value(v) }
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
@@ -11,5 +11,6 @@ module TRMNLP
11
11
  class InvalidApiKey < Error; end
12
12
  class AuthenticationFailed < Error; end
13
13
  class InvalidConfig < Error; end
14
+ class InvalidCustomFields < Error; end
14
15
  class RenderError < Error; end
15
16
  end
@@ -51,22 +51,27 @@ module TRMNLP
51
51
  newest_first.map { |number| { "v#{number}" => number } }
52
52
  end
53
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.
54
59
  def initialize(number, asset_host: DEFAULT_ASSET_HOST)
55
60
  @asset_host = asset_host
56
61
 
57
62
  if number.nil? || number == 'latest'
58
63
  @number = self.class.config.fetch('latest')
59
64
  @pinned = false
60
- elsif self.class.version_numbers.include?(number)
61
- @number = number
62
- @pinned = true
63
65
  else
64
- raise ArgumentError, "unknown framework version: #{number}"
66
+ @number = validated(number)
67
+ @pinned = true
65
68
  end
66
69
  end
67
70
 
68
71
  def pinned? = @pinned
69
72
 
73
+ def known? = self.class.version_numbers.include?(number)
74
+
70
75
  # Both a pinned and an unpinned ("latest") version resolve to a
71
76
  # concrete release here — #number is never the literal "latest" — so a
72
77
  # local preview renders the same bundle as the hosted service instead
@@ -86,5 +91,18 @@ module TRMNLP
86
91
  def as_json(*) = number
87
92
 
88
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
89
107
  end
90
108
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.11.0'
4
+ VERSION = '0.12.0'
5
5
  end
@@ -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 = '>= 3.4'
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.7.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.11.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.7.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.7.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: '3.4'
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.10
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: []