ruby-exclaim 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 23b0cbf8949adc14eff900789bf6e6e099179e8c06ddb36fd83092397466b653
4
+ data.tar.gz: d51958364005213af769660afa6d71a210203832b16fda5ee167164e0c080464
5
+ SHA512:
6
+ metadata.gz: 3bb5dee1daa8a139f034c0e08cb52cf32d3844a60f0d2f2761ae73bb0b031615e0ff28f35e9bb48577f7ddb11c96ecf340a6fd0937009291b0d33b9c7e40b4a0
7
+ data.tar.gz: f18f08e66dbf47ac312ec3958bce96bd25ab65045524f4d1b509de1b373381b3a4c06600edde85afddf46bba4a942c3ac7ef1c94be32ea6b76e40e8839346d18
@@ -0,0 +1,43 @@
1
+ version: 2.1
2
+ workflows:
3
+ version: 2
4
+ ruby-exclaim:
5
+ jobs:
6
+ - build:
7
+ context: Salsify
8
+ jobs:
9
+ build:
10
+ docker:
11
+ - image: salsify/ruby_ci:2.7.2
12
+ environment:
13
+ RACK_ENV: "test"
14
+ RAILS_ENV: "test"
15
+ CIRCLE_TEST_REPORTS: "test-results"
16
+ working_directory: ~/ruby-exclaim
17
+ steps:
18
+ - checkout
19
+ - restore_cache:
20
+ keys:
21
+ - v1-gems-ruby-2.7.2-{{ checksum "ruby-exclaim.gemspec" }}-{{ checksum "Gemfile" }}
22
+ - v1-gems-ruby-2.7.2-
23
+ - run:
24
+ name: Install Gems
25
+ command: |
26
+ if ! bundle check --path=vendor/bundle; then
27
+ bundle install --path=vendor/bundle --jobs=4 --retry=3
28
+ bundle clean
29
+ fi
30
+ - save_cache:
31
+ key: v1-gems-ruby-2.7.2-{{ checksum "ruby-exclaim.gemspec" }}-{{ checksum "Gemfile" }}
32
+ paths:
33
+ - "vendor/bundle"
34
+ - "gemfiles/vendor/bundle"
35
+ - run:
36
+ name: Run Rubocop
37
+ command: bundle exec rubocop
38
+ - run:
39
+ name: Run Tests
40
+ command: |
41
+ bundle exec rspec --format RspecJunitFormatter --out $CIRCLE_TEST_REPORTS/rspec/junit.xml --format progress spec
42
+ - store_test_results:
43
+ path: "test-results"
@@ -0,0 +1,29 @@
1
+ name: Release
2
+
3
+ on:
4
+ check_suite:
5
+ types: [completed]
6
+
7
+ jobs:
8
+ release:
9
+ name: Check and Release New Version
10
+ runs-on: ubuntu-latest
11
+ # `github.ref` from the `check_suite` trigger is always the default branch
12
+ if: format('refs/heads/{0}', github.event.check_suite.head_branch) == github.ref && github.event.check_suite.conclusion == 'success'
13
+ steps:
14
+ - name: Checkout Code
15
+ uses: actions/checkout@v2
16
+ with:
17
+ fetch-depth: 2
18
+
19
+ - name: Setup Ruby
20
+ uses: actions/setup-ruby@v1
21
+ with:
22
+ ruby-version: 2.6
23
+
24
+ - name: Release Gem
25
+ id: release-gem
26
+ uses: salsify/action-release-gem@v1.1.0
27
+ env:
28
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29
+ RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /.ruby-version
5
+ /.ruby-gemset
6
+ /_yardoc/
7
+ /coverage/
8
+ /doc/
9
+ /pkg/
10
+ /spec/reports/
11
+ /tmp/
12
+ /examples.txt
data/.overcommit.yml ADDED
@@ -0,0 +1,13 @@
1
+ PreCommit:
2
+ RuboCop:
3
+ enabled: true
4
+ required: false
5
+ on_warn: fail
6
+
7
+ HardTabs:
8
+ enabled: true
9
+ required: false
10
+
11
+ CommitMsg:
12
+ TrailingPeriod:
13
+ enabled: false
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,12 @@
1
+ inherit_gem:
2
+ salsify_rubocop: conf/rubocop.yml
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 2.7
6
+ Exclude:
7
+ - 'vendor/**/*'
8
+ - 'gemfiles/vendor/**/*'
9
+ - 'out/**/*'
10
+
11
+ Style/FrozenStringLiteralComment:
12
+ Enabled: true
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
6
+
7
+ ## Unreleased
8
+
9
+ ## 0.0.0 - 2021-02-12
10
+ ### Added
11
+ - Initial version
12
+ - When ready for release, bump `version.rb` to allow the release automation described in the README
13
+ to detect the change. Note: this requires at least 1 commit to the default branch with this
14
+ initial version.
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ source 'https://rubygems.org'
5
+
6
+
7
+ # override the :github shortcut to be secure by using HTTPS
8
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" }
9
+
10
+ # Specify your gem's dependencies in ruby-exclaim.gemspec
11
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Salsify, Inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,806 @@
1
+ # Exclaim
2
+
3
+ <!-- toc -->
4
+
5
+ - [What and Why](#what-and-why)
6
+ * [Design Goals](#design-goals)
7
+ * [Differences from Ember Exclaim](#differences-from-ember-exclaim)
8
+ - [Installation](#installation)
9
+ - [Usage](#usage)
10
+ * [Configuration](#configuration)
11
+ * [Creating an Exclaim::Ui](#creating-an-exclaimui)
12
+ * [Implementing Components and Helpers](#implementing-components-and-helpers)
13
+ + [Basic Examples](#basic-examples)
14
+ + [Defining the Implementation Map](#defining-the-implementation-map)
15
+ + [Child Components](#child-components)
16
+ + [Variable Environments](#variable-environments)
17
+ + [Shorthand Properties and Configuration Defaults](#shorthand-properties-and-configuration-defaults)
18
+ + [Security Considerations](#security-considerations)
19
+ - [Script Injection](#script-injection)
20
+ - [Unintended Tracking/HTTP Requests](#unintended-trackinghttp-requests)
21
+ * [Querying the Parsed UI](#querying-the-parsed-ui)
22
+ * [Utilities](#utilities)
23
+ - [Development](#development)
24
+ - [Contributing](#contributing)
25
+ - [License](#license)
26
+
27
+ <!-- tocstop -->
28
+
29
+ ## What and Why
30
+
31
+ Exclaim is a JSON format to declaratively specify a UI. The JSON includes references to named UI components.
32
+ You supply the Ruby implementations of these components.
33
+
34
+ For example, here is an Exclaim declaration of a `text` component:
35
+
36
+ ```
37
+ { "$component": "text, "content": "Hello, world!" }
38
+ ```
39
+
40
+ Your implementation of this `text` component could simply echo the configured `content` value:
41
+
42
+ ```
43
+ ->(config, env) { config['content'] }
44
+ ```
45
+
46
+ The above would render the plain string `Hello, world!`
47
+
48
+ Alternatively, your implementation could wrap the `content` in an HTML `span` tag:
49
+
50
+ ```
51
+ ->(config, env) { "<span>#{config['content']}</span>" }
52
+ ```
53
+
54
+ Then rendering the UI would produce `<span>Hello, world!</span>`
55
+
56
+ Similarly, you could implement an `image` component to replicate an HTML `img` tag:
57
+
58
+ ```
59
+ ->(config, env) { "<img src='#{config['source']}\' alt='#{config['alt']}'>" }
60
+ ```
61
+
62
+ and declare it in JSON like so:
63
+
64
+ ```
65
+ { "$component": "image, "source": "/picture.jpg", "alt": "My Picture" }
66
+ ```
67
+
68
+ These `text` and `image` components are just examples - Exclaim does not require implementing any specific components.
69
+ The needs of your domain determine the mix of components to implement.
70
+
71
+ By implementing more complex components, including ones that accept nested child components,
72
+ you prepare the building blocks to specify a full UI. Then, this library will accept JSON values representing
73
+ arbitrary UIs composed of those component references, and call your implementations to render them.
74
+
75
+ ### Design Goals
76
+
77
+ Exclaim has several high-level goals:
78
+
79
+ * Enable people to declare semi-arbitrary UIs, especially people who do not have direct access to application code.
80
+ * Support variable references within these UI declarations.
81
+ * Provide the ability to offer custom, domain-specific UI components, i.e. more than what standard HTML provides.
82
+ * Represent UI declarations in a data format that is relatively easy to parse and manipulate programmatically.
83
+ * Constrain UI declarations to help avoid the XSS/CSRF vulnerabilities and automatic URL loading built into HTML.
84
+ Exclaim component implementations still must handle these issues (see [Security Considerations](#security-considerations)),
85
+ but JSON provides an easier starting point.
86
+
87
+ Other good solutions exist that fulfill slightly different needs.
88
+
89
+ * [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML) itself enables declarative UIs, of course,
90
+ and with adequate input sanitization, a platform could host HTML authored by end users.
91
+ * Templating languages like [Handlebars](https://handlebarsjs.com/) or
92
+ [Liquid](https://shopify.github.io/liquid/) add variables and data transformation helpers.
93
+ * For a developer building an interactive web application,
94
+ it would be more straightforward to use any standard JavaScript framework, such as [Ember](https://emberjs.com/).
95
+ * The [Dhall](https://dhall-lang.org/) configuration language enables
96
+ safe evaluation of third-party-defined templates and functions, and has a similar spirit to Exclaim,
97
+ although it does not use JSON as its source format.
98
+
99
+ ### Differences from Ember Exclaim
100
+
101
+ Salsify's [Ember Exclaim](https://github.com/salsify/ember-exclaim) JavaScript package originated the format,
102
+ and this Ruby gem aims to work compatibly with it, aside from intentional differences described below.
103
+
104
+ Ember Exclaim puts more emphasis on providing interactive UI components.
105
+ It leverages Ember [Components](https://api.emberjs.com/ember/release/classes/Component) to back
106
+ the Exclaim components referenced in the JSON, and Ember Components expressly exist
107
+ to render HTML that dynamically reacts to user actions.
108
+
109
+ In both JavaScript and Ruby, Exclaim components render in the context of a bound data environment,
110
+ but Ember Exclaim sets up two-way data binding for the components,
111
+ where user input automatically flows back into the UI's environment.
112
+
113
+ In contrast, the Ruby side focuses on one-way rendering,
114
+ with more emphasis on bulk rendering a UI for multiple data environments.
115
+ For example, at Salsify a key data entity is a product,
116
+ and this library could take a customer's UI configuration to display info about a product
117
+ and render it for each of many products (data environments).
118
+
119
+ Furthermore, this gem omits several features of
120
+ [Ember Exclaim](https://github.com/salsify/ember-exclaim):
121
+
122
+ * It does not implement `resolveFieldMeta`, `metaForField`, or `resolveMeta`.
123
+ These features are secondary to Exclaim's core functionality.
124
+ * It does not support `onChange` actions, which are more relevant for interactive components.
125
+ * It does not accept a `wrapper` component to wrap every declared component in a UI configuration,
126
+ as this is rarely required.
127
+
128
+ Please reach out if you have a concrete need for these features in Ruby.
129
+
130
+ ## Installation
131
+
132
+ Add this line to your application's Gemfile:
133
+
134
+ ```ruby
135
+ gem 'ruby-exclaim'
136
+ ```
137
+
138
+ And then execute:
139
+
140
+ $ bundle
141
+
142
+ Or install it yourself as:
143
+
144
+ $ gem install ruby-exclaim
145
+
146
+ ## Usage
147
+
148
+ ### Configuration
149
+
150
+ The only configuration option is the logger,
151
+ which expects an interface compatible with the standard Ruby
152
+ [`Logger`](https://ruby-doc.org/stdlib/libdoc/logger/rdoc/Logger.html).
153
+ In Rails, it will default it to `Rails.logger`.
154
+
155
+ ```
156
+ Exclaim.configure do |config|
157
+ config.logger = Logger.new($stdout)
158
+ end
159
+ ```
160
+
161
+ ### Creating an Exclaim::Ui
162
+
163
+ We will cover how to implement components shortly.
164
+ For now, assume that you have a simple `text` component implementation,
165
+ and an _implementation map_ containing it:
166
+
167
+ ````
168
+ text_component = ->(config, env) { config['content'] }
169
+ my_implementation_map = { "text" => text_component }
170
+ ````
171
+
172
+ First, instantiate an `Exclaim::Ui`:
173
+
174
+ ```
175
+ exclaim_ui = Exclaim::Ui.new(implementation_map: my_implementation_map)
176
+ ```
177
+
178
+ Then, assume that you have a JSON UI configuration referencing the `text` component:
179
+
180
+ ```
181
+ { "$component": "text", "content": "Hello, world!" }
182
+ ```
183
+
184
+ This JSON could be stored in your DB, fetched from a web API, or supplied any other way.
185
+
186
+ To use it with this library, the JSON must be parsed into a Ruby Hash.
187
+ Note that the hash keys must remain as type String.
188
+
189
+ ```
190
+ my_ui_config = { "$component" => "text, "content" => "Hello, world!" }
191
+ ```
192
+
193
+ Call the `parse_ui!` method to ingest the UI declaration, preparing it for rendering:
194
+
195
+ ```
196
+ exclaim_ui.parse_ui!(my_ui_config)
197
+ ```
198
+
199
+ Finally, call the `render` method to render the UI:
200
+
201
+ ```
202
+ exclaim_ui.render
203
+ => "Hello, world!"
204
+ ```
205
+
206
+ The UI JSON may include `$bind` references, which act like variables:
207
+
208
+ ```
209
+ { "$component": "text, "content": { "$bind": "greeting" } }
210
+ ```
211
+
212
+ This will render with a Hash of values supplied as the _environment_ (usually abbreviated as `env`):
213
+
214
+ ```
215
+ my_environment = { "greeting" => "Good morning, world!" }
216
+ exclaim_ui.render(env: my_environment)
217
+ => "Good morning, world!"
218
+ ```
219
+
220
+ Dot-separated `$bind` paths dig into nested `env` values: `a.b.c` refers to `{ "a" => { "b" => { "c" => "value" } } }`
221
+
222
+ If a `$bind` path segment is an Integer,
223
+ the library will attempt to treat it as an Array index when resolving the value at render time:
224
+
225
+ `"my_array.1"` refers to array index 1 in an `env` like `{ "my_array: ["zero", "one", ...] }`
226
+
227
+ ### Implementing Components and Helpers
228
+
229
+ Note that implementations have __important [Security Considerations](#security-considerations)__.
230
+
231
+ Component implementations typically return HTML Strings.
232
+ As desired, you can leverage a Ruby templating tool like [ERB](https://rubygems.org/gems/erb)
233
+ to do this, but simple string interpolation works too.
234
+
235
+ Rendering HTML is the primary purpose of Exclaim, and in situations
236
+ when you want the UI configurations to work interchangeably in Ember and Ruby,
237
+ the Ruby component implementations will need to produce equivalent HTML to the Ember components.
238
+
239
+ However, Ruby components technically do not _need_ to return HTML Strings.
240
+ They could return some other Ruby value, like a Hash representing the JSON payload to submit to some API.
241
+
242
+ In addition to components, Exclaim also has helpers.
243
+ The distinction between components and helpers is stronger in Ember Exclaim,
244
+ since there components are Ember [Components](https://api.emberjs.com/ember/release/classes/Component),
245
+ while helpers are plain JavaScript functions.
246
+
247
+ Nevertheless, helpers have the same spirit in the Ruby version:
248
+ They do not render output directly, but instead to transform data supplied as component configuration.
249
+
250
+ As an example, suppose you define
251
+ a `coalesce` helper intended to extract the first non-nil value available from an Array.
252
+ It would support UI declarations like below, where the `text` component's `content` configuration
253
+ becomes a dynamic `pizza_topping` value from the `env`, if present, or falls back to `"plain cheese"`:
254
+
255
+ ```
256
+ {
257
+ "$component": "text",
258
+ "content": {
259
+ "$helper": "coalesce",
260
+ "candidates": [{ "$bind": "pizza_topping" }, "plain cheese"]
261
+ }
262
+ }
263
+ ```
264
+
265
+ The following implementation could back this `coalesce` helper:
266
+
267
+ ```
268
+ ->(config, env) { config['candidates'].compact.first }
269
+ ```
270
+
271
+ In Ruby Exclaim, both component and helper implementations are objects that respond to `call`,
272
+ such as a [lambda or proc](https://ruby-doc.org/core-3.0.0/Proc.html),
273
+ [`Method`](https://ruby-doc.org/core-3.0.0/Method.html) object,
274
+ or instance of a custom class which defines a `call` method.
275
+
276
+ More precisely, implementations:
277
+
278
+ * Must provide a `call` interface.
279
+ * The `call` interface must accept two positional parameters, `config` and `env`, both Hashes.
280
+ * Component implementations can optionally accept a block parameter, `&render_child`,
281
+ which the implementation can use to render child components specified in its config.
282
+ That does not apply to helper implementations.
283
+
284
+ In addition, these implementations must define either a `component?` or `helper?` predicate method.
285
+ These must return a truthy or falsy value to identify their type.
286
+
287
+ #### Basic Examples
288
+
289
+ See also the `lib/exclaim/implementations` directory for more code examples,
290
+ and `spec/integration_spec.rb` to see them in action.
291
+
292
+ Returning to the `text` component mentioned above, we could implement it a few different ways.
293
+
294
+ A lambda:
295
+
296
+ ```
297
+ text_component = ->(config, env) { config['content'] }
298
+ text_component.define_singleton_method(:component?) { true }
299
+ ```
300
+
301
+ Or a custom class:
302
+ ```
303
+ class Text
304
+ def call(config, env)
305
+ config['content']
306
+ end
307
+
308
+ def component?
309
+ true
310
+ end
311
+ end
312
+
313
+ text_component = Text.new
314
+ ```
315
+
316
+ If needed, a different `call`-able, such as a block or Method object:
317
+
318
+ ```
319
+ def generate_implementation(is_component:, &implementation_block)
320
+ implementation_block.define_singleton_method(:component?) { is_component }
321
+ implementation_block
322
+ end
323
+
324
+ text_component = generate_implementation(is_component: true) do |config, env|
325
+ config['content']
326
+ end
327
+ ```
328
+
329
+ Helpers are very similar:
330
+
331
+ ```
332
+ # lambda
333
+ join_helper = ->(config, env) { config['items'].to_a.join(config['separator']) }
334
+ join_helper.define_singleton_method(:helper?) { true }
335
+
336
+ # class
337
+ class Join
338
+ def call(config, env)
339
+ config['items'].to_a.join(config['separator'])
340
+ end
341
+
342
+ def helper?
343
+ true
344
+ end
345
+ end
346
+
347
+ join_helper = Join.new
348
+ ```
349
+
350
+ Implementations may define both `component?` and `helper?`, as long as they have opposite truth-values.
351
+ They only need to define one of them, though, since one implies the converse value for the other.
352
+
353
+ #### Defining the Implementation Map
354
+
355
+ With some components and helpers implemented, an application should put them in an _implementation map_ Hash.
356
+
357
+ ```
358
+ IMPLEMENTATION_MAP = {
359
+ "text" => text_component,
360
+ "vbox" => vbox_component,
361
+ "list" => list_component,
362
+ "coalesce" => coalesce_helper
363
+ "join" => join_helper
364
+ }
365
+ ```
366
+
367
+ Pass it in when instantiating an `Exclaim::Ui`:
368
+
369
+ ```
370
+ exclaim_ui = Exclaim::Ui.new(implementation_map: IMPLEMENTATION_MAP)
371
+ ```
372
+
373
+ This library comes with several element implementations, collected into an example implementation map.
374
+ You can freely use some or all of them, but there is no requirement to do so.
375
+ A basic assumption of Exclaim is that client code will provide a custom mix of components.
376
+
377
+ Many applications will only need a single, application-wide implementation map,
378
+ but it is quite possible to define more than one,
379
+ passing them into different `Exclaim::Ui` instances.
380
+
381
+ Example reasons why an application might define multiple implementation maps:
382
+
383
+ * One set of implementations to render HTML for public consumption,
384
+ another that draws highlights around elements for internal reviewers.
385
+ * You have two target websites that need dramatically different HTML organization or CSS classes.
386
+ * You want to implement multiple `brand_container` components that embed parallel stylesheets and logos.
387
+ * One set of implementations that renders HTML, another to render JSON payloads for an API.
388
+ * A set of implementations that should only be used with trusted UI configuration/environment values,
389
+ and another more constrained set to use with untrusted values.
390
+
391
+ Another way to accomplish the goals above would be to put conditional logic
392
+ in the implementations, and passing variable `env` Hashes to drive it when rendering.
393
+ The right strategy depends on the amount of variation and how you want to organize your implementations.
394
+
395
+ #### Child Components
396
+
397
+ Components can have nested child components, where the parent incorporates
398
+ the rendered child values into its own rendered output.
399
+
400
+ Consider a `vbox` component which renders its children in a vertically oriented `div`:
401
+
402
+ ```
403
+ {
404
+ "$component": "vbox",
405
+ "children": [
406
+ { "$component": "span", "content": "Child 1" },
407
+ { "$component": "span", "content": "Child 2" }
408
+ ]
409
+ }
410
+ ```
411
+
412
+ With an implementation like this:
413
+
414
+ ```
415
+ vbox_component = ->(config, env, &render_child) do
416
+ rendered_children = config['children'].map do |child_component|
417
+ render_child.call(child_component, env)
418
+ end
419
+
420
+ "<div style='display: flex; flex-flow: column'>#{rendered_children.join}</div>"
421
+ end
422
+ ```
423
+
424
+ Ultimately rendering this output, assuming a simple `span` component implementation for the children:
425
+ ```
426
+ <div style="display: flex; flex-flow: column"><span>Child 1</span><span>Child 2</span></div>
427
+ ```
428
+
429
+ To render the children, the component implementation must accept a `&render_child` block argument
430
+ (although it may name that argument whatever it wants).
431
+
432
+ Note that Ruby lambdas cannot use the `yield` keyword, so they must reference the block argument explicitly:
433
+ ```
434
+ render_child.call(child_component, env)
435
+ ```
436
+
437
+ Conversely, a custom `call` method can `yield` to that block implicitly, and hence does not need to name it:
438
+ ```
439
+ def call(config, env)
440
+ rendered_children = config['children'].map do |child_component|
441
+ yield child_component, env
442
+ end
443
+ end
444
+ ```
445
+
446
+ This illustrates the main difference between components and helpers.
447
+ Unlike components, helpers cannot take rendered components as config values.
448
+
449
+ The only narrow exception is that helpers can return un-rendered components specified in their config.
450
+ An example would be an `if` helper that evaluates a condition specified in its config.
451
+ That helper's config could also include the component declarations to return for true and false conditions.
452
+ That works, but the helper implementation cannot "touch" those components, it can only pass them through,
453
+ since it does not have access to their rendered values. (See the `if` helper in `lib/exclaim/implementations`.)
454
+
455
+ #### Variable Environments
456
+
457
+ In most of the earlier examples, implementations did not use the `env` value passed as an argument.
458
+ They only referenced their `config` argument. In fact, this library takes care of evaluating
459
+ `$bind` references from the `env` prior to handing the resolved `config` to the implementation.
460
+
461
+ However, the child component example above shows why that `env` argument exists:
462
+ When rendering child components, parent components must pass the `env` down to them:
463
+
464
+ ```
465
+ render_child.call(child_component, env)
466
+ ```
467
+
468
+ Actually, the parent component does not have to pass the `env` as-is when rendering the children.
469
+ The `env` is a Ruby Hash, and the implementation can vary it, either by setting a new key,
470
+ merging another Hash, or passing a separate Hash altogether.
471
+
472
+ Here is a `list` component creating a child `env` with just the item index value:
473
+
474
+ ```
475
+ list_component = ->(config, env, &render_child) do
476
+ rendered_children = config['list_items'].each_with_index.map do |child_component, idx|
477
+ child_env = { 'n' => idx }
478
+ value = render_child.call(child_component, child_env)
479
+ "<li>#{value}</li>"
480
+ end
481
+
482
+ "<ul> #{rendered_children.join(' ')} </ul>"
483
+ end
484
+ ```
485
+
486
+ Then given this UI declaration:
487
+
488
+ ```
489
+ {
490
+ "$component": "list",
491
+ "list_items": [{ "$bind": "n" }, { "$bind": "n" }, { "$bind": "n" }] }
492
+ }
493
+ ```
494
+
495
+ It would render like so, where the bound `n` varies for each item:
496
+ ```
497
+ <ul> <li>0</li> <li>1</li> <li>2</li> </ul>
498
+ ```
499
+
500
+ Why create a new child `env` vs. set a new key in the existing `env`, or merge another Hash onto it?
501
+
502
+ That depends on your implementations and the details of your domain.
503
+ The first guideline is to only vary the child `env` when necessary,
504
+ otherwise just pass down the original `env` when rendering child components.
505
+
506
+ When you do need to vary the child `env`, the tradeoffs are:
507
+
508
+ * Merging a new Hash onto the existing `env` means that the child components will have all the
509
+ existing `env` values plus whatever you add. This provides flexibility if you don't know exactly what child components need.
510
+ * On the other hand, merging will duplicate the Hash if you use `env.merge`, or mutate it if you use `env.merge!`
511
+ The former could allocate a lot of memory, depending on the size of the `env`
512
+ and how many nested components the UI config has. The latter could cause subtle bugs if you inadvertently
513
+ overwrite data in the parent `env`. Or if you have called `freeze` on it, mutating will raise a `FrozenError`.
514
+ * Similar concerns exist when just setting a new key on parent `env`.
515
+ * Constructing a new Hash as the child `env` may also allocate a lot of memory,
516
+ depending on the number of child components, but potentially less than duplicating the original `env`.
517
+ That avoids mutation-induced bugs as well.
518
+ * The caveat with a standalone child `env` is that if the JSON declares child component references which assume
519
+ the presence of values from an overall parent `env`, they will not exist. You may not know in advance
520
+ whether users will create declarations like that.
521
+
522
+ #### Shorthand Properties and Configuration Defaults
523
+
524
+ The JSON UI declarations can become a little verbose:
525
+
526
+ ```
527
+ {
528
+ "$component": "text",
529
+ "content": {
530
+ "$helper": "join,
531
+ "items": [1, 2, 3],
532
+ "separator": " + "
533
+ }
534
+ }
535
+ ```
536
+
537
+ This is fine when reading and writing the JSON programmatically,
538
+ but you can also let humans declare the JSON more concisely using Exclaim's shorthand syntax:
539
+
540
+ ```
541
+ {
542
+ "$text": { "$join": [1, 2, 3], "separator": " + " }
543
+ }
544
+ ```
545
+
546
+ To support these shorthand properties your implementation must look for them in the config:
547
+
548
+ ```
549
+ text_component = ->(config, env) { config['$text'] || config['content'] }
550
+
551
+ join_helper = ->(config, env) do
552
+ items = (config['$join'] || config['items']).to_a
553
+ items.join(config['separator'])
554
+ end
555
+ ```
556
+
557
+ Each component or helper can only have one shorthand property,
558
+ and typically it should be the configuration value that you consider "primary."
559
+
560
+ As a related concern, you may want an implementation to supply default config values:
561
+
562
+ ```
563
+ join_helper = ->(config, env) do
564
+ items = (config['$join'] || config['items']).to_a
565
+ separator = config['separator'] || ', '
566
+ items.join(separator)
567
+ end
568
+ ```
569
+
570
+ As a final point about shorthand declarations, note that even though
571
+ the UI configurations reference the components with a leading `$`,
572
+ that does not change anything about the implementation map.
573
+ Its keys should not start with the `$` symbol.
574
+ Both `"$text": ...` and `"$component": "text"` in UI configurations reference the `"text"` implementation map key.
575
+
576
+ #### Security Considerations
577
+
578
+ Allowing end users to declare UIs is a core goal of Exclaim,
579
+ whether they produce the JSON manually or utilize a GUI web application to compose it.
580
+
581
+ Like other systems that evaluate untrusted input, this poses a risk of security vulnerabilities.
582
+ The main concerns with Exclaim are:
583
+
584
+ * XSS or CSRF if a user can inject a `<script>` tag, or an executable HTML attribute like `onclick`.
585
+ * Unintended tracking, if the user can embed an arbitrary URL into an HTML element
586
+ that provokes automatic HTTP requests, like an `img` `src` attribute or CSS `url()` function.
587
+ * Server Side Request Forgery, if your server will render output that loads URLs, for example if you
588
+ produce a thumbnail image or PDF from rendered HTML, which will prompt fetching images/stylesheets.
589
+
590
+ Conceptually, the high-level security guidelines are:
591
+
592
+ * The UI `config` and rendering `env` are untrusted.
593
+ They intentionally contain values driven by end-users or other external parties.
594
+ * The implementations of components and helpers are trusted.
595
+ They contain arbitrary code authored by you, and will execute on your servers when rendering an arbitrary UI.
596
+
597
+ Declaring the UI `config` and `env` with JSON helps,
598
+ since it is simple to parse and has no automatically evaluated elements.
599
+ Nevertheless, since those values prompt your implementations to execute,
600
+ they can indirectly enable malicious content injection.
601
+
602
+ Thus, the goal is to define implementations that avoid that. The following points help with that:
603
+
604
+ ##### Script Injection
605
+
606
+ This library HTML-escapes all resolved configuration values by default.
607
+ Assuming this `text` component implementation:
608
+
609
+ ```
610
+ ->(config, env) { config['content'] }
611
+ ```
612
+
613
+ Then given a JSON UI declaration like this:
614
+
615
+ ```
616
+ { "$component" "text",
617
+ "content": "<script>alert('Hello, I am executing arbitary code.');</script>"
618
+ }
619
+ ```
620
+
621
+ When calling `exclaim_ui.render`, this library will pass the `config` to the implementation with the values escaped:
622
+ ```
623
+ {
624
+ "$component" "text",
625
+ "content": "&lt;script&gt;alert(&#39;Hello, I am executing arbitary code.&#39;);&lt;/script&gt;"
626
+ }
627
+ ```
628
+
629
+ The same escaping applies to values obtained from the bound `env`.
630
+
631
+ If you do need to embed raw HTML, and you are _certain_ you can trust the input,
632
+ your implementation can call `CGI.unescape_html` or `CGI.unescape_element`.
633
+ See [CGI::Util](https://ruby-doc.org/stdlib-3.0.0/libdoc/cgi/rdoc/CGI/Util.html)
634
+ in the Ruby standard library for details.
635
+
636
+ ##### Unintended Tracking/HTTP Requests
637
+
638
+ If you don't need to implement components with configurable URLs, just avoid it completely.
639
+ For example, do not support arbitrary CSS snippets as configuration,
640
+ and instead enumerate some basic styling options that work for your domain.
641
+
642
+ If you do need configurable URLs, establish an allowed set of domains,
643
+ and then in your component implementation, verify that all the URL(s) in the configuration fall within that set:
644
+
645
+ ```
646
+ youtube_embed_component = ->(config, env) do
647
+ parsed_uri = URI.parse(config['source'])
648
+ raise "Invalid Youtube URL" unless parsed_uri.host == "www.youtube.com"
649
+
650
+ "<iframe src="#{parsed_uri}" other youtube attributes...></iframe>"
651
+ end
652
+ ```
653
+
654
+ In general, component implementations can use this pattern to validate configuration.
655
+ At render time, they will receive the resolved configuration values,
656
+ after integrating bound `env` values and evaluating helpers.
657
+
658
+ Keep in mind that you may need to do this at multiple levels.
659
+ For example, `join` helper might validate the array of items in its configuration,
660
+ but a component should still validate the joined result passed to it as resolved config.
661
+
662
+ To prevent SSRF, again the simplest solution is do not render HTML on your server.
663
+ Though if you do need a feature like taking a screenshot of rendered HTML (e.g. with a headless browser),
664
+ here are some tips:
665
+
666
+ * Use the steps above to validate configuration values.
667
+ * Render the HTML within a sandboxed host that cannot access any sensitive URLs within your network.
668
+
669
+ As a similar concern to SSRF, your servers may include credentials in OS environment variables,
670
+ sensitive files, etc. Within reason, write your implementations as pure functions that only
671
+ reference the `config`, `env`, and `&render_child` arguments, and do nothing besides compute the output value.
672
+
673
+ In other words, implementations should avoid loading data from a database, making network requests,
674
+ or other actions that have different results depending on what computer executes them.
675
+
676
+ ### Querying the Parsed UI
677
+
678
+ After calling `parse_ui!`, an `Exclaim::Ui` instance provides some functions to query the UI.
679
+
680
+ Given a UI declaration like so:
681
+
682
+ ```
683
+ {
684
+ "$component": "text",
685
+ "content": {
686
+ "$helper": "coalesce",
687
+ "candidates": [
688
+ { "$bind" => "a" },
689
+ { "$bind" => "plan.b" },
690
+ { "$bind" => "default.0" },
691
+ "Static default"
692
+ ]
693
+ }
694
+ }
695
+ ```
696
+
697
+ The `unique_bind_paths` method will return
698
+ all the `$bind` paths included in the configuration:
699
+
700
+ ```
701
+ exclaim_ui.unique_bind_paths
702
+ => ["a", "plan.b", "default.0"]
703
+ ```
704
+
705
+ This can be useful for checking that the UI configuration has valid `$bind` references,
706
+ or when you need to assemble the context-specific data to populate the `env`.
707
+
708
+ The `each_element` method yields each sub-Hash of the UI configuration matching
709
+ given element names. When not given a block, it returns an `Enumerator`.
710
+
711
+ The example above only has two elements, a component and a helper,
712
+ so the results are simple. This call would yield the single `coalesce` configuration:
713
+ ```
714
+ exclaim_ui.each_element("coalesce").to_a
715
+ => [{
716
+ "$helper" => "coalesce",
717
+ "candidates" => [
718
+ { "$bind" => "a" },
719
+ { "$bind" => "plan.b" },
720
+ { "$bind" => "default.0" },
721
+ "Static default"
722
+ ]
723
+ }]
724
+ ```
725
+
726
+ While this call would return the top-level `text` component, which happens to be the entire UI configuration:
727
+
728
+ ```
729
+ exclaim_ui.each_element("text").to_a
730
+ => [{
731
+ "$component" => "text",
732
+ "content" => {
733
+ ...
734
+ }]
735
+ ```
736
+
737
+ `each_element` also accepts the target element names as an array:
738
+
739
+ ```
740
+ exclaim_ui.each_element(["text", "coalesce"]).to_a
741
+ => [ <text config Hash>, <coalesce config sub-Hash> ]
742
+ ```
743
+
744
+ When not given an `element_names` argument at all,
745
+ it enumerates _every_ Exclaim element within the UI configuration.
746
+
747
+ The `each_element` method comes in handy with more complicated, nested UI declarations.
748
+ As an example, a UI may have several `image` components at arbitrary places
749
+ throughout the UI, and you want to validate that each has `alt` text configuration:
750
+
751
+ ```
752
+ exclaim_ui.each_element("image") do |image_component|
753
+ if image_component['alt'].nil?
754
+ Exclaim.logger.warn("Image component lacks alt configuration")
755
+ end
756
+ end
757
+ ```
758
+
759
+ It traverses the elements recursively, starting with the top-level of the UI config,
760
+ and descending down through each leaf element.
761
+ When configuration elements are Array values, it will search through each item.
762
+
763
+ ### Utilities
764
+
765
+ In addition to the `Exclaim::Ui` features documented above,
766
+ this gem provides top-level utility functions.
767
+
768
+ **`Exclaim.element_name(config_hash)`**
769
+
770
+ Given a Hash including with the parsed JSON, extracts the Exclaim component or helper name.
771
+
772
+ ```
773
+ Exclaim.element_name({ "$component" => "text", "content" => "Hello" })
774
+ => "text
775
+
776
+ Exclaim.element_name({ "$text" => "Hello" })
777
+ => "text"
778
+
779
+ Exclaim.element_name({ "no" => "exclaim element" })
780
+ => nil
781
+ ```
782
+
783
+ ## Development
784
+
785
+ After checking out the repo, run `bin/setup` to install dependencies. Then,
786
+ run `rake spec` to run the tests. You can also run `bin/console` for an
787
+ interactive prompt that will allow you to experiment.
788
+
789
+ To install this gem onto your local machine, run `bundle exec rake install`.
790
+
791
+ To release a new version, update the version number in `version.rb`. When merged
792
+ to the default branch, [a GitHub action](.github/workflows/release.yml) will
793
+ automatically will create a git tag for the version, push git commits and tags,
794
+ and push the `.gem` file to
795
+ [rubygems.org](https://rubygems.org).
796
+
797
+ ## Contributing
798
+
799
+ Bug reports and pull requests are welcome on GitHub at
800
+ https://github.com/salsify/ruby-exclaim.
801
+
802
+ ## License
803
+
804
+ The gem is available as open source under the terms of the
805
+ [MIT License](http://opensource.org/licenses/MIT).
806
+