ruby-exclaim 0.0.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 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
+